Standardize UI typography, charts and empty states
This commit is contained in:
@@ -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: '' };
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user