Standardize UI typography, charts and empty states

This commit is contained in:
2026-10-07 19:40:02 -04:00
parent 05bdeaf624
commit 0ed3cd6b47
57 changed files with 417 additions and 382 deletions
+12
View File
@@ -35,6 +35,18 @@ async function setup() {
}
describe('fleet event refreshes', () => {
it('hydrates and refreshes the all-host attention count without an additional request', async () => {
const store = await import('../../src/lib/ui/stores/fleet.js');
const integrations = { rcon: true, rconTimeoutMs: 2000, defaultRconHost: '' };
store.hydrateFleet({ servers: [], operations: [], integrations, serverNow: Date.now(), hostAttention: 2 });
expect(get(store.fleet).hostAttention).toBe(2);
vi.mocked(fetch).mockImplementation(async url => Response.json(String(url) === '/api/servers'
? { serverNow: Date.now(), servers: [], integrations, hostAttention: 0 } : { operations: [] }));
await store.refreshFleet();
expect(get(store.fleet).hostAttention).toBe(0);
expect(fetch).toHaveBeenCalledTimes(2);
expect(vi.mocked(fetch).mock.calls.map(([url]) => url).sort()).toEqual(['/api/fleet', '/api/servers']);
});
it('refreshes server history through one scoped request without replacing the fleet snapshot', async () => {
const store = await import('../../src/lib/ui/stores/fleet.js');
const integrations = { rcon: true, rconTimeoutMs: 2000, defaultRconHost: '' };
+21
View File
@@ -30,6 +30,27 @@ beforeEach(async () => {
afterEach(async () => { await fixture.cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); });
describe('fleet and server response scope', () => {
it('counts unattached hosts needing attention and rechecks heartbeat age with each snapshot', async () => {
const hosts = await import('../../src/lib/server/hosts/index.js');
const { fleetSnapshot } = await import('../../src/lib/server/fleet/snapshot.js');
const base: import('../../src/lib/hosts.js').Host = { id: 'empty-host', name: 'Empty host', hostname: 'empty-host',
portMin: 28960, portMax: 28980, bindAddress: '0.0.0.0', publicAddress: '', minimumFreeBytes: 1024, createdAt: iso(),
connected: true, connectionState: 'online', lastHeartbeat: iso(),
enrolled: true, credentialActive: true, mode: 'active', readiness: 'ready', readinessReasons: [],
inventory: {}, decommission: null };
vi.spyOn(hosts, 'listHosts').mockReturnValue([
base,
{ ...base, id: 'offline', connected: false },
{ ...base, id: 'degraded', readiness: 'degraded' },
{ ...base, id: 'disabled', connected: false, mode: 'disabled' },
{ ...base, id: 'unenrolled', connected: false, enrolled: false }
]);
expect(fleetSnapshot().hostAttention).toBe(2);
vi.setSystemTime(now + 91_000);
expect(fleetSnapshot().hostAttention).toBe(3);
// None of these hosts has an assigned instance in this fixture.
expect(fleetSnapshot().servers.every(server => !server.host_id)).toBe(true);
});
it('omits completed history from fleet REST and SSR responses', async () => {
const { GET } = await import('../../src/routes/api/fleet/+server.js');
const { load } = await import('../../src/routes/+page.server.js');
+3
View File
@@ -39,6 +39,8 @@ it('keeps current estimates and host comparison before history with scoped histo
expect(body).toContain('No queued instances');
expect(body).not.toContain('href="/provisioning"');
expect(body).not.toContain('Manage hosts');
expect(body).toContain('Updates every 30 seconds');
expect(body).not.toContain('>Refresh<');
});
it('keeps fleet-wide queue scope explicit and preserves unavailable estimates', () => {
@@ -61,6 +63,7 @@ it('keeps ineligible hosts and their reasons visible while collapsing detailed a
expect(body).toMatch(/<tr hidden[^>]*id="capacity-host-ec-1"/);
expect(body).toContain('aria-expanded="false"');
expect(body).toContain('Running instance');
expect(body).toContain('aria-label="Host capacity comparison" tabindex="0"');
});
it('preserves chart gaps, keyboard inspection and reporting coverage in the compact chart', () => {
+9 -1
View File
@@ -41,12 +41,17 @@ it('shows a summary and one paginated breakdown, preserving scope on range links
expect(body).toContain('Partial previous history');
expect(body).toContain('About this report');
expect(body).not.toContain('Explore this period');
expect(body).toContain('Updates every minute');
expect(body).not.toContain('>Refresh<');
const heatmap = body.slice(body.indexOf('class="heatmap'), body.indexOf('aria-label="Metrics breakdown"'));
expect(heatmap).toContain('<h2'); expect(heatmap).toContain('Activity by weekday and hour');
expect(body).not.toContain('class="weekly');
});
it('keeps percentage points, relative changes and unavailable capacity distinct', () => {
const data = report();
let body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('+10 pp'); expect(body).toContain('+100%');
expect(body).toContain('+10 percentage points'); expect(body).toContain('+100%');
data.pooledPressure = { value: null, fullMapHours: 0, knownMapHours: 0, coveredMaps: 0, totalMaps: 24 };
data.maps = [map(1, { capacityObserved: 0 })];
data.previous.summary.playerHours = 0;
@@ -116,6 +121,9 @@ it('keeps accessible chart selection, partial periods and gaps while collapsing
expect(body).toContain('Inspect a period');
expect(body).toContain('Partial period');
expect(body).not.toMatch(/<details[^>]*open/);
expect(body).toContain('Weekdays show when each period starts in America/New York.');
expect(body).toContain('Daily periods are 24-hour windows.');
expect(body).toContain('Server activity over time');
});
it('uses shallow URL state for the selected breakdown while retaining the common summary', () => {
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { compareMetricValues, metricDuration, metricNumber, metricPercent } from '../../src/lib/ui/metrics.js';
import { compareMetricValues, metricDuration, metricNumber, metricPercent, metricPeriodLabels } from '../../src/lib/ui/metrics.js';
describe('metric presentation', () => {
it('distinguishes unavailable evidence, zero, and tiny nonzero values', () => {
@@ -18,4 +18,20 @@ describe('metric presentation', () => {
expect([...values].sort((a, b) => compareMetricValues(a, b, 'desc'))).toEqual([100, 10, 2, 0, null]);
expect([...values].sort((a, b) => compareMetricValues(a, b, 'asc'))).toEqual([0, 2, 10, 100, null]);
});
it('labels daily buckets by their actual starting weekday in the report timezone', () => {
const points = Array.from({ length: 8 }, (_, day) => ({ startAt: Date.parse('2026-10-01T00:00:00Z') + day * 86_400_000 }));
expect(metricPeriodLabels(points, 7, 'America/New_York').map(item => item.label)).toEqual(['Wed', 'Thu', 'Fri', 'Sat', 'Sun', 'Mon', 'Tue', 'Wed']);
expect(metricPeriodLabels(points, 7, 'UTC')[0].label).toBe('Thu');
expect(metricPeriodLabels(points, 7, 'UTC')[0].position).toBe(6.25);
expect(metricPeriodLabels([], 7, 'UTC')).toEqual([]);
});
it('keeps hourly and long-range labels sparse without relabelling UTC buckets as local days', () => {
const points = Array.from({ length: 31 }, (_, day) => ({ startAt: Date.parse('2026-10-01T00:00:00Z') + day * 86_400_000 }));
const labels = metricPeriodLabels(points, 30, 'UTC');
expect(labels.length).toBeLessThanOrEqual(6); expect(labels[0]).toMatchObject({ label: 'Thu', date: '1' });
const hourly = metricPeriodLabels(Array.from({ length: 25 }, (_, hour) => ({ startAt: Date.parse('2026-11-01T04:00:00Z') + hour * 3_600_000 })), 1, 'America/New_York');
expect(hourly[0].label).toBe('12 AM'); expect(hourly[1].label).toBe('4 AM'); expect(hourly).toHaveLength(5);
});
});
+7
View File
@@ -34,6 +34,13 @@ describe('sidebar navigation', () => {
}] });
expect(render(Navigation, { props: { role: 'admin' } }).body).toContain('aria-label="Servers, 1 needs attention"');
});
it('counts hosts without servers and clears the badge when the next host summary is healthy', () => {
fleet.set({ receivedAt: Date.now(), operations: [], servers: [], hostAttention: 2 });
expect(render(Navigation, { props: { role: 'admin' } }).body).toContain('aria-label="Hosts, 2 need attention"');
fleet.update(value => ({ ...value, hostAttention: 0 }));
expect(render(Navigation, { props: { role: 'admin' } }).body).not.toMatch(/aria-label="Hosts, \d/);
});
});
const user: AuthUser = { id: '1', username: 'operator', displayName: 'Operator', role: 'operator', disabled: false,