small ahh change But Format
This commit is contained in:
+4
-1
@@ -7,7 +7,9 @@
|
|||||||
"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 .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"prettier": "^3.8.2",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.8"
|
"vite": "^5.4.8"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+866
-506
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -36,7 +36,8 @@ export function App() {
|
|||||||
setState("loading");
|
setState("loading");
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const [nextAgents, nextAlerts, nextHistory, nextAudit] = await Promise.all([
|
const [nextAgents, nextAlerts, nextHistory, nextAudit] =
|
||||||
|
await Promise.all([
|
||||||
fetchAgents(),
|
fetchAgents(),
|
||||||
fetchAlerts(),
|
fetchAlerts(),
|
||||||
fetchAlertHistory(20),
|
fetchAlertHistory(20),
|
||||||
@@ -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"
|
||||||
|
|||||||
+26
-8
@@ -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(
|
||||||
|
`/api/v1/control/agents/${encodeURIComponent(agentId)}/command`,
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+217
-68
@@ -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"
|
||||||
? String((statusRaw as Record<string, unknown>).kind)
|
? String((statusRaw as Record<string, unknown>).kind)
|
||||||
: "unknown");
|
: "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,12 +711,16 @@ 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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user