paseo-plugin-machine-status 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +367 -0
- package/client/charts.tsx +335 -0
- package/client/cleanup.tsx +169 -0
- package/client/docker.tsx +494 -0
- package/client/menu.tsx +181 -0
- package/client/pill-card.tsx +195 -0
- package/client/pill.tsx +155 -0
- package/client/processes.tsx +321 -0
- package/client/services.tsx +251 -0
- package/client/status.tsx +514 -0
- package/client/ui.tsx +263 -0
- package/index.client.tsx +31 -0
- package/index.server.ts +41 -0
- package/package.json +45 -0
- package/paseo-plugin.json +4 -0
- package/server/cleanup.ts +222 -0
- package/server/docker.ts +260 -0
- package/server/exec.ts +62 -0
- package/server/explain-rules.ts +188 -0
- package/server/explain.ts +95 -0
- package/server/metrics.ts +312 -0
- package/server/processes.ts +165 -0
- package/server/services.ts +428 -0
- package/shared/contracts.ts +170 -0
- package/shared/docker.ts +95 -0
- package/shared/explain.ts +24 -0
- package/shared/format.ts +39 -0
- package/shared/pill.ts +63 -0
- package/shared/services-classify.ts +190 -0
- package/shared/services.ts +52 -0
- package/shared/viz.ts +159 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineRpc } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const ExplanationSchema = z.object({
|
|
5
|
+
headline: z.string(),
|
|
6
|
+
kind: z.enum(["apple-system", "agent-spawned", "container", "dev-runtime", "user-app", "unknown"]),
|
|
7
|
+
state: z.enum(["just-started", "sustained-load", "memory-heavy", "steady"]),
|
|
8
|
+
concern: z.enum(["none", "watch", "act"]),
|
|
9
|
+
findings: z.array(z.string()),
|
|
10
|
+
advice: z.array(z.string()),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const explainProcess = defineRpc({
|
|
14
|
+
name: "process.explain",
|
|
15
|
+
input: z.object({ pid: z.number().int().min(0) }),
|
|
16
|
+
output: z.object({
|
|
17
|
+
found: z.boolean(),
|
|
18
|
+
name: z.string(),
|
|
19
|
+
pid: z.number(),
|
|
20
|
+
explanation: ExplanationSchema,
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export type Explanation = z.output<typeof ExplanationSchema>;
|
package/shared/format.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Presentation helpers shared by every surface. Pure functions, no runtime imports. */
|
|
2
|
+
|
|
3
|
+
const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"] as const;
|
|
4
|
+
|
|
5
|
+
export function formatBytes(bytes: number, fractionDigits = 1): string {
|
|
6
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
7
|
+
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), BYTE_UNITS.length - 1);
|
|
8
|
+
const value = bytes / 1024 ** exponent;
|
|
9
|
+
const digits = exponent === 0 ? 0 : fractionDigits;
|
|
10
|
+
return `${value.toFixed(digits)} ${BYTE_UNITS[exponent]}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatPercent(value: number | null, fractionDigits = 0): string {
|
|
14
|
+
if (value === null || !Number.isFinite(value)) return "—";
|
|
15
|
+
return `${value.toFixed(fractionDigits)}%`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatDuration(totalSeconds: number): string {
|
|
19
|
+
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return "—";
|
|
20
|
+
const days = Math.floor(totalSeconds / 86_400);
|
|
21
|
+
const hours = Math.floor((totalSeconds % 86_400) / 3_600);
|
|
22
|
+
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
|
23
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
24
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
25
|
+
return `${minutes}m`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatMinutes(minutes: number | null): string {
|
|
29
|
+
if (minutes === null || !Number.isFinite(minutes) || minutes <= 0) return "—";
|
|
30
|
+
const hours = Math.floor(minutes / 60);
|
|
31
|
+
const rest = Math.round(minutes % 60);
|
|
32
|
+
return hours > 0 ? `${hours}h ${rest}m` : `${rest}m`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Clamps a raw percentage into the 0..100 range a progress bar can render. */
|
|
36
|
+
export function clampPercent(value: number | null): number {
|
|
37
|
+
if (value === null || !Number.isFinite(value)) return 0;
|
|
38
|
+
return Math.max(0, Math.min(100, value));
|
|
39
|
+
}
|
package/shared/pill.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { PortRow } from "./services";
|
|
2
|
+
import { severityOf, type Severity } from "./viz";
|
|
3
|
+
|
|
4
|
+
export interface PillReading {
|
|
5
|
+
/** What the pill shows: two percentages, nothing else fits. */
|
|
6
|
+
text: string;
|
|
7
|
+
/** The worse of the two readings, so one colour can speak for both. */
|
|
8
|
+
severity: Severity;
|
|
9
|
+
/** Spoken label, where there is room to say which number is which. */
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const RANK: Record<Severity, number> = { good: 0, warning: 1, serious: 2, critical: 3 };
|
|
14
|
+
|
|
15
|
+
const show = (value: number | null): string =>
|
|
16
|
+
value === null || !Number.isFinite(value) ? "—" : `${Math.round(value)}%`;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Reduces the machine to what fits beside a text field.
|
|
20
|
+
*
|
|
21
|
+
* CPU load and memory pressure are the two numbers that change while an agent
|
|
22
|
+
* works, and they fail in different ways: one saturates, the other swaps. Both
|
|
23
|
+
* are shown; the colour follows whichever is worse, because a pill has room for
|
|
24
|
+
* one colour and the user needs to know that something is wrong before knowing
|
|
25
|
+
* which one.
|
|
26
|
+
*/
|
|
27
|
+
export function pillReading(cpuLoadPercent: number, pressurePercent: number | null): PillReading {
|
|
28
|
+
const cpu = cpuSeverity(cpuLoadPercent);
|
|
29
|
+
const memory = memorySeverity(pressurePercent);
|
|
30
|
+
return {
|
|
31
|
+
text: `${show(cpuLoadPercent)} · ${show(pressurePercent)}`,
|
|
32
|
+
severity: RANK[cpu] >= RANK[memory] ? cpu : memory,
|
|
33
|
+
label: `Machine load: CPU ${show(cpuLoadPercent)}, memory ${show(pressurePercent)}`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The thresholds the pill colours by, shared with its card so the two never
|
|
39
|
+
* disagree about the same number. CPU load reads as utilisation; memory pressure
|
|
40
|
+
* keeps the higher thresholds the pill has always used.
|
|
41
|
+
*/
|
|
42
|
+
export function cpuSeverity(loadPercent: number | null): Severity {
|
|
43
|
+
return severityOf(loadPercent);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function memorySeverity(pressurePercent: number | null): Severity {
|
|
47
|
+
return severityOf(pressurePercent, 80, 92);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Development ports held by an agent or by a process an agent started, once per
|
|
52
|
+
* port number: the side effects of agent work that outlive the turn that made
|
|
53
|
+
* them. IPv4 and IPv6 listeners on the same port collapse into one row.
|
|
54
|
+
*/
|
|
55
|
+
export function agentPorts(ports: readonly PortRow[], limit = 3): PortRow[] {
|
|
56
|
+
const byPort = new Map<number, PortRow>();
|
|
57
|
+
for (const port of ports) {
|
|
58
|
+
const kind = port.attribution?.kind;
|
|
59
|
+
if (port.relevance !== "dev" || (kind !== "agent" && kind !== "agent-child")) continue;
|
|
60
|
+
if (!byPort.has(port.port)) byPort.set(port.port, port);
|
|
61
|
+
}
|
|
62
|
+
return [...byPort.values()].sort((a, b) => a.port - b.port).slice(0, limit);
|
|
63
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-neutral classification for ports and services.
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately imports nothing from `@getpaseo/plugin`: the same
|
|
5
|
+
* rules are used by the Paseo plugin and by the standalone perf-agent, and the
|
|
6
|
+
* agent has no Paseo runtime to load `defineRpc` from.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
11
|
+
const WILDCARD = new Set(["*", "0.0.0.0", "::", "[::]"]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A wildcard bind is reachable from the network; a loopback bind is not. The
|
|
15
|
+
* distinction is the whole point of the list, so it is computed from the address
|
|
16
|
+
* rather than guessed from the port number.
|
|
17
|
+
*/
|
|
18
|
+
export function scopeOf(address: string): "public" | "lan" | "local" {
|
|
19
|
+
const value = address.replace(/^\[|\]$/g, "");
|
|
20
|
+
if (WILDCARD.has(value)) return "public";
|
|
21
|
+
if (LOOPBACK.has(value)) return "local";
|
|
22
|
+
return "lan";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** netstat writes `127.0.0.1.49165`, `*.3620`, `::1.15432` — the port is after the last dot. */
|
|
26
|
+
export function parseNetstatAddress(field: string): { address: string; port: number } | null {
|
|
27
|
+
const index = field.lastIndexOf(".");
|
|
28
|
+
if (index <= 0) return null;
|
|
29
|
+
const port = Number(field.slice(index + 1));
|
|
30
|
+
if (!Number.isInteger(port) || port <= 0) return null;
|
|
31
|
+
return { address: field.slice(0, index), port };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** lsof writes `*:49258`, `127.0.0.1:15432`, `[::1]:15432` — the port is after the last colon. */
|
|
35
|
+
export function parseLsofAddress(name: string): { address: string; port: number } | null {
|
|
36
|
+
if (name.includes("->")) return null; // an established connection, not a bound socket
|
|
37
|
+
const index = name.lastIndexOf(":");
|
|
38
|
+
if (index <= 0) return null;
|
|
39
|
+
const port = Number(name.slice(index + 1));
|
|
40
|
+
if (!Number.isInteger(port) || port <= 0) return null;
|
|
41
|
+
return { address: name.slice(0, index).replace(/^\[|\]$/g, ""), port };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function portKey(protocol: string, address: string, port: number): string {
|
|
45
|
+
return `${protocol}:${address.replace(/^\[|\]$/g, "")}:${port}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `ps` reports commands as a full path, a bare name, or a name with a version
|
|
50
|
+
* suffix (`next-server (v16.3.1)`). All three have to reduce to the same token.
|
|
51
|
+
*/
|
|
52
|
+
export function executableName(command: string): string {
|
|
53
|
+
const withoutVersion = command.replace(/\s*\(.*\)\s*$/, "").trim();
|
|
54
|
+
return withoutVersion.includes("/")
|
|
55
|
+
? (withoutVersion.split("/").pop() ?? withoutVersion)
|
|
56
|
+
: withoutVersion;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Paths macOS reserves for itself. Nothing under these is development work. */
|
|
60
|
+
const SYSTEM_PREFIXES = [
|
|
61
|
+
"/System/",
|
|
62
|
+
"/usr/libexec/",
|
|
63
|
+
"/usr/sbin/",
|
|
64
|
+
"/Library/Apple/",
|
|
65
|
+
"/Library/PrivilegedHelperTools/",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** Runtimes and services a developer actually starts. */
|
|
69
|
+
const DEV_RUNTIMES = new Set([
|
|
70
|
+
"node", "bun", "deno", "npm", "npx", "pnpm", "yarn", "tsx", "ts-node", "vite",
|
|
71
|
+
"next-server", "next", "esbuild", "webpack", "turbo", "nodemon",
|
|
72
|
+
"python", "python3", "uvicorn", "gunicorn", "flask", "django-admin",
|
|
73
|
+
"ruby", "rails", "puma", "php", "php-fpm", "java", "gradle",
|
|
74
|
+
"go", "air", "cargo", "rustc", "dotnet", "elixir", "beam.smp",
|
|
75
|
+
"postgres", "postmaster", "mysqld", "mongod", "redis-server", "valkey-server",
|
|
76
|
+
"kubectl", "minikube", "ngrok", "cloudflared", "caddy", "nginx",
|
|
77
|
+
"opencode", "claude", "codex", "kimi", "gemini", "aider", "amp", "goose",
|
|
78
|
+
"Paseo Daemon",
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
export interface RelevanceInput {
|
|
82
|
+
kind: "agent" | "agent-child" | "docker" | "process" | null;
|
|
83
|
+
/** Full path when `ps` knows it; the bare command otherwise. */
|
|
84
|
+
command: string;
|
|
85
|
+
owner: "you" | "other";
|
|
86
|
+
home: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Development ports are identified by evidence, never by port number. Ranges do
|
|
91
|
+
* not separate them: 5000 and 7000 here are AirPlay, while 49165 is a coding
|
|
92
|
+
* agent.
|
|
93
|
+
*/
|
|
94
|
+
export function classifyRelevance({ kind, command, owner, home }: RelevanceInput): "dev" | "system" {
|
|
95
|
+
if (kind === "agent" || kind === "agent-child" || kind === "docker") return "dev";
|
|
96
|
+
if (owner === "other") return "system";
|
|
97
|
+
if (SYSTEM_PREFIXES.some((prefix) => command.startsWith(prefix))) return "system";
|
|
98
|
+
|
|
99
|
+
const name = executableName(command);
|
|
100
|
+
if (DEV_RUNTIMES.has(name)) return "dev";
|
|
101
|
+
if (/^com\.docker/.test(name)) return "dev";
|
|
102
|
+
|
|
103
|
+
// Anything the user installed or built under their own home, except the
|
|
104
|
+
// Library tree, which is where macOS keeps per-user daemons.
|
|
105
|
+
if (home.length > 0 && command.startsWith(`${home}/`) && !command.startsWith(`${home}/Library/`)) {
|
|
106
|
+
return "dev";
|
|
107
|
+
}
|
|
108
|
+
return "system";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Known binaries get a readable name. Everything else keeps the command it
|
|
113
|
+
* actually runs — inventing a plausible service name would make the list read as
|
|
114
|
+
* knowledge it does not have.
|
|
115
|
+
*/
|
|
116
|
+
const NAMES: readonly (readonly [RegExp, string])[] = [
|
|
117
|
+
[/^vite$|[/\s]vite$/i, "Vite dev server"],
|
|
118
|
+
[/next-server|^next$/i, "Next.js"],
|
|
119
|
+
[/^postgres$|^postmaster$/i, "PostgreSQL"],
|
|
120
|
+
[/^redis-server$/i, "Redis"],
|
|
121
|
+
[/^valkey/i, "Valkey"],
|
|
122
|
+
[/^mysqld$/i, "MySQL"],
|
|
123
|
+
[/^mongod$/i, "MongoDB"],
|
|
124
|
+
[/^sshd$/i, "OpenSSH"],
|
|
125
|
+
[/^tailscaled?$/i, "Tailscale"],
|
|
126
|
+
[/^com\.docke|^docker/i, "Docker"],
|
|
127
|
+
[/^esbuild$/i, "esbuild"],
|
|
128
|
+
[/^ControlCenter$/i, "Control Center"],
|
|
129
|
+
[/^rapportd$/i, "Handoff (rapportd)"],
|
|
130
|
+
[/^kubectl/i, "kubectl port-forward"],
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
export function friendlyName(command: string): string {
|
|
134
|
+
const base = command.split("/").pop() ?? command;
|
|
135
|
+
for (const [pattern, name] of NAMES) {
|
|
136
|
+
if (pattern.test(base)) return name;
|
|
137
|
+
}
|
|
138
|
+
return base;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Only a locally reachable TCP port can be opened in a browser. */
|
|
142
|
+
export function httpUrlFor(row: {
|
|
143
|
+
protocol: string;
|
|
144
|
+
scope: string;
|
|
145
|
+
port: number;
|
|
146
|
+
}): string | null {
|
|
147
|
+
if (row.protocol !== "tcp") return null;
|
|
148
|
+
if (row.scope === "lan") return null;
|
|
149
|
+
return `http://127.0.0.1:${row.port}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The SDK's list shape is not pinned down by the docs, so the result is read
|
|
154
|
+
* defensively: an array, or the first array-valued key of an object. A shape
|
|
155
|
+
* change degrades this panel instead of breaking the whole RPC.
|
|
156
|
+
*/
|
|
157
|
+
export function normalizeAgents(
|
|
158
|
+
raw: unknown,
|
|
159
|
+
/** Expands the `~/…` form the Paseo CLI prints; the SDK returns absolute paths. */
|
|
160
|
+
home = "",
|
|
161
|
+
): {
|
|
162
|
+
id: string;
|
|
163
|
+
provider: string;
|
|
164
|
+
cwd: string;
|
|
165
|
+
title: string;
|
|
166
|
+
status: string;
|
|
167
|
+
}[] {
|
|
168
|
+
const list = Array.isArray(raw)
|
|
169
|
+
? raw
|
|
170
|
+
: raw && typeof raw === "object"
|
|
171
|
+
? (Object.values(raw as Record<string, unknown>).find(Array.isArray) as unknown[] | undefined) ?? []
|
|
172
|
+
: [];
|
|
173
|
+
|
|
174
|
+
return list
|
|
175
|
+
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === "object")
|
|
176
|
+
.map((entry) => {
|
|
177
|
+
const config = (entry.config ?? {}) as Record<string, unknown>;
|
|
178
|
+
const provider = String(entry.provider ?? config.provider ?? "");
|
|
179
|
+
const cwd = String(entry.cwd ?? "");
|
|
180
|
+
return {
|
|
181
|
+
id: String(entry.id ?? ""),
|
|
182
|
+
// "codex/gpt-5.5" identifies the binary by its first segment.
|
|
183
|
+
provider: provider.split("/")[0]?.toLowerCase() ?? "",
|
|
184
|
+
cwd: home && cwd.startsWith("~/") ? `${home}/${cwd.slice(2)}` : cwd,
|
|
185
|
+
title: String(entry.title ?? entry.name ?? ""),
|
|
186
|
+
status: String(entry.status ?? ""),
|
|
187
|
+
};
|
|
188
|
+
})
|
|
189
|
+
.filter((agent) => agent.provider.length > 0);
|
|
190
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { defineRpc } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const PortSchema = z.object({
|
|
5
|
+
id: z.string(),
|
|
6
|
+
port: z.number(),
|
|
7
|
+
protocol: z.enum(["tcp", "udp"]),
|
|
8
|
+
address: z.string(),
|
|
9
|
+
scope: z.enum(["public", "lan", "local"]),
|
|
10
|
+
pid: z.number().nullable(),
|
|
11
|
+
command: z.string().nullable(),
|
|
12
|
+
/** "other" means the socket exists but belongs to a user we cannot inspect. */
|
|
13
|
+
owner: z.enum(["you", "other"]),
|
|
14
|
+
/** Whether this port belongs to development work or to the OS. */
|
|
15
|
+
relevance: z.enum(["dev", "system"]),
|
|
16
|
+
attribution: z
|
|
17
|
+
.object({
|
|
18
|
+
kind: z.enum(["agent", "agent-child", "docker", "process"]),
|
|
19
|
+
label: z.string(),
|
|
20
|
+
detail: z.string(),
|
|
21
|
+
})
|
|
22
|
+
.nullable(),
|
|
23
|
+
url: z.string().nullable(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const ServiceSchema = z.object({
|
|
27
|
+
id: z.string(),
|
|
28
|
+
name: z.string(),
|
|
29
|
+
subtitle: z.string(),
|
|
30
|
+
ports: z.array(z.number()),
|
|
31
|
+
status: z.enum(["running", "stopped"]),
|
|
32
|
+
source: z.enum(["agent", "agent-child", "docker", "listener"]),
|
|
33
|
+
relevance: z.enum(["dev", "system"]),
|
|
34
|
+
pid: z.number().nullable(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const readServices = defineRpc({
|
|
38
|
+
name: "services.read",
|
|
39
|
+
input: z.object({}),
|
|
40
|
+
output: z.object({
|
|
41
|
+
collectedAt: z.string(),
|
|
42
|
+
ports: z.array(PortSchema),
|
|
43
|
+
services: z.array(ServiceSchema),
|
|
44
|
+
/** Anything the collection could not see, stated rather than silently dropped. */
|
|
45
|
+
notes: z.array(z.string()),
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export type PortRow = z.output<typeof PortSchema>;
|
|
50
|
+
export type ServiceRow = z.output<typeof ServiceSchema>;
|
|
51
|
+
|
|
52
|
+
export * from "./services-classify";
|
package/shared/viz.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart palette and geometry. Pure values and pure functions only, so the colour
|
|
3
|
+
* decisions and the bar maths are both testable without a renderer.
|
|
4
|
+
*
|
|
5
|
+
* Paseo exposes six theme tokens and never says which theme is active, so the
|
|
6
|
+
* data palette is chosen by measuring the luminance of `surface0`. Chrome comes
|
|
7
|
+
* from the theme; series colour does not — a categorical hue has to stay the
|
|
8
|
+
* same hue across themes or it stops identifying anything.
|
|
9
|
+
*
|
|
10
|
+
* Both columns below are the reference categorical order and were validated with
|
|
11
|
+
* scripts/validate_palette.js against their own surface: every hard gate passes
|
|
12
|
+
* in both modes (worst adjacent CVD ΔE 8.4 dark / 9.1 light, normal-vision 19.8
|
|
13
|
+
* dark / 22.9 light). The light column's aqua and yellow sit under 3:1, which the
|
|
14
|
+
* relief rule permits because every series is directly labelled with its value.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface VizPalette {
|
|
18
|
+
/** Categorical slots in fixed order. Assigned by slot, never cycled. */
|
|
19
|
+
series: readonly [string, string, string, string];
|
|
20
|
+
/** For "free"/"unused" remainders, which are an absence rather than a category. */
|
|
21
|
+
neutral: string;
|
|
22
|
+
grid: string;
|
|
23
|
+
/**
|
|
24
|
+
* Hairline ring. Deliberately NOT a text token: `foregroundMuted` is tuned to be
|
|
25
|
+
* legible as type, which makes it far too loud for a 1px edge.
|
|
26
|
+
*/
|
|
27
|
+
border: string;
|
|
28
|
+
borderStrong: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DARK_PALETTE: VizPalette = {
|
|
32
|
+
series: ["#3987e5", "#d95926", "#199e70", "#c98500"],
|
|
33
|
+
neutral: "#383835",
|
|
34
|
+
grid: "#2c2c2a",
|
|
35
|
+
border: "rgba(255,255,255,0.10)",
|
|
36
|
+
borderStrong: "rgba(255,255,255,0.18)",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const LIGHT_PALETTE: VizPalette = {
|
|
40
|
+
series: ["#2a78d6", "#eb6834", "#1baf7a", "#eda100"],
|
|
41
|
+
neutral: "#c3c2b7",
|
|
42
|
+
grid: "#e1e0d9",
|
|
43
|
+
border: "rgba(11,11,11,0.10)",
|
|
44
|
+
borderStrong: "rgba(11,11,11,0.18)",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Fixed across themes: a status colour must never impersonate a series. */
|
|
48
|
+
export const STATUS = {
|
|
49
|
+
good: "#0ca30c",
|
|
50
|
+
warning: "#fab219",
|
|
51
|
+
serious: "#ec835a",
|
|
52
|
+
critical: "#d03b3b",
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
export type Severity = keyof typeof STATUS;
|
|
56
|
+
|
|
57
|
+
const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
58
|
+
const RGB = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i;
|
|
59
|
+
|
|
60
|
+
function channel(value: number): number {
|
|
61
|
+
const c = value / 255;
|
|
62
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** WCAG relative luminance, or null when the colour is in a format we cannot read. */
|
|
66
|
+
export function relativeLuminance(color: string): number | null {
|
|
67
|
+
const trimmed = color.trim();
|
|
68
|
+
|
|
69
|
+
const hex = HEX.exec(trimmed);
|
|
70
|
+
if (hex) {
|
|
71
|
+
const digits = hex[1].length === 3 ? hex[1].replace(/./g, (d) => d + d) : hex[1];
|
|
72
|
+
const [r, g, b] = [0, 2, 4].map((offset) => parseInt(digits.slice(offset, offset + 2), 16));
|
|
73
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rgb = RGB.exec(trimmed);
|
|
77
|
+
if (rgb) {
|
|
78
|
+
const [r, g, b] = [1, 2, 3].map((index) => Number(rgb[index]));
|
|
79
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Unreadable colour formats fall back to dark, which is Paseo's default. */
|
|
86
|
+
export function isDarkSurface(color: string): boolean {
|
|
87
|
+
const luminance = relativeLuminance(color);
|
|
88
|
+
return luminance === null ? true : luminance < 0.4;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function paletteFor(surface: string): VizPalette {
|
|
92
|
+
return isDarkSurface(surface) ? DARK_PALETTE : LIGHT_PALETTE;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Severity for a utilisation percentage. Always paired with a text label in the
|
|
97
|
+
* UI: a status colour never carries the meaning on its own.
|
|
98
|
+
*/
|
|
99
|
+
export function severityOf(percent: number | null, warnAt = 75, criticalAt = 90): Severity {
|
|
100
|
+
if (percent === null || !Number.isFinite(percent)) return "good";
|
|
101
|
+
if (percent >= criticalAt) return "critical";
|
|
102
|
+
if (percent >= warnAt) return "warning";
|
|
103
|
+
return "good";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const SEVERITY_LABEL: Record<Severity, string> = {
|
|
107
|
+
good: "OK",
|
|
108
|
+
warning: "High",
|
|
109
|
+
serious: "Serious",
|
|
110
|
+
critical: "Critical",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Column heights as percentages, for a bar-per-sample chart. */
|
|
114
|
+
export function columnHeights(values: readonly number[], min: number, max: number): number[] {
|
|
115
|
+
const span = max - min;
|
|
116
|
+
if (span <= 0) return values.map(() => 0);
|
|
117
|
+
return values.map((value) => {
|
|
118
|
+
const clamped = Math.max(min, Math.min(max, value));
|
|
119
|
+
return ((clamped - min) / span) * 100;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Splits one stacked column into segment percentages of `total`. The remainder is
|
|
125
|
+
* returned as the last entry so the column always sums to 100.
|
|
126
|
+
*/
|
|
127
|
+
export function stackedColumn(parts: readonly number[], total: number): number[] {
|
|
128
|
+
if (total <= 0) return parts.map(() => 0).concat(100);
|
|
129
|
+
const segments = parts.map((part) => Math.max(0, (part / total) * 100));
|
|
130
|
+
const used = segments.reduce((sum, segment) => sum + segment, 0);
|
|
131
|
+
return segments.concat(Math.max(0, 100 - used));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Right-aligns a series into a fixed number of slots, padding the left with nulls.
|
|
136
|
+
*
|
|
137
|
+
* A live monitor's plot has a constant width whether or not history has filled it:
|
|
138
|
+
* the newest sample belongs at the right edge next to "now", and the empty left is
|
|
139
|
+
* the honest picture of how much history exists. Stretching a handful of samples
|
|
140
|
+
* across the full width would instead imply they span the whole window.
|
|
141
|
+
*/
|
|
142
|
+
export function padSlots<T>(values: readonly T[], slots: number): (T | null)[] {
|
|
143
|
+
if (slots <= 0) return [];
|
|
144
|
+
if (values.length >= slots) return values.slice(values.length - slots) as T[];
|
|
145
|
+
return [...new Array<null>(slots - values.length).fill(null), ...values];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function takeLast<T>(items: readonly T[], count: number): T[] {
|
|
149
|
+
return count >= items.length ? [...items] : items.slice(items.length - count);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Human span between the first and last sample, for an honest axis caption. */
|
|
153
|
+
export function spanLabel(first: string | undefined, last: string | undefined): string {
|
|
154
|
+
if (!first || !last) return "no history yet";
|
|
155
|
+
const seconds = Math.round((Date.parse(last) - Date.parse(first)) / 1000);
|
|
156
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return "just started";
|
|
157
|
+
if (seconds < 90) return `last ${seconds}s`;
|
|
158
|
+
return `last ${Math.round(seconds / 60)} min`;
|
|
159
|
+
}
|