pages
This commit is contained in:
+2
-1
@@ -11,7 +11,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
|
|||||||
Generated
+34
-13
@@ -14,6 +14,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1([email protected])
|
version: 18.3.1([email protected])
|
||||||
|
react-router-dom:
|
||||||
|
specifier: ^6.28.0
|
||||||
|
version: 6.30.3([email protected]([email protected]))([email protected])
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.10.2
|
specifier: ^22.10.2
|
||||||
@@ -276,6 +279,10 @@ packages:
|
|||||||
'@jridgewell/[email protected]':
|
'@jridgewell/[email protected]':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@remix-run/[email protected]':
|
||||||
|
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||||
|
|
||||||
@@ -313,79 +320,66 @@ packages:
|
|||||||
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
|
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
|
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
|
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
|
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
|
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
|
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
|
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
|
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
|
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
|
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
|
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
|
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
|
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
|
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
|
||||||
@@ -551,6 +545,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8'
|
||||||
|
react-dom: '>=16.8'
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8'
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -821,6 +828,8 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@remix-run/[email protected]': {}
|
||||||
|
|
||||||
'@rolldown/[email protected]': {}
|
'@rolldown/[email protected]': {}
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
@@ -1043,6 +1052,18 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]([email protected]))([email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@remix-run/router': 1.23.2
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1([email protected])
|
||||||
|
react-router: 6.30.3([email protected])
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@remix-run/router': 1.23.2
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
|
|||||||
+87
-112
@@ -1,16 +1,23 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type Agent,
|
type Agent,
|
||||||
type Alert,
|
type Alert,
|
||||||
type AlertTransition,
|
type AlertTransition,
|
||||||
type AuditEvent,
|
type AuditEvent,
|
||||||
type CommandKind,
|
|
||||||
fetchAgents,
|
fetchAgents,
|
||||||
fetchAlerts,
|
|
||||||
fetchAlertHistory,
|
fetchAlertHistory,
|
||||||
|
fetchAlerts,
|
||||||
fetchAudit,
|
fetchAudit,
|
||||||
runCommand,
|
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
import { AppLayout } from "@/layout/AppLayout";
|
||||||
|
import { AgentsPage } from "@/pages/AgentsPage";
|
||||||
|
import { AlertsPage } from "@/pages/AlertsPage";
|
||||||
|
import { AuditPage } from "@/pages/AuditPage";
|
||||||
|
import { CommandsPage } from "@/pages/CommandsPage";
|
||||||
|
import { DashboardPage } from "@/pages/DashboardPage";
|
||||||
|
import { TokensPage } from "@/pages/TokensPage";
|
||||||
|
|
||||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -19,32 +26,28 @@ export function App() {
|
|||||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||||
const [history, setHistory] = useState<AlertTransition[]>([]);
|
const [history, setHistory] = useState<AlertTransition[]>([]);
|
||||||
const [audit, setAudit] = useState<AuditEvent[]>([]);
|
const [audit, setAudit] = useState<AuditEvent[]>([]);
|
||||||
|
const [selectedAgentId, setSelectedAgentId] = useState("");
|
||||||
const [agentId, setAgentId] = useState("");
|
|
||||||
const [kind, setKind] = useState<CommandKind>("devs");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [commandOut, setCommandOut] = useState("Select agent and run a command");
|
|
||||||
|
|
||||||
const [state, setState] = useState<LoadState>("idle");
|
const [state, setState] = useState<LoadState>("idle");
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const connectedCount = useMemo(() => agents.filter((a) => a.connected).length, [agents]);
|
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
setState("loading");
|
setState("loading");
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const [a, al, h, au] = await Promise.all([
|
const [nextAgents, nextAlerts, nextHistory, nextAudit] = await Promise.all([
|
||||||
fetchAgents(),
|
fetchAgents(),
|
||||||
fetchAlerts(),
|
fetchAlerts(),
|
||||||
fetchAlertHistory(20),
|
fetchAlertHistory(20),
|
||||||
fetchAudit(30),
|
fetchAudit(30),
|
||||||
]);
|
]);
|
||||||
setAgents(a);
|
setAgents(nextAgents);
|
||||||
setAlerts(al);
|
setAlerts(nextAlerts);
|
||||||
setHistory(h);
|
setHistory(nextHistory);
|
||||||
setAudit(au);
|
setAudit(nextAudit);
|
||||||
if (!agentId && a[0]) setAgentId(a[0].agent_id);
|
if (!selectedAgentId && nextAgents[0]) {
|
||||||
|
setSelectedAgentId(nextAgents[0].agent_id);
|
||||||
|
}
|
||||||
setState("ready");
|
setState("ready");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState("error");
|
setState("error");
|
||||||
@@ -52,6 +55,12 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshAlertsAndHistory() {
|
||||||
|
const [nextAlerts, nextHistory] = await Promise.all([fetchAlerts(), fetchAlertHistory(20)]);
|
||||||
|
setAlerts(nextAlerts);
|
||||||
|
setHistory(nextHistory);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadAll();
|
void loadAll();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -59,6 +68,7 @@ export function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/api/v1/control/alerts/ws`;
|
const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/api/v1/control/alerts/ws`;
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
ws.onmessage = (evt) => {
|
ws.onmessage = (evt) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(String(evt.data)) as {
|
const payload = JSON.parse(String(evt.data)) as {
|
||||||
@@ -68,111 +78,76 @@ export function App() {
|
|||||||
if (payload.alerts) setAlerts(payload.alerts);
|
if (payload.alerts) setAlerts(payload.alerts);
|
||||||
if (payload.recent_transitions) setHistory(payload.recent_transitions);
|
if (payload.recent_transitions) setHistory(payload.recent_transitions);
|
||||||
} catch {
|
} catch {
|
||||||
// Keep stream parse failures isolated from primary UI state.
|
// Ignore malformed stream payloads and keep current UI state.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
// Poll fallback while stream is down.
|
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
void fetchAlerts().then(setAlerts).catch(() => undefined);
|
void refreshAlertsAndHistory().catch(() => undefined);
|
||||||
}, 8000);
|
}, 8000);
|
||||||
ws.onclose = () => window.clearInterval(id);
|
ws.onclose = () => window.clearInterval(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => ws.close();
|
return () => ws.close();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function onRunCommand(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!agentId) return;
|
|
||||||
|
|
||||||
setCommandOut("Running...");
|
|
||||||
try {
|
|
||||||
const out = await runCommand(agentId, kind, query.trim());
|
|
||||||
setCommandOut(JSON.stringify(out, null, 2));
|
|
||||||
const au = await fetchAudit(30);
|
|
||||||
setAudit(au);
|
|
||||||
} catch (err) {
|
|
||||||
setCommandOut(String(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<>
|
||||||
<header className="topbar">
|
|
||||||
<div>
|
|
||||||
<h1>Wakey Operator UI</h1>
|
|
||||||
<p>Fast ops surface for agents, commands, audits, and alerts</p>
|
|
||||||
</div>
|
|
||||||
<div className={`pill ${state}`}>
|
|
||||||
{state === "ready" ? "Ready" : state === "loading" ? "Loading" : state === "error" ? "Error" : "Idle"}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{error && <pre className="error">{error}</pre>}
|
{error && <pre className="error">{error}</pre>}
|
||||||
|
<Routes>
|
||||||
<section className="stats">
|
<Route path="/" element={<AppLayout />}>
|
||||||
<div className="card stat"><h3>Agents</h3><strong>{agents.length}</strong></div>
|
<Route
|
||||||
<div className="card stat"><h3>Connected</h3><strong>{connectedCount}</strong></div>
|
index
|
||||||
<div className="card stat"><h3>Active Alerts</h3><strong>{alerts.length}</strong></div>
|
element={
|
||||||
<div className="card stat"><h3>Transitions</h3><strong>{history.length}</strong></div>
|
<DashboardPage
|
||||||
</section>
|
agents={agents}
|
||||||
|
alerts={alerts}
|
||||||
<main className="grid">
|
transitions={history}
|
||||||
<section className="card">
|
loading={state === "loading"}
|
||||||
<div className="row-head"><h2>Agents</h2><button onClick={() => void loadAll()}>Refresh</button></div>
|
onRefresh={loadAll}
|
||||||
<div className="list">
|
/>
|
||||||
{agents.map((a) => (
|
}
|
||||||
<button key={a.agent_id} className={`row ${agentId === a.agent_id ? "selected" : ""}`} onClick={() => setAgentId(a.agent_id)}>
|
/>
|
||||||
<span>{a.agent_id}</span>
|
<Route
|
||||||
<span>{a.connected ? "connected" : "offline"}</span>
|
path="agents"
|
||||||
</button>
|
element={
|
||||||
))}
|
<AgentsPage
|
||||||
{!agents.length && <div className="empty">No agents found</div>}
|
agents={agents}
|
||||||
</div>
|
selectedAgentId={selectedAgentId}
|
||||||
</section>
|
onSelectAgent={setSelectedAgentId}
|
||||||
|
/>
|
||||||
<section className="card">
|
}
|
||||||
<h2>Command Runner</h2>
|
/>
|
||||||
<form className="form" onSubmit={onRunCommand}>
|
<Route
|
||||||
<label>Agent ID<input value={agentId} onChange={(e) => setAgentId(e.target.value)} required /></label>
|
path="commands"
|
||||||
<label>Command
|
element={
|
||||||
<select value={kind} onChange={(e) => setKind(e.target.value as CommandKind)}>
|
<CommandsPage
|
||||||
<option value="devs">devs</option>
|
agents={agents}
|
||||||
<option value="status">status</option>
|
selectedAgentId={selectedAgentId}
|
||||||
<option value="leases">leases</option>
|
onSelectAgent={setSelectedAgentId}
|
||||||
<option value="inventory">inventory</option>
|
onAfterCommand={loadAll}
|
||||||
<option value="wake">wake</option>
|
/>
|
||||||
</select>
|
}
|
||||||
</label>
|
/>
|
||||||
<label>Query<input value={query} onChange={(e) => setQuery(e.target.value)} /></label>
|
<Route
|
||||||
<button type="submit">Run command</button>
|
path="audit"
|
||||||
</form>
|
element={<AuditPage events={audit} onRefresh={() => fetchAudit(30).then(setAudit)} />}
|
||||||
<pre className="output">{commandOut}</pre>
|
/>
|
||||||
</section>
|
<Route
|
||||||
|
path="alerts"
|
||||||
<section className="card">
|
element={
|
||||||
<div className="row-head"><h2>Active Alerts</h2><button onClick={() => void fetchAlerts().then(setAlerts)}>Refresh</button></div>
|
<AlertsPage
|
||||||
<div className="list">
|
alerts={alerts}
|
||||||
{alerts.map((a) => (
|
transitions={history}
|
||||||
<div key={a.alert_id} className="row plain">
|
onRefresh={refreshAlertsAndHistory}
|
||||||
<span>{a.kind}{a.agent_id ? ` (${a.agent_id})` : ""}</span>
|
/>
|
||||||
<span>{a.severity}</span>
|
}
|
||||||
</div>
|
/>
|
||||||
))}
|
<Route path="tokens" element={<TokensPage />} />
|
||||||
{!alerts.length && <div className="empty">No active alerts</div>}
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</div>
|
</Route>
|
||||||
</section>
|
</Routes>
|
||||||
|
</>
|
||||||
<section className="card">
|
|
||||||
<h2>Alert Transitions</h2>
|
|
||||||
<pre className="output small">{JSON.stringify(history, null, 2)}</pre>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card span-2">
|
|
||||||
<div className="row-head"><h2>Recent Audit</h2><button onClick={() => void fetchAudit(30).then(setAudit)}>Refresh</button></div>
|
|
||||||
<pre className="output">{JSON.stringify(audit, null, 2)}</pre>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
["/", "Dashboard"],
|
||||||
|
["/agents", "Agents"],
|
||||||
|
["/commands", "Commands"],
|
||||||
|
["/audit", "Audit"],
|
||||||
|
["/alerts", "Alerts"],
|
||||||
|
["/tokens", "Tokens"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function AppLayout() {
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>Wakey Operator UI</h1>
|
||||||
|
<p>Control-plane operations, audits, and live alerts</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="tabs">
|
||||||
|
{navItems.map(([to, label]) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
end={to === "/"}
|
||||||
|
className={({ isActive }) => `tab ${isActive ? "active" : ""}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { App } from "@/App";
|
import { App } from "@/App";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<BrowserRouter basename="/ui">
|
||||||
<App />
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Agent } from "@/api";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[];
|
||||||
|
selectedAgentId: string;
|
||||||
|
onSelectAgent: (agentId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgentsPage({ agents, selectedAgentId, onSelectAgent }: Props) {
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Agents</h2>
|
||||||
|
<div className="list">
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<button
|
||||||
|
key={agent.agent_id}
|
||||||
|
className={`row ${selectedAgentId === agent.agent_id ? "selected" : ""}`}
|
||||||
|
onClick={() => onSelectAgent(agent.agent_id)}
|
||||||
|
>
|
||||||
|
<span>{agent.agent_id}</span>
|
||||||
|
<span>{agent.connected ? "connected" : "offline"}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!agents.length && <div className="empty">No agents enrolled</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Alert, AlertTransition } from "@/api";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
alerts: Alert[];
|
||||||
|
transitions: AlertTransition[];
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||||
|
return (
|
||||||
|
<section className="two-col">
|
||||||
|
<div className="card">
|
||||||
|
<div className="row-head">
|
||||||
|
<h2>Active Alerts</h2>
|
||||||
|
<button onClick={() => void onRefresh()}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div className="list">
|
||||||
|
{alerts.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>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Transition History</h2>
|
||||||
|
<pre className="output small">{JSON.stringify(transitions, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { AuditEvent } from "@/api";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
events: AuditEvent[];
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuditPage({ events, onRefresh }: Props) {
|
||||||
|
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>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { runCommand, type Agent, type CommandKind } from "@/api";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[];
|
||||||
|
selectedAgentId: string;
|
||||||
|
onSelectAgent: (agentId: string) => void;
|
||||||
|
onAfterCommand?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedAgentId) return;
|
||||||
|
setRunning(true);
|
||||||
|
setOutput("Running...");
|
||||||
|
try {
|
||||||
|
const out = await runCommand(selectedAgentId, kind, query.trim());
|
||||||
|
setOutput(JSON.stringify(out, null, 2));
|
||||||
|
if (onAfterCommand) await onAfterCommand();
|
||||||
|
} catch (err) {
|
||||||
|
setOutput(String(err));
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Command Runner</h2>
|
||||||
|
<form className="form" onSubmit={submit}>
|
||||||
|
<label>
|
||||||
|
Agent
|
||||||
|
<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"})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Command
|
||||||
|
<select value={kind} onChange={(e) => setKind(e.target.value as CommandKind)}>
|
||||||
|
<option value="devs">devs</option>
|
||||||
|
<option value="status">status</option>
|
||||||
|
<option value="leases">leases</option>
|
||||||
|
<option value="inventory">inventory</option>
|
||||||
|
<option value="wake">wake</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Query
|
||||||
|
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="optional" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={running || !selectedAgentId}>{running ? "Running..." : "Run command"}</button>
|
||||||
|
</form>
|
||||||
|
<pre className="output">{output}</pre>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Agent, Alert, AlertTransition } from "@/api";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[];
|
||||||
|
alerts: Alert[];
|
||||||
|
transitions: AlertTransition[];
|
||||||
|
loading: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 span-full">
|
||||||
|
<div className="row-head">
|
||||||
|
<h2>Overview</h2>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function TokensPage() {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,27 @@ body {
|
|||||||
.topbar h1 { margin: 0; }
|
.topbar h1 { margin: 0; }
|
||||||
.topbar p { margin: 0.35rem 0 0; color: var(--muted); }
|
.topbar p { margin: 0.35rem 0 0; color: var(--muted); }
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--muted);
|
||||||
|
background: #0b1528;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: #6b84ad;
|
||||||
|
}
|
||||||
|
|
||||||
.pill {
|
.pill {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -41,6 +62,12 @@ body {
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
.stat h3 { margin: 0; color: var(--muted); font-weight: 500; }
|
.stat h3 { margin: 0; color: var(--muted); font-weight: 500; }
|
||||||
.stat strong { font-size: 1.5rem; }
|
.stat strong { font-size: 1.5rem; }
|
||||||
|
|
||||||
@@ -49,6 +76,12 @@ body {
|
|||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.two-col {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
|
||||||
|
}
|
||||||
.card {
|
.card {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -56,7 +89,9 @@ body {
|
|||||||
padding: 0.8rem;
|
padding: 0.8rem;
|
||||||
}
|
}
|
||||||
.span-2 { grid-column: 1 / -1; }
|
.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; }
|
.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;
|
||||||
|
|||||||
+6
-2
@@ -17,11 +17,15 @@ function sourcemapEnabled(): boolean {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react({
|
react(
|
||||||
|
process.env.UI_REACT_COMPILER === "1"
|
||||||
|
? {
|
||||||
babel: {
|
babel: {
|
||||||
plugins: ["babel-plugin-react-compiler"],
|
plugins: ["babel-plugin-react-compiler"],
|
||||||
},
|
},
|
||||||
}),
|
}
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
base: "/ui/",
|
base: "/ui/",
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user