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