Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions cmd/teploy-dash/frontend-test/backup_setup.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -493,3 +493,37 @@ test('no emoji in the page, and the layout holds at phone width', () => {
const css = readFileSync(join(root, 'css/style.css'), 'utf8');
assert.ok(css.includes('.setup-step input:not([type="checkbox"]):not([type="radio"]), .setup-step select { width: 100%; max-width: 100%; }'));
});

test('the home list stays a glance: one row before setup, grouped after, capped', async () => {
const h = harness('/');
const rows = [];
for (let i = 0; i < 12; i++) rows.push({ server: i < 9 ? 'a' : 'b', name: 'app' + i + '/volumes', state: 'uncovered' });
rows.push({ server: 'a', name: 'ship/postgres', state: 'failing', last_error: 'upload failed' });
const open = rows.map((r) => ({ key: 'backup:' + r.server + ':uncovered:' + r.name, source: 'backup', name: r.name + ' on ' + r.server + ' is not covered', severity: 'warning' }));
open.push({ key: 'm1', source: 'monitor', name: 'Shop', severity: 'critical' });
let view = baseView({}, { rows });
h.respond((url) => url === '/api/backups' ? response(view) : url === '/api/alerts/incidents' ? response({ open }) : response({}));
const home = h.components.homeStatus();
await home.init();
let keys = home.attention().map((r) => r.key);
assert.deepEqual(keys, ['inc:m1', 'protect', 'bk:a/ship/postgres'], 'before setup: the monitor, one protect row, and the failing backup');
view = baseView({ state: 'partial', headline: 'One of two servers is protected.' }, { rows });
await home.loadOne('backups');
const att = home.attention();
keys = att.map((r) => r.key);
assert.deepEqual(keys, ['inc:m1', 'protect', 'bk:a/ship/postgres', 'bk-uncovered:a', 'bk-uncovered:b'], 'after setup: uncovered targets are one row per server');
assert.equal(att[3].title, '9 targets on a are not backed up');
assert.equal(att[3].detail, 'app0/volumes, app1/volumes, app2/volumes and 6 more');
// A failed backups read keeps the backup incidents, so nothing goes unsaid.
const fresh = h.components.homeStatus();
h.respond((url) => url === '/api/backups' ? response({}, { status: 500 }) : url === '/api/alerts/incidents' ? response({ open }) : response({}));
await fresh.init();
assert.equal(fresh.attention().length, open.length);
assert.equal(fresh.attentionShown().length, 8);
assert.equal(fresh.attentionHidden(), open.length - 8);
fresh.showAllAttention = true;
assert.equal(fresh.attentionShown().length, open.length);
home.destroy(); fresh.destroy();
const html = h.readHTML();
assert.ok(html.includes('attentionShown()') && html.includes("'Show ' + attentionHidden() + ' more'"));
});
4 changes: 3 additions & 1 deletion cmd/teploy-dash/frontend/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,9 @@ tbody tr:hover { background: var(--bg-card-hover); }
a.status-row:hover { background: var(--bg-card-hover); }
.status-row-main { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 1px; }
.status-row-title { font-size: 13px; overflow-wrap: anywhere; }
.status-row-detail { color: var(--text-muted); font-size: 12px; overflow-wrap: anywhere; }
.status-row-detail { display: -webkit-box; overflow: hidden; color: var(--text-muted); font-size: 12px; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; }
.status-row-more { width: 100%; border: 0; border-top: 1px solid var(--border); background: none; color: var(--text-muted); font: inherit; font-size: 13px; cursor: pointer; justify-content: center; }
.status-row-more:hover { background: var(--bg); color: var(--text); }
.status-row-meta { flex-shrink: 0; color: var(--text-muted); font-size: 12px; white-space: nowrap; }
.status-row-action { flex-shrink: 0; color: var(--accent); font-size: 12px; white-space: nowrap; }
.status-row-facts { display: flex; flex-shrink: 0; gap: 14px; color: var(--text-secondary); font-size: 12px; white-space: nowrap; }
Expand Down
10 changes: 8 additions & 2 deletions cmd/teploy-dash/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -162,17 +162,23 @@ <h2 id="home-attention-title" class="section-title">Needs attention</h2>
<span class="text-muted home-asof" x-show="oldestObservation()" :title="'Oldest observation: ' + formatObservedAt(oldestObservation())" x-text="'checked ' + relTime(oldestObservation())"></span>
</div>
<div class="status-list">
<template x-for="item in attention()" :key="item.key">
<template x-for="item in attentionShown()" :key="item.key">
<a class="status-row" :href="item.href" @click="go(item.href, $event)">
<span class="dot" :class="item.level === 'bad' ? 'red' : 'yellow'"></span>
<span class="status-row-main">
<span class="status-row-title" x-text="item.title"></span>
<span class="status-row-detail" x-show="item.detail" x-text="item.detail"></span>
<span class="status-row-detail" x-show="item.detail" :title="item.detail" x-text="item.detail"></span>
</span>
<span class="status-row-meta" x-show="item.at" :title="formatObservedAt(item.at)" x-text="(item.verb ? item.verb + ' ' : '') + relTime(item.at)"></span>
<span class="status-row-action" x-show="item.action" x-text="item.action"></span>
</a>
</template>
<template x-if="attentionHidden() > 0">
<button type="button" class="status-row status-row-more" @click="showAllAttention = true" x-text="'Show ' + attentionHidden() + ' more'"></button>
</template>
<template x-if="showAllAttention && attention().length > attentionLimit">
<button type="button" class="status-row status-row-more" @click="showAllAttention = false">Show fewer</button>
</template>
<template x-for="src in unchecked()" :key="'unchecked:' + src.key">
<div class="status-row status-row-static" role="alert">
<span class="dot gray"></span>
Expand Down
38 changes: 31 additions & 7 deletions cmd/teploy-dash/frontend/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4895,7 +4895,11 @@ document.addEventListener('alpine:init', () => {
attention() {
const rows = [];
const inc = this.sources.incidents.data;
const bk = this.sources.backups.data;
for (const i of arr(inc && inc.open)) {
// Backup incidents repeat what the backup rows below say, target by
// target; they only stand in when the backup read itself is missing.
if (i.source === 'backup' && bk) continue;
rows.push({ key: 'inc:' + (i.key || i.id || i.name), level: i.severity === 'critical' ? 'bad' : 'warn',
title: i.name + (i.source === 'monitor' || i.source === 'heartbeat' || !i.source ? ' is down' : ''),
detail: i.message || 'open incident', at: i.started_at, verb: 'since',
Expand All @@ -4916,18 +4920,29 @@ document.addEventListener('alpine:init', () => {
title: r.host + (r.overall === 'fail' ? ' is failing' + (r.first_failing ? ' at ' + r.first_failing.toUpperCase() : '') : ': certificate needs attention'),
detail: r.evidence || (r.cert && r.cert.detail) || '', at: '', href: '/domains' });
}
const bk = this.sources.backups.data;
const setup = bk && bk.setup;
if (setup && setup.supported && setup.state !== 'protected') {
const unprotected = setup && setup.supported && setup.state !== 'protected';
if (unprotected) {
rows.push({ key: 'protect', level: setup.state === 'none' ? 'warn' : 'bad', title: setup.headline || 'Your data is not fully protected',
detail: '', at: '', href: '/backups', action: setup.state === 'none' ? 'Protect my data' : 'See backups' });
}
// Before backups are set up at all, every target is uncovered: the one
// row above says so. After that, failing backups are named one by one
// and uncovered targets are counted per server.
const uncovered = {};
for (const r of arr(bk && bk.rows)) {
if (r.state !== 'failing' && r.state !== 'uncovered') continue;
rows.push({ key: 'bk:' + r.server + '/' + r.name, level: r.state === 'failing' ? 'bad' : 'warn',
title: r.name + (r.state === 'failing' ? ' backup is failing' : ' is not backed up'),
detail: r.last_error || (r.app ? r.app + ' on ' + r.server : r.server), at: r.state === 'failing' ? r.last_good : '', verb: 'last good',
href: '/backups' });
if (r.state === 'failing') {
rows.push({ key: 'bk:' + r.server + '/' + r.name, level: 'bad', title: r.name + ' backup is failing',
detail: r.last_error || (r.app ? r.app + ' on ' + r.server : r.server), at: r.last_good, verb: 'last good', href: '/backups' });
} else if (r.state === 'uncovered' && !(unprotected && setup.state === 'none')) {
(uncovered[r.server] = uncovered[r.server] || []).push(r.name);
}
}
for (const [server, names] of Object.entries(uncovered)) {
rows.push({ key: 'bk-uncovered:' + server, level: 'warn',
title: names.length === 1 ? names[0] + ' on ' + server + ' is not backed up' : names.length + ' targets on ' + server + ' are not backed up',
detail: names.length === 1 ? '' : names.slice(0, 3).join(', ') + (names.length > 3 ? ' and ' + (names.length - 3) + ' more' : ''),
at: '', href: '/backups' });
}
const ops = this.sources.operations.data;
const dayAgo = Date.now() - 86400 * 1000;
Expand All @@ -4941,6 +4956,15 @@ document.addEventListener('alpine:init', () => {
return rows;
},

// Home stays a glance: the first rows, then a count behind one button.
attentionLimit: 8,
showAllAttention: false,
attentionShown() {
const rows = this.attention();
return this.showAllAttention || rows.length <= this.attentionLimit ? rows : rows.slice(0, this.attentionLimit);
},
attentionHidden() { return this.showAllAttention ? 0 : Math.max(0, this.attention().length - this.attentionLimit); },

running() {
const live = ['queued', 'running', 'cancel_requested', 'stopping'];
return arr(this.sources.operations.data).filter((op) => live.includes(op.status));
Expand Down
Loading