Ts
This commit is contained in:
@@ -41,6 +41,22 @@ export type AuditEvent = {
|
||||
|
||||
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> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
@@ -79,6 +95,26 @@ export function fetchAudit(limit = 50): Promise<AuditEvent[]> {
|
||||
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> {
|
||||
const payload = buildCommandPayload(kind, query);
|
||||
return request(`/api/v1/control/agents/${encodeURIComponent(agentId)}/command`, {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { Alert, AlertTransition } from "@/api";
|
||||
|
||||
type Props = {
|
||||
@@ -7,6 +9,44 @@ type 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 (
|
||||
<section className="two-col">
|
||||
<div className="card">
|
||||
@@ -14,20 +54,58 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
<h2>Active Alerts</h2>
|
||||
<button onClick={() => void onRefresh()}>Refresh</button>
|
||||
</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">
|
||||
{alerts.map((alert) => (
|
||||
{filteredAlerts.map((alert) => (
|
||||
<div className="row plain" key={alert.alert_id}>
|
||||
<span>{alert.kind}{alert.agent_id ? ` (${alert.agent_id})` : ""}</span>
|
||||
<span>{alert.severity}</span>
|
||||
</div>
|
||||
))}
|
||||
{!alerts.length && <div className="empty">No active alerts</div>}
|
||||
{!filteredAlerts.length && <div className="empty">No active alerts</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Transition History</h2>
|
||||
<pre className="output small">{JSON.stringify(transitions, null, 2)}</pre>
|
||||
<div className="row-head">
|
||||
<h2>Transition History</h2>
|
||||
<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>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { AuditEvent } from "@/api";
|
||||
|
||||
type Props = {
|
||||
@@ -6,13 +8,66 @@ type 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 (
|
||||
<section className="card span-full">
|
||||
<div className="row-head">
|
||||
<h2>Recent Audit Events</h2>
|
||||
<button onClick={() => void onRefresh()}>Refresh</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+127
-5
@@ -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() {
|
||||
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 (
|
||||
<section className="card">
|
||||
<h2>Tokens</h2>
|
||||
<p className="muted">
|
||||
Token operations UI is the next increment. Backend endpoints are already available for issue/list/revoke.
|
||||
</p>
|
||||
<section className="two-col">
|
||||
<div className="card">
|
||||
<div className="row-head">
|
||||
<h2>Enroll Tokens</h2>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,23 @@ 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; }
|
||||
|
||||
.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; }
|
||||
.row {
|
||||
display: flex;
|
||||
@@ -120,6 +137,11 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
|
||||
}
|
||||
.row.selected { border-color: #6b84ad; }
|
||||
.row.plain { cursor: default; }
|
||||
.token-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.empty { color: var(--muted); padding: 0.35rem 0.2rem; }
|
||||
|
||||
.output {
|
||||
|
||||
Reference in New Issue
Block a user