small ahh change But Format

This commit is contained in:
lda
2026-04-14 02:18:07 +07:00 Unverified
parent 458123428e
commit f52b7c18d9
12 changed files with 1409 additions and 699 deletions
+4 -1
View File
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"react": "^18.3.1",
@@ -21,6 +23,7 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.1",
"babel-plugin-react-compiler": "^1.0.0",
"prettier": "^3.8.2",
"typescript": "^5.6.3",
"vite": "^5.4.8"
}
+860 -500
View File
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -36,7 +36,8 @@ export function App() {
setState("loading");
setError("");
try {
const [nextAgents, nextAlerts, nextHistory, nextAudit] = await Promise.all([
const [nextAgents, nextAlerts, nextHistory, nextAudit] =
await Promise.all([
fetchAgents(),
fetchAlerts(),
fetchAlertHistory(20),
@@ -57,7 +58,10 @@ export function App() {
}
async function refreshAlertsAndHistory() {
const [nextAlerts, nextHistory] = await Promise.all([fetchAlerts(), fetchAlertHistory(20)]);
const [nextAlerts, nextHistory] = await Promise.all([
fetchAlerts(),
fetchAlertHistory(20),
]);
setAlerts(nextAlerts);
setHistory(nextHistory);
}
@@ -144,7 +148,12 @@ export function App() {
/>
<Route
path="audit"
element={<AuditPage events={audit} onRefresh={() => fetchAudit(30).then(setAudit)} />}
element={
<AuditPage
events={audit}
onRefresh={() => fetchAudit(30).then(setAudit)}
/>
}
/>
<Route
path="alerts"
+26 -8
View File
@@ -88,42 +88,60 @@ export function fetchAlerts(): Promise<Alert[]> {
}
export function fetchAlertHistory(limit = 50): Promise<AlertTransition[]> {
return request<AlertTransition[]>(`/api/v1/control/alerts/history?limit=${limit}`);
return request<AlertTransition[]>(
`/api/v1/control/alerts/history?limit=${limit}`,
);
}
export function fetchAudit(limit = 50): Promise<AuditEvent[]> {
return request<AuditEvent[]>(`/api/v1/control/audit/events?limit=${limit}`);
}
export function fetchEnrollTokens(includeExpired = false): Promise<EnrollTokenStatus[]> {
export function fetchEnrollTokens(
includeExpired = false,
): Promise<EnrollTokenStatus[]> {
return request<EnrollTokenStatus[]>(
`/api/v1/control/enroll-tokens?include_expired=${includeExpired ? "true" : "false"}`,
);
}
export function issueEnrollToken(ttlSeconds: number): Promise<IssueEnrollTokenResponse> {
export function issueEnrollToken(
ttlSeconds: number,
): Promise<IssueEnrollTokenResponse> {
return request<IssueEnrollTokenResponse>(
`/api/v1/control/enroll-token?ttl_seconds=${Math.max(1, Math.floor(ttlSeconds))}`,
{ method: "POST" },
);
}
export function revokeEnrollToken(token: string): Promise<RevokeEnrollTokenResponse> {
export function revokeEnrollToken(
token: string,
): Promise<RevokeEnrollTokenResponse> {
return request<RevokeEnrollTokenResponse>(
`/api/v1/control/enroll-tokens/${encodeURIComponent(token)}`,
{ method: "DELETE" },
);
}
export function runCommand(agentId: string, kind: CommandKind, query: string): Promise<unknown> {
export function runCommand(
agentId: string,
kind: CommandKind,
query: string,
): Promise<unknown> {
const payload = buildCommandPayload(kind, query);
return request(`/api/v1/control/agents/${encodeURIComponent(agentId)}/command`, {
return request(
`/api/v1/control/agents/${encodeURIComponent(agentId)}/command`,
{
method: "POST",
body: JSON.stringify(payload),
});
},
);
}
function buildCommandPayload(kind: CommandKind, query: string): { command: Record<string, unknown> } {
function buildCommandPayload(
kind: CommandKind,
query: string,
): { command: Record<string, unknown> } {
if (kind === "devs") {
return { command: { kind: "devs", dev: null, up_only: false } };
}
+40 -19
View File
@@ -28,23 +28,26 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
);
const filteredAlerts = useMemo(
() => alerts.filter((a) => (
(severity === "all" || a.severity === severity)
&& (status === "all" || a.status === status)
&& (kind === "all" || a.kind === kind)
)),
() =>
alerts.filter(
(a) =>
(severity === "all" || a.severity === severity) &&
(status === "all" || a.status === status) &&
(kind === "all" || a.kind === kind),
),
[alerts, severity, status, kind],
);
const filteredTransitions = useMemo(() => {
const q = transitionQ.trim().toLowerCase();
if (!q) return transitions;
return transitions.filter((t) => (
t.kind.toLowerCase().includes(q)
|| t.to_status.toLowerCase().includes(q)
|| t.message.toLowerCase().includes(q)
|| (t.agent_id || "").toLowerCase().includes(q)
));
return transitions.filter(
(t) =>
t.kind.toLowerCase().includes(q) ||
t.to_status.toLowerCase().includes(q) ||
t.message.toLowerCase().includes(q) ||
(t.agent_id || "").toLowerCase().includes(q),
);
}, [transitions, transitionQ]);
return (
@@ -57,9 +60,14 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
<div className="grid-3 compact">
<label>
Severity
<select value={severity} onChange={(e) => setSeverity(e.target.value)}>
<select
value={severity}
onChange={(e) => setSeverity(e.target.value)}
>
{severities.map((v) => (
<option key={v} value={v}>{v}</option>
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
@@ -67,7 +75,9 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
Status
<select value={status} onChange={(e) => setStatus(e.target.value)}>
{statuses.map((v) => (
<option key={v} value={v}>{v}</option>
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
@@ -75,20 +85,29 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
Kind
<select value={kind} onChange={(e) => setKind(e.target.value)}>
{kinds.map((v) => (
<option key={v} value={v}>{v}</option>
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
</div>
<p className="muted">Showing {filteredAlerts.length} of {alerts.length}</p>
<p className="muted">
Showing {filteredAlerts.length} of {alerts.length}
</p>
<div className="list">
{filteredAlerts.map((alert) => (
<div className="row plain" key={alert.alert_id}>
<span>{alert.kind}{alert.agent_id ? ` (${alert.agent_id})` : ""}</span>
<span>
{alert.kind}
{alert.agent_id ? ` (${alert.agent_id})` : ""}
</span>
<span>{alert.severity}</span>
</div>
))}
{!filteredAlerts.length && <div className="empty">No active alerts</div>}
{!filteredAlerts.length && (
<div className="empty">No active alerts</div>
)}
</div>
</div>
@@ -105,7 +124,9 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
placeholder="kind, status, message, agent"
/>
</label>
<pre className="output small">{JSON.stringify(filteredTransitions, null, 2)}</pre>
<pre className="output small">
{JSON.stringify(filteredTransitions, null, 2)}
</pre>
</div>
</section>
);
+28 -11
View File
@@ -19,7 +19,10 @@ export function AuditPage({ events, onRefresh }: Props) {
const [needle, setNeedle] = useState(initialNeedle);
const eventTypes = useMemo(
() => ["all", ...Array.from(new Set(events.map((e) => e.event_type))).sort()],
() => [
"all",
...Array.from(new Set(events.map((e) => e.event_type))).sort(),
],
[events],
);
@@ -35,11 +38,12 @@ export function AuditPage({ events, onRefresh }: Props) {
if (outcome !== "all" && e.outcome !== outcome) return false;
if (!q) return true;
return (
e.message.toLowerCase().includes(q)
|| e.event_type.toLowerCase().includes(q)
|| e.outcome.toLowerCase().includes(q)
|| (e.actor_id || "").toLowerCase().includes(q)
|| (e.agent_id || "").toLowerCase().includes(q)
e.message.toLowerCase().includes(q) ||
e.event_type.toLowerCase().includes(q) ||
e.outcome.toLowerCase().includes(q) ||
e.request_id?.toLowerCase().includes(q) || // why does the one flow from devicespage use this
(e.actor_id || "").toLowerCase().includes(q) ||
(e.agent_id || "").toLowerCase().includes(q)
);
});
}, [events, eventType, outcome, needle]);
@@ -53,9 +57,14 @@ export function AuditPage({ events, onRefresh }: Props) {
<div className="grid-3 compact">
<label>
Event type
<select value={eventType} onChange={(e) => setEventType(e.target.value)}>
<select
value={eventType}
onChange={(e) => setEventType(e.target.value)}
>
{eventTypes.map((v) => (
<option key={v} value={v}>{v}</option>
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
@@ -63,16 +72,24 @@ export function AuditPage({ events, onRefresh }: Props) {
Outcome
<select value={outcome} onChange={(e) => setOutcome(e.target.value)}>
{outcomes.map((v) => (
<option key={v} value={v}>{v}</option>
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
<label>
Search
<input value={needle} onChange={(e) => setNeedle(e.target.value)} placeholder="message, agent, actor" />
<input
value={needle}
onChange={(e) => setNeedle(e.target.value)}
placeholder="message, agent, actor"
/>
</label>
</div>
<p className="muted">Showing {filtered.length} of {events.length}</p>
<p className="muted">
Showing {filtered.length} of {events.length}
</p>
<pre className="output">{JSON.stringify(filtered, null, 2)}</pre>
</section>
);
+26 -6
View File
@@ -9,7 +9,12 @@ type Props = {
onAfterCommand?: () => Promise<void>;
};
export function CommandsPage({ agents, selectedAgentId, onSelectAgent, onAfterCommand }: Props) {
export function CommandsPage({
agents,
selectedAgentId,
onSelectAgent,
onAfterCommand,
}: Props) {
const [kind, setKind] = useState<CommandKind>("devs");
const [query, setQuery] = useState("");
const [output, setOutput] = useState("Select agent and run a command");
@@ -37,8 +42,14 @@ export function CommandsPage({ agents, selectedAgentId, onSelectAgent, onAfterCo
<form className="form" onSubmit={submit}>
<label>
Agent
<select value={selectedAgentId} onChange={(e) => onSelectAgent(e.target.value)} required>
<option value="" disabled>Select agent</option>
<select
value={selectedAgentId}
onChange={(e) => onSelectAgent(e.target.value)}
required
>
<option value="" disabled>
Select agent
</option>
{agents.map((agent) => (
<option key={agent.agent_id} value={agent.agent_id}>
{agent.agent_id} ({agent.connected ? "connected" : "offline"})
@@ -48,7 +59,10 @@ export function CommandsPage({ agents, selectedAgentId, onSelectAgent, onAfterCo
</label>
<label>
Command
<select value={kind} onChange={(e) => setKind(e.target.value as CommandKind)}>
<select
value={kind}
onChange={(e) => setKind(e.target.value as CommandKind)}
>
<option value="devs">devs</option>
<option value="leases">leases</option>
<option value="inventory">inventory</option>
@@ -57,9 +71,15 @@ export function CommandsPage({ agents, selectedAgentId, onSelectAgent, onAfterCo
</label>
<label>
Query
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="optional" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="optional"
/>
</label>
<button type="submit" disabled={running || !selectedAgentId}>{running ? "Running..." : "Run command"}</button>
<button type="submit" disabled={running || !selectedAgentId}>
{running ? "Running..." : "Run command"}
</button>
</form>
<pre className="output">{output}</pre>
</section>
+28 -7
View File
@@ -8,21 +8,42 @@ type Props = {
onRefresh: () => void;
};
export function DashboardPage({ agents, alerts, transitions, loading, onRefresh }: Props) {
export function DashboardPage({
agents,
alerts,
transitions,
loading,
onRefresh,
}: Props) {
const connected = agents.filter((a) => a.connected).length;
return (
<section className="card-grid">
<div className="card stat"><h3>Agents</h3><strong>{agents.length}</strong></div>
<div className="card stat"><h3>Connected</h3><strong>{connected}</strong></div>
<div className="card stat"><h3>Active Alerts</h3><strong>{alerts.length}</strong></div>
<div className="card stat"><h3>Transitions</h3><strong>{transitions.length}</strong></div>
<div className="card stat">
<h3>Agents</h3>
<strong>{agents.length}</strong>
</div>
<div className="card stat">
<h3>Connected</h3>
<strong>{connected}</strong>
</div>
<div className="card stat">
<h3>Active Alerts</h3>
<strong>{alerts.length}</strong>
</div>
<div className="card stat">
<h3>Transitions</h3>
<strong>{transitions.length}</strong>
</div>
<div className="card span-full">
<div className="row-head">
<h2>Overview</h2>
<button onClick={onRefresh} disabled={loading}>{loading ? "Refreshing..." : "Refresh"}</button>
<button onClick={onRefresh} disabled={loading}>
{loading ? "Refreshing..." : "Refresh"}
</button>
</div>
<p className="muted">
Use tabs to run commands, inspect audits, and monitor alerts in real time.
Use tabs to run commands, inspect audits, and monitor alerts in real
time.
</p>
</div>
</section>
+217 -68
View File
@@ -1,12 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
// Utility for sorting
const sorters = {
name: (a: DeviceRow, b: DeviceRow) => a.name.localeCompare(b.name),
ip: (a: DeviceRow, b: DeviceRow) => (a.ips[0] || "").localeCompare(b.ips[0] || ""),
mac: (a: DeviceRow, b: DeviceRow) => (a.macs[0] || "").localeCompare(b.macs[0] || ""),
presence: (a: DeviceRow, b: DeviceRow) => a.presence.localeCompare(b.presence),
ip: (a: DeviceRow, b: DeviceRow) =>
(a.ips[0] || "").localeCompare(b.ips[0] || ""),
mac: (a: DeviceRow, b: DeviceRow) =>
(a.macs[0] || "").localeCompare(b.macs[0] || ""),
presence: (a: DeviceRow, b: DeviceRow) =>
a.presence.localeCompare(b.presence),
};
type SortKey = keyof typeof sorters;
@@ -41,7 +43,12 @@ type WakeEvent = {
};
const WAKE_HISTORY_KEY = "wakey_recent_wakes_v1";
const PRESENCE_FILTERS: PresenceFilter[] = ["all", "online", "likely_online", "unknown"];
const PRESENCE_FILTERS: PresenceFilter[] = [
"all",
"online",
"likely_online",
"unknown",
];
function parseInventoryRows(payload: unknown): DeviceRow[] {
if (!payload || typeof payload !== "object") return [];
@@ -53,13 +60,20 @@ function parseInventoryRows(payload: unknown): DeviceRow[] {
.map((raw, idx) => {
if (!raw || typeof raw !== "object") return null;
const device = raw as Record<string, unknown>;
const names = Array.isArray(device.names) ? device.names.filter((v) => typeof v === "string") as string[] : [];
const ips = Array.isArray(device.ips) ? device.ips.filter((v) => typeof v === "string") as string[] : [];
const macs = Array.isArray(device.macs) ? device.macs.filter((v) => typeof v === "string") as string[] : [];
const interfaces = Array.isArray(device.interfaces)
? device.interfaces.filter((v) => typeof v === "string") as string[]
const names = Array.isArray(device.names)
? (device.names.filter((v) => typeof v === "string") as string[])
: [];
const presence = typeof device.presence === "string" ? device.presence : "unknown";
const ips = Array.isArray(device.ips)
? (device.ips.filter((v) => typeof v === "string") as string[])
: [];
const macs = Array.isArray(device.macs)
? (device.macs.filter((v) => typeof v === "string") as string[])
: [];
const interfaces = Array.isArray(device.interfaces)
? (device.interfaces.filter((v) => typeof v === "string") as string[])
: [];
const presence =
typeof device.presence === "string" ? device.presence : "unknown";
const id = macs[0] || ips[0] || names[0] || `row-${idx}`;
return {
@@ -74,17 +88,24 @@ function parseInventoryRows(payload: unknown): DeviceRow[] {
.filter((v): v is DeviceRow => Boolean(v));
}
function parseWakeSummary(response: unknown): { outcome: string; requestId: string; detail: string } {
function parseWakeSummary(response: unknown): {
outcome: string;
requestId: string;
detail: string;
} {
if (!response || typeof response !== "object") {
return { outcome: "error", requestId: "", detail: "invalid wake response" };
}
const envelope = response as Record<string, unknown>;
const requestId = typeof envelope.request_id === "string" ? envelope.request_id : "";
const status = typeof envelope.status === "string" ? envelope.status : "error";
const requestId =
typeof envelope.request_id === "string" ? envelope.request_id : "";
const status =
typeof envelope.status === "string" ? envelope.status : "error";
if (status !== "ok") {
const error = envelope.error as Record<string, unknown> | undefined;
const detail = typeof error?.message === "string" ? error.message : "wake failed";
const detail =
typeof error?.message === "string" ? error.message : "wake failed";
return { outcome: "error", requestId, detail };
}
@@ -102,24 +123,32 @@ function parseWakeSummary(response: unknown): { outcome: string; requestId: stri
const status =
typeof statusRaw === "string"
? statusRaw
: (typeof (statusRaw as Record<string, unknown> | undefined)?.kind === "string"
: typeof (statusRaw as Record<string, unknown> | undefined)?.kind ===
"string"
? String((statusRaw as Record<string, unknown>).kind)
: "unknown");
: "unknown";
const ip = typeof rowObj.ip === "string" ? rowObj.ip : "?";
const mac = typeof rowObj.mac === "string" ? rowObj.mac : "?";
return `${status}(${ip}/${mac})`;
})
.filter(Boolean);
const detail = targetOutcomes.length ? targetOutcomes.join(", ") : "wake dispatched";
const failed = targetOutcomes.some((s) => s.startsWith("incomplete") || s.startsWith("nonexistent_address") || s.startsWith("wrong_size"));
const detail = targetOutcomes.length
? targetOutcomes.join(", ")
: "wake dispatched";
const failed = targetOutcomes.some(
(s) =>
s.startsWith("incomplete") ||
s.startsWith("nonexistent_address") ||
s.startsWith("wrong_size"),
);
return { outcome: failed ? "error" : "ok", requestId, detail };
}
function chooseWakeTarget(device: DeviceRow): string {
return device.name !== "(unnamed)"
? device.name
: (device.macs[0] || device.ips[0] || "");
: device.macs[0] || device.ips[0] || "";
}
function summarize(values: string[]): string {
@@ -138,12 +167,12 @@ function loadHistory(): WakeEvent[] {
if (!row || typeof row !== "object") return false;
const event = row as Record<string, unknown>;
return (
typeof event.ts === "number"
&& typeof event.target === "string"
&& typeof event.agentId === "string"
&& typeof event.outcome === "string"
&& typeof event.requestId === "string"
&& typeof event.detail === "string"
typeof event.ts === "number" &&
typeof event.target === "string" &&
typeof event.agentId === "string" &&
typeof event.outcome === "string" &&
typeof event.requestId === "string" &&
typeof event.detail === "string"
);
});
} catch {
@@ -152,10 +181,18 @@ function loadHistory(): WakeEvent[] {
}
function saveHistory(history: WakeEvent[]) {
window.localStorage.setItem(WAKE_HISTORY_KEY, JSON.stringify(history.slice(0, 20)));
window.localStorage.setItem(
WAKE_HISTORY_KEY,
JSON.stringify(history.slice(0, 20)),
);
}
export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWake }: Props) {
export function DevicesPage({
agents,
selectedAgentId,
onSelectAgent,
onAfterWake,
}: Props) {
const location = useLocation();
const navigate = useNavigate();
@@ -173,11 +210,16 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
function getPresenceFromUrl(): PresenceFilter {
const params = new URLSearchParams(location.search);
const raw = params.get("presence");
return PRESENCE_FILTERS.includes(raw as PresenceFilter) ? (raw as PresenceFilter) : "all";
return PRESENCE_FILTERS.includes(raw as PresenceFilter)
? (raw as PresenceFilter)
: "all";
}
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>(getSortFromUrl());
const [presenceFilter, setPresenceFilter] = useState<PresenceFilter>(getPresenceFromUrl());
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>(
getSortFromUrl(),
);
const [presenceFilter, setPresenceFilter] =
useState<PresenceFilter>(getPresenceFromUrl());
// Keep view state in sync with URL
useEffect(() => {
@@ -221,7 +263,6 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
});
}
useEffect(() => {
setRecentWakes(loadHistory());
}, []);
@@ -257,7 +298,14 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
await wakeTarget(target, device.id);
}
async function wakeTarget(target: string, busyId: string, opts: { refresh: boolean; notify: boolean } = { refresh: true, notify: true }) {
async function wakeTarget(
target: string,
busyId: string,
opts: { refresh: boolean; notify: boolean } = {
refresh: true,
notify: true,
},
) {
if (!target || !selectedAgentId) return;
setWakeBusyId(busyId);
try {
@@ -297,7 +345,10 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
for (const row of selectedRows) {
const target = chooseWakeTarget(row);
if (!target) continue;
await wakeTarget(target, `bulk:${row.id}`, { refresh: false, notify: false });
await wakeTarget(target, `bulk:${row.id}`, {
refresh: false,
notify: false,
});
}
await loadInventory();
if (onAfterWake) await onAfterWake();
@@ -323,7 +374,9 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
}, [selectedAgentId]);
useEffect(() => {
setSelectedIds((prev) => prev.filter((id) => rows.some((row) => row.id === id)));
setSelectedIds((prev) =>
prev.filter((id) => rows.some((row) => row.id === id)),
);
}, [rows]);
useEffect(() => {
@@ -339,12 +392,13 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
result = result.filter((row) => row.presence === presenceFilter);
}
if (q) {
result = result.filter((row) =>
row.name.toLowerCase().includes(q)
|| row.presence.toLowerCase().includes(q)
|| row.ips.some((v) => v.toLowerCase().includes(q))
|| row.macs.some((v) => v.toLowerCase().includes(q))
|| row.interfaces.some((v) => v.toLowerCase().includes(q)),
result = result.filter(
(row) =>
row.name.toLowerCase().includes(q) ||
row.presence.toLowerCase().includes(q) ||
row.ips.some((v) => v.toLowerCase().includes(q)) ||
row.macs.some((v) => v.toLowerCase().includes(q)) ||
row.interfaces.some((v) => v.toLowerCase().includes(q)),
);
}
// Sort
@@ -354,8 +408,11 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
return result;
}, [rows, query, sort, presenceFilter]);
const selectedVisibleCount = filtered.filter((row) => selectedIds.includes(row.id)).length;
const allVisibleSelected = filtered.length > 0 && selectedVisibleCount === filtered.length;
const selectedVisibleCount = filtered.filter((row) =>
selectedIds.includes(row.id),
).length;
const allVisibleSelected =
filtered.length > 0 && selectedVisibleCount === filtered.length;
function toggleRowSelection(id: string, checked: boolean) {
setSelectedIds((prev) => {
@@ -366,7 +423,9 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
function toggleAllVisible(checked: boolean) {
if (!checked) {
setSelectedIds((prev) => prev.filter((id) => !filtered.some((row) => row.id === id)));
setSelectedIds((prev) =>
prev.filter((id) => !filtered.some((row) => row.id === id)),
);
return;
}
setSelectedIds((prev) => {
@@ -381,7 +440,10 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
<div className="card">
<div className="row-head">
<h2>Devices</h2>
<button onClick={() => void loadInventory()} disabled={loading || !selectedAgentId}>
<button
onClick={() => void loadInventory()}
disabled={loading || !selectedAgentId}
>
{loading ? "Refreshing..." : "Refresh"}
</button>
</div>
@@ -394,7 +456,9 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
onChange={(e) => onSelectAgent(e.target.value)}
required
>
<option value="" disabled>Select agent</option>
<option value="" disabled>
Select agent
</option>
{agents.map((agent) => (
<option key={agent.agent_id} value={agent.agent_id}>
{agent.agent_id} ({agent.connected ? "connected" : "offline"})
@@ -423,13 +487,19 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
</label>
<button
onClick={() => void wakeTarget(quickWake.trim(), "quick")}
disabled={!selectedAgentId || !quickWake.trim() || wakeBusyId === "quick"}
disabled={
!selectedAgentId || !quickWake.trim() || wakeBusyId === "quick"
}
>
{wakeBusyId === "quick" ? "Waking..." : "Wake target"}
</button>
</div>
<div className="presence-filters" role="group" aria-label="Filter by presence">
<div
className="presence-filters"
role="group"
aria-label="Filter by presence"
>
{PRESENCE_FILTERS.map((value) => (
<button
key={value}
@@ -457,7 +527,9 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
disabled={!selectedVisibleCount || !selectedAgentId || bulkWakeBusy}
type="button"
>
{bulkWakeBusy ? "Waking selected..." : `Wake selected (${selectedVisibleCount})`}
{bulkWakeBusy
? "Waking selected..."
: `Wake selected (${selectedVisibleCount})`}
</button>
<button
onClick={() => setSelectedIds([])}
@@ -469,7 +541,9 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
{copyStatus && <span className="muted">{copyStatus}</span>}
</div>
<p className="muted">Showing {filtered.length} of {rows.length}</p>
<p className="muted">
Showing {filtered.length} of {rows.length}
</p>
{error && <pre className="error">{error}</pre>}
<div className="list device-list">
<div className="row plain device-row device-header">
@@ -484,27 +558,48 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
</span>
<span
className="sortable-col device-cell"
onClick={() => setSort((s) => ({ key: "name", dir: s.key === "name" && s.dir === "asc" ? "desc" : "asc" }))}
onClick={() =>
setSort((s) => ({
key: "name",
dir: s.key === "name" && s.dir === "asc" ? "desc" : "asc",
}))
}
>
Name {sort.key === "name" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span>
<span
className="sortable-col device-cell"
onClick={() => setSort((s) => ({ key: "ip", dir: s.key === "ip" && s.dir === "asc" ? "desc" : "asc" }))}
onClick={() =>
setSort((s) => ({
key: "ip",
dir: s.key === "ip" && s.dir === "asc" ? "desc" : "asc",
}))
}
>
IP {sort.key === "ip" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span>
<span
className="sortable-col device-cell"
onClick={() => setSort((s) => ({ key: "mac", dir: s.key === "mac" && s.dir === "asc" ? "desc" : "asc" }))}
onClick={() =>
setSort((s) => ({
key: "mac",
dir: s.key === "mac" && s.dir === "asc" ? "desc" : "asc",
}))
}
>
MAC {sort.key === "mac" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span>
<span
className="sortable-col device-cell"
onClick={() => setSort((s) => ({ key: "presence", dir: s.key === "presence" && s.dir === "asc" ? "desc" : "asc" }))}
onClick={() =>
setSort((s) => ({
key: "presence",
dir: s.key === "presence" && s.dir === "asc" ? "desc" : "asc",
}))
}
>
Presence {sort.key === "presence" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
Presence{" "}
{sort.key === "presence" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span>
<span className="device-cell">Interfaces</span>
<span className="device-cell device-action">Actions</span>
@@ -519,18 +614,63 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
aria-label={`Select ${row.name}`}
/>
</span>
<span className="device-cell" data-label="Name" title={row.name}>{row.name}</span>
<span className="device-cell muted" data-label="IP" title={row.ips.join(", ") || "-"}>{summarize(row.ips)}</span>
<span className="device-cell muted" data-label="MAC" title={row.macs.join(", ") || "-"}>{summarize(row.macs)}</span>
<span className="device-cell" data-label="Presence"><span className="pill">{row.presence}</span></span>
<span className="device-cell" data-label="Interfaces" title={row.interfaces.join(", ") || "-"}>{summarize(row.interfaces)}</span>
<span className="device-cell" data-label="Name" title={row.name}>
{row.name}
</span>
<span
className="device-cell muted"
data-label="IP"
title={row.ips.join(", ") || "-"}
>
{summarize(row.ips)}
</span>
<span
className="device-cell muted"
data-label="MAC"
title={row.macs.join(", ") || "-"}
>
{summarize(row.macs)}
</span>
<span className="device-cell" data-label="Presence">
<span className="pill">{row.presence}</span>
</span>
<span
className="device-cell"
data-label="Interfaces"
title={row.interfaces.join(", ") || "-"}
>
{summarize(row.interfaces)}
</span>
<span className="device-cell device-action" data-label="">
<button onClick={() => void wakeDevice(row)} disabled={wakeBusyId === row.id || !selectedAgentId || bulkWakeBusy}>
<button
onClick={() => void wakeDevice(row)}
disabled={
wakeBusyId === row.id || !selectedAgentId || bulkWakeBusy
}
>
{wakeBusyId === row.id ? "Waking..." : "Wake"}
</button>
<button type="button" className="mini-btn" onClick={() => void copyValue("name", chooseWakeTarget(row))}>Copy name</button>
<button type="button" className="mini-btn" onClick={() => void copyValue("ip", row.ips[0] || "")}>Copy IP</button>
<button type="button" className="mini-btn" onClick={() => void copyValue("mac", row.macs[0] || "")}>Copy MAC</button>
<button
type="button"
className="mini-btn"
onClick={() => void copyValue("name", chooseWakeTarget(row))}
>
Copy name
</button>
<button
type="button"
className="mini-btn"
onClick={() => void copyValue("ip", row.ips[0] || "")}
>
Copy IP
</button>
<button
type="button"
className="mini-btn"
onClick={() => void copyValue("mac", row.macs[0] || "")}
>
Copy MAC
</button>
</span>
</div>
))}
@@ -552,10 +692,15 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
</div>
<div className="list">
{recentWakes.map((event, idx) => (
<div className="row plain" key={`${event.ts}-${event.target}-${idx}`}>
<div
className="row plain"
key={`${event.ts}-${event.target}-${idx}`}
>
<div>
<strong>{event.target}</strong>
<div className="muted">{new Date(event.ts).toLocaleString()} on {event.agentId}</div>
<div className="muted">
{new Date(event.ts).toLocaleString()} on {event.agentId}
</div>
<div className="muted">{event.detail}</div>
{event.requestId && (
<Link
@@ -566,12 +711,16 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
</Link>
)}
</div>
<span className={`pill ${event.outcome === "ok" ? "ready" : "error"}`}>
<span
className={`pill ${event.outcome === "ok" ? "ready" : "error"}`}
>
{event.outcome}
</span>
</div>
))}
{!recentWakes.length && <div className="empty">No wake actions yet</div>}
{!recentWakes.length && (
<div className="empty">No wake actions yet</div>
)}
</div>
</div>
</section>
+26 -8
View File
@@ -19,7 +19,10 @@ export function TokensPage() {
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const activeCount = useMemo(() => tokens.filter((t) => !t.expired).length, [tokens]);
const activeCount = useMemo(
() => tokens.filter((t) => !t.expired).length,
[tokens],
);
async function load() {
setBusy(true);
@@ -41,7 +44,9 @@ export function TokensPage() {
try {
const ttl = Number.parseInt(ttlSeconds, 10);
const issued = await issueEnrollToken(Number.isFinite(ttl) ? ttl : 86400);
setStatus(`Issued ${issued.enroll_token} (expires ${formatUnix(issued.expires_at_unix)})`);
setStatus(
`Issued ${issued.enroll_token} (expires ${formatUnix(issued.expires_at_unix)})`,
);
await load();
} catch (err) {
setError(String(err));
@@ -56,7 +61,9 @@ export function TokensPage() {
setStatus("");
try {
const result = await revokeEnrollToken(token);
setStatus(result.revoked ? `Revoked ${token}` : `${token} was already absent`);
setStatus(
result.revoked ? `Revoked ${token}` : `${token} was already absent`,
);
await load();
} catch (err) {
setError(String(err));
@@ -74,7 +81,9 @@ export function TokensPage() {
<div className="card">
<div className="row-head">
<h2>Enroll Tokens</h2>
<button onClick={() => void load()} disabled={busy}>Refresh</button>
<button onClick={() => void load()} disabled={busy}>
Refresh
</button>
</div>
<div className="form">
<label>
@@ -88,19 +97,26 @@ export function TokensPage() {
</select>
</label>
</div>
<p className="muted">{activeCount} active, {tokens.length} shown</p>
<p className="muted">
{activeCount} active, {tokens.length} shown
</p>
<div className="list">
{tokens.map((token) => (
<div className="row" key={token.enroll_token}>
<div>
<strong>{token.enroll_token}</strong>
<div className="muted">expires: {formatUnix(token.expires_at_unix)}</div>
<div className="muted">
expires: {formatUnix(token.expires_at_unix)}
</div>
</div>
<div className="token-actions">
<span className={`pill ${token.expired ? "error" : "ready"}`}>
{token.expired ? "expired" : "active"}
</span>
<button onClick={() => void onRevoke(token.enroll_token)} disabled={busy}>
<button
onClick={() => void onRevoke(token.enroll_token)}
disabled={busy}
>
Revoke
</button>
</div>
@@ -122,7 +138,9 @@ export function TokensPage() {
onChange={(e) => setTtlSeconds(e.target.value)}
/>
</label>
<button onClick={() => void onIssue()} disabled={busy}>Issue</button>
<button onClick={() => void onIssue()} disabled={busy}>
Issue
</button>
</div>
{status && <pre className="output">{status}</pre>}
{error && <pre className="error">{error}</pre>}
+100 -26
View File
@@ -6,7 +6,9 @@
--muted: #9bb0d1;
}
* { box-sizing: border-box; }
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--text);
@@ -14,7 +16,9 @@ body {
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
}
.app { padding: 1rem; }
.app {
padding: 1rem;
}
.topbar {
display: flex;
justify-content: space-between;
@@ -22,8 +26,13 @@ body {
gap: 1rem;
margin-bottom: 1rem;
}
.topbar h1 { margin: 0; }
.topbar p { margin: 0.35rem 0 0; color: var(--muted); }
.topbar h1 {
margin: 0;
}
.topbar p {
margin: 0.35rem 0 0;
color: var(--muted);
}
.tabs {
display: flex;
@@ -52,9 +61,18 @@ body {
padding: 0.35rem 0.7rem;
font-size: 0.82rem;
}
.pill.ready { color: #86efac; border-color: #14532d; }
.pill.loading { color: #fde68a; border-color: #713f12; }
.pill.error { color: #fca5a5; border-color: #7f1d1d; }
.pill.ready {
color: #86efac;
border-color: #14532d;
}
.pill.loading {
color: #fde68a;
border-color: #713f12;
}
.pill.error {
color: #fca5a5;
border-color: #7f1d1d;
}
.stats {
display: grid;
@@ -68,8 +86,14 @@ body {
gap: 0.8rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.stat h3 { margin: 0; color: var(--muted); font-weight: 500; }
.stat strong { font-size: 1.5rem; }
.stat h3 {
margin: 0;
color: var(--muted);
font-weight: 500;
}
.stat strong {
font-size: 1.5rem;
}
.grid {
display: grid;
@@ -85,26 +109,60 @@ body {
.card {
border: 1px solid var(--line);
border-radius: 12px;
background: linear-gradient(180deg, rgba(17,26,45,0.92), rgba(17,26,45,0.72));
background: linear-gradient(
180deg,
rgba(17, 26, 45, 0.92),
rgba(17, 26, 45, 0.72)
);
padding: 0.8rem;
}
.span-2 { grid-column: 1 / -1; }
.span-full { grid-column: 1 / -1; }
.row-head { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
.muted { color: var(--muted); }
.span-2 {
grid-column: 1 / -1;
}
.span-full {
grid-column: 1 / -1;
}
.row-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.muted {
color: var(--muted);
}
button, input, select {
button,
input,
select {
border-radius: 8px;
border: 1px solid var(--line);
background: #0b1528;
color: var(--text);
}
button { padding: 0.45rem 0.65rem; cursor: pointer; }
button:hover { border-color: #6b84ad; }
input, select { width: 100%; padding: 0.45rem 0.55rem; }
button {
padding: 0.45rem 0.65rem;
cursor: pointer;
}
button:hover {
border-color: #6b84ad;
}
input,
select {
width: 100%;
padding: 0.45rem 0.55rem;
}
.form { display: grid; gap: 0.65rem; }
.form label { display: grid; gap: 0.35rem; color: var(--muted); font-size: 0.9rem; }
.form {
display: grid;
gap: 0.65rem;
}
.form label {
display: grid;
gap: 0.35rem;
color: var(--muted);
font-size: 0.9rem;
}
.grid-3 {
display: grid;
@@ -182,7 +240,11 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
text-decoration: underline;
}
.list { display: grid; gap: 0.4rem; margin-top: 0.55rem; }
.list {
display: grid;
gap: 0.4rem;
margin-top: 0.55rem;
}
.row {
display: flex;
justify-content: space-between;
@@ -194,8 +256,12 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
padding: 0.45rem 0.55rem;
text-align: left;
}
.row.selected { border-color: #6b84ad; }
.row.plain { cursor: default; }
.row.selected {
border-color: #6b84ad;
}
.row.plain {
cursor: default;
}
.token-actions {
display: flex;
align-items: center;
@@ -207,7 +273,10 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
.device-row {
display: grid;
grid-template-columns: 36px minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(130px, 1.5fr) minmax(110px, 0.9fr) minmax(120px, 1fr) minmax(220px, auto);
grid-template-columns: 36px minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(
130px,
1.5fr
) minmax(110px, 0.9fr) minmax(120px, 1fr) minmax(220px, auto);
align-items: center;
gap: 0.55rem;
}
@@ -318,7 +387,10 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
grid-template-columns: 1fr;
}
}
.empty { color: var(--muted); padding: 0.35rem 0.2rem; }
.empty {
color: var(--muted);
padding: 0.35rem 0.2rem;
}
.output {
margin: 0.65rem 0 0;
@@ -330,7 +402,9 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
overflow: auto;
font-size: 0.84rem;
}
.output.small { max-height: 230px; }
.output.small {
max-height: 230px;
}
.error {
border: 1px solid #7f1d1d;
background: rgba(127, 29, 29, 0.2);
+2 -2
View File
@@ -16,8 +16,8 @@ function sourcemapEnabled(): boolean {
}
const ReactCompilerConfig = {
target: '18',
runtimeModule: 'react-compiler-runtime', // Redirects the missing specifier
target: "18",
runtimeModule: "react-compiler-runtime", // Redirects the missing specifier
};
export default defineConfig({