This commit is contained in:
lda
2026-04-12 03:11:02 +07:00 Unverified
parent d0006f2db5
commit 5efcdf75ea
5 changed files with 323 additions and 10 deletions
+36
View File
@@ -41,6 +41,22 @@ export type AuditEvent = {
export type CommandKind = "status" | "devs" | "leases" | "inventory" | "wake"; export type CommandKind = "status" | "devs" | "leases" | "inventory" | "wake";
export type EnrollTokenStatus = {
enroll_token: string;
expires_at_unix: number;
expired: boolean;
};
export type IssueEnrollTokenResponse = {
enroll_token: string;
expires_at_unix: number;
};
export type RevokeEnrollTokenResponse = {
token: string;
revoked: boolean;
};
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
...init, ...init,
@@ -79,6 +95,26 @@ 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[]> {
return request<EnrollTokenStatus[]>(
`/api/v1/control/enroll-tokens?include_expired=${includeExpired ? "true" : "false"}`,
);
}
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> {
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); const payload = buildCommandPayload(kind, query);
return request(`/api/v1/control/agents/${encodeURIComponent(agentId)}/command`, { return request(`/api/v1/control/agents/${encodeURIComponent(agentId)}/command`, {
+81 -3
View File
@@ -1,3 +1,5 @@
import { useMemo, useState } from "react";
import type { Alert, AlertTransition } from "@/api"; import type { Alert, AlertTransition } from "@/api";
type Props = { type Props = {
@@ -7,6 +9,44 @@ type Props = {
}; };
export function AlertsPage({ alerts, transitions, onRefresh }: Props) { export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
const [severity, setSeverity] = useState("all");
const [status, setStatus] = useState("all");
const [kind, setKind] = useState("all");
const [transitionQ, setTransitionQ] = useState("");
const severities = useMemo(
() => ["all", ...Array.from(new Set(alerts.map((a) => a.severity))).sort()],
[alerts],
);
const statuses = useMemo(
() => ["all", ...Array.from(new Set(alerts.map((a) => a.status))).sort()],
[alerts],
);
const kinds = useMemo(
() => ["all", ...Array.from(new Set(alerts.map((a) => a.kind))).sort()],
[alerts],
);
const filteredAlerts = useMemo(
() => 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)
));
}, [transitions, transitionQ]);
return ( return (
<section className="two-col"> <section className="two-col">
<div className="card"> <div className="card">
@@ -14,20 +54,58 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
<h2>Active Alerts</h2> <h2>Active Alerts</h2>
<button onClick={() => void onRefresh()}>Refresh</button> <button onClick={() => void onRefresh()}>Refresh</button>
</div> </div>
<div className="grid-3 compact">
<label>
Severity
<select value={severity} onChange={(e) => setSeverity(e.target.value)}>
{severities.map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
</label>
<label>
Status
<select value={status} onChange={(e) => setStatus(e.target.value)}>
{statuses.map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
</label>
<label>
Kind
<select value={kind} onChange={(e) => setKind(e.target.value)}>
{kinds.map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
</label>
</div>
<p className="muted">Showing {filteredAlerts.length} of {alerts.length}</p>
<div className="list"> <div className="list">
{alerts.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>
))} ))}
{!alerts.length && <div className="empty">No active alerts</div>} {!filteredAlerts.length && <div className="empty">No active alerts</div>}
</div> </div>
</div> </div>
<div className="card"> <div className="card">
<div className="row-head">
<h2>Transition History</h2> <h2>Transition History</h2>
<pre className="output small">{JSON.stringify(transitions, null, 2)}</pre> <span className="muted">{filteredTransitions.length} shown</span>
</div>
<label>
Search
<input
value={transitionQ}
onChange={(e) => setTransitionQ(e.target.value)}
placeholder="kind, status, message, agent"
/>
</label>
<pre className="output small">{JSON.stringify(filteredTransitions, null, 2)}</pre>
</div> </div>
</section> </section>
); );
+56 -1
View File
@@ -1,3 +1,5 @@
import { useMemo, useState } from "react";
import type { AuditEvent } from "@/api"; import type { AuditEvent } from "@/api";
type Props = { type Props = {
@@ -6,13 +8,66 @@ type Props = {
}; };
export function AuditPage({ events, onRefresh }: Props) { export function AuditPage({ events, onRefresh }: Props) {
const [eventType, setEventType] = useState("all");
const [outcome, setOutcome] = useState("all");
const [needle, setNeedle] = useState("");
const eventTypes = useMemo(
() => ["all", ...Array.from(new Set(events.map((e) => e.event_type))).sort()],
[events],
);
const outcomes = useMemo(
() => ["all", ...Array.from(new Set(events.map((e) => e.outcome))).sort()],
[events],
);
const filtered = useMemo(() => {
const q = needle.trim().toLowerCase();
return events.filter((e) => {
if (eventType !== "all" && e.event_type !== eventType) return false;
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)
);
});
}, [events, eventType, outcome, needle]);
return ( return (
<section className="card span-full"> <section className="card span-full">
<div className="row-head"> <div className="row-head">
<h2>Recent Audit Events</h2> <h2>Recent Audit Events</h2>
<button onClick={() => void onRefresh()}>Refresh</button> <button onClick={() => void onRefresh()}>Refresh</button>
</div> </div>
<pre className="output">{JSON.stringify(events, null, 2)}</pre> <div className="grid-3 compact">
<label>
Event type
<select value={eventType} onChange={(e) => setEventType(e.target.value)}>
{eventTypes.map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
</label>
<label>
Outcome
<select value={outcome} onChange={(e) => setOutcome(e.target.value)}>
{outcomes.map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
</label>
<label>
Search
<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>
<pre className="output">{JSON.stringify(filtered, null, 2)}</pre>
</section> </section>
); );
} }
+127 -5
View File
@@ -1,10 +1,132 @@
import { useEffect, useMemo, useState } from "react";
import {
type EnrollTokenStatus,
fetchEnrollTokens,
issueEnrollToken,
revokeEnrollToken,
} from "@/api";
function formatUnix(unix: number): string {
return new Date(unix * 1000).toLocaleString();
}
export function TokensPage() { export function TokensPage() {
const [tokens, setTokens] = useState<EnrollTokenStatus[]>([]);
const [includeExpired, setIncludeExpired] = useState(false);
const [ttlSeconds, setTtlSeconds] = useState("86400");
const [status, setStatus] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const activeCount = useMemo(() => tokens.filter((t) => !t.expired).length, [tokens]);
async function load() {
setBusy(true);
setError("");
try {
const next = await fetchEnrollTokens(includeExpired);
setTokens(next);
} catch (err) {
setError(String(err));
} finally {
setBusy(false);
}
}
async function onIssue() {
setBusy(true);
setError("");
setStatus("");
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)})`);
await load();
} catch (err) {
setError(String(err));
} finally {
setBusy(false);
}
}
async function onRevoke(token: string) {
setBusy(true);
setError("");
setStatus("");
try {
const result = await revokeEnrollToken(token);
setStatus(result.revoked ? `Revoked ${token}` : `${token} was already absent`);
await load();
} catch (err) {
setError(String(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
}, [includeExpired]);
return ( return (
<section className="card"> <section className="two-col">
<h2>Tokens</h2> <div className="card">
<p className="muted"> <div className="row-head">
Token operations UI is the next increment. Backend endpoints are already available for issue/list/revoke. <h2>Enroll Tokens</h2>
</p> <button onClick={() => void load()} disabled={busy}>Refresh</button>
</div>
<div className="form">
<label>
Include expired
<select
value={includeExpired ? "yes" : "no"}
onChange={(e) => setIncludeExpired(e.target.value === "yes")}
>
<option value="no">No</option>
<option value="yes">Yes</option>
</select>
</label>
</div>
<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>
<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}>
Revoke
</button>
</div>
</div>
))}
{!tokens.length && <div className="empty">No tokens found</div>}
</div>
</div>
<div className="card">
<h2>Issue Token</h2>
<div className="form">
<label>
TTL seconds
<input
type="number"
min={1}
value={ttlSeconds}
onChange={(e) => setTtlSeconds(e.target.value)}
/>
</label>
<button onClick={() => void onIssue()} disabled={busy}>Issue</button>
</div>
{status && <pre className="output">{status}</pre>}
{error && <pre className="error">{error}</pre>}
</div>
</section> </section>
); );
} }
+22
View File
@@ -106,6 +106,23 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
.form { display: grid; gap: 0.65rem; } .form { display: grid; gap: 0.65rem; }
.form label { display: grid; gap: 0.35rem; color: var(--muted); font-size: 0.9rem; } .form label { display: grid; gap: 0.35rem; color: var(--muted); font-size: 0.9rem; }
.grid-3 {
display: grid;
gap: 0.6rem;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.grid-3.compact {
margin-top: 0.65rem;
}
.grid-3 label {
display: grid;
gap: 0.35rem;
color: var(--muted);
font-size: 0.9rem;
}
.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;
@@ -120,6 +137,11 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
} }
.row.selected { border-color: #6b84ad; } .row.selected { border-color: #6b84ad; }
.row.plain { cursor: default; } .row.plain { cursor: default; }
.token-actions {
display: flex;
align-items: center;
gap: 0.45rem;
}
.empty { color: var(--muted); padding: 0.35rem 0.2rem; } .empty { color: var(--muted); padding: 0.35rem 0.2rem; }
.output { .output {