diff --git a/decnet_web/src/components/SwarmHosts/helpers.test.ts b/decnet_web/src/components/SwarmHosts/helpers.test.ts new file mode 100644 index 00000000..c4228940 --- /dev/null +++ b/decnet_web/src/components/SwarmHosts/helpers.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { + AGENT_NAME_RE, bundleSecondsLeft, formatMmSs, shortFp, +} from './helpers'; + +describe('shortFp', () => { + it('truncates long fingerprints with an ellipsis', () => { + expect(shortFp('a'.repeat(64))).toBe('aaaaaaaaaaaaaaaa…'); + }); + + it('returns em-dash for empty input', () => { + expect(shortFp('')).toBe('—'); + }); +}); + +describe('AGENT_NAME_RE', () => { + it('accepts lowercase / digits / dashes within 1..63 chars', () => { + expect(AGENT_NAME_RE.test('agent-1')).toBe(true); + expect(AGENT_NAME_RE.test('a')).toBe(true); + expect(AGENT_NAME_RE.test('1')).toBe(true); + }); + + it('rejects uppercase, leading dash, and oversized names', () => { + expect(AGENT_NAME_RE.test('Agent-1')).toBe(false); + expect(AGENT_NAME_RE.test('-leading')).toBe(false); + expect(AGENT_NAME_RE.test('a'.repeat(64))).toBe(false); + expect(AGENT_NAME_RE.test('')).toBe(false); + }); +}); + +describe('bundleSecondsLeft', () => { + it('returns the floor of (expires - now) / 1000 in seconds', () => { + const now = new Date('2026-05-09T08:00:00Z').getTime(); + const exp = new Date('2026-05-09T08:04:30Z').toISOString(); + expect(bundleSecondsLeft(exp, now)).toBe(270); + }); + + it('clamps to zero when already expired', () => { + const now = new Date('2026-05-09T08:10:00Z').getTime(); + expect(bundleSecondsLeft('2026-05-09T08:05:00Z', now)).toBe(0); + }); + + it('returns 0 for unparseable timestamps', () => { + expect(bundleSecondsLeft('not-a-date', Date.now())).toBe(0); + }); +}); + +describe('formatMmSs', () => { + it('zero-pads minutes and seconds', () => { + expect(formatMmSs(75)).toEqual({ mm: '01', ss: '15' }); + expect(formatMmSs(9)).toEqual({ mm: '00', ss: '09' }); + expect(formatMmSs(0)).toEqual({ mm: '00', ss: '00' }); + }); +}); diff --git a/decnet_web/src/components/SwarmHosts/helpers.ts b/decnet_web/src/components/SwarmHosts/helpers.ts new file mode 100644 index 00000000..606f5cda --- /dev/null +++ b/decnet_web/src/components/SwarmHosts/helpers.ts @@ -0,0 +1,29 @@ +import type { ApiError } from '../../utils/api'; + +export const AGENT_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/; + +export const shortFp = (fp: string): string => + (fp ? fp.slice(0, 16) + '…' : '—'); + +export function extractErrorDetail(err: unknown, fallback: string): string { + const e = err as ApiError; + if (e?.response?.data?.detail) return e.response.data.detail; + if (e?.response?.status === 403) return 'Insufficient permissions (admin only)'; + if (e?.response?.status === 401) return 'Session expired — please log in again'; + if (e?.message) return e.message; + return fallback; +} + +/** Seconds remaining until the bundle expires, clamped to >= 0. */ +export function bundleSecondsLeft(expiresAt: string, now: number): number { + const t = new Date(expiresAt).getTime(); + if (Number.isNaN(t)) return 0; + return Math.max(0, Math.floor((t - now) / 1000)); +} + +export function formatMmSs(seconds: number): { mm: string; ss: string } { + return { + mm: Math.floor(seconds / 60).toString().padStart(2, '0'), + ss: (seconds % 60).toString().padStart(2, '0'), + }; +} diff --git a/decnet_web/src/components/SwarmHosts/types.ts b/decnet_web/src/components/SwarmHosts/types.ts new file mode 100644 index 00000000..851a5c78 --- /dev/null +++ b/decnet_web/src/components/SwarmHosts/types.ts @@ -0,0 +1,27 @@ +export interface SwarmHost { + uuid: string; + name: string; + address: string; + agent_port: number; + status: string; + last_heartbeat: string | null; + client_cert_fingerprint: string; + updater_cert_fingerprint: string | null; + enrolled_at: string; + notes: string | null; +} + +export interface BundleResult { + token: string; + host_uuid: string; + command: string; + expires_at: string; +} + +export interface BundleRequest { + master_host: string; + agent_name: string; + with_updater: boolean; + use_ipvlan: boolean; + services_ini: string | null; +}