blob: 0d50adefba64dd802d7b3615a1d94807adeb0885 [file] [log] [blame]
import 'elements-sk/styles/buttons'
import 'elements-sk/icon/alarm-icon-sk'
import 'elements-sk/icon/create-icon-sk'
import 'elements-sk/icon/warning-icon-sk'
import 'elements-sk/spinner-sk'
import 'elements-sk/error-toast-sk'
import { errorMessage } from 'elements-sk/errorMessage'
import 'infra-sk/modules/app-sk'
import 'infra-sk/modules/confirm-dialog-sk'
import 'infra-sk/modules/systemd-unit-status-sk'
import 'infra-sk/modules/login-sk'
import { $$ } from 'common-sk/modules/dom'
import { fromObject } from 'common-sk/modules/query'
import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow'
import { stateReflector } from 'common-sk/modules/stateReflector'
import { html, render } from 'lit-html'
import '../push-selection-sk'
// How often we should poll for status updates.
const UPDATE_MS = 5000;
// Utility functions for templating.
const monURI = (name) => `https://${name}-10000-proxy.skia.org`;
const logsURI = (name) => `https://console.cloud.google.com/logs/viewer?project=google.com:skia-buildbots&minLogLevel=200&expandAll=false&resource=logging_log%2Fname%2F${name}`;
const prefixOf = (s) => s.split('/')[0];
const fullHash = (s) => s.slice(s.length-44, s.length-4);
const shorten = (s) => fullHash(s).slice(0, 6);
const alarmVisibility = (ele, installed) => {
if (!ele._packageLookup[installed]) {
return 'invisible'
} else {
return ele._packageLookup[installed].Latest ? 'invisible' : '';
}
};
const dirtyVisibility = (ele, installed) => {
if (!ele._packageLookup[installed]) {
return 'invisible'
} else {
return ele._packageLookup[installed].Dirty ? '' : 'invisible';
}
};
const logsFullURI = (name, installed) => {
let app = installed.split('/')[0];
return `https://console.cloud.google.com/logs/viewer?project=google.com:skia-buildbots&minLogLevel=200&expandAll=false&resource=logging_log%2Fname%2F${ name }&logName=projects%2Fgoogle.com:skia-buildbots%2Flogs%2F${ app }`;
};
const servicesOf = (ele, installed) => {
let p = ele._packageLookup[installed];
return p ? p.Services : [];
};
const listServices = (ele, server, installed) => servicesOf(ele, installed).map(service => {
return html`<systemd-unit-status-sk machine='${server.Name}' .value=${ele._state.status[server.Name + ':' + service]} ></systemd-unit-status-sk>`;
});
const listApplications = (ele, server) => server.Installed.map(installed => html`
<div class=applicationRow>
<button class=application data-server='${server.Name}' data-name='${installed}' data-app='${prefixOf(installed)}' @click=${ele._startChoose}><create-icon-sk title='Edit which package is installed.'></create-icon-sk></button>
<warning-icon-sk class='${dirtyVisibility(ele, installed)}' title='Out of date.'></warning-icon-sk>
<alarm-icon-sk class='${alarmVisibility(ele, installed)}' title='Uncommited changes when the package was built.'></alarm-icon-sk>
<div class=serviceName><a href='https://github.com/google/skia-buildbot/compare/${fullHash(installed)}...HEAD'>${shorten(installed)}</a></div>
<div class=logs><a href='${logsFullURI(server.Name, installed)}'>logs</a></div>
<div>
${listServices(ele, server, installed)}
</div>
<div class=appName>${prefixOf(installed)}</div>
</div>`);
// Only display a server if it matches the current filter.
const classMatchFilter = (ele, server) => {
let search = ele._query.search;
// Short-circuit the most common case.
if (!search) {
return '';
}
return (server.Name.includes(search) || server.Installed.find(installed => prefixOf(installed).includes(search))) ? '' : 'hidden';
};
const listServers = (ele) => ele._state.servers.map(server => html`
<section class='${classMatchFilter(ele, server)}'>
<h2>${server.Name}</h2>
<button class=reboot raised data-action='start' data-name='reboot.target' data-server='${server.Name}' @click=${ele._reboot}>Reboot</button>
[<a target=_blank href='${monURI(server.Name)}'>mon</a>]
[<a target=_blank href='${logsURI(server.Name)}'>logs</a>]
<div class=appContainer>
${listApplications(ele, server)}
</div>
</section>`);
const template = (ele) => html`
<app-sk>
<header><h1>Push</h1> <login-sk></login-sk></header>
<main @unit-action=${(e) => ele._unitAction(e.detail)}>
<section class=controls>
<button id=refresh @click=${ele._refreshClick}>Refresh Packages</button>
<spinner-sk id=spinner></spinner-sk>
<label>Filter servers/apps: <input type=text @input=${ele._filterInput} value='${ele._query.search}'></input></label>
</section>
${listServers(ele)}
</main>
<footer>
<error-toast-sk></error-toast-sk>
<push-selection-sk id='push-selection' @package-change=${ele._packageChange}></push-selection-sk>
<confirm-dialog-sk id='confirm-dialog'></confirm-dialog-sk>
</footer>
</app-sk>`;
/** <code>push-app-sk</code> custom element declaration.
*
* <p>
* The main element for the push application.
* </p>
*/
class PushAppSk extends HTMLElement {
constructor() {
super();
// Populated from push/main AllUI type.
this._state = {
servers: [],
packages: {},
status: {},
};
// Bits of state that get reflected to/from the URL query string.
this._query = {
// The current value of the filter text box.
search: '',
}
}
connectedCallback() {
this._render();
this._spinner = $$('#spinner');
this._push_selection = $$('#push-selection');
this._chosenServer = '';
fetch('/_/state').then(jsonOrThrow).then(state => {
this._setState(state);
this._updateStatus();
this._render();
}).catch(errorMessage);
this._stateHasChanged = stateReflector(() => this._query, (query) => {
this._query = query;
this._render();
});
}
_render() {
render(template(this), this, {eventContext: this});
}
// Called when the user presses the button to choose a different package version.
// Presents a dialog of available package versions to choose from.
_startChoose(e) {
let target = e.target;
if (target.nodeName !== 'BUTTON') {
target = target.parentElement;
}
this._chosenServer = target.dataset.server;
let choices = this._state.packages[target.dataset.app];
let chosen = choices.findIndex(choice => choice.Name === target.dataset.name);
this._push_selection.choices = choices;
this._push_selection.chosen = chosen;
this._push_selection.show();
}
// Called when the user has actually made a selection from the dialog that
// was displayed when _startChoose() was called.
_packageChange(e) {
this._push_selection.hide();
this._spinner.active = true;
let body = {
name: e.detail.name,
server: this._chosenServer,
}
fetch('/_/state', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'content-type': 'application/json'
},
credentials: 'include',
}).then(jsonOrThrow).then(state => {
this._spinner.active = false;
this._setState(state);
}).catch(err => {
this._spinner.active = false;
errorMessage(err);
});
}
_reboot(e) {
let button = e.target;
$$('#confirm-dialog').open(`Proceed with rebooting ${ button.dataset.server }?`).then(() => {
this._unitAction({
machine: button.dataset.server,
name: button.dataset.name,
action: button.dataset.action,
});
});
}
// Perform an action on a systemd unit. The 'detail' must have a 'name',
// 'action', and 'machine' properties.
_unitAction(detail) {
this._spinner.active = true;
fetch('/_/change?' + fromObject(detail), {
method: 'POST',
credentials: 'include',
}).then(jsonOrThrow).then(json => {
this._spinner.active = false;
errorMessage(json.result);
}).catch(err => {
this._spinner.active = false;
errorMessage(err);
});
}
// Set the new state of push.
_setState(value) {
this._state = value;
this._packageLookup = {};
for (let appName in this._state.packages) {
let latest = true;
this._state.packages[appName].forEach(details => {
this._packageLookup[details.Name] = details;
this._packageLookup[details.Name].Latest = latest;
latest = false;
});
}
this._render();
}
// Get the new status from the push server.
_updateStatus() {
fetch('/_/status').then(jsonOrThrow).then(json => {
this._state.status = json;
this._render();
window.setTimeout(() => this._updateStatus(), UPDATE_MS);
}).catch(err => {
errorMessage(err)
window.setTimeout(() => this._updateStatus(), UPDATE_MS);
});
}
// Refresh the full state from push, not just the status.
_refreshClick(e) {
this._spinner.active = true;
fetch('/_/state?refresh=true').then(jsonOrThrow).then(json => {
this._setState(json);
this._spinner.active = false;
}).catch(err => {
this._spinner.active = false;
errorMessage(err);
});
}
// Called when the user edits the filter text box.
_filterInput(e) {
this._query.search = e.target.value;
this._stateHasChanged();
this._render();
}
}
window.customElements.define('push-app-sk', PushAppSk);