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