Skip to content

Commit 71aba19

Browse files
committed
feat: service uptime sparklines, styles, and tests
- Render a 30-day uptime sparkline for each service row and compute its SVG geometry from history data. Introduces SPARK constants, dailyUptimePercent and buildSparkline helpers, a computed sparklines cache and sparklineFor() accessor in StatusComponent. - Adds the sparkline SVG to the status template, unit tests covering rendering and omission when history is insufficient, and UI polish: sparkline CSS, status-dot pulse, banner gradients, shadows, hover effects and reduced-motion support.
1 parent 3dd8602 commit 71aba19

4 files changed

Lines changed: 390 additions & 9 deletions

File tree

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,27 @@ <h2 class="heading-section-lg">
348348
</div>
349349
</div>
350350
}
351+
@if (sparklineFor(svc.id); as spark) {
352+
<svg
353+
class="status-sparkline status-sparkline--{{
354+
statusView(svc.status).modifier
355+
}}"
356+
viewBox="0 0 100 28"
357+
width="100"
358+
height="28"
359+
preserveAspectRatio="none"
360+
aria-hidden="true"
361+
>
362+
<polygon
363+
class="status-sparkline__area"
364+
[attr.points]="spark.area"
365+
></polygon>
366+
<polyline
367+
class="status-sparkline__line"
368+
[attr.points]="spark.line"
369+
></polyline>
370+
</svg>
371+
}
351372
<span
352373
class="status-pill status-pill--{{
353374
statusView(svc.status).modifier

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

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import { AuthService } from '../../services/auth.service';
1212
import { TranslatorService } from '../../services/translator.service';
1313
import { clientAgentInterceptor } from '../../services/interceptors/client-agent.interceptor';
1414
import { HordeUser } from '../../types/horde-user';
15+
import {
16+
PublicComponentsResponse,
17+
PublicHistoryDay,
18+
PublicHistoryResponse,
19+
} from '../../types/status';
1520

1621
function moderatorUser(): HordeUser {
1722
return { username: 'Mod#1', id: 1, kudos: 0, moderator: true };
@@ -100,3 +105,98 @@ describe('StatusComponent moderator gating', () => {
100105
expect(panelPresent()).toBe(true);
101106
});
102107
});
108+
109+
describe('StatusComponent service sparkline', () => {
110+
function day(date: string, operational: number, down = 0): PublicHistoryDay {
111+
return {
112+
date,
113+
status_level: down > 0 ? 2 : 0,
114+
operational_seconds: operational,
115+
degraded_seconds: 0,
116+
down_seconds: down,
117+
maintenance_seconds: 0,
118+
unknown_seconds: 0,
119+
};
120+
}
121+
122+
function setup(
123+
buckets: PublicHistoryDay[],
124+
): ComponentFixture<StatusComponent> {
125+
const components: PublicComponentsResponse = {
126+
components: [
127+
{
128+
id: 'api',
129+
name: 'AI Horde API',
130+
description: 'Public REST API',
131+
status: 'operational',
132+
uptime_90d: 99.9,
133+
last_change_at: null,
134+
},
135+
],
136+
overall: 'operational',
137+
generated_at: '2026-06-21T00:00:00Z',
138+
};
139+
140+
const history: PublicHistoryResponse = {
141+
component_id: 'api',
142+
days: buckets.length,
143+
buckets,
144+
uptime_percent: 99.9,
145+
};
146+
147+
const statusMock: Partial<StatusService> = {
148+
getComponents: () => of(components),
149+
getIncidents: () => of(null),
150+
getMaintenance: () => of(null),
151+
getHistory: () => of(history),
152+
};
153+
154+
TestBed.configureTestingModule({
155+
imports: [
156+
StatusComponent,
157+
TranslocoTestingModule.forRoot({
158+
langs: { en: {} },
159+
translocoConfig: { availableLangs: ['en'], defaultLang: 'en' },
160+
}),
161+
],
162+
providers: [
163+
provideHttpClient(withInterceptors([clientAgentInterceptor])),
164+
provideHttpClientTesting(),
165+
{ provide: StatusService, useValue: statusMock },
166+
{
167+
provide: StatusAdminService,
168+
useValue: { getComponents: () => of([]) },
169+
},
170+
{
171+
provide: NetworkStatusService,
172+
useValue: { performance: signal(null) },
173+
},
174+
{ provide: AuthService, useValue: { currentUser: signal(null) } },
175+
{ provide: TranslatorService, useValue: { get: () => of('') } },
176+
],
177+
});
178+
179+
const fixture = TestBed.createComponent(StatusComponent);
180+
fixture.detectChanges();
181+
return fixture;
182+
}
183+
184+
it('renders a sparkline polyline once a component history loads', () => {
185+
const fixture = setup([
186+
day('2026-06-19', 86400),
187+
day('2026-06-20', 80000, 6400),
188+
day('2026-06-21', 86400),
189+
]);
190+
const line = fixture.nativeElement.querySelector(
191+
'.status-sparkline__line',
192+
) as SVGPolylineElement | null;
193+
expect(line).not.toBeNull();
194+
// Three days produce three coordinate pairs in the polyline.
195+
expect(line?.getAttribute('points')?.trim().split(' ').length).toBe(3);
196+
});
197+
198+
it('omits the sparkline when there is too little history to draw a line', () => {
199+
const fixture = setup([day('2026-06-21', 86400)]);
200+
expect(fixture.nativeElement.querySelector('.status-sparkline')).toBeNull();
201+
});
202+
});

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

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,63 @@ interface DayBar {
4747
labelKey: string;
4848
/**
4949
* True when the day stayed green but still saw some down/degraded time below
50-
* the colouring threshold rendered as a small marker under the tick so brief
50+
* the colouring threshold, rendered as a small marker under the tick so brief
5151
* instability is visible without being escalated to a full outage.
5252
*/
5353
flapping: boolean;
5454
}
5555

56+
/** Pre-computed SVG point strings for a single service's uptime sparkline. */
57+
interface SparkPoints {
58+
/** `points` for the trend `<polyline>`. */
59+
line: string;
60+
/** `points` for the filled `<polygon>` beneath the trend. */
61+
area: string;
62+
}
63+
64+
/** Coordinate space the sparkline is authored in; CSS scales it responsively. */
65+
const SPARK_WIDTH = 100;
66+
const SPARK_HEIGHT = 28;
67+
68+
/** Trailing days of history charted in the sparkline, matching the mock-up. */
69+
const SPARK_DAYS = 30;
70+
71+
/**
72+
* A single day's operational fraction (0-100). Matches the backend definition:
73+
* uptime = operational / (operational + degraded + down). Unknown and
74+
* maintenance seconds are excluded so pre-monitoring gaps do not count as
75+
* downtime. Days with no tracked time read as fully up.
76+
*/
77+
function dailyUptimePercent(day: PublicHistoryDay): number {
78+
const active =
79+
day.operational_seconds + day.degraded_seconds + day.down_seconds;
80+
if (active <= 0) return 100;
81+
return (day.operational_seconds / active) * 100;
82+
}
83+
84+
/**
85+
* Project a uptime series onto the sparkline coordinate space. Mirrors the
86+
* mock-up's normalisation: the visible range is padded by 0.1% on each side so
87+
* a flat 100% line still renders with a little headroom. Returns null when
88+
* there are too few points to draw a line.
89+
*/
90+
function buildSparkline(series: number[]): SparkPoints | null {
91+
if (series.length < 2) return null;
92+
const min = Math.min(...series) - 0.1;
93+
const max = Math.max(...series) + 0.1;
94+
const range = Math.max(0.1, max - min);
95+
const points = series.map((value, index) => {
96+
const x = (index / (series.length - 1)) * SPARK_WIDTH;
97+
const y = SPARK_HEIGHT - ((value - min) / range) * (SPARK_HEIGHT - 2) - 1;
98+
return `${x.toFixed(1)},${y.toFixed(1)}`;
99+
});
100+
const line = points.join(' ');
101+
return {
102+
line,
103+
area: `0,${SPARK_HEIGHT} ${line} ${SPARK_WIDTH},${SPARK_HEIGHT}`,
104+
};
105+
}
106+
56107
/** How a status value reads in the UI: a translation key and a CSS modifier. */
57108
interface StatusView {
58109
/** transloco key for the human label, e.g. `status.state.operational`. */
@@ -129,6 +180,17 @@ export class StatusComponent {
129180
/** Per-component 90-day daily buckets, keyed by component id. */
130181
private readonly histories = signal<Record<string, PublicHistoryDay[]>>({});
131182

183+
/** Sparkline point strings derived from the trailing daily buckets. */
184+
private readonly sparklines = computed<Record<string, SparkPoints>>(() => {
185+
const result: Record<string, SparkPoints> = {};
186+
for (const [id, days] of Object.entries(this.histories())) {
187+
const series = days.slice(-SPARK_DAYS).map(dailyUptimePercent);
188+
const points = buildSparkline(series);
189+
if (points) result[id] = points;
190+
}
191+
return result;
192+
});
193+
132194
/** True once the components call has returned without any data to show. */
133195
public readonly backendUnavailable = computed(
134196
() => this.loaded() && this.components() === null,
@@ -279,6 +341,16 @@ export class StatusComponent {
279341
};
280342
}
281343

344+
/** Sparkline geometry for a component, or null when history is too sparse. */
345+
public sparklineFor(componentId: string): SparkPoints | null {
346+
return this.sparklines()[componentId] ?? null;
347+
}
348+
349+
/** A day's bar modifier: `ok` | `minor` | `major` | `maintenance`. */
350+
public historyLevelModifier(level: number): string {
351+
return ['ok', 'minor', 'major', 'maintenance'][level] ?? 'ok';
352+
}
353+
282354
/** Compact human duration for tooltips: `15s`, `4m`, `1h 32m`. */
283355
public formatDuration(seconds: number): string {
284356
if (seconds >= 3600) {

0 commit comments

Comments
 (0)