Skip to content

Commit eafc02f

Browse files
committed
feat: enhance status history visualization with flapping indicators and tooltips
1 parent d92e679 commit eafc02f

4 files changed

Lines changed: 146 additions & 13 deletions

File tree

src/app/pages/status/status.component.html

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -364,16 +364,41 @@ <h2 class="heading-section-lg">
364364
[attr.aria-label]="'status.history_label' | transloco"
365365
>
366366
@for (day of historyFor(svc.id); track day.date) {
367+
@let bar = dayBar(day);
367368
<span
368-
class="status-history__bar status-history__bar--{{
369-
historyLevelModifier(day.status_level)
370-
}}"
369+
class="status-history__cell"
371370
[title]="
372371
day.date +
373372
' — ' +
374-
(historyLevelLabelKey(day.status_level) | transloco)
373+
(bar.labelKey | transloco) +
374+
(day.down_seconds > 0
375+
? ' · ' +
376+
formatDuration(day.down_seconds) +
377+
' ' +
378+
('status.history_tooltip.down' | transloco)
379+
: '') +
380+
(day.degraded_seconds > 0
381+
? ' · ' +
382+
formatDuration(day.degraded_seconds) +
383+
' ' +
384+
('status.history_tooltip.degraded' | transloco)
385+
: '') +
386+
(bar.flapping
387+
? ' · ' + ('status.history_flapping' | transloco)
388+
: '')
375389
"
376-
></span>
390+
>
391+
<span
392+
class="status-history__bar status-history__bar--{{
393+
bar.modifier
394+
}}"
395+
></span>
396+
<span
397+
class="status-history__flap"
398+
[class.status-history__flap--on]="bar.flapping"
399+
aria-hidden="true"
400+
></span>
401+
</span>
377402
}
378403
</div>
379404
}

src/app/pages/status/status.component.ts

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,33 @@ import {
2424
PublicMaintenance,
2525
} from '../../types/status';
2626

27+
/**
28+
* Duration thresholds for the 90-day history bars, mirroring the backend's
29+
* `BucketThresholds`. A day goes major only when hard-down time crosses an
30+
* absolute floor OR a fraction of the day's observed signal (whichever is
31+
* larger), and minor likewise for degraded time. Kept in lockstep with
32+
* `ai-horde-service-alerts`'s defaults so the client- and server-side
33+
* classifications agree.
34+
*/
35+
const BAR_MAJOR_DOWN_FLOOR_S = 300;
36+
const BAR_MAJOR_DOWN_FRACTION = 0.01;
37+
const BAR_MINOR_DEGRADED_FLOOR_S = 600;
38+
const BAR_MINOR_DEGRADED_FRACTION = 0.05;
39+
40+
/** A history day's rendered bar: its colour modifier, label, and flap marker. */
41+
interface DayBar {
42+
/** BEM modifier suffix for the bar: `ok` | `minor` | `major` | `maintenance`. */
43+
modifier: 'ok' | 'minor' | 'major' | 'maintenance';
44+
/** transloco key for the bar's status label, e.g. `status.history_level.ok`. */
45+
labelKey: string;
46+
/**
47+
* True when the day stayed green but still saw some down/degraded time below
48+
* the colouring threshold — rendered as a small marker under the tick so brief
49+
* instability is visible without being escalated to a full outage.
50+
*/
51+
flapping: boolean;
52+
}
53+
2754
/** How a status value reads in the UI: a translation key and a CSS modifier. */
2855
interface StatusView {
2956
/** transloco key for the human label, e.g. `status.state.operational`. */
@@ -188,13 +215,66 @@ export class StatusComponent {
188215
return this.histories()[componentId] ?? [];
189216
}
190217

191-
/** A day's bar modifier: `ok` | `minor` | `major` | `maintenance`. */
192-
public historyLevelModifier(level: number): string {
193-
return ['ok', 'minor', 'major', 'maintenance'][level] ?? 'ok';
218+
/**
219+
* Classify a day's bar from its raw per-day seconds, mirroring the backend's
220+
* duration-weighted thresholds ({@link BAR_MAJOR_DOWN_FLOOR_S} et al).
221+
*
222+
* This is deliberately independent of the backend's `status_level`: if the
223+
* backend ever regresses to a worst-observed rollup again, the page still
224+
* refuses to paint a whole day red over a few seconds of blip. Sub-threshold
225+
* down/degraded time surfaces as {@link DayBar.flapping} (a small marker)
226+
* rather than a coloured bar.
227+
*/
228+
public dayBar(day: PublicHistoryDay): DayBar {
229+
const signal =
230+
day.operational_seconds + day.degraded_seconds + day.down_seconds;
231+
if (signal === 0) {
232+
const modifier = day.maintenance_seconds > 0 ? 'maintenance' : 'ok';
233+
return {
234+
modifier,
235+
labelKey: `status.history_level.${modifier}`,
236+
flapping: false,
237+
};
238+
}
239+
const majorCut = Math.max(
240+
BAR_MAJOR_DOWN_FLOOR_S,
241+
BAR_MAJOR_DOWN_FRACTION * signal,
242+
);
243+
if (day.down_seconds >= majorCut) {
244+
return {
245+
modifier: 'major',
246+
labelKey: 'status.history_level.major',
247+
flapping: false,
248+
};
249+
}
250+
const minorCut = Math.max(
251+
BAR_MINOR_DEGRADED_FLOOR_S,
252+
BAR_MINOR_DEGRADED_FRACTION * signal,
253+
);
254+
if (day.degraded_seconds >= minorCut) {
255+
return {
256+
modifier: 'minor',
257+
labelKey: 'status.history_level.minor',
258+
flapping: false,
259+
};
260+
}
261+
return {
262+
modifier: 'ok',
263+
labelKey: 'status.history_level.ok',
264+
flapping: day.down_seconds > 0 || day.degraded_seconds > 0,
265+
};
194266
}
195267

196-
/** transloco key for a history bar's status, e.g. `status.history_level.ok`. */
197-
public historyLevelLabelKey(level: number): string {
198-
return `status.history_level.${this.historyLevelModifier(level)}`;
268+
/** Compact human duration for tooltips: `15s`, `4m`, `1h 32m`. */
269+
public formatDuration(seconds: number): string {
270+
if (seconds >= 3600) {
271+
const hours = Math.floor(seconds / 3600);
272+
const minutes = Math.round((seconds % 3600) / 60);
273+
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
274+
}
275+
if (seconds >= 60) {
276+
return `${Math.round(seconds / 60)}m`;
277+
}
278+
return `${Math.max(0, Math.round(seconds))}s`;
199279
}
200280
}

src/assets/i18n/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,9 @@
231231
"status.history_level.minor": "Minor issues",
232232
"status.history_level.major": "Major outage",
233233
"status.history_level.maintenance": "Maintenance",
234+
"status.history_tooltip.down": "down",
235+
"status.history_tooltip.degraded": "degraded",
236+
"status.history_flapping": "Brief instability",
234237
"status.tbd_note": "Live worker and queue figures are sourced from the AI Horde performance API. Per-incident subscriptions, an RSS feed, and the internal operations view are not yet available (TBD).",
235238
"status.summary.operational": "All systems operational",
236239
"status.summary.degraded": "Some systems are experiencing degraded performance",

src/styles/_status.css

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -605,16 +605,41 @@
605605
display: flex;
606606
gap: 2px;
607607
align-items: stretch;
608-
height: 1.75rem;
609608
}
610609

611-
.status-history__bar {
610+
/* Each day is a column: the uptime bar, plus a reserved strip below it for the
611+
"flapping" marker so all bars stay vertically aligned whether or not a given
612+
day carries a marker. */
613+
.status-history__cell {
612614
flex: 1;
613615
min-width: 2px;
616+
display: flex;
617+
flex-direction: column;
618+
align-items: stretch;
619+
}
620+
621+
.status-history__bar {
622+
height: 1.75rem;
614623
border-radius: 1.5px;
615624
background: var(--color-success-500);
616625
}
617626

627+
/* Sub-threshold instability marker: a small dot under the tick, hidden (but
628+
space-reserved) on clean days. */
629+
.status-history__flap {
630+
align-self: center;
631+
width: 5px;
632+
height: 5px;
633+
margin-top: 2px;
634+
border-radius: 50%;
635+
background: var(--color-warning-500);
636+
opacity: 0;
637+
}
638+
639+
.status-history__flap--on {
640+
opacity: 0.9;
641+
}
642+
618643
.status-history__bar--ok {
619644
background: var(--color-success-500);
620645
opacity: 0.85;

0 commit comments

Comments
 (0)