mirror of
https://github.com/zalando/postgres-operator.git
synced 2026-10-01 11:49:41 +02:00
Initial commit for Postgres Operator UI (#613)
* Initial commit for our basic Postgres Operator UI: * Create and modify Postgres manifests * Watch Operator Logs in the UI * Observe cluster creation progress * S3 Backup browser for clone and restore Many thanks to Manuel Gomez and Jan Mussler for the initial UI work a long time ago!
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import jQuery from 'jquery'
|
||||
import riot from 'riot'
|
||||
|
||||
import 'riot-hot-reload'
|
||||
|
||||
import './edit.tag.pug'
|
||||
import './postgresql.tag.pug'
|
||||
import './help-edit.tag.pug'
|
||||
import './help-general.tag.pug'
|
||||
import './postgresqls.tag.pug'
|
||||
import './logs.tag.pug'
|
||||
import './new.tag.pug'
|
||||
import './status.tag.pug'
|
||||
import './app.tag.pug'
|
||||
import './restore.tag.pug'
|
||||
|
||||
Object.fromEntries = entries => entries.length === 0 ? {} : Object.assign(...entries.map(([k, v]) => ({[k]: v})))
|
||||
Object.mapValues = (o, f) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, f(v, k)]))
|
||||
Object.mapEntries = (o, f) => Object.fromEntries(Object.entries(o).map(f).filter(x => x))
|
||||
Object.filterEntries = (o, f) => Object.mapEntries(o, entry => f(entry) && entry)
|
||||
Object.filterValues = (o, f) => Object.filterEntries(o, ([key, value]) => f(value) && [key, value])
|
||||
|
||||
|
||||
const getDefaulting = (object, key, def) => (
|
||||
object.hasOwnProperty(key) ? object[key] : def
|
||||
)
|
||||
|
||||
|
||||
const Dynamic = (options={}) => {
|
||||
const instance = {
|
||||
init: getDefaulting(options, 'init', () => ''),
|
||||
refresh: getDefaulting(options, 'refresh', () => true),
|
||||
update: getDefaulting(options, 'update', value => (instance.state = value, true)),
|
||||
validState: getDefaulting(options, 'validState', state => (
|
||||
state !== undefined &&
|
||||
state !== null &&
|
||||
typeof state === 'string' &&
|
||||
state.length > 0
|
||||
)),
|
||||
|
||||
edit: event => (instance.update(event.target.value, instance, event), true),
|
||||
valid: () => instance.validState(instance.state),
|
||||
}
|
||||
|
||||
instance.state = instance.init()
|
||||
return instance
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
Dynamics manages a dynamic array whose elements are themselves Dynamic objects.
|
||||
|
||||
The default initializer builds an empty array as the initial state.
|
||||
|
||||
The "add" DOM event callback is provided to add a newly initialized Dynamic
|
||||
object to the end of the state array. The Dynamic array item is initialized
|
||||
with the "itemInit" callback, which can be specified with a constructor option
|
||||
and defaults to creating a Dynamic with all default options.
|
||||
|
||||
The "remove" DOM event callback is provided to handle DOM events that should
|
||||
or remove a specific item. For events on elements generated by iterating the
|
||||
state with an each= attribute, the event.item will be set to the correct value.
|
||||
|
||||
The refresh callback is forwarded to all constituent Dynamic objects.
|
||||
*/
|
||||
const Dynamics = (options={}) => {
|
||||
const instance = Object.assign(
|
||||
Dynamic(
|
||||
Object.assign(
|
||||
{ init: () => [] },
|
||||
'refresh' in options
|
||||
? { refresh: options.refresh }
|
||||
: undefined
|
||||
)
|
||||
),
|
||||
|
||||
{
|
||||
itemInit: getDefaulting(options, 'itemInit', () =>
|
||||
Dynamic(
|
||||
'refresh' in options
|
||||
? { refresh: options.refresh }
|
||||
: {}
|
||||
)
|
||||
),
|
||||
itemValid: getDefaulting(options, 'itemValid', item => item.valid()),
|
||||
validState: state => state.every(instance.itemValid),
|
||||
update: () => true,
|
||||
edit: () => true,
|
||||
|
||||
add: _event => {
|
||||
instance.state.push(instance.itemInit())
|
||||
instance.refresh()
|
||||
return true
|
||||
},
|
||||
|
||||
remove: event => {
|
||||
instance.state.splice(instance.state.indexOf(event.item), 1)
|
||||
instance.refresh()
|
||||
return true
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
Object.defineProperty(instance, 'valids', { get: () =>
|
||||
instance.state.filter(instance.itemValid)
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
DynamicSet manages a keyed collection of Dynamic objects. The constructor
|
||||
receives an object mapping keys to initialization functions, and its state is a
|
||||
mapping from the same keys to Dynamics initialized using the corresponding
|
||||
key's initialization function. A DynamicSet is valid when its constituent
|
||||
Dynamics are all simultaneously valid. The refresh callback is forwarded to
|
||||
all constituent Dynamic objects.
|
||||
|
||||
Example:
|
||||
|
||||
DynamicSet({
|
||||
foo: undefined,
|
||||
bar: () => 'baz',
|
||||
})
|
||||
|
||||
This call would create a DynamicSet with two constituent dynamics in its state:
|
||||
one of them under the 'foo' key of the state object, built with the default
|
||||
Dynamic initializer, and another under the 'bar' key of the state object, whose
|
||||
state, in turn, would initially hold the value 'baz'.
|
||||
*/
|
||||
const DynamicSet = (items, options={}) => Object.assign(
|
||||
Dynamic(
|
||||
Object.assign(
|
||||
{
|
||||
init: () => Object.mapValues(items, init =>
|
||||
Dynamic(
|
||||
Object.assign(
|
||||
init ? { init: init } : undefined,
|
||||
'refresh' in options ? { refresh: options.refresh } : undefined
|
||||
)
|
||||
)
|
||||
),
|
||||
},
|
||||
'refresh' in options
|
||||
? { refresh: options.refresh }
|
||||
: undefined
|
||||
)
|
||||
),
|
||||
|
||||
{
|
||||
items: items,
|
||||
validState: state => Object.values(state).every(item => item.valid()),
|
||||
edit: () => true,
|
||||
update: () => true,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
const delete_cluster = (namespace, clustername) => {
|
||||
jQuery.confirm({
|
||||
backgroundDismiss: true,
|
||||
content: `
|
||||
<p>
|
||||
Are you sure you want to remove this PostgreSQL cluster? If so,
|
||||
please <strong>type the cluster name here
|
||||
(<code>${namespace}/${clustername}</code>)</strong> and click the
|
||||
confirm button:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
class="confirm-delete"
|
||||
placeholder="cluster name"
|
||||
style="width: 100%"
|
||||
>
|
||||
<hr>
|
||||
<p><small>
|
||||
<strong>Note</strong>: if you create a cluster with the same name as
|
||||
this one after deleting it, the new cluster will restore the data
|
||||
from this cluster's current backups stored in AWS S3. This behavior
|
||||
will change soon and you will be able to reuse a cluster name and
|
||||
get a completely new cluster.
|
||||
</small></p>
|
||||
`,
|
||||
escapeKey: true,
|
||||
icon: 'glyphicon glyphicon-warning-sign',
|
||||
title: 'Confirm cluster deletion?',
|
||||
typeAnimated: true,
|
||||
type: 'red',
|
||||
onOpen: function () {
|
||||
const dialog = this
|
||||
const confirm = dialog.buttons.confirm
|
||||
const confirmSelector = jQuery(confirm.el)
|
||||
const input = dialog.$content.find('input')
|
||||
input.on('input', () => {
|
||||
if (input.val() === namespace + '/' + clustername) {
|
||||
confirmSelector.removeClass('btn-default').addClass('btn-danger')
|
||||
confirm.enable()
|
||||
} else {
|
||||
confirm.disable()
|
||||
confirmSelector.removeClass('btn-danger').addClass('btn-default')
|
||||
}
|
||||
})
|
||||
},
|
||||
buttons: {
|
||||
cancel: {
|
||||
text: 'Cancel',
|
||||
},
|
||||
confirm: {
|
||||
btnClass: 'btn-default',
|
||||
isDisabled: true,
|
||||
text: 'Delete cluster',
|
||||
action: () => {
|
||||
jQuery.ajax({
|
||||
type: 'DELETE',
|
||||
url: (
|
||||
'/postgresqls/'
|
||||
+ encodeURI(namespace)
|
||||
+ '/' + encodeURI(clustername)
|
||||
),
|
||||
dataType: 'text',
|
||||
success: () => location.assign('/#/list'),
|
||||
error: (r, status, error) => location.assign('/#/list'), // TODO: show error
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/* Unfortunately, there does not appear to be a good way to import local modules
|
||||
inside a Riot tag, so we define/import things here and pass them manually in the
|
||||
opts variable. Remember to propagate opts manually when instantiating tags. */
|
||||
riot.mount('app', {
|
||||
Dynamic: Dynamic,
|
||||
Dynamics: Dynamics,
|
||||
DynamicSet: DynamicSet,
|
||||
delete_cluster: delete_cluster,
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
app
|
||||
|
||||
nav.navbar.navbar-inverse.navbar-fixed-top
|
||||
.container
|
||||
|
||||
.navbar-header
|
||||
a.navbar-brand(href='/')
|
||||
| PostgreSQL Operator UI
|
||||
|
||||
#navbar.navbar-collapse.collapse
|
||||
ul.nav.navbar-nav
|
||||
|
||||
li(class='{ active: ["EDIT", "LIST", "LOGS", "STATUS"].includes(activenav) }')
|
||||
a(href='/#/list') PostgreSQL clusters
|
||||
|
||||
li(class='{ active: "BACKUPS" === activenav }')
|
||||
a(href='/#/backups') Backups
|
||||
|
||||
li(class='{ active: "OPERATOR" === activenav }')
|
||||
a(href='/#/operator') Status
|
||||
|
||||
li(class='{ active: "NEW" === activenav }')
|
||||
a(href='/#/new') New cluster
|
||||
|
||||
li(if='{ config }')
|
||||
a(href='{ config.docs_link }' target='_blank') Documentation
|
||||
|
||||
.container-fluid
|
||||
|
||||
.alert.alert-warning.alert-dismissible(
|
||||
if='{ config && config.kubernetes_in_maintenance }'
|
||||
role='alert'
|
||||
)
|
||||
button.close(
|
||||
aria-label='Close'
|
||||
data-dismiss='alert'
|
||||
type='button'
|
||||
)
|
||||
span.glyphicon.glyphicon-remove(aria-hidden='true')
|
||||
|
||||
p.lead
|
||||
span.glyphicon.glyphicon-exclamation-sign(aria-hidden='true')
|
||||
span.sr-only Warning:
|
||||
|
|
||||
| This Kubernetes cluster appears to be undergoing maintenance. You may experience delays in database cluster creation and changes.
|
||||
|
||||
.sk-spinner-pulse(
|
||||
if='{ config !== null && teams !== null && (config === undefined || teams === undefined) }'
|
||||
)
|
||||
|
||||
p(if='{ config === null || teams === null }')
|
||||
| Error loading UI configuration. Please
|
||||
|
|
||||
a(onclick="window.location.reload(true);") try again
|
||||
|
|
||||
| or
|
||||
|
|
||||
a(href="/") start over
|
||||
| .
|
||||
|
||||
div(if='{ config }')
|
||||
|
||||
edit(
|
||||
if='{ activenav === "EDIT" }'
|
||||
clustername='{ clustername }'
|
||||
config='{ config }'
|
||||
namespace='{ namespace }'
|
||||
opts='{ opts }'
|
||||
read_write='{ read_write }'
|
||||
teams='{ teams }'
|
||||
)
|
||||
|
||||
logs(
|
||||
if='{ activenav === "LOGS"}'
|
||||
clustername='{ clustername }'
|
||||
config='{ config }'
|
||||
namespace='{ namespace }'
|
||||
opts='{ opts }'
|
||||
read_write='{ read_write }'
|
||||
teams='{ teams }'
|
||||
)
|
||||
|
||||
new(
|
||||
if='{ activenav === "NEW" }'
|
||||
backup_name='{ backup_name }'
|
||||
backup_timestamp='{ backup_timestamp }'
|
||||
backup_uid='{ backup_uid }'
|
||||
config='{ config }'
|
||||
opts='{ opts }'
|
||||
read_write='{ read_write }'
|
||||
teams='{ teams }'
|
||||
)
|
||||
|
||||
postgresql(
|
||||
if='{ activenav === "STATUS" }'
|
||||
clustername='{ clustername }'
|
||||
config='{ config }'
|
||||
namespace='{ namespace }'
|
||||
opts='{ opts }'
|
||||
read_write='{ read_write }'
|
||||
teams='{ teams }'
|
||||
)
|
||||
|
||||
postgresqls(
|
||||
if='{ activenav === "LIST" }'
|
||||
config='{ config }'
|
||||
opts='{ opts }'
|
||||
read_write='{ read_write }'
|
||||
teams='{ teams }'
|
||||
)
|
||||
|
||||
restore(
|
||||
if='{ activenav === "BACKUPS" }'
|
||||
config='{ config }'
|
||||
opts='{ opts }'
|
||||
read_write='{ read_write }'
|
||||
teams='{ teams }'
|
||||
)
|
||||
|
||||
status(
|
||||
if='{ activenav === "OPERATOR" }'
|
||||
config='{ config }'
|
||||
opts='{ opts }'
|
||||
read_write='{ read_write }'
|
||||
teams='{ teams }'
|
||||
)
|
||||
|
||||
script.
|
||||
|
||||
this.config = undefined
|
||||
this.teams = undefined
|
||||
|
||||
this.activenav = 'INIT'
|
||||
this.read_write = false
|
||||
|
||||
const nav = (path, page, parameters, f) => {
|
||||
route(path, (...args) => {
|
||||
parameters && parameters.forEach((parameter, index) =>
|
||||
this[parameter] = args[index]
|
||||
)
|
||||
this.activenav = page
|
||||
this.update()
|
||||
f && f(...args)
|
||||
})
|
||||
}
|
||||
|
||||
const navs = (paths, ...args) => (
|
||||
paths.forEach(path =>
|
||||
nav(path, ...args)
|
||||
)
|
||||
)
|
||||
|
||||
;(
|
||||
jQuery
|
||||
.get('/config')
|
||||
.done(config => {
|
||||
this.config = config
|
||||
;(
|
||||
jQuery
|
||||
.get('/teams')
|
||||
.done(teams => {
|
||||
this.teams = teams.sort()
|
||||
this.team = this.teams[0]
|
||||
|
||||
this.read_write = (
|
||||
this.config
|
||||
&& (
|
||||
!this.config.read_only_mode
|
||||
|| this.teams.includes(this.config.superuser_team)
|
||||
)
|
||||
)
|
||||
|
||||
nav('/backups', 'BACKUPS')
|
||||
nav('/edit/*/*', 'EDIT', ['namespace', 'clustername'])
|
||||
nav('/list', 'LIST')
|
||||
nav('/logs/*/*', 'LOGS', ['namespace', 'clustername'])
|
||||
nav('/operator..', 'OPERATOR')
|
||||
nav('/status/*/*', 'STATUS', ['namespace', 'clustername'])
|
||||
|
||||
nav(
|
||||
'/new',
|
||||
'NEW',
|
||||
['backup_name', 'backup_uid', 'backup_timestamp'],
|
||||
() => this.tags['new'].reset_form(),
|
||||
)
|
||||
|
||||
navs(
|
||||
[
|
||||
'/clone/*/*',
|
||||
'/clone/*/*/*',
|
||||
],
|
||||
'NEW',
|
||||
['backup_name', 'backup_uid', 'backup_timestamp']
|
||||
)
|
||||
|
||||
route.start(true)
|
||||
|
||||
if (this.activenav === 'INIT') {
|
||||
route(this.read_write ? '/new' : '/list')
|
||||
}
|
||||
})
|
||||
.fail(() => this.teams = null)
|
||||
.always(() => this.update())
|
||||
)
|
||||
})
|
||||
.fail(() => this.config = null)
|
||||
.always(() => this.update())
|
||||
)
|
||||
@@ -0,0 +1,205 @@
|
||||
edit
|
||||
.container-fluid
|
||||
|
||||
h1.page-header
|
||||
nav(aria-label="breadcrumb")
|
||||
ol.breadcrumb
|
||||
|
||||
li.breadcrumb-item
|
||||
a(href='/#/list')
|
||||
| PostgreSQL clusters
|
||||
|
||||
li.breadcrumb-item(if='{ cluster_path }')
|
||||
a(href='/#/status/{ cluster_path }')
|
||||
| { qname }
|
||||
|
||||
li.breadcrumb-item.active(
|
||||
aria-current='page'
|
||||
if='{ cluster_path }'
|
||||
)
|
||||
a(href='/#/edit/{ cluster_path }')
|
||||
| Edit
|
||||
|
||||
.row(if='{ cluster_path }')
|
||||
|
||||
.col-lg-4
|
||||
h2 Cluster YAML definition
|
||||
div
|
||||
pre
|
||||
code.language-yaml(ref='yamlNice')
|
||||
|
||||
div
|
||||
.col-lg-5
|
||||
|
||||
h3 Edit supported properties
|
||||
textarea.textarea(
|
||||
style='width: 100%; height: 200px; font-family: monospace'
|
||||
ref='changedProperties'
|
||||
onkeyup='{ updateEditable }'
|
||||
onchange='{ updateEditable }'
|
||||
)
|
||||
| { editablePropertiesText }
|
||||
|
||||
h3 Preview changes to spec
|
||||
pre
|
||||
code.language-yaml(ref='yamlEditable')
|
||||
| { editablePropertiesPreview }
|
||||
|
||||
button.btn.btn-success(
|
||||
if='{ opts.read_write }'
|
||||
value='Save changes'
|
||||
onclick='{ saveChanges }'
|
||||
)
|
||||
| Apply changes
|
||||
|
||||
.alert.alert-danger(if='{ saveResult === false }')
|
||||
b Error
|
||||
|
|
||||
| { saveMessage }
|
||||
|
||||
.alert.alert-success(if='{ saveResult === true }')
|
||||
b Changes applied
|
||||
|
|
||||
| updates to cluster pending
|
||||
|
||||
.col-lg-3
|
||||
help-edit(config='{ opts.config }')
|
||||
|
||||
script.
|
||||
|
||||
const yamlParser = require('js-yaml')
|
||||
|
||||
this.updateEditable = e => {
|
||||
if (this.refs.changedProperties.value) {
|
||||
this.editablePropertiesPreview = yamlParser.safeDump(
|
||||
yamlParser.safeLoad(
|
||||
this.refs.changedProperties.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.saveChanges = e => {
|
||||
this.saving = true
|
||||
this.saveResult = null
|
||||
this.saveMessage = ''
|
||||
|
||||
jsonPayload = JSON.stringify(
|
||||
yamlParser.safeLoad(
|
||||
this.refs.changedProperties.value,
|
||||
),
|
||||
)
|
||||
|
||||
jQuery.ajax({
|
||||
type: 'POST',
|
||||
url: '/postgresqls/' + this.cluster_path,
|
||||
contentType:"application/json",
|
||||
data: jsonPayload,
|
||||
processData: false,
|
||||
success: ()=> {
|
||||
this.saveResult = true
|
||||
this.update()
|
||||
this.pollProgress()
|
||||
},
|
||||
error: (r, status, error) => {
|
||||
this.saveResult = false
|
||||
this.saveMessage = r.responseJSON.error
|
||||
this.update()
|
||||
this.pollProgress()
|
||||
},
|
||||
dataType: "json",
|
||||
})
|
||||
}
|
||||
|
||||
this.pollProgress = () => {
|
||||
jQuery.get(
|
||||
'/postgresqls/' + this.cluster_path,
|
||||
).then(data => {
|
||||
|
||||
// Input data:
|
||||
const i = {}
|
||||
this.progress.thirdParty = true
|
||||
i.postgresql = this.progress.thirdPartySpec = data
|
||||
i.metadata = i.postgresql.metadata
|
||||
i.spec = i.postgresql.spec
|
||||
|
||||
if (i.metadata.selfLink) { delete i.metadata.selfLink }
|
||||
if (i.metadata.uid) { delete i.metadata.uid }
|
||||
if (i.metadata.resourceVersion) { delete i.metadata.resourceVersion }
|
||||
|
||||
this.update()
|
||||
this.refs.yamlNice.innerHTML = yamlParser.safeDump(i.postgresql, {sortKeys: true})
|
||||
|
||||
// Output data:
|
||||
const o = this.editableProperties = { spec: {} }
|
||||
|
||||
o.spec.allowedSourceRanges = i.spec.allowedSourceRanges || []
|
||||
o.spec.numberOfInstances = i.spec.numberOfInstances
|
||||
o.spec.enableMasterLoadBalancer = i.spec.enableMasterLoadBalancer || false
|
||||
o.spec.enableReplicaLoadBalancer = i.spec.enableReplicaLoadBalancer || false
|
||||
o.spec.volume = { size: i.spec.volume.size }
|
||||
|
||||
if ('users' in i.spec && typeof i.spec.users === 'object') {
|
||||
o.spec.users = Object.mapValues(i.spec.users, roleFlags =>
|
||||
!Array.isArray(roleFlags)
|
||||
? []
|
||||
: roleFlags.filter(roleFlag => typeof roleFlag === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
if ('databases' in i.spec && typeof i.spec.databases === 'object') {
|
||||
o.spec.databases = Object.filterValues(i.spec.databases, owner =>
|
||||
typeof owner === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
if ('resources' in i.spec && typeof i.spec.resources === 'object') {
|
||||
o.spec.resources = {};
|
||||
['limits', 'requests'].forEach(section => {
|
||||
const resources = i.spec.resources[section]
|
||||
o.spec.resources[section] = {}
|
||||
if (typeof resources === 'object') {
|
||||
[
|
||||
'cpu',
|
||||
'memory',
|
||||
].forEach(resourceType => {
|
||||
if (resourceType in resources) {
|
||||
const resourceClaim = resources[resourceType]
|
||||
if (typeof resourceClaim === '') {
|
||||
o.spec.resources[section][resourceType] = resources[resourceType]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.editablePropertiesText = (
|
||||
yamlParser
|
||||
.safeDump(this.editableProperties)
|
||||
.slice(0, -1)
|
||||
)
|
||||
this.editablePropertiesPreview = this.editablePropertiesText
|
||||
|
||||
this.update()
|
||||
})
|
||||
}
|
||||
|
||||
this.on('mount', () => {
|
||||
const namespace = this.namespace = this.opts.namespace
|
||||
const clustername = this.clustername = this.opts.clustername
|
||||
const qname = this.qname = namespace + '/' + clustername
|
||||
const cluster_path = this.cluster_path = (
|
||||
encodeURI(namespace)
|
||||
+ '/' + encodeURI(clustername)
|
||||
)
|
||||
this.progress = {
|
||||
requestStatus: 'OK',
|
||||
}
|
||||
this.pollProgress()
|
||||
})
|
||||
|
||||
this.on('updated', () => {
|
||||
this.refs.yamlEditable.innerHTML = this.editablePropertiesPreview
|
||||
Prism.highlightAll()
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
help-edit
|
||||
|
||||
h2 Help
|
||||
|
||||
.well
|
||||
|
||||
h3(style='margin-top: 0')
|
||||
| Docs
|
||||
|
||||
a(href="{ opts.config.docs_link }")
|
||||
| more...
|
||||
|
||||
h3 Editing
|
||||
|
||||
p.
|
||||
The text box shows you the properties that can currently edit. Verify that the preview shows a valid part of the spec before submitting. After a successful submit the changes may take some time to be applied.
|
||||
|
||||
h3 Volume size
|
||||
|
||||
p.
|
||||
You need to specify in format "123Gi". You can only increase the volume size. You can only increase volume size once very 6 hours, per AWS limitation.
|
||||
|
||||
virtual(
|
||||
if='{ opts.config.static_network_whitelist && Object.keys(opts.config.static_network_whitelist).length > 0 }'
|
||||
)
|
||||
h3 IP Ranges
|
||||
|
||||
// Raw tags are required here, as otherwise either riotjs removes space it shouldn't, or pugjs adds space it shouldn't. And it has to be all in one line, as it has to be a pre tag (otherwise the riotjs compiler breaks the whitespace).
|
||||
<pre><virtual each="{ network, network_index in Object.keys(opts.config.static_network_whitelist) }"><virtual if="{ network_index > 0 }"><br></virtual> # { network }<br><virtual each="{ range, range_index in opts.config.static_network_whitelist[network] }"> - { range }<virtual if="index < network.length - 1"><br></virtual></virtual></virtual></pre>
|
||||
@@ -0,0 +1,18 @@
|
||||
help-general
|
||||
|
||||
h2 Help
|
||||
|
||||
.well
|
||||
|
||||
h3(style='margin-top: 0')
|
||||
| Docs
|
||||
|
||||
a(href="{ opts.config.docs_link }")
|
||||
| more...
|
||||
|
||||
h3 Basics
|
||||
|
||||
p.
|
||||
The PostgreSQL operator will use your definition to create a new
|
||||
PostgreSQL cluster for you. You can either copy the yaml definition
|
||||
to the repositiory or you can just hit create cluster.
|
||||
@@ -0,0 +1,81 @@
|
||||
logs
|
||||
|
||||
h1.page-header(if='{ cluster_path }')
|
||||
nav(aria-label="breadcrumb")
|
||||
ol.breadcrumb
|
||||
|
||||
li.breadcrumb-item
|
||||
a(href='/#/list')
|
||||
| PostgreSQL clusters
|
||||
|
||||
li.breadcrumb-item
|
||||
a(href='/#/status/{ cluster_path }')
|
||||
| { qname }
|
||||
|
||||
li.breadcrumb-item
|
||||
a(href='/#/logs/{ cluster_path }')
|
||||
| Logs
|
||||
|
||||
.sk-spinner-pulse(if='{ logs === undefined }')
|
||||
|
||||
.container-fluid(if='{ logs === null }')
|
||||
p
|
||||
| Error loading logs. Please
|
||||
|
|
||||
a(onclick="window.location.reload(true)") try again
|
||||
|
|
||||
| or
|
||||
|
|
||||
a(href="/") start over
|
||||
| .
|
||||
|
||||
.container-fluid(if='{ logs }')
|
||||
|
||||
table.table.table-hover
|
||||
|
||||
tr(each='{ logs }')
|
||||
|
||||
td(each='{ [levels[Level]] }')
|
||||
span.label.label-font-size(class='label-{ color_class }')
|
||||
| { label }
|
||||
|
||||
td(style='white-space: pre')
|
||||
| { Time }
|
||||
|
||||
td(style='font-family: monospace')
|
||||
| { Message }
|
||||
|
||||
script.
|
||||
|
||||
this.levels = {
|
||||
"panic": { label: "Panic" , color_class: "danger" },
|
||||
"fatal": { label: "Fatal" , color_class: "danger" },
|
||||
"error": { label: "Error" , color_class: "danger" },
|
||||
"warning": { label: "Warning", color_class: "warning" },
|
||||
"info": { label: "Info" , color_class: "primary" },
|
||||
"debug": { label: "Debug" , color_class: "warning" },
|
||||
}
|
||||
|
||||
this.logs = undefined
|
||||
|
||||
this.on('mount', () => {
|
||||
if (
|
||||
this.namespace !== this.opts.namespace
|
||||
|| this.clustername !== this.opts.clustername
|
||||
) {
|
||||
const namespace = this.namespace = this.opts.namespace
|
||||
const clustername = this.clustername = this.opts.clustername
|
||||
const qname = this.qname = namespace + '/' + clustername
|
||||
const cluster_path = this.cluster_path = (
|
||||
encodeURI(namespace)
|
||||
+ '/' + encodeURI(clustername)
|
||||
)
|
||||
;(
|
||||
jQuery
|
||||
.get(`/operator/clusters/${cluster_path}/logs`)
|
||||
.done(logs => this.logs = logs.reverse())
|
||||
.fail(() => this.logs = null)
|
||||
.always(() => this.update())
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,937 @@
|
||||
new
|
||||
|
||||
style.
|
||||
|
||||
.input-units {
|
||||
width: 2em;
|
||||
}
|
||||
|
||||
.resource-type {
|
||||
text-align: left;
|
||||
width: 7em;
|
||||
}
|
||||
|
||||
.container-fluid
|
||||
|
||||
h1.page-header
|
||||
nav(aria-label='breadcrumb')
|
||||
ol.breadcrumb
|
||||
|
||||
li.breadcrumb-item
|
||||
a(href='/#/new')
|
||||
| New PostgreSQL cluster
|
||||
|
||||
.row.text-center(if='{ !creating }')
|
||||
|
||||
.row
|
||||
|
||||
.col-lg-3
|
||||
h2 Cluster YAML definition
|
||||
div
|
||||
pre
|
||||
code.language-yaml(ref='yamlNice')
|
||||
|
||||
.col-lg-6
|
||||
|
||||
form(
|
||||
if='{ !creating }'
|
||||
action='javascript:void(0);'
|
||||
id='form'
|
||||
)
|
||||
|
||||
.btn-toolbar.pull-right
|
||||
.btn-group(
|
||||
aria-label='Actions'
|
||||
role='group'
|
||||
)
|
||||
|
||||
input.btn.btn-primary(
|
||||
type='submit'
|
||||
form='form'
|
||||
value='Validate'
|
||||
)
|
||||
|
||||
button.btn.btn-info.btn-copy
|
||||
| Copy definiton
|
||||
|
||||
button.btn.btn-success(
|
||||
if='{ !clusterExists && parent.read_write }'
|
||||
ref='submitbutton'
|
||||
onclick='{ requestCreate }'
|
||||
disabled='{ !allValid() }'
|
||||
)
|
||||
| Create cluster
|
||||
|
||||
a.btn.btn-small.btn-warning(
|
||||
if='{ clusterExists }'
|
||||
href='/#/status/{ namespace.state }/{ team }-{ name }'
|
||||
)
|
||||
| Cluster exists (show status)
|
||||
|
||||
h2 New cluster configuration
|
||||
|
||||
table.table
|
||||
|
||||
tr(
|
||||
each='{ backup.state.type.state === "empty" ? [] : [backup] }'
|
||||
)
|
||||
td(colspan='2')
|
||||
h3.text-center Source data
|
||||
|
||||
tr(
|
||||
each='{ backup.state.type.state === "empty" ? [] : [backup] }'
|
||||
)
|
||||
td Backup name
|
||||
td
|
||||
input.form-control(
|
||||
each='{ !["restore", "pitr"].includes(state.type.state) ? [] : [state.name] }'
|
||||
ref='backup_name'
|
||||
type='text'
|
||||
placeholder='Source backup name'
|
||||
required
|
||||
value='{ state }'
|
||||
onchange='{ edit }'
|
||||
onkeyup='{ edit }'
|
||||
)
|
||||
|
||||
tr(
|
||||
each='{ backup.state.type.state === "empty" ? [] : [backup] }'
|
||||
)
|
||||
td Backup UID
|
||||
td
|
||||
input.form-control(
|
||||
each='{ !["restore", "pitr"].includes(state.type.state) ? [] : [state.uid] }'
|
||||
ref='backup_uid'
|
||||
type='text'
|
||||
placeholder='Source backup ID'
|
||||
value='{ state }'
|
||||
onchange='{ edit }'
|
||||
onkeyup='{ edit }'
|
||||
)
|
||||
|
||||
tr(
|
||||
each='{ backup.state.type.state === "empty" ? [] : [backup] }'
|
||||
)
|
||||
td Target timestamp
|
||||
td
|
||||
input.timestamp.form-control(
|
||||
each='{ !["pitr"].includes(state.type.state) ? [] : [state.timestamp] }'
|
||||
ref='backup_timestamp'
|
||||
type='text'
|
||||
placeholder='Restore to state at timestamp'
|
||||
value='{ state }'
|
||||
onchange='{ edit }'
|
||||
onkeyup='{ edit }'
|
||||
)
|
||||
|
||||
tr(
|
||||
each='{ backup.state.type.state === "empty" ? [] : [backup] }'
|
||||
)
|
||||
td(colspan='2')
|
||||
h3.text-center New cluster settings
|
||||
|
||||
tr
|
||||
td
|
||||
| Name
|
||||
td
|
||||
input.form-control(
|
||||
ref='name'
|
||||
type='text'
|
||||
placeholder='new-cluster (can be { 53 - team.length - 1 } characters long)'
|
||||
title='Database cluster name, must be a valid hostname component'
|
||||
pattern='[a-z0-9]+[a-z0-9\-]+[a-z0-9]+'
|
||||
maxlength='{ 53 - team.length - 1 }'
|
||||
required
|
||||
value='{ name }'
|
||||
onchange='{ nameChange }'
|
||||
onkeyup='{ nameChange }'
|
||||
style='width: 100%'
|
||||
)
|
||||
|
||||
tr(
|
||||
each='{ !["", "*"].includes(config.target_namespace) ? [] : [namespace] }'
|
||||
)
|
||||
td
|
||||
| Namespace
|
||||
td
|
||||
select.form-control(
|
||||
ref='namespace'
|
||||
title='Database cluster Kubernetes namespace'
|
||||
onchange='{ edit }'
|
||||
onkeyup='{ edit }'
|
||||
style='width: 100%'
|
||||
)
|
||||
option(
|
||||
each='{ namespace in parent.config.namespaces }'
|
||||
selected='{ state === namespace }'
|
||||
value='{ namespace }'
|
||||
)
|
||||
| { namespace }
|
||||
|
||||
tr
|
||||
td Owning team
|
||||
td
|
||||
select.form-control(
|
||||
name='team'
|
||||
onchange='{ teamChange }'
|
||||
onkeyup='{ teamChange }'
|
||||
)
|
||||
option(
|
||||
each='{ team in teams }'
|
||||
value='{ team }'
|
||||
)
|
||||
| { team }
|
||||
|
||||
tr
|
||||
td PostgreSQL version
|
||||
td
|
||||
select.form-control(
|
||||
name='postgresqlVersion'
|
||||
onchange='{ versionChange }'
|
||||
onkeyup='{ versionChange }'
|
||||
)
|
||||
option(
|
||||
each='{ version in opts.config.postgresql_versions }'
|
||||
value='{ version }'
|
||||
)
|
||||
| { version }
|
||||
|
||||
tr
|
||||
td DNS name:
|
||||
td
|
||||
| { dnsName }
|
||||
|
||||
tr
|
||||
td Number of instances
|
||||
td
|
||||
input.form-control(
|
||||
ref='instanceCount'
|
||||
type='number'
|
||||
min='1'
|
||||
required
|
||||
value='{ instanceCount }'
|
||||
onchange='{ instanceCountChange }'
|
||||
onkeyup='{ instanceCountChange }'
|
||||
style='width: 100%'
|
||||
)
|
||||
|
||||
tr(if='{ [undefined, true].includes(config.master_load_balancer_visible) }')
|
||||
td Master load balancer
|
||||
td
|
||||
label
|
||||
input(
|
||||
type='checkbox'
|
||||
value='{ enableMasterLoadBalancer }'
|
||||
onchange='{ toggleEnableMasterLoadBalancer }'
|
||||
)
|
||||
|
|
||||
| Enable master ELB
|
||||
|
||||
tr(if='{ [undefined, true].includes(config.replica_load_balancer_visible) }')
|
||||
td Replica load balancer
|
||||
td
|
||||
label
|
||||
input(
|
||||
type='checkbox'
|
||||
value='{ enableReplicaLoadBalancer }'
|
||||
onchange='{ toggleEnableReplicaLoadBalancer }'
|
||||
)
|
||||
|
|
||||
| Enable replica ELB
|
||||
|
||||
tr
|
||||
td Volume size
|
||||
td
|
||||
.input-group
|
||||
input.form-control(
|
||||
ref='volumeSize'
|
||||
type='number'
|
||||
min='1'
|
||||
required
|
||||
value='{ volumeSize }'
|
||||
onchange='{ volumeChange }'
|
||||
onkeyup='{ volumeChange }'
|
||||
)
|
||||
.input-group-addon
|
||||
.input-units Gi
|
||||
|
||||
tr(if='{ config.users_visible }')
|
||||
td
|
||||
button.btn.btn-success.btn-xs(onclick='{ users.add }')
|
||||
span.glyphicon.glyphicon-plus
|
||||
|
|
||||
| Users
|
||||
td
|
||||
.input-group(each='{ users.state }')
|
||||
.input-group-btn
|
||||
button.btn.btn-danger(onclick='{ users.remove }')
|
||||
span.glyphicon.glyphicon-trash
|
||||
input.username.form-control(
|
||||
type='text'
|
||||
placeholder='Username'
|
||||
title='^[a-z0-9]([-_a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-_a-z0-9]*[a-z0-9])?)*$'
|
||||
pattern='^[a-z0-9]([-_a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-_a-z0-9]*[a-z0-9])?)*$'
|
||||
required
|
||||
value='{ state }'
|
||||
onchange='{ edit }'
|
||||
onkeyup='{ edit }'
|
||||
style='width: 100%'
|
||||
)
|
||||
|
||||
tr(if='{ config.databases_visible }')
|
||||
td
|
||||
button.btn.btn-success.btn-xs(onclick='{ databases.add }')
|
||||
span.glyphicon.glyphicon-plus
|
||||
|
|
||||
| Databases
|
||||
td
|
||||
.input-group(each='{ databases.state }')
|
||||
.input-group-btn
|
||||
button.btn.btn-danger(
|
||||
onclick='{ databases.remove }'
|
||||
style='float: right'
|
||||
)
|
||||
span.glyphicon.glyphicon-trash
|
||||
input.databasename.form-control(
|
||||
type='text'
|
||||
placeholder='Database name'
|
||||
title='Alphanumerics or underscore; may not start on a digit'
|
||||
pattern='^[a-zA-Z_][a-zA-Z0-9_]*$'
|
||||
required
|
||||
value='{ state }'
|
||||
onchange='{ edit }'
|
||||
onkeyup='{ edit }'
|
||||
style='width: 50%'
|
||||
)
|
||||
select.owner.form-control(
|
||||
onchange='{ owner.edit }'
|
||||
onkeyup='{ owner.edit }'
|
||||
disabled='{ users.valids.length === 0 }'
|
||||
style='width: 50%'
|
||||
)
|
||||
option(
|
||||
selected='{ owner.state.length === 0 }'
|
||||
value=''
|
||||
disabled
|
||||
hidden
|
||||
)
|
||||
| Select owner…
|
||||
option(
|
||||
each='{ users.valids }'
|
||||
selected='{ state === parent.owner.state }'
|
||||
value='{ state }'
|
||||
)
|
||||
| { state }
|
||||
|
||||
tr(if='{ !jQuery.isEmptyObject(config.static_network_whitelist) }')
|
||||
td Allowed IP ranges
|
||||
td
|
||||
ul.ips
|
||||
li(each='{ value, name in config.static_network_whitelist }')
|
||||
label
|
||||
input(
|
||||
type='checkbox'
|
||||
value='{ name }'
|
||||
onchange='{ toggleIPRange }'
|
||||
checked='{ name in ranges }'
|
||||
)
|
||||
|
|
||||
| { name }
|
||||
|
||||
tr(if='{ config.nat_gateways_visible }')
|
||||
td
|
||||
button.btn.btn-success.btn-xs(onclick='{ nats.add }')
|
||||
span.glyphicon.glyphicon-plus
|
||||
|
|
||||
| AWS NAT IPs
|
||||
td
|
||||
div(
|
||||
each='{ nats.state }'
|
||||
)
|
||||
.input-group
|
||||
.input-group-btn
|
||||
button.btn.btn-danger(onclick='{ nats.remove }')
|
||||
span.glyphicon.glyphicon-trash
|
||||
input.form-control(
|
||||
type='text'
|
||||
required
|
||||
value='{ state }'
|
||||
onchange='{ edit }'
|
||||
onkeyup='{ edit }'
|
||||
)
|
||||
.input-group-addon
|
||||
.input-units / 32
|
||||
|
||||
tr(if='{ config.odd_host_visible }')
|
||||
td Odd host
|
||||
td
|
||||
.input-group
|
||||
input.form-control(
|
||||
ref='odd'
|
||||
type='text'
|
||||
name='odd'
|
||||
placeholder='IP'
|
||||
onchange='{ oddChanges }'
|
||||
onkeyup='{ oddChanges }'
|
||||
value='{ odd }'
|
||||
)
|
||||
.input-group-addon
|
||||
.input-units / 32
|
||||
|
||||
tr(if='{ config.resources_visible }')
|
||||
td Resources
|
||||
td
|
||||
table(width='100%')
|
||||
|
||||
tr
|
||||
td CPU
|
||||
td
|
||||
|
||||
.input-group
|
||||
.input-group-addon.resource-type Request
|
||||
input.form-control(
|
||||
ref='cpuRequest'
|
||||
type='number'
|
||||
placeholder='{ cpu.state.request.initialValue }'
|
||||
min='1'
|
||||
required
|
||||
value='{ cpu.state.request.state }'
|
||||
onchange='{ cpu.state.request.edit }'
|
||||
onkeyup='{ cpu.state.request.edit }'
|
||||
)
|
||||
.input-group-addon
|
||||
.input-units m
|
||||
|
||||
.input-group
|
||||
.input-group-addon.resource-type Limit
|
||||
input.form-control(
|
||||
ref='cpuLimit'
|
||||
type='number'
|
||||
placeholder='{ cpu.state.limit.initialValue }'
|
||||
min='1'
|
||||
required
|
||||
value='{ cpu.state.limit.state }'
|
||||
onchange='{ cpu.state.limit.edit }'
|
||||
onkeyup='{ cpu.state.limit.edit }'
|
||||
)
|
||||
.input-group-addon
|
||||
.input-units m
|
||||
|
||||
tr
|
||||
td Memory
|
||||
td
|
||||
|
||||
.input-group
|
||||
.input-group-addon.resource-type Request
|
||||
input.form-control(
|
||||
ref='memoryRequest'
|
||||
type='number'
|
||||
placeholder='{ memory.state.request.initialValue }'
|
||||
min='1'
|
||||
required
|
||||
value='{ memory.state.request.state }'
|
||||
onchange='{ memory.state.request.edit }'
|
||||
onkeyup='{ memory.state.request.edit }'
|
||||
)
|
||||
.input-group-addon
|
||||
.input-units Gi
|
||||
|
||||
.input-group
|
||||
.input-group-addon.resource-type Limit
|
||||
input.form-control(
|
||||
ref='memoryLimit'
|
||||
type='number'
|
||||
placeholder='{ memory.state.limit.initialValue }'
|
||||
min='1'
|
||||
required
|
||||
value='{ memory.state.limit.state }'
|
||||
onchange='{ memory.state.limit.edit }'
|
||||
onkeyup='{ memory.state.limit.edit }'
|
||||
)
|
||||
.input-group-addon
|
||||
.input-units Gi
|
||||
|
||||
.col-lg-3
|
||||
help-general(config='{ opts.config }')
|
||||
|
||||
script.
|
||||
|
||||
// Pass a refresh callback for this tag to the options constructor argument
|
||||
// used for all Dynamic objects built in this tag:
|
||||
const add_refresh = object => Object.assign(
|
||||
{},
|
||||
object,
|
||||
{ refresh: () => this.update() }
|
||||
)
|
||||
const Dynamic = options => this.opts.opts.Dynamic(add_refresh(options))
|
||||
const Dynamics = options => this.opts.opts.Dynamics(add_refresh(options))
|
||||
const DynamicSet = (items, options) => (
|
||||
this.opts.opts.DynamicSet(items, add_refresh(options))
|
||||
)
|
||||
|
||||
const dedent = require('dedent-js')
|
||||
|
||||
// Dedent twice because pug adds tabs:
|
||||
this.yamlTemplate = dedent`
|
||||
kind: "postgresql"
|
||||
apiVersion: "acid.zalan.do/v1"
|
||||
|
||||
metadata:
|
||||
name: "{{ team }}-{{ name }}"
|
||||
namespace: "{{ namespace.state }}"
|
||||
labels:
|
||||
team: {{ team }}
|
||||
|
||||
spec:
|
||||
teamId: "{{ team }}"
|
||||
postgresql:
|
||||
version: "{{ postgresqlVersion }}"
|
||||
numberOfInstances: {{ instanceCount }}
|
||||
{{#if enableMasterLoadBalancer}}
|
||||
enableMasterLoadBalancer: true
|
||||
{{/if}}
|
||||
{{#if enableReplicaLoadBalancer}}
|
||||
enableReplicaLoadBalancer: true
|
||||
{{/if}}
|
||||
volume:
|
||||
size: "{{ volumeSize }}Gi"
|
||||
{{#if users}}
|
||||
users:{{#each users}}
|
||||
{{ state }}: []{{/each}}{{/if}}
|
||||
{{#if databases}}
|
||||
databases:{{#each databases}}
|
||||
{{ state }}: {{ owner.state }}{{/each}}{{/if}}
|
||||
allowedSourceRanges:
|
||||
# IP ranges to access your cluster go here
|
||||
{{#each ranges}} # {{ @key }}
|
||||
{{#each this }}
|
||||
- {{ this }}
|
||||
{{/each}}
|
||||
{{/each}}{{#if nats}}
|
||||
# NAT instances
|
||||
{{#each nats}}
|
||||
- {{ state }}/32
|
||||
{{/each}}{{/if}}{{#if odd}}
|
||||
# Your odd host IP
|
||||
- {{ odd }}/32
|
||||
{{/if}}
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: {{ cpu.state.request.state }}m
|
||||
memory: {{ memory.state.request.state }}Gi
|
||||
limits:
|
||||
cpu: {{ cpu.state.limit.state }}m
|
||||
memory: {{ memory.state.limit.state }}Gi{{#if restoring}}
|
||||
|
||||
clone:
|
||||
cluster: "{{ backup.state.name.state }}"
|
||||
uid: "{{ backup.state.uid.state }}"{{#if pitr}}
|
||||
timestamp: "{{ backup.state.timestamp.state }}"{{/if}}{{/if}}
|
||||
`
|
||||
|
||||
const yamlParser = require('js-yaml')
|
||||
this.teams = this.opts.teams
|
||||
this.config = this.opts.config
|
||||
|
||||
this.getContext = () => {
|
||||
return {
|
||||
name: this.name.toLowerCase(),
|
||||
team: this.team.toLowerCase(),
|
||||
postgresqlVersion: this.postgresqlVersion,
|
||||
instanceCount: this.instanceCount,
|
||||
enableMasterLoadBalancer: this.enableMasterLoadBalancer,
|
||||
enableReplicaLoadBalancer: this.enableReplicaLoadBalancer,
|
||||
volumeSize: this.volumeSize,
|
||||
users: this.users.valids,
|
||||
databases: this.databases.valids,
|
||||
ranges: this.ranges,
|
||||
nats: this.nats.valids,
|
||||
odd: this.odd,
|
||||
cpu: this.cpu,
|
||||
memory: this.memory,
|
||||
backup: this.backup,
|
||||
namespace: this.namespace,
|
||||
restoring: this.backup.state.type.state !== 'empty',
|
||||
pitr: this.backup.state.type.state === 'pitr',
|
||||
}
|
||||
}
|
||||
|
||||
this.getYAML = () => {
|
||||
yaml = Handlebars.compile(this.yamlTemplate)
|
||||
yaml = yaml(this.getContext())
|
||||
return yaml
|
||||
}
|
||||
|
||||
this.on('update', () => {
|
||||
this.teams = this.opts.teams
|
||||
this.config = this.opts.config
|
||||
|
||||
yaml = Handlebars.compile(this.yamlTemplate)
|
||||
yaml = yaml(this.getContext())
|
||||
|
||||
this.refs.yamlNice.innerHTML = yaml
|
||||
|
||||
Prism.highlightAll()
|
||||
})
|
||||
|
||||
this.oddChanges = e => {
|
||||
this.odd = e.target.value
|
||||
}
|
||||
|
||||
this.ranges = {}
|
||||
|
||||
this.toggleIPRange = e => {
|
||||
if (e.target.checked) {
|
||||
this.ranges[e.target.value] = this.config.static_network_whitelist[e.target.value]
|
||||
} else {
|
||||
delete this.ranges[e.target.value]
|
||||
}
|
||||
this.update()
|
||||
}
|
||||
|
||||
this.toggleEnableMasterLoadBalancer = e => {
|
||||
this.enableMasterLoadBalancer = !this.enableMasterLoadBalancer
|
||||
}
|
||||
|
||||
this.toggleEnableReplicaLoadBalancer = e => {
|
||||
this.enableReplicaLoadBalancer = !this.enableReplicaLoadBalancer
|
||||
}
|
||||
|
||||
this.volumeChange = e => {
|
||||
this.volumeSize = +e.target.value
|
||||
}
|
||||
|
||||
this.updateDNSName = () => {
|
||||
this.dnsName = this.config.dns_format_string.format(
|
||||
this.name,
|
||||
this.team,
|
||||
this.namespace.state,
|
||||
)
|
||||
}
|
||||
|
||||
this.updateClusterName = () => {
|
||||
this.clusterName = (this.team + '-' + this.name).toLowerCase()
|
||||
this.checkClusterExists()
|
||||
this.updateDNSName()
|
||||
}
|
||||
|
||||
this.nameChange = e => {
|
||||
this.name = e.target.value.toLowerCase()
|
||||
this.updateClusterName()
|
||||
}
|
||||
|
||||
this.teamChange = e => {
|
||||
this.team = e.target.value.toLowerCase()
|
||||
this.updateClusterName()
|
||||
}
|
||||
|
||||
this.instanceCountChange = e => {
|
||||
this.instanceCount = +e.target.value
|
||||
}
|
||||
|
||||
this.checkClusterExists = () => (
|
||||
jQuery
|
||||
.get(
|
||||
'/postgresqls/'
|
||||
+ this.namespace.state
|
||||
+ '/'
|
||||
+ this.clusterName
|
||||
)
|
||||
.done(() => this.clusterExists = true)
|
||||
.fail(() => this.clusterExists = false)
|
||||
.always(() => this.update())
|
||||
)
|
||||
|
||||
this.versionChange = e => {
|
||||
this.postgresqlVersion = e.target.value
|
||||
}
|
||||
|
||||
this.requestCreate = e => {
|
||||
jsonPayload = JSON.stringify(yamlParser.safeLoad(this.getYAML()))
|
||||
|
||||
this.creating = true
|
||||
this.update()
|
||||
|
||||
jQuery.ajax({
|
||||
type: 'POST',
|
||||
url: '/create-cluster',
|
||||
contentType:'application/json',
|
||||
data: jsonPayload,
|
||||
processData: false,
|
||||
success: () => {
|
||||
route(
|
||||
'/status/'
|
||||
+ encodeURI(this.namespace.state)
|
||||
+ '/'
|
||||
+ encodeURI(this.clusterName)
|
||||
)
|
||||
},
|
||||
error: (r, status, error) => {
|
||||
console.log('Create request failed')
|
||||
},
|
||||
dataType: 'json'
|
||||
})
|
||||
}
|
||||
|
||||
const clipboard = new Clipboard('.btn-copy', {
|
||||
text: () => { return this.getYAML() }
|
||||
})
|
||||
|
||||
this.namespace = Dynamic({
|
||||
init: () => (
|
||||
!this.config.target_namespace
|
||||
|| ['', '*'].includes(this.config.target_namespace)
|
||||
? 'default'
|
||||
: this.config.target_namespace
|
||||
),
|
||||
})
|
||||
;{
|
||||
const update_namespace = this.namespace.update
|
||||
this.namespace.update = value => {
|
||||
update_namespace(value)
|
||||
this.updateClusterName()
|
||||
this.update()
|
||||
}
|
||||
}
|
||||
|
||||
this.nats = Dynamics()
|
||||
|
||||
this.users = Dynamics()
|
||||
this.users.itemInit = () => Dynamic({
|
||||
update: (value, instance, event) => {
|
||||
if (value === '' || instance.state.length > 0) {
|
||||
this.databases.state.forEach(database => {
|
||||
if (database.owner.state === instance.state) {
|
||||
database.owner.update(value, database.owner, event)
|
||||
}
|
||||
})
|
||||
}
|
||||
instance.state = value
|
||||
this.update()
|
||||
// Note: there is a bug here somewhere.
|
||||
//
|
||||
// To reproduce it:
|
||||
// 1. Add user user1
|
||||
// 2. Add user user2
|
||||
// 3. Add user user22
|
||||
// 4. Add database 1
|
||||
// 5. Add database 2
|
||||
// 6. Set database 1 owner to user2
|
||||
// 7. Set database 2 owner to user22
|
||||
// 8. Type another 2 at the end of the username for user2, so that it becomes a repeated copy of user22. It should automatically set the database 1 owner to user22.
|
||||
// 9. Delete the character you just typed, so that the first user22, which used to be user2 before the last step, becomes user2 again.
|
||||
//
|
||||
// In principle, that last step should set the owners for both database 1 and database 2 to user2, as both databases had owner user22 which was edited. Instead, the owner for database 1 becomes user1 (WTF???) and the owner for database 2 becomes user2 (which is correct). Note that the tag states are updated correctly, and the HTML shown in the Chrome element inspector has the selected attribute in the correct option elements — yet the wrong owner option is selected in the rendered select control on the page for the owner of database 1.
|
||||
//
|
||||
// Solution attempted that failed because riotjs does weird things with the DOM:
|
||||
//
|
||||
// $('select.owner', this.root).each(select => {
|
||||
// var options = $(select).children()
|
||||
// for (var i = 0; i < options.length; i++) {
|
||||
// const option = options[i]
|
||||
// const selected = $(option).attr('selected')
|
||||
// if (selected === 'true' || selected === 'selected') {
|
||||
// select.selectedIndex = i
|
||||
// child.selected = true
|
||||
// } else {
|
||||
// child.selected = false
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// Hours wasted on this stupid bug: 3
|
||||
// Please keep this counter updated.
|
||||
}
|
||||
})
|
||||
|
||||
{
|
||||
const baseRemoveUser = this.users.remove
|
||||
this.users.remove = event => {
|
||||
this.databases.state.forEach(database => {
|
||||
if (database.owner.state === event.item.state) {
|
||||
database.owner.update('', database.owner, event)
|
||||
}
|
||||
})
|
||||
baseRemoveUser(event)
|
||||
this.update()
|
||||
}
|
||||
}
|
||||
|
||||
this.databases = Dynamics({
|
||||
itemInit: () => {
|
||||
const item = Dynamic()
|
||||
const baseItemUpdate = item.update
|
||||
item.update = (value, instance, event) => {
|
||||
this.check()
|
||||
baseItemUpdate(value, instance, event)
|
||||
}
|
||||
item.owner = Dynamic({
|
||||
validState: username => (
|
||||
this.users.valids.find(user => user.state === username) !== undefined
|
||||
)
|
||||
})
|
||||
return item
|
||||
},
|
||||
itemValid: item => item.valid() && item.owner.valid(),
|
||||
})
|
||||
|
||||
const DynamicResource = options => {
|
||||
const instance = DynamicSet({
|
||||
request: () => options.request,
|
||||
limit: () => options.limit,
|
||||
})
|
||||
instance.state.request.initialValue = options.request
|
||||
instance.state.limit.initialValue = options.limit
|
||||
return instance
|
||||
}
|
||||
|
||||
this.cpu = DynamicResource({ request: 100, limit: 1000 })
|
||||
this.memory = DynamicResource({ request: 1, limit: 1 })
|
||||
|
||||
this.backup = DynamicSet({
|
||||
type: () => 'empty',
|
||||
name: () => '',
|
||||
uid: () => '',
|
||||
timestamp: () => '',
|
||||
})
|
||||
|
||||
const pitr_timestamp_format = 'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
||||
this.backup.state.timestamp.validState = (
|
||||
base => (
|
||||
state => {
|
||||
if (!base(state)) {
|
||||
return false
|
||||
}
|
||||
const parsed = moment.utc(state, pitr_timestamp_format, true)
|
||||
return moment.isMoment(parsed) && !isNaN(parsed.utcOffset())
|
||||
}
|
||||
)
|
||||
)(this.backup.state.timestamp.validState)
|
||||
|
||||
this.allValid = () => (
|
||||
!this.creating &&
|
||||
this.refs.name && this.refs.name.validity.valid &&
|
||||
this.refs.instanceCount && this.refs.instanceCount.validity.valid && Number.isInteger(+this.instanceCount) &&
|
||||
this.refs.volumeSize && this.refs.volumeSize.validity.valid && Number.isInteger(+this.volumeSize) &&
|
||||
this.refs.cpuRequest && this.refs.cpuRequest.validity.valid && Number.isInteger(+this.cpu.state.request.state) &&
|
||||
this.refs.cpuLimit && this.refs.cpuLimit.validity.valid && Number.isInteger(+this.cpu.state.limit.state) &&
|
||||
this.refs.memoryRequest && this.refs.memoryRequest.validity.valid && Number.isInteger(+this.memory.state.request.state) &&
|
||||
this.refs.memoryLimit && this.refs.memoryLimit.validity.valid && Number.isInteger(+this.memory.state.limit.state) &&
|
||||
[
|
||||
this.users,
|
||||
this.databases,
|
||||
this.backup,
|
||||
].every(x => x.valid())
|
||||
)
|
||||
|
||||
this.check = () => {
|
||||
const setValidity = validity => (_, element) => (
|
||||
element.setCustomValidity(
|
||||
validity(element)
|
||||
)
|
||||
)
|
||||
|
||||
$('select.owner', this.root).each(setValidity(owner =>
|
||||
owner.value ? '' : 'Must select an owner for this database'
|
||||
))
|
||||
|
||||
const counts = (values, key=x => x) => values.map(key).reduce(
|
||||
(o, item) => {
|
||||
if (item in o) { o[item] += 1 }
|
||||
else { o[item] = 1 }
|
||||
return o
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
$('input.timestamp', this.root).each(setValidity(timestamp =>
|
||||
this.backup.state.timestamp.valid() ? '' :
|
||||
'Timestamp must be formatted as ISO-8601 with milliseconds; note that a UTC offset as ±HH:mm is required: YYYY-MM-DDTHH:mm:ss.SSSZ e.g. 2018-12-31T23:59:59.999+14:00'
|
||||
))
|
||||
|
||||
const userCounts = counts(this.users.state, item => item.state)
|
||||
$('input.username', this.root).each(setValidity(name =>
|
||||
!name.value || userCounts[name.value] === 1 ? '' :
|
||||
'Usernames must not repeat'
|
||||
))
|
||||
|
||||
const databaseCounts = counts(this.databases.state, item => item.state)
|
||||
$('input.databasename', this.root).each(setValidity(name =>
|
||||
!name.value || databaseCounts[name.value] === 1 ? '' :
|
||||
'Database names must not repeat'
|
||||
))
|
||||
|
||||
if (this.refs.submitbutton) {
|
||||
this.refs.submitbutton.disabled = (
|
||||
$(document.querySelectorAll(':invalid')).length !== 0
|
||||
) || $('select.owner:disabled').length !== 0
|
||||
}
|
||||
}
|
||||
|
||||
this.on('updated', this.check)
|
||||
|
||||
this.reset_form = () => {
|
||||
if (this.pollProgressTimer) {
|
||||
clearInterval(this.pollProgressTimer)
|
||||
}
|
||||
|
||||
this.creating = false
|
||||
this.name = ''
|
||||
|
||||
if (this.teams && this.teams.length > 0) {
|
||||
this.team = this.teams[0]
|
||||
} else {
|
||||
this.team = ''
|
||||
}
|
||||
|
||||
this.clusterName = (this.name + '-' + this.team).toLowerCase()
|
||||
this.volumeSize = 10
|
||||
this.instanceCount = 1
|
||||
this.ranges = {}
|
||||
this.odd = ''
|
||||
this.enableMasterLoadBalancer = false
|
||||
this.enableReplicaLoadBalancer = false
|
||||
|
||||
this.postgresqlVersion = this.postgresqlVersion = (
|
||||
this.config.postgresql_versions[0]
|
||||
)
|
||||
|
||||
this.updateDNSName();
|
||||
|
||||
this.check()
|
||||
|
||||
this.backup.state.type.update(
|
||||
this.opts.backup_name === undefined
|
||||
? 'empty'
|
||||
: this.opts.backup_timestamp === undefined
|
||||
? 'restore'
|
||||
: 'pitr'
|
||||
)
|
||||
|
||||
this.backup.state.name.update(
|
||||
decodeURI(this.opts.backup_name || '')
|
||||
)
|
||||
|
||||
this.backup.state.uid.update(
|
||||
!this.opts.backup_uid || this.opts.backup_uid === 'base'
|
||||
? ''
|
||||
: this.opts.backup_uid
|
||||
)
|
||||
|
||||
this.backup.state.timestamp.update(
|
||||
!this.opts.backup_timestamp
|
||||
? ''
|
||||
: moment.utc(
|
||||
decodeURI(this.opts.backup_timestamp)
|
||||
).format(pitr_timestamp_format)
|
||||
)
|
||||
|
||||
this.opts && this.opts.backup_name && delete this.opts.backup_name
|
||||
this.opts && this.opts.backup_uid && delete this.opts.backup_uid
|
||||
this.opts && this.opts.backup_timestamp && delete this.opts.backup_timestamp
|
||||
|
||||
this.update()
|
||||
}
|
||||
|
||||
this.on('mount', this.reset_form)
|
||||
@@ -0,0 +1,219 @@
|
||||
postgresql
|
||||
.container-fluid
|
||||
|
||||
h1.page-header
|
||||
nav(aria-label='breadcrumb')
|
||||
ol.breadcrumb
|
||||
|
||||
li.breadcrumb-item
|
||||
a(href='/#/list')
|
||||
| PostgreSQL clusters
|
||||
|
||||
li.breadcrumb-item(if='{ cluster_path }')
|
||||
a(href='/#/status/{ cluster_path }')
|
||||
| { qname }
|
||||
|
||||
.row(if='{ cluster_path }')
|
||||
|
||||
.col-lg-3
|
||||
h2 Cluster YAML definition
|
||||
|
||||
div(if='{ progress.postgresql }')
|
||||
<pre><code ref="yamlNice" class="language-yaml"></code></pre>
|
||||
|
||||
pre(if='{ !progress.postgresql }')
|
||||
code # Loading
|
||||
|
||||
virtual(if='{ uid }')
|
||||
h3 Cluster UID
|
||||
code { uid }
|
||||
|
||||
.col-lg-6
|
||||
|
||||
div
|
||||
|
||||
.btn-toolbar.pull-right
|
||||
.btn-group(
|
||||
aria-label='Actions'
|
||||
role='group'
|
||||
)
|
||||
|
||||
a.btn.btn-info(
|
||||
href='/#/logs/{ cluster_path }'
|
||||
)
|
||||
| Logs
|
||||
|
||||
a.btn(
|
||||
class='btn-{ opts.read_write ? "primary" : "info" }'
|
||||
if='{ progress.postgresql }'
|
||||
href='/#/clone/{ clustername }/{ uid }/{ encodeURI(new Date().toISOString()) }'
|
||||
)
|
||||
| Clone
|
||||
|
||||
a.btn(
|
||||
class='btn-{ opts.read_write ? "warning" : "info" }'
|
||||
href='/#/edit/{ cluster_path }'
|
||||
)
|
||||
| Edit
|
||||
|
||||
button.btn.btn-danger(
|
||||
if='{ opts.read_write }'
|
||||
onclick='{ delete_cluster }'
|
||||
)
|
||||
| Delete
|
||||
|
||||
h2 Checking status of cluster
|
||||
|
||||
.progress
|
||||
.progress-bar.progress-bar-success(style='width: 20%' if='{ progress.requestStatus === "OK" || progress.masterLabel }')
|
||||
.progress-bar.progress-bar-success(style='width: 20%' if='{ progress.postgresql }')
|
||||
.progress-bar.progress-bar-success(style='width: 20%' if='{ progress.statefulSet }')
|
||||
.progress-bar.progress-bar-success(style='width: 20%' if='{ progress.containerFirst }')
|
||||
.progress-bar.progress-bar-success(style='width: 20%' if='{ progress.masterLabel }')
|
||||
.progress-bar.progress-bar-info.progress-bar-striped.active(if='{ !progress.masterLabel }' style='width: 10%')
|
||||
|
||||
.alert.alert-info(if='{ !progress.requestStatus }') PostgreSQL cluster requested
|
||||
.alert.alert-danger(if='{ progress.requestStatus !== "OK" }') Create request failed
|
||||
.alert.alert-success(if='{ progress.requestStatus === "OK" }') Create request successful ({ new Date(progress.createdTimestamp).toLocaleString() })
|
||||
|
||||
.alert.alert-info(if='{ !progress.statefulSet }') StatefulSet pending
|
||||
.alert.alert-success(if='{ progress.statefulSet }') StatefulSet created
|
||||
|
||||
.alert.alert-info(if='{ progress.statefulSet && !progress.containerFirst }') Waiting for 1st container to spawn
|
||||
.alert.alert-success(if='{ progress.containerFirst }') First PostgreSQL cluster container spawned
|
||||
|
||||
.alert.alert-info(if='{ !progress.postgresql }') PostgreSQL cluster manifest pending
|
||||
.alert.alert-success(if='{ progress.postgresql }') PostgreSQL cluster manifest created
|
||||
|
||||
.alert.alert-info(if='{ progress.containerFirst && !progress.masterLabel }') Waiting for master to become available
|
||||
.alert.alert-success(if='{ progress.masterLabel }') PostgreSQL master available, label is attached
|
||||
.alert.alert-success(if='{ progress.masterLabel && progress.dnsName }') PostgreSQL ready: <strong>{ progress.dnsName }</strong>
|
||||
|
||||
.col-lg-3
|
||||
help-general(config='{ opts.config }')
|
||||
|
||||
script.
|
||||
|
||||
var yamlParser = require('js-yaml')
|
||||
|
||||
this.delete_cluster = _ => this.parent.opts.delete_cluster(
|
||||
this.namespace,
|
||||
this.clustername,
|
||||
)
|
||||
|
||||
this.progress = {}
|
||||
this.progress.requestStatus = 'OK'
|
||||
|
||||
this.pollProgressTimer = false
|
||||
|
||||
this.startPollProgress = () => {
|
||||
this.pollProgressTimer = setInterval(this.pollProgress, 10000)
|
||||
}
|
||||
|
||||
this.stopPollProgress = () => {
|
||||
clearInterval(this.pollProgressTimer)
|
||||
this.pollProgressTimer = false
|
||||
}
|
||||
|
||||
this.pollProgress = () => {
|
||||
jQuery.get(
|
||||
'/postgresqls/' + this.cluster_path,
|
||||
).done(data => {
|
||||
this.progress.postgresql = true
|
||||
this.progress.postgresqlManifest = data
|
||||
this.progress.createdTimestamp = data.metadata.creationTimestamp
|
||||
this.uid = this.progress.postgresqlManifest.metadata.uid
|
||||
this.update()
|
||||
|
||||
jQuery.get(
|
||||
'/statefulsets/' + this.cluster_path,
|
||||
).done(data => {
|
||||
this.progress.statefulSet = true
|
||||
this.update()
|
||||
|
||||
jQuery.get(
|
||||
'/statefulsets/' + this.cluster_path + '/pods',
|
||||
).done(data => {
|
||||
if (data.length > 0) {
|
||||
this.progress.containerFirst = true
|
||||
}
|
||||
|
||||
masters = data.filter((x) => { return x.labels['spilo-role'] === 'master'} )
|
||||
if (masters.length === 1) {
|
||||
this.progress.masterLabel = true
|
||||
}
|
||||
|
||||
this.update()
|
||||
|
||||
jQuery.get(
|
||||
'/services/' + this.cluster_path,
|
||||
).done(data => {
|
||||
if (data.metadata && data.metadata.annotations && 'zalando.org/dnsname' in data.metadata.annotations) {
|
||||
this.progress.dnsName = data.metadata.annotations['zalando.org/dnsname']
|
||||
} else if (data.metadata && data.metadata.annotations && 'external-dns.alpha.kubernetes.io/hostname' in data.metadata.annotations) {
|
||||
this.progress.dnsName = data.metadata.annotations['external-dns.alpha.kubernetes.io/hostname']
|
||||
} else {
|
||||
// Kubernetes Service name should resolve
|
||||
this.progress.dnsName = data.metadata.name + '.' + data.metadata.namespace
|
||||
}
|
||||
|
||||
this.update()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
this.on('mount', () => {
|
||||
this.uid = undefined
|
||||
const namespace = this.namespace = this.opts.namespace
|
||||
const clustername = this.clustername = this.opts.clustername
|
||||
const qname = this.qname = namespace + '/' + clustername
|
||||
const cluster_path = this.cluster_path = (
|
||||
encodeURI(namespace)
|
||||
+ '/' + encodeURI(clustername)
|
||||
)
|
||||
this.stopPollProgress()
|
||||
this.pollProgress()
|
||||
this.startPollProgress()
|
||||
})
|
||||
|
||||
this.on('unmount', () =>
|
||||
this.stopPollProgress()
|
||||
)
|
||||
|
||||
this.on('update', () => {
|
||||
if (this.progress.postgresqlManifest) {
|
||||
const manifest = this.progress.postgresqlManifest
|
||||
|
||||
const last_applied = 'kubectl.kubernetes.io/last-applied-configuration'
|
||||
if (manifest.metadata.annotations) {
|
||||
delete manifest.metadata.annotations[last_applied]
|
||||
}
|
||||
|
||||
delete manifest.metadata.creationTimestamp
|
||||
delete manifest.metadata.deletionGracePeriodSeconds
|
||||
delete manifest.metadata.deletionTimestamp
|
||||
delete manifest.metadata.generation
|
||||
delete manifest.metadata.resourceVersion
|
||||
delete manifest.metadata.selfLink
|
||||
delete manifest.metadata.uid
|
||||
delete manifest.status
|
||||
|
||||
if (this.refs.yamlNice) {
|
||||
this.refs.yamlNice.innerHTML = yamlParser.safeDump(
|
||||
this.progress.postgresqlManifest,
|
||||
{
|
||||
sortKeys: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
} else {
|
||||
if(this.refs.yamlNice) {
|
||||
this.refs.yamlNice.innerHTML = '# Loading postgresql cluster manifest'
|
||||
}
|
||||
}
|
||||
|
||||
Prism.highlightAll()
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
postgresqls
|
||||
.container-fluid
|
||||
|
||||
h1.page-header
|
||||
nav(aria-label='breadcrumb')
|
||||
ol.breadcrumb
|
||||
|
||||
li.breadcrumb-item
|
||||
a(href='/#/list')
|
||||
| PostgreSQL clusters
|
||||
|
||||
.sk-spinner-pulse(
|
||||
if='{ my_clusters !== null && other_clusters !== null && (my_clusters === undefined || other_clusters === undefined) }'
|
||||
)
|
||||
|
||||
p(if='{ my_clusters === null || other_clusters === null }')
|
||||
| Error loading clusters. Please
|
||||
|
|
||||
a(onclick='window.location.reload(true)') try again
|
||||
|
|
||||
| or
|
||||
|
|
||||
a(href='/') start over
|
||||
| .
|
||||
|
||||
div(
|
||||
if='{ my_clusters && other_clusters }'
|
||||
)
|
||||
|
||||
p
|
||||
| Search:
|
||||
|
|
||||
input(
|
||||
type='text'
|
||||
onchange='{ filter.edit }'
|
||||
onkeyup='{ filter.edit }'
|
||||
value='{ filter.state }'
|
||||
)
|
||||
|
||||
.page-header
|
||||
h1 My team's clusters ({ my_clusters.length })
|
||||
|
||||
table.table.table-hover(if='{ my_clusters.length > 0 }')
|
||||
|
||||
thead
|
||||
tr
|
||||
th(style='width: 120px') Team
|
||||
th(style='width: 50px') Pods
|
||||
th(style='width: 140px') CPU
|
||||
th(style='width: 130px') Memory
|
||||
th(style='width: 100px') Size
|
||||
th(style='width: 130px') Namespace
|
||||
th Name
|
||||
|
||||
tbody
|
||||
tr(
|
||||
each='{ my_clusters }'
|
||||
hidden='{ !namespaced_name.toLowerCase().includes(filter.state.toLowerCase()) }'
|
||||
)
|
||||
td { team }
|
||||
td { nodes }
|
||||
td { cpu } / { cpu_limit }
|
||||
td { memory } / { memory_limit }
|
||||
td { volume_size }
|
||||
|
||||
td(style='white-space: pre')
|
||||
| { namespace }
|
||||
|
||||
td
|
||||
a(
|
||||
href='/#/status/{ cluster_path(this) }'
|
||||
)
|
||||
| { name }
|
||||
|
||||
.btn-group.pull-right(
|
||||
aria-label='Cluster { qname } actions'
|
||||
role='group'
|
||||
style='display: flex'
|
||||
)
|
||||
|
||||
a.btn.btn-info(
|
||||
href='/#/status/{ cluster_path(this) }'
|
||||
)
|
||||
i.fa.fa-check-circle.regular
|
||||
| Status
|
||||
|
||||
a.btn.btn-info(
|
||||
if='{ opts.config.pgview_link }'
|
||||
href='{ opts.config.pgview_link }{ cluster_path(this) }'
|
||||
)
|
||||
i.fa.fa-chart-line
|
||||
| Pgview
|
||||
|
||||
a.btn.btn-info(
|
||||
href='/#/logs/{ cluster_path(this) }'
|
||||
)
|
||||
i.fa.fa-align-justify
|
||||
| Logs
|
||||
|
||||
a.btn(
|
||||
class='btn-{ opts.read_write ? "primary" : "info" }'
|
||||
href='/#/clone/{ encodeURI(name) }/{ encodeURI(uid) }/{ encodeURI(new Date().toISOString()) }'
|
||||
)
|
||||
i.fa.fa-clone.regular
|
||||
| Clone
|
||||
|
||||
a.btn(
|
||||
class='btn-{ opts.read_write ? "warning" : "info" }'
|
||||
href='/#/edit/{ cluster_path(this) }'
|
||||
)
|
||||
| Edit
|
||||
|
||||
button.btn.btn-danger(
|
||||
if='{ opts.read_write }'
|
||||
onclick='{ delete_cluster }'
|
||||
)
|
||||
| Delete
|
||||
|
||||
.page-header
|
||||
h1 Other clusters ({ other_clusters.length})
|
||||
|
||||
table.table.table-hover(if='{ other_clusters.length > 0 }')
|
||||
|
||||
thead
|
||||
tr
|
||||
th(style='width: 120px') Team
|
||||
th(style='width: 50px') Pods
|
||||
th(style='width: 140px') CPU
|
||||
th(style='width: 130px') Memory
|
||||
th(style='width: 100px') Size
|
||||
th(style='width: 130px') Namespace
|
||||
th Name
|
||||
|
||||
tbody
|
||||
tr(
|
||||
each='{ other_clusters }'
|
||||
hidden='{ !namespaced_name.toLowerCase().includes(filter.state.toLowerCase()) }'
|
||||
)
|
||||
td { team }
|
||||
td { nodes }
|
||||
td { cpu } / { cpu_limit }
|
||||
td { memory } / { memory_limit }
|
||||
td { volume_size }
|
||||
|
||||
td(style='white-space: pre')
|
||||
| { namespace }
|
||||
|
||||
td
|
||||
|
||||
a(
|
||||
href='/#/status/{ cluster_path(this) }'
|
||||
)
|
||||
| { name }
|
||||
|
||||
.btn-group.pull-right(
|
||||
aria-label='Cluster { qname } actions'
|
||||
role='group'
|
||||
style='display: flex'
|
||||
)
|
||||
|
||||
a.btn.btn-info(
|
||||
href='/#/status/{ cluster_path(this) }'
|
||||
)
|
||||
i.fa.fa-check-circle.regular
|
||||
| Status
|
||||
|
||||
a.btn.btn-info(
|
||||
if='{ opts.config.pgview_link }'
|
||||
href='{ opts.config.pgview_link }{ cluster_path(this) }'
|
||||
target='_blank'
|
||||
)
|
||||
i.fa.fa-chart-line
|
||||
| Pgview
|
||||
|
||||
a.btn.btn-info(
|
||||
href='/#/logs/{ cluster_path(this) }'
|
||||
)
|
||||
i.fa.fa-align-justify
|
||||
| Logs
|
||||
|
||||
a.btn(
|
||||
class='btn-{ opts.read_write ? "primary" : "info" }'
|
||||
href='/#/clone/{ encodeURI(name) }/{ encodeURI(uid) }/{ encodeURI(new Date().toISOString()) }'
|
||||
)
|
||||
i.fa.fa-clone.regular
|
||||
| Clone
|
||||
|
||||
a.btn(
|
||||
class='btn-{ opts.read_write ? "warning" : "info" }'
|
||||
href='/#/edit/{ cluster_path(this) }'
|
||||
)
|
||||
| Edit
|
||||
|
||||
button.btn.btn-danger(
|
||||
if='{ opts.read_write }'
|
||||
onclick='{ delete_cluster }'
|
||||
)
|
||||
| Delete
|
||||
|
||||
script.
|
||||
|
||||
// Pass a refresh callback for this tag to the options constructor argument
|
||||
// used for all Dynamic objects built in this tag:
|
||||
const add_refresh = object => Object.assign(
|
||||
{},
|
||||
object,
|
||||
{ refresh: () => this.update() }
|
||||
)
|
||||
const Dynamic = options => this.parent.opts.Dynamic(add_refresh(options))
|
||||
|
||||
this.filter = Dynamic()
|
||||
|
||||
this.my_clusters = undefined
|
||||
this.other_clusters = undefined
|
||||
|
||||
this.delete_cluster = event => this.parent.opts.delete_cluster(
|
||||
event.item.namespace,
|
||||
event.item.name,
|
||||
)
|
||||
|
||||
const cluster_path = this.cluster_path = cluster => (
|
||||
encodeURI(cluster.namespace)
|
||||
+ '/' + encodeURI(cluster.name)
|
||||
)
|
||||
|
||||
this.on('mount', () =>
|
||||
jQuery
|
||||
.get('/postgresqls')
|
||||
.done(clusters => {
|
||||
this.my_clusters = []
|
||||
this.other_clusters = []
|
||||
clusters.forEach(cluster =>
|
||||
(
|
||||
this.opts.teams.includes(
|
||||
cluster.team.toLowerCase()
|
||||
)
|
||||
? this.my_clusters
|
||||
: this.other_clusters
|
||||
).push(cluster)
|
||||
)
|
||||
})
|
||||
.fail(() => {
|
||||
this.my_clusters = null
|
||||
this.other_clusters = null
|
||||
})
|
||||
.always(() => this.update())
|
||||
)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,472 @@
|
||||
restore
|
||||
.container-fluid
|
||||
|
||||
.sk-spinner-pulse(if='{ stored_clusters === undefined }')
|
||||
|
||||
p(if='{ stored_clusters === null }')
|
||||
| Error loading stored clusters. Please
|
||||
|
|
||||
a(onclick="window.location.reload(true);") try again
|
||||
|
|
||||
| or
|
||||
|
|
||||
a(href="/") start over
|
||||
| .
|
||||
|
||||
p(if='{ stored_clusters && stored_clusters.length === 0 }')
|
||||
| No stored clusters found.
|
||||
|
||||
div(if='{ stored_clusters && stored_clusters.length > 0 }')
|
||||
|
||||
h1.page-header
|
||||
nav(aria-label='breadcrumb')
|
||||
ol.breadcrumb
|
||||
|
||||
li.breadcrumb-item
|
||||
a(href='/#/backups')
|
||||
| PostgreSQL cluster backups ({ stored_clusters.length })
|
||||
|
||||
p
|
||||
| Search:
|
||||
|
|
||||
input(
|
||||
each='{ [filter] }'
|
||||
type='text'
|
||||
onchange='{ edit }'
|
||||
onkeyup='{ edit }'
|
||||
value='{ state }'
|
||||
)
|
||||
|
||||
.stored-clusters.panel-group.collapsible
|
||||
.stored-clusters.panel.panel-default.collapsible(
|
||||
each='{ stored_clusters }'
|
||||
hidden='{ !name.includes(filter.state) }'
|
||||
)
|
||||
|
||||
.stored-clusters.panel-heading.collapsible(
|
||||
id='{ id }-head'
|
||||
class='{ collapsed ? "collapsed" : "" }'
|
||||
data-toggle='collapse'
|
||||
data-target='#{ id }-collapse'
|
||||
)
|
||||
a.panel-title.collapsible
|
||||
| { name }
|
||||
|
||||
.stored-clusters.panel-collapse.collapse.collapsible(
|
||||
id='{ id }-collapse'
|
||||
data-stored-clusters='{ name }'
|
||||
)
|
||||
.stored-clusters.panel-body.collapsible(id='{ id }-body')
|
||||
|
||||
.sk-spinner-pulse(if='{ versions === undefined }')
|
||||
|
||||
p(if='{ versions === null }')
|
||||
| Error loading backups. Please try again or
|
||||
|
|
||||
a(href="/") start over
|
||||
| .
|
||||
|
||||
p(if='{ versions && versions.length === 0 }')
|
||||
| No backups found.
|
||||
|
||||
div(if='{ versions && versions.length > 0 }')
|
||||
|
||||
.versions.panel-group.collapsible(style='margin-top: 0.3em')
|
||||
.versions.panel.panel-default.collapsible(each='{ versions }')
|
||||
|
||||
.versions.panel-heading.collapsible(
|
||||
id='{ id }-head'
|
||||
class='{ collapsed ? "collapsed" : "" }'
|
||||
data-toggle='collapse'
|
||||
data-target='#{ id }-collapse'
|
||||
)
|
||||
a.versions.panel-title.collapsible
|
||||
| { name }
|
||||
|
||||
.versions.panel-collapse.collapse.collapsible(
|
||||
id='{ id }-collapse'
|
||||
data-stored-clusters='{ parent.name }'
|
||||
data-versions='{ name }'
|
||||
)
|
||||
|
||||
.versions.panel-body.collapsible(id='{ id }-body')
|
||||
|
||||
.sk-spinner-pulse(if='{ basebackups === undefined }')
|
||||
|
||||
p(if='{ basebackups === null }')
|
||||
| Error loading snapshots. Please try again or
|
||||
|
|
||||
a(href="/") start over
|
||||
| .
|
||||
|
||||
p(if='{ basebackups && basebackups.length === 0 }')
|
||||
| No snapshots found.
|
||||
|
||||
div(if='{ basebackups && basebackups.length > 0 }')
|
||||
|
||||
div(
|
||||
style='margin-bottom: 0.3em'
|
||||
)
|
||||
|
||||
.pull-left(style='margin-right: 0.3em')
|
||||
button.btn.btn-primary.pull-left(
|
||||
id='{ id }-clone'
|
||||
)
|
||||
| Clone at latest state
|
||||
|
||||
div
|
||||
.input-group
|
||||
.input-group-btn
|
||||
button.btn.btn-info(id='{ id }-pitr')
|
||||
| Clone at
|
||||
.input.form-control(type='text')
|
||||
| { clone_time && to_clone_time(clone_time) }
|
||||
|
||||
.timeline(id='{ id }-timeline')
|
||||
|
||||
.basebackups.panel-group.collapsible(style='margin-top: 0.3em')
|
||||
.basebackups.panel.panel-default.collapsible(
|
||||
each='{ basebackups }'
|
||||
)
|
||||
|
||||
.basebackups.panel-heading.collapsible(
|
||||
id='{ id }-head'
|
||||
class='{ collapsed ? "collapsed" : "" }'
|
||||
data-toggle='collapse'
|
||||
data-target='#{ id }-collapse'
|
||||
)
|
||||
a.basebackups.panel-title.collapsible
|
||||
| { relative_time(last_modified) }
|
||||
span.label.label-success(
|
||||
if='{ index === basebackups.length - 1 }'
|
||||
style='margin-left: 0.5em'
|
||||
)
|
||||
| latest snapshot
|
||||
|
||||
.basebackups.panel-collapse.collapse.collapsible(
|
||||
id='{ id }-collapse'
|
||||
data-stored-clusters='{ parent.parent.name }'
|
||||
data-versions='{ parent.name }'
|
||||
data-backups='{ name }'
|
||||
)
|
||||
|
||||
.basebackups.panel-body.collapsible(id='{ id }-body')
|
||||
|
||||
table.basebackups.table
|
||||
|
||||
tr
|
||||
th Name
|
||||
td { name }
|
||||
|
||||
tr
|
||||
th Size
|
||||
td { expanded_size_bytes }
|
||||
|
||||
tr
|
||||
th Last modified
|
||||
td { relative_time(last_modified) }
|
||||
|
||||
tr
|
||||
th WAL segment backup start
|
||||
td { wal_segment_backup_start }
|
||||
|
||||
tr
|
||||
th WAL segment backup stop
|
||||
td { wal_segment_backup_stop }
|
||||
|
||||
tr
|
||||
th WAL segment offset backup start
|
||||
td { wal_segment_offset_backup_start }
|
||||
|
||||
tr
|
||||
th WAL segment offset backup stop
|
||||
td { wal_segment_offset_backup_stop }
|
||||
|
||||
button.btn.btn-info(
|
||||
id='{ id }-restore'
|
||||
)
|
||||
| Clone at this snapshot
|
||||
|
||||
|
||||
script.
|
||||
|
||||
const sort_by = require('sort-by')
|
||||
|
||||
// Pass a refresh callback for this tag to the options constructor argument
|
||||
// used for all Dynamic objects built in this tag:
|
||||
const add_refresh = object => Object.assign(
|
||||
{},
|
||||
object,
|
||||
{ refresh: () => this.update() }
|
||||
)
|
||||
const Dynamic = options => this.parent.opts.Dynamic(add_refresh(options))
|
||||
|
||||
const filter = this.filter = Dynamic()
|
||||
|
||||
const to_timestamp = this.to_timestamp = time => (
|
||||
time
|
||||
.replace('T', ' ')
|
||||
.replace('.000Z', '')
|
||||
)
|
||||
|
||||
const trunc_timestamp = this.trunc_timestamp = time => (
|
||||
Math.trunc(time / 1000) * 1000
|
||||
)
|
||||
|
||||
const to_clone_time = this.to_clone_time = time => to_timestamp(
|
||||
new Date(trunc_timestamp(time))
|
||||
.toISOString()
|
||||
)
|
||||
|
||||
const min = (a, b) => a <= b ? a : b
|
||||
const max = (a, b) => a >= b ? a : b
|
||||
const minimum = array => array.reduce(min)
|
||||
const maximum = array => array.reduce(max)
|
||||
const both = (f, g, x) => [f(x), g(x)]
|
||||
|
||||
const setting = property => (object, value) => {
|
||||
object[property] = value
|
||||
return object
|
||||
}
|
||||
|
||||
const load_time = this.load_time = +new Date()
|
||||
const relative_time = this.relative_time = time => {
|
||||
const relative = humanizeDuration(
|
||||
trunc_timestamp(load_time)
|
||||
- Date.parse(time)
|
||||
)
|
||||
return `${to_timestamp(time)} UTC (${relative} ago)`
|
||||
}
|
||||
|
||||
const q = selector => jQuery(selector, this.root)
|
||||
|
||||
const route_on_click = (selector, target) => (
|
||||
q(selector).on('click', event =>
|
||||
route(target(event).join('/'))
|
||||
)
|
||||
)
|
||||
|
||||
const on_collapse = action => (
|
||||
({
|
||||
klass,
|
||||
collection,
|
||||
predicate = () => true,
|
||||
selector_prefix = '',
|
||||
body,
|
||||
}) => (
|
||||
q(`${selector_prefix}.${klass}.collapse`)
|
||||
.on(
|
||||
action + '.bs.collapse',
|
||||
event => {
|
||||
if (!(
|
||||
event.target.classList.contains(klass)
|
||||
&& predicate(event.target.dataset)
|
||||
)) {
|
||||
return true
|
||||
}
|
||||
const target = collection.find(item =>
|
||||
item.name === event.target.getAttribute('data-' + klass)
|
||||
)
|
||||
target['collapsed'] = action === 'hide'
|
||||
body(target)
|
||||
this.update()
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const collapsible_handlers = options => {
|
||||
on_collapse('hide')(Object.assign({}, options, { body: _ => {}}))
|
||||
on_collapse('show')(options)
|
||||
}
|
||||
|
||||
const get_subresources_once = ({
|
||||
parent_resource,
|
||||
key,
|
||||
url,
|
||||
body,
|
||||
build_subresource = subresource => subresource,
|
||||
build_subresources = subresources => subresources,
|
||||
}) => {
|
||||
if (parent_resource[key] === undefined) {
|
||||
(
|
||||
jQuery
|
||||
.get(url)
|
||||
.done(values => {
|
||||
parent_resource[key] = (
|
||||
build_subresources(values)
|
||||
.map(build_subresource)
|
||||
)
|
||||
this.update()
|
||||
body(parent_resource[key])
|
||||
})
|
||||
.fail(() => parent_resource[key] = null)
|
||||
.always(() => this.update())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.stored_clusters = undefined
|
||||
|
||||
this.on('mount', () =>
|
||||
get_subresources_once({
|
||||
parent_resource: this,
|
||||
key: 'stored_clusters',
|
||||
url: '/stored_clusters',
|
||||
build_subresource: stored_cluster_name => ({
|
||||
id: 'stored-cluster-' + stored_cluster_name,
|
||||
name: stored_cluster_name,
|
||||
collapsed: true,
|
||||
}),
|
||||
body: stored_clusters => collapsible_handlers({
|
||||
klass: 'stored-clusters',
|
||||
collection: stored_clusters,
|
||||
body: stored_cluster => get_subresources_once({
|
||||
parent_resource: stored_cluster,
|
||||
key: 'versions',
|
||||
url: '/stored_clusters/' + stored_cluster.name,
|
||||
build_subresource: version_name => ({
|
||||
id: stored_cluster.id + '-version-' + version_name,
|
||||
name: version_name,
|
||||
collapsed: true,
|
||||
stored_cluster: stored_cluster,
|
||||
}),
|
||||
body: versions => collapsible_handlers({
|
||||
klass: 'versions',
|
||||
collection: versions,
|
||||
selector_prefix: `#${stored_cluster.id}-collapse `,
|
||||
predicate: data => stored_cluster.name === data.storedClusters,
|
||||
body: version => get_subresources_once({
|
||||
parent_resource: version,
|
||||
key: 'basebackups',
|
||||
url: (
|
||||
'/stored_clusters/' + stored_cluster.name
|
||||
+ '/' + version.name
|
||||
),
|
||||
build_subresource: basebackup => Object.assign(basebackup, {
|
||||
id: version.id + '-basebackup-' + basebackup.name,
|
||||
}),
|
||||
build_subresources: basebackups => (
|
||||
basebackups
|
||||
.sort(sort_by('last_modified'))
|
||||
.map(setting('index'))
|
||||
),
|
||||
body: basebackups => {
|
||||
if (basebackups.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const basebackup_age = basebackup => (
|
||||
+new Date(basebackup.last_modified)
|
||||
)
|
||||
|
||||
const oldest = version.basebackups[0]
|
||||
const newest = version.basebackups[
|
||||
version.basebackups.length - 1
|
||||
]
|
||||
const [start, end] = both(
|
||||
maximum,
|
||||
minimum,
|
||||
[
|
||||
load_time,
|
||||
...[oldest, newest].map(basebackup_age),
|
||||
],
|
||||
)
|
||||
const span = end - start
|
||||
const padding_time = 0.1 * span
|
||||
|
||||
basebackups.forEach(basebackup => {
|
||||
route_on_click(
|
||||
'#' + basebackup.id + '-restore',
|
||||
() => [
|
||||
'/clone',
|
||||
encodeURI(stored_cluster.name),
|
||||
encodeURI(version.name),
|
||||
// TODO: Ideally, this should use the basebackup's end
|
||||
// LSN, not the S3 last modification timestamp. However,
|
||||
// the current implementation of the clone feature does
|
||||
// not allow specifying `recovery_target_lsn`.
|
||||
encodeURI(basebackup.last_modified),
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
version.timeline = new vis.Timeline(
|
||||
q('#' + version.id + '-timeline')[0],
|
||||
new vis.DataSet([
|
||||
...version.basebackups.map(
|
||||
basebackup => ({
|
||||
id: basebackup.index,
|
||||
content: (
|
||||
to_timestamp(basebackup.last_modified)
|
||||
.replace(' ', '<br>')
|
||||
+ ' (UTC)'
|
||||
),
|
||||
start: basebackup.last_modified,
|
||||
})
|
||||
),
|
||||
{
|
||||
id: version.basebackups.length,
|
||||
content: 'now',
|
||||
start: load_time,
|
||||
type: 'point',
|
||||
}
|
||||
]),
|
||||
{
|
||||
min: min - padding_time,
|
||||
max: max + 5 * padding_time,
|
||||
moment: time => vis.moment(time).utc(),
|
||||
clickToUse: true,
|
||||
moveable: true,
|
||||
zoomable: true,
|
||||
showCurrentTime: true,
|
||||
}
|
||||
)
|
||||
|
||||
version.clone_time = trunc_timestamp(end - span / 3)
|
||||
version.timeline.addCustomTime(
|
||||
version.clone_time,
|
||||
'clone_time',
|
||||
)
|
||||
|
||||
version.timeline.on('timechange', properties => {
|
||||
version.clone_time = +properties.time
|
||||
this.update()
|
||||
})
|
||||
|
||||
version.timeline.on('select', selection =>
|
||||
version.basebackups.forEach(basebackup =>
|
||||
q('#' + basebackup.id + '-collapse').collapse(
|
||||
selection.items.includes(basebackup.index)
|
||||
? 'show'
|
||||
: 'hide'
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
route_on_click(
|
||||
'#' + version.id + '-clone',
|
||||
() => [
|
||||
'/clone',
|
||||
encodeURI(stored_cluster.name),
|
||||
encodeURI(version.name),
|
||||
encodeURI(to_clone_time(load_time)),
|
||||
],
|
||||
)
|
||||
|
||||
route_on_click(
|
||||
'#' + version.id + '-pitr',
|
||||
() => [
|
||||
'/clone',
|
||||
encodeURI(stored_cluster.name),
|
||||
encodeURI(version.name),
|
||||
encodeURI(to_clone_time(version.clone_time)),
|
||||
],
|
||||
)
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
status
|
||||
.container-fluid
|
||||
|
||||
h1.page-header
|
||||
nav(aria-label="breadcrumb")
|
||||
ol.breadcrumb
|
||||
|
||||
li.breadcrumb-item
|
||||
a(href='/#/operator')
|
||||
| Workers
|
||||
|
||||
virtual(if='{ operatorShowLogs && logs }')
|
||||
li.breadcrumb-item { worker_id }
|
||||
li.breadcrumb-item
|
||||
a(href='/#/operator/worker/{ worker_id }/logs')
|
||||
| Logs
|
||||
|
||||
virtual(if='{ operatorShowQueue && queue }')
|
||||
li.breadcrumb-item { worker_id }
|
||||
li.breadcrumb-item
|
||||
a(href='/#/operator/worker/{ worker_id }/queue')
|
||||
| Queue
|
||||
|
||||
div(if='{ status }')
|
||||
|
||||
div(if='{ !operatorShowLogs && !operatorShowQueue }')
|
||||
|
||||
table.table.table-hover
|
||||
|
||||
thead
|
||||
tr
|
||||
td Worker ID
|
||||
td Queue size
|
||||
td Actions
|
||||
|
||||
tr(each='{ queue_size, worker_id in status.WorkerQueueSize}')
|
||||
td { worker_id }
|
||||
td { queue_size }
|
||||
|
||||
td
|
||||
.btn-group(
|
||||
aria-label="Worker { worker_id} actions"
|
||||
role="group"
|
||||
)
|
||||
|
||||
a.btn.btn-info(
|
||||
href='/#/operator/worker/{ worker_id }/logs'
|
||||
)
|
||||
| Logs
|
||||
|
||||
a.btn.btn-info(
|
||||
href='/#/operator/worker/{ worker_id }/queue'
|
||||
)
|
||||
| Queue
|
||||
|
||||
div(if='{ operatorShowLogs && logs }')
|
||||
table.table.table-hover
|
||||
tr(each='{ logs }')
|
||||
|
||||
td
|
||||
span.label.label-font-size(class='{ levels[Level].class }')
|
||||
| { levels[Level].label }
|
||||
|
||||
td(style='white-space: pre')
|
||||
| { Time }
|
||||
|
||||
td(style='white-space: pre')
|
||||
| { ClusterName }
|
||||
|
||||
td { Message }
|
||||
|
||||
div(if='{ operatorShowQueue && queue }')
|
||||
table.table.table-hover
|
||||
tr(each='{ queue }')
|
||||
|
||||
td(style='white-space: pre')
|
||||
| { EventTime }
|
||||
|
||||
td(style='white-space: pre')
|
||||
| { EventType }
|
||||
|
||||
script.
|
||||
|
||||
this.levels = {
|
||||
0: { class: '', label: 'Panic' },
|
||||
1: { class: '', label: 'Fatal'},
|
||||
2: { class: 'label-danger', label: 'Error'},
|
||||
3: { class: 'label-warning', label: 'Warning'},
|
||||
4: { class: 'label-primary', label: 'Info'},
|
||||
5: { class: 'label-info', label: 'Debug'},
|
||||
}
|
||||
|
||||
this.status = {
|
||||
'Clusters': 0,
|
||||
}
|
||||
|
||||
this.pollStatus = () => {
|
||||
jQuery.get(
|
||||
'/operator/status',
|
||||
).done(data => {
|
||||
this.update({status: data})
|
||||
})
|
||||
}
|
||||
|
||||
this.logs = []
|
||||
this.queue = []
|
||||
|
||||
this.on('mount', () => {
|
||||
route.exec()
|
||||
})
|
||||
|
||||
this.pollLogs = id => {
|
||||
jQuery.get(
|
||||
'/operator/workers/' + id + '/logs',
|
||||
).done(data => {
|
||||
data.reverse()
|
||||
this.update({logs: data})
|
||||
})
|
||||
}
|
||||
|
||||
this.pollQueue = id => {
|
||||
jQuery.get(
|
||||
'/operator/workers/' + id + '/queue',
|
||||
).done(data =>
|
||||
this.update({queue: data.List})
|
||||
)
|
||||
}
|
||||
|
||||
var subRoute = route.create()
|
||||
|
||||
subRoute('/operator/worker/*/logs', id => {
|
||||
this.worker_id = id
|
||||
this.operatorShowLogs = true
|
||||
this.operatorShowQueue = false
|
||||
this.pollLogs(this.worker_id)
|
||||
})
|
||||
|
||||
subRoute('/operator/worker/*/queue', id => {
|
||||
this.worker_id = id
|
||||
this.operatorShowLogs = false
|
||||
this.operatorShowQueue = true
|
||||
this.pollQueue(this.worker_id)
|
||||
})
|
||||
|
||||
subRoute('/operator', () => {
|
||||
this.operatorShowLogs = false
|
||||
this.operatorShowQueue = false
|
||||
this.pollStatus()
|
||||
})
|
||||
Reference in New Issue
Block a user