1/** Shapes and rules shared by the server and the web app; keep this free of Node imports. */
2
3export type Health = "healthy" | "degraded" | "down" | "stopped" | "deploying" | "restarting" | "starting";
4
5/** Columnar series, the layout uPlot consumes directly. Times are unix seconds. */
6export interface Series {
7 name: string;
8 t: number[];
9 v: (number | null)[];
10}
11
12export interface Me {
13 name: string;
14 groups: string[];
15 sections: Section[];
16 /** An admin previewing the dashboard as `groups`. */
17 viewing: boolean;
18}
19
20/** Cookie holding the comma-separated groups an admin previews the dashboard as. */
21export const VIEW_AS = "view-as";
22
23/** The Keycloak group that opens each dashboard section; null opens it to everyone. */
24const SECTION_GROUPS = { launcher: null, admin: "infra-admin", metrics: "metrics", media: "media-manage", vms: "vm" } as const;
25export type Section = keyof typeof SECTION_GROUPS;
26
27/** Admins reach everything; `access` null is open to every signed-in user. */
28export const canOpen = (groups: string[], access: string | null) =>
29 access === null || groups.includes("infra-admin") || groups.includes(access);
30
31export const sectionsOf = (groups: string[]) =>
32 (Object.keys(SECTION_GROUPS) as Section[]).filter((section) => canOpen(groups, SECTION_GROUPS[section]));
33
34export interface ServiceSummary {
35 id: string;
36 name: string;
37 health: Health;
38 url: string | null;
39 /** Versioned image URLs per color scheme; the same URL twice when the service has one image. */
40 icon: { light: string; dark: string } | null;
41 /** Keycloak group that sees this service in the launcher; null means everyone. */
42 access: string | null;
43 /** What the app is for, in a few words, for people who don't know it by name. */
44 tagline: string | null;
45 /** Cores in use and reserved; use is null while Nomad doesn't measure it. */
46 cpu: number | null;
47 cpuLimit: number;
48 /** Bytes in use and reserved; use is null while Nomad doesn't measure it. */
49 memory: number | null;
50 memoryLimit: number;
51}
52
53/** States that need someone to look, unlike stopped (on purpose) or the passing ones like restarting. */
54export const trouble = (health: Health) => health === "down" || health === "degraded";
55
56/** A service that is currently down or degraded. */
57export interface Issue {
58 service: Pick<ServiceSummary, "id" | "name" | "icon" | "url">;
59 health: Health;
60 /** The failing check's output while it's down or degraded. */
61 failing: string | null;
62}
63
64/** When a task runs relative to the main ones; null for a main task. */
65export type Hook = "prestart" | "poststart" | "poststop" | null;
66
67/** A Nomad task; its restarts count within the current allocation. */
68export interface Container {
69 name: string;
70 /** As configured; null for tasks that don't run an image. */
71 image: string | null;
72 hook: Hook;
73 state: "running" | "pending" | "dead";
74 restarts: number;
75 lastRestart: { t: number; reason: string } | null;
76 startedAt: number | null;
77}
78
79/** The service on the other end of a dependency, and what it provides ("database", "sign-in"). */
80export interface ServiceLink {
81 id: string;
82 name: string;
83 kind: string;
84 health: Health;
85}
86
87/** The latest restart, unless it happened before restarts were last acknowledged. */
88export function unacknowledgedRestart(service: Pick<ServiceDetail, "containers" | "restartsAcknowledged">) {
89 const last = service.containers.flatMap((container) => container.lastRestart ?? []).sort((a, b) => b.t - a.t)[0];
90 return last && (service.restartsAcknowledged === null || last.t > service.restartsAcknowledged) ? last : null;
91}
92
93/** Lists that are null come from a source the host doesn't expose yet. */
94export interface ServiceDetail extends ServiceSummary {
95 /** Application spans observed in the trace store, excluding Caddy's edge spans. */
96 applicationTraces: boolean;
97 /** Metric names ingested for this service. */
98 applicationMetrics: string[];
99 /** The release this service's job was rendered from. */
100 release: string | null;
101 /** When Nomad was last handed this job. */
102 deployedAt: number;
103 rollout: "simple" | "overlapped";
104 containers: Container[];
105 checks: { name: string; passing: boolean; output: string }[];
106 requirements: ServiceLink[] | null;
107 dependents: ServiceLink[] | null;
108 /** Generated secrets can be rotated; the rest are supplied by hand. */
109 secrets: { name: string; generated: boolean }[] | null;
110 /** `used` excludes what snapshots hold. */
111 datasets: { name: string; mountpoint: string; used: number; snapshots: number }[];
112 /** When restarts were last acknowledged; ones before it no longer need attention. */
113 restartsAcknowledged: number | null;
114 /** This service's logs in the Logs web UI; null when that UI isn't set up. */
115 logs: AppLink | null;
116}
117
118/** A service's Nomad job as submitted, one entry per task group. Durations are seconds. */
119export interface ServiceDefinition {
120 groups: {
121 name: string;
122 count: number;
123 /** Restarts allowed per `interval`, `delay` apart; once spent, `fail` gives up instead of waiting out the interval. */
124 restart: { attempts: number; interval: number; delay: number; fail: boolean };
125 services: {
126 name: string;
127 port: string;
128 /** Hostnames the router sends here; empty for a service only other services reach. */
129 hostnames: string[];
130 /** The Keycloak group that must sign in first; null for no sign-in gate. */
131 authRole: string | null;
132 /** `restartAfter` failures in a row restart `task`, counted once `grace` has passed since it started. */
133 check: {
134 task: string;
135 type: string;
136 path: string;
137 interval: number;
138 timeout: number;
139 restartAfter: { failures: number; grace: number } | null;
140 } | null;
141 }[];
142 tasks: TaskDefinition[];
143 }[];
144 /** The service file in the current release; null when the release has none. */
145 source: string | null;
146}
147
148export interface TaskDefinition {
149 name: string;
150 hook: Hook;
151 /** A hook task that keeps running beside the main ones. */
152 sidecar: boolean;
153 image: string | null;
154 /** `uid:gid`; null runs as the image's user. */
155 user: string | null;
156 /** Cores and bytes reserved; `memoryMax` is null without room to burst past `memory`. */
157 cpu: number;
158 memory: number;
159 memoryMax: number | null;
160 /** `host` is null for a port Nomad picks at each start; `network` is "loopback" or "default". */
161 ports: { label: string; container: number | null; host: number | null; network: string }[];
162 mounts: { source: string; target: string; readOnly: boolean }[];
163 tmpfs: string[];
164 devices: string[];
165 capabilities: string[];
166 hostNetwork: boolean;
167 extraHosts: string[];
168 /** Variable names only; values never leave the server. `secret` and `template` ones are filled in as the task starts. */
169 env: { name: string; from: "job" | "secret" | "template" }[];
170}
171
172/** A deep link into another app's web UI. */
173export interface AppLink {
174 app: Pick<ServiceSummary, "id" | "name" | "icon">;
175 url: string;
176}
177
178/** An OpenTelemetry span; times are unix seconds. */
179export interface Span {
180 id: string;
181 parent: string | null;
182 service: string;
183 name: string;
184 start: number;
185 duration: number;
186 /** The status message of a failed span. */
187 error: string | null;
188 attributes: Record<string, string | number>;
189}
190
191/** Spans depth first, so the root comes first and every parent precedes its children. */
192export interface Trace {
193 id: string;
194 spans: Span[];
195}
196
197/** A trace as a list shows it, without the spans under its root. */
198export interface TraceSummary {
199 id: string;
200 root: Span;
201 spans: number;
202 services: string[];
203 /** The first failed span's status message. */
204 error: string | null;
205}
206
207export interface LogLine {
208 t: number;
209 container: string;
210 stream: "stdout" | "stderr";
211 level: "debug" | "info" | "warn" | "error" | null;
212 text: string;
213}
214
215export interface HostInfo {
216 cores: number;
217 memory: number;
218 bootedAt: number;
219 /**
220 * Stretches the UPS ran on battery, oldest first, from upsmon's ONBATT and ONLINE events; `end` is null until mains
221 * returns. Null while no UPS is connected.
222 */
223 outages: { start: number; end: number | null }[] | null;
224}
225
226export interface Ups {
227 status: "online" | "battery" | "charging" | "unknown";
228 /** Seconds of battery at the current load. */
229 runtime: number;
230 /** Watts. */
231 load: number;
232 /** Battery charge in percent. */
233 charge: number;
234}
235
236/** Seconds between ticks of the live stream. */
237export const LIVE_INTERVAL = 2;
238
239/** qBittorrent's `eta` when it has no estimate. */
240export const UNKNOWN_ETA = 8640000;
241
242/** One tick of the live stream; every value is the latest sample. */
243export interface Live {
244 t: number;
245 /** `cpu` in percent of the machine; `arc` is null without ZFS; `temperature` is the CPU package's in °C, null without a sensor. */
246 host: { cpu: number; memory: number; arc: number | null; temperature: number | null };
247 /** Empty while Nomad can't be reached. */
248 services: Record<string, Pick<ServiceSummary, "cpu" | "memory" | "health">>;
249 /** Null while no UPS is connected. */
250 ups: Ups | null;
251}
252
253export const METRICS = [
254 "host.cpu", "host.memory", "host.temperature", "host.power", "host.gpu", "host.network",
255 "service.cpu", "service.memory", "vm.cpu", "vm.memory",
256] as const;
257export type Metric = (typeof METRICS)[number];
258
259export const ACTIONS = ["restart", "stop", "start"] as const;
260export type Action = (typeof ACTIONS)[number];