Ts
This commit is contained in:
@@ -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`, {
|
||||||
|
|||||||
@@ -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">
|
||||||
<h2>Transition History</h2>
|
<div className="row-head">
|
||||||
<pre className="output small">{JSON.stringify(transitions, null, 2)}</pre>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user