/** * Pure helpers for the Home dashboard — stat derivation, health classification, * project sorting, and activity formatting. Kept DOM-free so they unit-test in * isolation; `Home.vue` and its composables stay thin orchestration. */ import type { DiscoveredProject } from "../composables/useDiscovery"; import type { ManifestView } from "./manifest"; import type { TelemetryRecord } from "./telemetry"; import { isFailure } from "./telemetry"; import { recordColorKey } from "./kind-colors"; /** * A project's reachability, derived from its live status. The server reports * `{ hasManifest, processes }` per cwd (see `/__nwire/projects/status`): * * - `running` — at least one live process is registered * - `on-disk` — no process, but a `.nwire/manifest.json` exists (browsable) * - `unreachable` — neither a process nor a manifest (stale catalog entry) */ export type ProjectHealth = "running" | "on-disk" | "unreachable"; /** Status fields we actually read off the discovery response (shape is server-owned). */ interface StatusShape { readonly hasManifest?: boolean; readonly processes?: ReadonlyArray<{ readonly port?: number }>; readonly running?: boolean; readonly port?: number; } /** Classify a discovered project's reachability from its live status. */ export function projectHealth(project: DiscoveredProject): ProjectHealth { const status = project.status as StatusShape | undefined; const procCount = status?.processes?.length ?? 0; if (procCount > 0 || status?.running === true) return "running"; if (status?.hasManifest === true) return "on-disk"; return "unreachable"; } /** Map a health to a `StatusBadge` status token. */ export function healthBadge(health: ProjectHealth): "live" | "idle" | "error" { switch (health) { case "running": return "live"; case "on-disk": return "idle"; case "unreachable": return "error"; } } /** Human label for a health state. */ export function healthLabel(health: ProjectHealth): string { switch (health) { case "running": return "Running"; case "on-disk": return "On disk"; case "unreachable": return "Unreachable"; } } /** First live port for a running project, if the status carries one. */ export function projectPort(project: DiscoveredProject): number | undefined { const status = project.status as StatusShape | undefined; return status?.processes?.find((p) => typeof p.port === "number")?.port ?? status?.port; } const HEALTH_RANK: Record = { running: 0, "on-disk": 1, unreachable: 2, }; /** * Sort discovered projects for the grid: active first, then by health * (running → on-disk → unreachable), then alphabetically. Pure; returns a new * array. */ export function sortProjects(projects: readonly DiscoveredProject[]): DiscoveredProject[] { return [...projects].sort((a, b) => { if (a.active !== b.active) return a.active ? -1 : 1; const rank = HEALTH_RANK[projectHealth(a)] - HEALTH_RANK[projectHealth(b)]; if (rank !== 0) return rank; return a.snapshot.name.localeCompare(b.snapshot.name); }); } /** The quick-stat counts shown on a project card, from its catalog snapshot. */ export interface ProjectStats { readonly apps: number; readonly plugins: number; readonly actions: number; readonly events: number; readonly resolvers: number; readonly workflows: number; } /** * Composition counts for a project card. Drawn from the catalog snapshot the * project's own Studio last wrote — tolerant of an absent snapshot (a freshly * discovered project that hasn't been opened yet shows zeros, not a crash). */ export function projectStats(project: DiscoveredProject): ProjectStats { const c = project.snapshot.composition; return { apps: c?.apps ?? 0, plugins: c?.plugins ?? 0, actions: c?.actions ?? 0, events: c?.events ?? 0, resolvers: c?.resolvers ?? 0, workflows: c?.workflows ?? 0, }; } /** True when a snapshot carries no composition yet (manifest not generated). */ export function statsArePending(project: DiscoveredProject): boolean { return project.snapshot.composition == null; } /** One KPI in the active-project quick-stats strip. */ export interface QuickStat { readonly label: string; readonly value: number; readonly kind: string; } /** * Quick-stats for the active project's KPI strip, derived from the live * manifest view + a telemetry error count. Tolerates a null view (manifest not * loaded / empty) by reporting zeros. Pure. */ export function quickStats(view: ManifestView | null, errorCount: number): QuickStat[] { const count = (kind: string) => (view ? view.byKind(kind).length : 0); const total = view ? view.model.nodes.length : 0; return [ { label: "Nodes", value: total, kind: "app" }, { label: "Handlers", value: count("handler"), kind: "handler" }, { label: "Actions", value: count("action"), kind: "action" }, { label: "Events", value: count("event"), kind: "event" }, { label: "Projections", value: count("projection"), kind: "projection" }, { label: "Errors", value: errorCount, kind: "error" }, ]; } /** Count failure records in a telemetry buffer (the "errors in window" KPI). */ export function countErrors(records: readonly TelemetryRecord[]): number { let n = 0; for (const r of records) if (isFailure(r)) n++; return n; } /** A compact activity row for the recent-activity feed. */ export interface ActivityItem { readonly kind: string; /** Colour key for `kindColor`/`KindBadge` (the dotted kind's family). */ readonly colorKey: string; /** Short human label — the handler/event name where present, else the kind. */ readonly label: string; readonly ts?: string; readonly correlationId?: string; readonly failed: boolean; } /** * Pull a stable label out of a telemetry record — the named subject * (action/event/handler) when present, falling back to the kind. */ export function activityLabel(rec: TelemetryRecord): string { const candidates = ["action", "event", "handler", "name", "query", "actor", "projection"]; for (const key of candidates) { const v = (rec as Record)[key]; if (typeof v === "string" && v.length > 0) return v; } return rec.kind; } /** * The latest `limit` telemetry records as compact activity items, newest first. * The buffer is oldest-first; we take the tail and reverse. Pure. */ export function recentActivity(records: readonly TelemetryRecord[], limit = 8): ActivityItem[] { const tail = records.slice(Math.max(0, records.length - limit)); return tail.reverse().map((rec) => ({ kind: rec.kind, colorKey: recordColorKey(rec.kind), label: activityLabel(rec), ts: rec.ts, correlationId: rec.envelope?.correlationId, failed: isFailure(rec), })); }