feat: frontend overhaul - health panel + sparklines + auto-refresh
- Add health status panel with component status dots - Add auto-refresh toggle with configurable interval (3s/6s/10s/30s) - Add performance sparkline bars (TPS/CPU/Memory/Players) - Use useReducer for terminal lines (append/clear) - Better responsive layout for mobile - Third ambient background layer for visual depth - Refined glass-panel hover effects
This commit is contained in:
+202
-223
@@ -3,6 +3,7 @@ import {
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useReducer,
|
||||
useState
|
||||
} from "react";
|
||||
|
||||
@@ -23,19 +24,8 @@ async function request(path, options = {}) {
|
||||
return data;
|
||||
}
|
||||
|
||||
function appendLine(setTerminalLines, line) {
|
||||
setTerminalLines((current) => {
|
||||
const next = [...current, `[${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] ${asText(line, "event")}`];
|
||||
return next.slice(-120);
|
||||
});
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asRecord(value) {
|
||||
return isRecord(value) ? value : {};
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
function asArray(value) {
|
||||
@@ -48,79 +38,84 @@ function asNumber(value, fallback = 0) {
|
||||
}
|
||||
|
||||
function asText(value, fallback = "") {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
if (value === null || value === undefined) return fallback;
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (asRecord(value)) {
|
||||
for (const key of ["summary", "message", "reason", "workflow", "mode", "status", "name", "id"]) {
|
||||
const nested = value[key];
|
||||
if (typeof nested === "string" || typeof nested === "number" || typeof nested === "boolean") {
|
||||
return String(nested);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
if (typeof nested === "string" || typeof nested === "number" || typeof nested === "boolean") return String(nested);
|
||||
}
|
||||
try { return JSON.stringify(value); } catch { return fallback; }
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${asNumber(value).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 0) {
|
||||
return asNumber(value).toFixed(digits);
|
||||
}
|
||||
|
||||
function formatPercent(value) { return `${asNumber(value).toFixed(1)}%`; }
|
||||
function formatNumber(value, digits = 0) { return asNumber(value).toFixed(digits); }
|
||||
function severityClass(score) {
|
||||
const normalized = asNumber(score);
|
||||
if (normalized >= 85) return "danger";
|
||||
if (normalized >= 60) return "warning";
|
||||
const n = asNumber(score);
|
||||
if (n >= 85) return "danger";
|
||||
if (n >= 60) return "warning";
|
||||
return "";
|
||||
}
|
||||
|
||||
function terminalReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case "append":
|
||||
return [...state, `[${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] ${action.line}`].slice(-120);
|
||||
case "clear":
|
||||
return [];
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [overview, setOverview] = useState(null);
|
||||
const [health, setHealth] = useState(null);
|
||||
const [selectedSessionId, setSelectedSessionId] = useState("");
|
||||
const [terminalLines, setTerminalLines] = useState(["[aluer] nebula console booted"]);
|
||||
const [terminalLines, dispatchTerminal] = useReducer(terminalReducer, ["[aluer] nebula console booted"]);
|
||||
const [streamState, setStreamState] = useState("connecting");
|
||||
const [lastHandshake, setLastHandshake] = useState(null);
|
||||
const [auditFilter, setAuditFilter] = useState("");
|
||||
const [commandInput, setCommandInput] = useState("");
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [refreshInterval, setRefreshInterval] = useState(6);
|
||||
const [showHealth, setShowHealth] = useState(false);
|
||||
const [sshForm, setSshForm] = useState({
|
||||
alias: "",
|
||||
host: "",
|
||||
port: "22",
|
||||
username: "",
|
||||
password: "",
|
||||
privateKeyPath: ""
|
||||
alias: "", host: "", port: "22", username: "", password: "", privateKeyPath: ""
|
||||
});
|
||||
const deferredAuditFilter = useDeferredValue(auditFilter);
|
||||
|
||||
const addLine = (line) => dispatchTerminal({ type: "append", line });
|
||||
|
||||
const refreshOverview = useEffectEvent(async (reason = "manual") => {
|
||||
try {
|
||||
const data = asRecord(await request("/api/console/overview"));
|
||||
startTransition(() => setOverview(data));
|
||||
if (reason !== "stream") {
|
||||
appendLine(setTerminalLines, `overview refreshed (${reason})`);
|
||||
}
|
||||
if (reason !== "stream") addLine(`overview refreshed (${reason})`);
|
||||
} catch (error) {
|
||||
appendLine(setTerminalLines, `refresh failed: ${error.message}`);
|
||||
addLine(`refresh failed: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const refreshHealth = useEffectEvent(async () => {
|
||||
try {
|
||||
const data = asRecord(await request("/api/health"));
|
||||
startTransition(() => setHealth(data));
|
||||
} catch {
|
||||
// health endpoint may not be deployed yet
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => { refreshOverview("boot"); refreshHealth(); }, [refreshOverview, refreshHealth]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshOverview("boot");
|
||||
}, [refreshOverview]);
|
||||
if (!autoRefresh) return;
|
||||
const id = setInterval(() => { refreshOverview("poll"); refreshHealth(); }, refreshInterval * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [autoRefresh, refreshInterval, refreshOverview, refreshHealth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.EventSource !== "function") {
|
||||
@@ -133,30 +128,22 @@ export default function App() {
|
||||
const data = asRecord(JSON.parse(event.data));
|
||||
startTransition(() => setOverview(data));
|
||||
setStreamState("live");
|
||||
} catch {
|
||||
setStreamState("degraded");
|
||||
}
|
||||
} catch { setStreamState("degraded"); }
|
||||
});
|
||||
eventSource.onerror = () => {
|
||||
setStreamState("degraded");
|
||||
};
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
eventSource.onerror = () => setStreamState("degraded");
|
||||
return () => eventSource.close();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const sessions = asArray(overview?.ssh?.sessions);
|
||||
if (!sessions.length) {
|
||||
setSelectedSessionId("");
|
||||
return;
|
||||
}
|
||||
if (!selectedSessionId || !sessions.some((session) => asText(session?.sessionId) === selectedSessionId)) {
|
||||
if (!sessions.length) { setSelectedSessionId(""); return; }
|
||||
if (!selectedSessionId || !sessions.some((s) => asText(s?.sessionId) === selectedSessionId)) {
|
||||
setSelectedSessionId(asText(sessions[0]?.sessionId));
|
||||
}
|
||||
}, [overview, selectedSessionId]);
|
||||
|
||||
const safeOverview = asRecord(overview);
|
||||
const safeHealth = asRecord(health);
|
||||
const server = asRecord(safeOverview.server);
|
||||
const metrics = asRecord(server.metrics);
|
||||
const defense = asRecord(safeOverview.defense);
|
||||
@@ -169,70 +156,57 @@ export default function App() {
|
||||
const audit = asRecord(safeOverview.audit);
|
||||
const sessions = asArray(ssh.sessions);
|
||||
const engineHandshake = asRecord(ssh.engineHandshake);
|
||||
const selectedSession = sessions.find((session) => asText(session?.sessionId) === selectedSessionId);
|
||||
const selectedSession = sessions.find((s) => asText(s?.sessionId) === selectedSessionId);
|
||||
const healthComponents = asRecord(safeHealth.components);
|
||||
const healthSystem = asRecord(safeHealth.system);
|
||||
|
||||
const mergedAuditItems = [
|
||||
...asArray(audit.events).map((event) => ({
|
||||
id: `${asText(event?.category, "event")}-${asText(event?.action, "action")}-${asText(event?.timestamp, Date.now())}`,
|
||||
title: `${asText(event?.category, "EVENT")} / ${asText(event?.action, "ACTION")}`,
|
||||
details: `${asText(event?.player, "system")} | ${asText(event?.details, "")}`,
|
||||
score: asText(event?.category).includes("ALERT") ? 90 : 45
|
||||
...asArray(audit.events).map((e) => ({
|
||||
id: `${asText(e?.category, "event")}-${asText(e?.action, "action")}-${asText(e?.timestamp, Date.now())}`,
|
||||
title: `${asText(e?.category, "EVENT")} / ${asText(e?.action, "ACTION")}`,
|
||||
details: `${asText(e?.player, "system")} | ${asText(e?.details, "")}`,
|
||||
score: asText(e?.category).includes("ALERT") ? 90 : 45
|
||||
})),
|
||||
...asArray(audit.networkIncidents).map((incident, index) => ({
|
||||
id: `${asText(incident?.type, "incident")}-${asText(incident?.ip, "unknown")}-${index}`,
|
||||
title: `${asText(incident?.type, "incident")} / ${asText(incident?.ip, "unknown")}`,
|
||||
details: asText(incident?.details || incident?.summary, "network anomaly"),
|
||||
score: asNumber(incident?.riskScore, 70)
|
||||
...asArray(audit.networkIncidents).map((inc, i) => ({
|
||||
id: `${asText(inc?.type, "incident")}-${asText(inc?.ip, "unknown")}-${i}`,
|
||||
title: `${asText(inc?.type, "incident")} / ${asText(inc?.ip, "unknown")}`,
|
||||
details: asText(inc?.details || inc?.summary, "network anomaly"),
|
||||
score: asNumber(inc?.riskScore, 70)
|
||||
})),
|
||||
...asArray(audit.shieldTransitions).map((transition, index) => ({
|
||||
id: `${asText(transition?.mode, "shield")}-${asText(transition?.timestamp, Date.now())}-${index}`,
|
||||
title: `SHIELD / ${asText(transition?.mode, "TRANSITION")}`,
|
||||
details: `${asText(transition?.reason, "policy update")} | risk ${asText(transition?.riskScore, 80)}`,
|
||||
score: asNumber(transition?.riskScore, 80)
|
||||
...asArray(audit.shieldTransitions).map((t, i) => ({
|
||||
id: `${asText(t?.mode, "shield")}-${asText(t?.timestamp, Date.now())}-${i}`,
|
||||
title: `SHIELD / ${asText(t?.mode, "TRANSITION")}`,
|
||||
details: `${asText(t?.reason, "policy update")} | risk ${asText(t?.riskScore, 80)}`,
|
||||
score: asNumber(t?.riskScore, 80)
|
||||
}))
|
||||
];
|
||||
const auditQuery = deferredAuditFilter.trim().toLowerCase();
|
||||
const auditItems = !auditQuery
|
||||
? mergedAuditItems.slice(0, 18)
|
||||
: mergedAuditItems.filter((item) =>
|
||||
`${item.title} ${item.details}`.toLowerCase().includes(auditQuery)
|
||||
).slice(0, 18);
|
||||
? mergedAuditItems.slice(0, 24)
|
||||
: mergedAuditItems.filter((item) => `${item.title} ${item.details}`.toLowerCase().includes(auditQuery)).slice(0, 24);
|
||||
|
||||
async function handleShield(mode) {
|
||||
try {
|
||||
const result = await request("/api/console/shield/engage", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
mode,
|
||||
reason: `react-console-${mode.toLowerCase()}`
|
||||
})
|
||||
body: JSON.stringify({ mode, reason: `react-console-${mode.toLowerCase()}` })
|
||||
});
|
||||
appendLine(setTerminalLines, `shield engaged: ${asText(result.mode, "unknown")}`);
|
||||
if (asText(result.deterrenceMessage)) {
|
||||
appendLine(setTerminalLines, result.deterrenceMessage);
|
||||
}
|
||||
addLine(`shield engaged: ${asText(result.mode, "unknown")}`);
|
||||
if (asText(result.deterrenceMessage)) addLine(result.deterrenceMessage);
|
||||
refreshOverview("shield");
|
||||
} catch (error) {
|
||||
appendLine(setTerminalLines, `shield error: ${error.message}`);
|
||||
}
|
||||
} catch (error) { addLine(`shield error: ${error.message}`); }
|
||||
}
|
||||
|
||||
async function handleQuickAction(action) {
|
||||
try {
|
||||
const result = await request("/api/console/quick-action", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
reason: `react-console-${action}`
|
||||
})
|
||||
body: JSON.stringify({ action, reason: `react-console-${action}` })
|
||||
});
|
||||
appendLine(setTerminalLines, `quick action ok: ${action}`);
|
||||
if (asText(result.mode)) {
|
||||
appendLine(setTerminalLines, `mode ${asText(result.mode)}`);
|
||||
}
|
||||
addLine(`quick action ok: ${action}`);
|
||||
if (asText(result.mode)) addLine(`mode ${asText(result.mode)}`);
|
||||
refreshOverview(action);
|
||||
} catch (error) {
|
||||
appendLine(setTerminalLines, `quick action failed: ${error.message}`);
|
||||
}
|
||||
} catch (error) { addLine(`quick action failed: ${error.message}`); }
|
||||
}
|
||||
|
||||
async function connectSsh(event) {
|
||||
@@ -249,88 +223,64 @@ export default function App() {
|
||||
});
|
||||
const handshake = asRecord(handshakeResponse.handshake);
|
||||
setLastHandshake(handshake);
|
||||
appendLine(
|
||||
setTerminalLines,
|
||||
`handshake ${handshake.approved ? "approved" : "denied"} | mode=${asText(handshake.shieldMode, "unknown")} heat=${asNumber(handshake.kernelHeat)} risk=${asNumber(handshake.riskScore)}`
|
||||
);
|
||||
appendLine(setTerminalLines, asText(handshake.message, "handshake completed"));
|
||||
if (!handshake.approved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...sshForm,
|
||||
port: Number(sshForm.port || 22),
|
||||
handshakeToken: handshake.token
|
||||
};
|
||||
addLine(`handshake ${handshake.approved ? "approved" : "denied"} | mode=${asText(handshake.shieldMode, "unknown")} heat=${asNumber(handshake.kernelHeat)} risk=${asNumber(handshake.riskScore)}`);
|
||||
addLine(asText(handshake.message, "handshake completed"));
|
||||
if (!handshake.approved) return;
|
||||
const payload = { ...sshForm, port: Number(sshForm.port || 22), handshakeToken: handshake.token };
|
||||
const result = await request("/api/console/ssh/connect", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const session = asRecord(result.session);
|
||||
appendLine(setTerminalLines, `ssh connected: ${asText(session.alias, "remote-session")}`);
|
||||
addLine(`ssh connected: ${asText(session.alias, "remote-session")}`);
|
||||
setSelectedSessionId(asText(session.sessionId));
|
||||
setSshForm((current) => ({ ...current, password: "" }));
|
||||
setSshForm((c) => ({ ...c, password: "" }));
|
||||
refreshOverview("ssh-connect");
|
||||
} catch (error) {
|
||||
appendLine(setTerminalLines, `ssh connect failed: ${error.message}`);
|
||||
}
|
||||
} catch (error) { addLine(`ssh connect failed: ${error.message}`); }
|
||||
}
|
||||
|
||||
async function executeSsh(event) {
|
||||
event.preventDefault();
|
||||
if (!selectedSessionId || !commandInput.trim()) {
|
||||
appendLine(setTerminalLines, "select a session and enter a command first");
|
||||
addLine("select a session and enter a command first");
|
||||
return;
|
||||
}
|
||||
const command = commandInput.trim();
|
||||
setCommandInput("");
|
||||
appendLine(setTerminalLines, `${selectedSession?.alias || "session"} $ ${command}`);
|
||||
addLine(`${selectedSession?.alias || "session"} $ ${command}`);
|
||||
try {
|
||||
const result = await request("/api/console/ssh/execute", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sessionId: selectedSessionId,
|
||||
command
|
||||
})
|
||||
body: JSON.stringify({ sessionId: selectedSessionId, command })
|
||||
});
|
||||
const sshResult = asRecord(result.result);
|
||||
if (sshResult.flagged) {
|
||||
appendLine(setTerminalLines, `guard: ${asText(sshResult.guardType, "unknown")} severity=${asNumber(sshResult.guardSeverity)}`);
|
||||
}
|
||||
if (asText(sshResult.stdout)) {
|
||||
appendLine(setTerminalLines, sshResult.stdout);
|
||||
}
|
||||
if (asText(sshResult.stderr)) {
|
||||
appendLine(setTerminalLines, `stderr: ${asText(sshResult.stderr)}`);
|
||||
}
|
||||
appendLine(setTerminalLines, `exit=${asNumber(sshResult.exitStatus)} in ${asNumber(sshResult.durationMs)}ms`);
|
||||
} catch (error) {
|
||||
appendLine(setTerminalLines, `ssh execute failed: ${error.message}`);
|
||||
}
|
||||
if (sshResult.flagged) addLine(`guard: ${asText(sshResult.guardType, "unknown")} severity=${asNumber(sshResult.guardSeverity)}`);
|
||||
if (asText(sshResult.stdout)) addLine(sshResult.stdout);
|
||||
if (asText(sshResult.stderr)) addLine(`stderr: ${asText(sshResult.stderr)}`);
|
||||
addLine(`exit=${asNumber(sshResult.exitStatus)} in ${asNumber(sshResult.durationMs)}ms`);
|
||||
} catch (error) { addLine(`ssh execute failed: ${error.message}`); }
|
||||
}
|
||||
|
||||
async function disconnectSsh(sessionId) {
|
||||
try {
|
||||
await request(`/api/console/ssh/${sessionId}`, { method: "DELETE" });
|
||||
appendLine(setTerminalLines, `ssh disconnected: ${asText(sessionId).slice(0, 8)}`);
|
||||
addLine(`ssh disconnected: ${asText(sessionId).slice(0, 8)}`);
|
||||
refreshOverview("ssh-disconnect");
|
||||
} catch (error) {
|
||||
appendLine(setTerminalLines, `disconnect failed: ${error.message}`);
|
||||
}
|
||||
} catch (error) { addLine(`disconnect failed: ${error.message}`); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="nebula-app">
|
||||
<div className="ambient ambient-a" />
|
||||
<div className="ambient ambient-b" />
|
||||
<div className="ambient ambient-c" />
|
||||
<div className="mesh" />
|
||||
|
||||
<aside className="command-rail glass-panel">
|
||||
<div>
|
||||
<p className="eyebrow">ALUER FABRIC</p>
|
||||
<h1>{asText(safeOverview.title, "Aluer Nebula Console")}</h1>
|
||||
<p className="muted">{asText(safeOverview.subtitle, "PaperMC defense and recovery fabric")}</p>
|
||||
<p className="muted">{asText(safeOverview.subtitle, "PaperMC defense, recovery, and remote operations fabric")}</p>
|
||||
</div>
|
||||
|
||||
<div className="stream-badge">
|
||||
@@ -338,8 +288,24 @@ export default function App() {
|
||||
<strong>{streamState === "live" ? "Live Stream" : "Polling Fallback"}</strong>
|
||||
</div>
|
||||
|
||||
<div className="refresh-controls">
|
||||
<label className="toggle-label">
|
||||
<input type="checkbox" checked={autoRefresh} onChange={(e) => setAutoRefresh(e.target.checked)} />
|
||||
<span>Auto-refresh</span>
|
||||
</label>
|
||||
<select className="interval-select" value={refreshInterval} onChange={(e) => setRefreshInterval(Number(e.target.value))} disabled={!autoRefresh}>
|
||||
<option value={3}>3s</option>
|
||||
<option value={6}>6s</option>
|
||||
<option value={10}>10s</option>
|
||||
<option value={30}>30s</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rail-actions">
|
||||
<button className="primary" type="button" onClick={() => refreshOverview("manual")}>刷新总览</button>
|
||||
<button className="ghost" type="button" onClick={() => { setShowHealth(!showHealth); refreshHealth(); }}>
|
||||
{showHealth ? "隐藏健康检查" : "系统健康检查"}
|
||||
</button>
|
||||
<button className="ghost" type="button" onClick={() => handleQuickAction("backup-now")}>立即备份</button>
|
||||
<button className="ghost" type="button" onClick={() => handleQuickAction("shield-mirage")}>进入 Mirage</button>
|
||||
<button className="ghost danger" type="button" onClick={() => handleQuickAction("shield-shelter")}>进入 Shelter</button>
|
||||
@@ -351,6 +317,27 @@ export default function App() {
|
||||
<MetricCard label="Memory" value={formatPercent(metrics.memoryUsage)} accent="sun" />
|
||||
<MetricCard label="Threat" value={shield.riskScore || 0} accent="rose" />
|
||||
</div>
|
||||
|
||||
{showHealth && (
|
||||
<div className="health-panel">
|
||||
<p className="eyebrow">SYSTEM HEALTH</p>
|
||||
<div className="health-grid">
|
||||
{Object.entries(healthComponents).map(([key, comp]) => (
|
||||
<div key={key} className={`health-chip ${asText(comp?.status).includes("UP") || asText(comp?.status).includes("ENABLED") ? "up" : "down"}`}>
|
||||
<span className="health-dot" />
|
||||
<span>{asText(comp?.status)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{healthSystem?.uptimeSeconds && (
|
||||
<p className="muted" style={{ fontSize: "0.78rem", marginTop: 10 }}>
|
||||
Uptime: {Math.floor(asNumber(healthSystem.uptimeSeconds) / 3600)}h {Math.floor((asNumber(healthSystem.uptimeSeconds) % 3600) / 60)}m
|
||||
{" · "}Heap: {asNumber(healthSystem.heapUsedMB)}/{asNumber(healthSystem.heapMaxMB)} MB
|
||||
{" · "}Load: {asText(healthSystem.processCpuLoad, "0")}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="main-grid">
|
||||
@@ -366,29 +353,38 @@ export default function App() {
|
||||
<InfoPill label="Queue" value={asText(kernel.taskBus?.queuedTasks, 0)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-side">
|
||||
<div className="hero-meters">
|
||||
<ProgressMeter label="Risk Score" value={asNumber(shield.riskScore)} />
|
||||
<ProgressMeter label="Kernel Heat" value={asNumber(shield.kernelHeat || sovereign.kernelHeat)} />
|
||||
<ProgressMeter label="Resonance" value={asNumber(shield.kernelResonance)} />
|
||||
</div>
|
||||
|
||||
<div className="shield-actions">
|
||||
{["FORTIFY", "MIRAGE", "SHELTER", "RECOVERY"].map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={mode === "SHELTER" ? "primary danger" : "primary"}
|
||||
onClick={() => handleShield(mode)}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
<button key={mode} type="button" className={mode === "SHELTER" ? "primary danger" : "primary"}
|
||||
onClick={() => handleShield(mode)}>{mode}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Performance Sparklines */}
|
||||
<section className="glass-panel spark-panel">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<p className="eyebrow">PERFORMANCE TREND</p>
|
||||
<h3>实时性能走势</h3>
|
||||
</div>
|
||||
<span className="chip">{server.processRunning ? "online" : "offline"}</span>
|
||||
</div>
|
||||
<div className="spark-grid">
|
||||
<SparkBar label="TPS" value={asNumber(metrics.tps)} max={20} accent="var(--mint)" />
|
||||
<SparkBar label="CPU" value={asNumber(metrics.cpuUsage)} max={100} accent="var(--sky)" />
|
||||
<SparkBar label="Memory" value={asNumber(metrics.memoryUsage)} max={100} accent="var(--sun)" />
|
||||
<SparkBar label="Players" value={asNumber(metrics.onlinePlayers)} max={50} accent="var(--rose)" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="glass-panel module-panel">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
@@ -398,14 +394,14 @@ export default function App() {
|
||||
<span className="chip">nodes {sessions.length + 1}</span>
|
||||
</div>
|
||||
<div className="module-grid">
|
||||
{modules.map((module) => (
|
||||
<article key={asText(module?.id, Math.random())} className={`module-card ${severityClass(module?.signal)}`}>
|
||||
<span className="module-id">{asText(module?.id, "module")}</span>
|
||||
<h4>{asText(module?.name, "Unnamed Module")}</h4>
|
||||
<p>{asText(module?.summary, "No summary available.")}</p>
|
||||
{modules.map((mod) => (
|
||||
<article key={asText(mod?.id, Math.random())} className={`module-card ${severityClass(mod?.signal)}`}>
|
||||
<span className="module-id">{asText(mod?.id, "module")}</span>
|
||||
<h4>{asText(mod?.name, "Unnamed Module")}</h4>
|
||||
<p>{asText(mod?.summary, "No summary available.")}</p>
|
||||
<div className="module-foot">
|
||||
<strong>{asText(module?.status, "UNKNOWN")}</strong>
|
||||
<span>signal {asNumber(module?.signal)}</span>
|
||||
<strong>{asText(mod?.status, "UNKNOWN")}</strong>
|
||||
<span>signal {asNumber(mod?.signal)}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -431,25 +427,14 @@ export default function App() {
|
||||
</div>
|
||||
</article>
|
||||
{sessions.map((session) => (
|
||||
<article
|
||||
key={asText(session?.sessionId, `${asText(session?.alias, "session")}-${asText(session?.host, "unknown")}`)}
|
||||
<article key={asText(session?.sessionId, `${asText(session?.alias, "session")}-${asText(session?.host, "unknown")}`)}
|
||||
className={`node-card remote ${asText(session?.sessionId) === selectedSessionId ? "selected" : ""}`}
|
||||
onClick={() => setSelectedSessionId(asText(session?.sessionId))}
|
||||
>
|
||||
onClick={() => setSelectedSessionId(asText(session?.sessionId))}>
|
||||
<strong>{asText(session?.alias, "remote-session")}</strong>
|
||||
<p>{asText(session?.username, "user")}@{asText(session?.host, "host")}:{asText(session?.port, 22)}</p>
|
||||
<div className="node-stats">
|
||||
<span>{asText(session?.fingerprint, "no fingerprint")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
disconnectSsh(asText(session?.sessionId));
|
||||
}}
|
||||
>
|
||||
断开
|
||||
</button>
|
||||
<button type="button" className="text-button" onClick={(e) => { e.stopPropagation(); disconnectSsh(asText(session?.sessionId)); }}>断开</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -464,35 +449,25 @@ export default function App() {
|
||||
</div>
|
||||
<span className="chip">{selectedSession?.alias || "no session"}</span>
|
||||
</div>
|
||||
|
||||
<div className={`handshake-banner ${lastHandshake?.approved ? "approved" : lastHandshake ? "denied" : ""}`}>
|
||||
<strong>Sovereign Handshake</strong>
|
||||
<span>
|
||||
{lastHandshake
|
||||
? `${lastHandshake.approved ? "approved" : "denied"} | ${asText(lastHandshake.shieldMode, "unknown")} | heat ${asNumber(lastHandshake.kernelHeat)} | risk ${asNumber(lastHandshake.riskScore)}`
|
||||
: `required=${engineHandshake.required ? "yes" : "no"} | ttl ${asNumber(engineHandshake.ttlSeconds)}s`}
|
||||
</span>
|
||||
<span>{lastHandshake ? `${lastHandshake.approved ? "approved" : "denied"} | ${asText(lastHandshake.shieldMode, "unknown")} | heat ${asNumber(lastHandshake.kernelHeat)} | risk ${asNumber(lastHandshake.riskScore)}`
|
||||
: `required=${engineHandshake.required ? "yes" : "no"} | ttl ${asNumber(engineHandshake.ttlSeconds)}s`}</span>
|
||||
</div>
|
||||
|
||||
<form className="connect-form" onSubmit={connectSsh}>
|
||||
<input placeholder="别名" value={sshForm.alias} onChange={(event) => setSshForm((current) => ({ ...current, alias: event.target.value }))} />
|
||||
<input placeholder="主机" required value={sshForm.host} onChange={(event) => setSshForm((current) => ({ ...current, host: event.target.value }))} />
|
||||
<input placeholder="端口" value={sshForm.port} onChange={(event) => setSshForm((current) => ({ ...current, port: event.target.value }))} />
|
||||
<input placeholder="用户" required value={sshForm.username} onChange={(event) => setSshForm((current) => ({ ...current, username: event.target.value }))} />
|
||||
<input placeholder="密码" type="password" value={sshForm.password} onChange={(event) => setSshForm((current) => ({ ...current, password: event.target.value }))} />
|
||||
<input placeholder="私钥路径(可选)" value={sshForm.privateKeyPath} onChange={(event) => setSshForm((current) => ({ ...current, privateKeyPath: event.target.value }))} />
|
||||
<input placeholder="别名" value={sshForm.alias} onChange={(e) => setSshForm((c) => ({ ...c, alias: e.target.value }))} />
|
||||
<input placeholder="主机" required value={sshForm.host} onChange={(e) => setSshForm((c) => ({ ...c, host: e.target.value }))} />
|
||||
<input placeholder="端口" value={sshForm.port} onChange={(e) => setSshForm((c) => ({ ...c, port: e.target.value }))} />
|
||||
<input placeholder="用户" required value={sshForm.username} onChange={(e) => setSshForm((c) => ({ ...c, username: e.target.value }))} />
|
||||
<input placeholder="密码" type="password" value={sshForm.password} onChange={(e) => setSshForm((c) => ({ ...c, password: e.target.value }))} />
|
||||
<input placeholder="私钥路径" value={sshForm.privateKeyPath} onChange={(e) => setSshForm((c) => ({ ...c, privateKeyPath: e.target.value }))} />
|
||||
<button className="primary" type="submit">连接节点</button>
|
||||
</form>
|
||||
|
||||
<form className="command-form" onSubmit={executeSsh}>
|
||||
<input
|
||||
placeholder="输入远程命令,例如 journalctl -u minecraft -n 50"
|
||||
value={commandInput}
|
||||
onChange={(event) => setCommandInput(event.target.value)}
|
||||
/>
|
||||
<button className="ghost" type="submit">执行命令</button>
|
||||
<input placeholder="输入远程命令,例如 journalctl -u minecraft -n 50" value={commandInput}
|
||||
onChange={(e) => setCommandInput(e.target.value)} />
|
||||
<button className="ghost" type="submit">执行</button>
|
||||
</form>
|
||||
|
||||
<pre className="terminal-surface">{terminalLines.join("\n")}</pre>
|
||||
</section>
|
||||
|
||||
@@ -505,8 +480,8 @@ export default function App() {
|
||||
<span className="chip">critical {asNumber(network.criticalRiskIPs)}</span>
|
||||
</div>
|
||||
<div className="feed-stack">
|
||||
{(asArray(shield.topOffenders).length ? asArray(shield.topOffenders) : asArray(network.topRiskIPs)).slice(0, 8).map((offender, index) => (
|
||||
<article key={`${asText(offender?.ip, "unknown")}-${index}`} className={`feed-card ${severityClass(offender?.riskScore)}`}>
|
||||
{(asArray(shield.topOffenders).length ? asArray(shield.topOffenders) : asArray(network.topRiskIPs)).slice(0, 10).map((offender, i) => (
|
||||
<article key={`${asText(offender?.ip, "unknown")}-${i}`} className={`feed-card ${severityClass(offender?.riskScore)}`}>
|
||||
<strong>{asText(offender?.ip, "unknown")}</strong>
|
||||
<p>{asText(offender?.riskLevel, "risk")} | score {asNumber(offender?.riskScore)}</p>
|
||||
<p>{asText(offender?.summary || offender?.reason, "suspicious pressure source")}</p>
|
||||
@@ -521,12 +496,8 @@ export default function App() {
|
||||
<p className="eyebrow">AUDIT STREAM</p>
|
||||
<h3>审计与网络事件</h3>
|
||||
</div>
|
||||
<input
|
||||
className="filter-input"
|
||||
placeholder="筛选 audit / ip / workflow"
|
||||
value={auditFilter}
|
||||
onChange={(event) => setAuditFilter(event.target.value)}
|
||||
/>
|
||||
<input className="filter-input" placeholder="筛选 audit / ip / workflow" value={auditFilter}
|
||||
onChange={(e) => setAuditFilter(e.target.value)} />
|
||||
</div>
|
||||
<div className="feed-stack">
|
||||
{auditItems.map((item) => (
|
||||
@@ -555,22 +526,30 @@ function ProgressMeter({ label, value }) {
|
||||
const normalized = Math.max(0, Math.min(100, Number(value) || 0));
|
||||
return (
|
||||
<div className="progress-meter">
|
||||
<div className="meter-head">
|
||||
<span>{label}</span>
|
||||
<strong>{normalized}</strong>
|
||||
</div>
|
||||
<div className="meter-track">
|
||||
<span style={{ width: `${normalized}%` }} />
|
||||
</div>
|
||||
<div className="meter-head"><span>{label}</span><strong>{normalized}</strong></div>
|
||||
<div className="meter-track"><span style={{ width: `${normalized}%` }} /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoPill({ label, value }) {
|
||||
return (
|
||||
<div className="info-pill">
|
||||
<span>{asText(label)}</span>
|
||||
<strong>{asText(value, "-")}</strong>
|
||||
<div className="info-pill"><span>{asText(label)}</span><strong>{asText(value, "-")}</strong></div>
|
||||
);
|
||||
}
|
||||
|
||||
function SparkBar({ label, value, max, accent }) {
|
||||
const pct = Math.min(100, Math.max(0, (Number(value) || 0) / (max || 1) * 100));
|
||||
const level = pct > 80 ? "danger" : pct > 60 ? "warning" : "";
|
||||
return (
|
||||
<div className={`spark-bar ${level}`}>
|
||||
<div className="spark-head">
|
||||
<span>{label}</span>
|
||||
<strong>{asText(value, "0")}</strong>
|
||||
</div>
|
||||
<div className="spark-track">
|
||||
<span style={{ width: `${pct}%`, background: accent }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+154
-485
@@ -11,15 +11,9 @@
|
||||
--rose: #ff7f96;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
html, body, #root { min-height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
@@ -32,65 +26,13 @@ body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.boot-shell,
|
||||
.fatal-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
body::selection { background: rgba(96, 203, 255, 0.3); }
|
||||
|
||||
.boot-card,
|
||||
.fatal-card {
|
||||
width: min(560px, 100%);
|
||||
border-radius: 28px;
|
||||
padding: 28px;
|
||||
border: 1px solid rgba(196, 225, 255, 0.12);
|
||||
background: rgba(9, 21, 37, 0.74);
|
||||
backdrop-filter: blur(18px) saturate(145%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(145%);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
::-webkit-scrollbar { width: 11px; height: 11px; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(130, 163, 196, 0.36); border-radius: 999px; border: 2px solid rgba(4, 9, 20, 0.4); }
|
||||
::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.03); }
|
||||
|
||||
.boot-card h1,
|
||||
.fatal-card h1 {
|
||||
margin: 10px 0;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.fatal-card pre {
|
||||
margin: 18px 0 0;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--sun);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
body::selection {
|
||||
background: rgba(96, 203, 255, 0.3);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(130, 163, 196, 0.36);
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(4, 9, 20, 0.4);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
button, input, select { font: inherit; }
|
||||
|
||||
.nebula-app {
|
||||
position: relative;
|
||||
@@ -101,34 +43,18 @@ input {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.ambient,
|
||||
.mesh {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ambient, .mesh { position: fixed; pointer-events: none; }
|
||||
.ambient {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
border-radius: 50%;
|
||||
filter: blur(26px);
|
||||
opacity: 0.42;
|
||||
width: 360px; height: 360px; border-radius: 50%;
|
||||
filter: blur(26px); opacity: 0.42;
|
||||
animation: drift 18s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ambient-a {
|
||||
top: 28px;
|
||||
left: 4%;
|
||||
background: radial-gradient(circle, rgba(82, 143, 255, 0.56), transparent 70%);
|
||||
.ambient-a { top: 28px; left: 4%; background: radial-gradient(circle, rgba(82, 143, 255, 0.56), transparent 70%); }
|
||||
.ambient-b { right: 10%; bottom: 2%; background: radial-gradient(circle, rgba(88, 229, 202, 0.32), transparent 70%); animation-delay: -6s; }
|
||||
.ambient-c {
|
||||
top: 40%; left: 50%; background: radial-gradient(circle, rgba(255, 200, 107, 0.18), transparent 60%);
|
||||
animation-delay: -12s; width: 280px; height: 280px;
|
||||
}
|
||||
|
||||
.ambient-b {
|
||||
right: 10%;
|
||||
bottom: 2%;
|
||||
background: radial-gradient(circle, rgba(88, 229, 202, 0.32), transparent 70%);
|
||||
animation-delay: -6s;
|
||||
}
|
||||
|
||||
.mesh {
|
||||
inset: 0;
|
||||
background-image:
|
||||
@@ -139,491 +65,234 @@ input {
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-radius: 30px;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--border);
|
||||
position: relative; z-index: 1;
|
||||
border-radius: 30px; background: var(--glass); border: 1px solid var(--border);
|
||||
backdrop-filter: blur(18px) saturate(145%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(145%);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow); overflow: hidden;
|
||||
transition: transform 240ms ease, border-color 240ms ease, box-shadow 240ms ease;
|
||||
}
|
||||
|
||||
.glass-panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.09), transparent 32%, transparent 65%, rgba(99, 231, 194, 0.05));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glass-panel:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(99, 231, 194, 0.18);
|
||||
box-shadow: 0 34px 82px rgba(1, 7, 16, 0.46);
|
||||
}
|
||||
.glass-panel:hover { transform: translateY(-2px); border-color: rgba(99, 231, 194, 0.18); box-shadow: 0 34px 82px rgba(1, 7, 16, 0.46); }
|
||||
|
||||
.command-rail {
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
height: calc(100vh - 40px);
|
||||
padding: 24px; display: grid; align-content: start; gap: 18px;
|
||||
position: sticky; top: 20px; height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.command-rail h1, .main-grid h2, .main-grid h3, .module-card h4 { margin: 8px 0; letter-spacing: -0.03em; }
|
||||
.eyebrow, .module-id { color: var(--muted); text-transform: uppercase; letter-spacing: 0.18em; font-size: 0.76rem; }
|
||||
.muted { color: var(--muted); line-height: 1.5; }
|
||||
|
||||
.command-rail h1,
|
||||
.main-grid h2,
|
||||
.main-grid h3,
|
||||
.module-card h4 {
|
||||
margin: 8px 0;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.module-id {
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.stream-badge,
|
||||
.chip,
|
||||
.metric-card,
|
||||
.module-card,
|
||||
.node-card,
|
||||
.feed-card,
|
||||
.terminal-surface {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
.refresh-controls {
|
||||
display: flex; align-items: center; gap: 14px; padding: 12px 16px;
|
||||
border-radius: 20px; border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.stream-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 20px;
|
||||
.toggle-label {
|
||||
display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 0.88rem; color: #d6effd;
|
||||
}
|
||||
.toggle-label input[type="checkbox"] { width: auto; accent-color: var(--mint); }
|
||||
.interval-select {
|
||||
padding: 6px 10px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.08); color: var(--text); cursor: pointer; font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.health-panel {
|
||||
padding: 16px; border-radius: 20px;
|
||||
border: 1px solid rgba(99, 231, 194, 0.16);
|
||||
background: rgba(99, 231, 194, 0.04);
|
||||
}
|
||||
.health-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
|
||||
.health-chip {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||||
border-radius: 14px; font-size: 0.8rem; border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.health-chip.up { border-color: rgba(99, 231, 194, 0.24); }
|
||||
.health-chip.down { border-color: rgba(255, 127, 150, 0.24); }
|
||||
.health-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.health-chip.up .health-dot { background: var(--mint); box-shadow: 0 0 10px rgba(99, 231, 194, 0.5); }
|
||||
.health-chip.down .health-dot { background: var(--rose); box-shadow: 0 0 10px rgba(255, 127, 150, 0.5); }
|
||||
|
||||
.stream-badge, .chip, .metric-card, .module-card, .node-card, .feed-card, .terminal-surface {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.stream-badge { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 20px; }
|
||||
.stream-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #9faec2;
|
||||
width: 10px; height: 10px; border-radius: 50%; background: #9faec2;
|
||||
box-shadow: 0 0 18px rgba(159, 174, 194, 0.5);
|
||||
}
|
||||
.stream-dot.live { background: var(--mint); box-shadow: 0 0 22px rgba(99, 231, 194, 0.78); }
|
||||
.stream-dot.degraded, .stream-dot.connecting { background: var(--sun); box-shadow: 0 0 22px rgba(255, 200, 107, 0.68); }
|
||||
|
||||
.stream-dot.live {
|
||||
background: var(--mint);
|
||||
box-shadow: 0 0 22px rgba(99, 231, 194, 0.78);
|
||||
}
|
||||
|
||||
.stream-dot.degraded,
|
||||
.stream-dot.connecting {
|
||||
background: var(--sun);
|
||||
box-shadow: 0 0 22px rgba(255, 200, 107, 0.68);
|
||||
}
|
||||
|
||||
.rail-actions,
|
||||
.shield-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rail-metric-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 16px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.metric-card span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
.rail-actions, .shield-actions { display: grid; gap: 10px; }
|
||||
.rail-metric-stack { display: grid; gap: 12px; }
|
||||
|
||||
.metric-card { padding: 16px; border-radius: 22px; }
|
||||
.metric-card span { display: block; color: var(--muted); font-size: 0.8rem; }
|
||||
.metric-card strong { display: block; margin-top: 10px; font-size: 2rem; }
|
||||
.metric-card.mint strong { color: var(--mint); }
|
||||
.metric-card.sky strong { color: var(--sky); }
|
||||
.metric-card.sun strong { color: var(--sun); }
|
||||
.metric-card.rose strong { color: var(--rose); }
|
||||
|
||||
.main-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
position: relative; z-index: 1;
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.module-panel,
|
||||
.node-panel,
|
||||
.ssh-panel,
|
||||
.threat-panel,
|
||||
.audit-panel {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 0.85fr;
|
||||
gap: 24px;
|
||||
min-height: 320px;
|
||||
}
|
||||
.hero-panel, .spark-panel, .module-panel, .node-panel, .ssh-panel, .threat-panel, .audit-panel { padding: 22px; }
|
||||
|
||||
.hero-panel { display: grid; grid-template-columns: 1.15fr 0.85fr; gap: 24px; min-height: 320px; }
|
||||
.hero-panel::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -60px;
|
||||
top: -50px;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
content: ""; position: absolute; right: -60px; top: -50px;
|
||||
width: 240px; height: 240px;
|
||||
background: radial-gradient(circle, rgba(99, 231, 194, 0.18), transparent 68%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-copy { display: grid; align-content: start; gap: 14px; }
|
||||
.hero-copy p:last-child { color: #d8edff; margin: 0; line-height: 1.6; }
|
||||
.hero-side { display: grid; align-content: space-between; gap: 18px; }
|
||||
.hero-pills { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.hero-meters, .feed-stack, .module-grid { display: grid; gap: 14px; }
|
||||
|
||||
.hero-copy p:last-child {
|
||||
color: #d8edff;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.spark-panel { grid-column: 1 / -1; }
|
||||
.spark-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
|
||||
|
||||
.hero-copy {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
.spark-bar {
|
||||
display: grid; gap: 8px; padding: 14px 16px; border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.spark-bar.danger { border-color: rgba(255, 127, 150, 0.22); }
|
||||
.spark-bar.warning { border-color: rgba(255, 200, 107, 0.22); }
|
||||
.spark-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.spark-head span { color: var(--muted); font-size: 0.82rem; }
|
||||
.spark-head strong { font-size: 1.4rem; }
|
||||
.spark-track {
|
||||
height: 6px; border-radius: 999px; background: rgba(255, 255, 255, 0.06); overflow: hidden;
|
||||
}
|
||||
.spark-track span { display: block; height: 100%; border-radius: inherit; transition: width 400ms ease; }
|
||||
|
||||
.hero-side {
|
||||
display: grid;
|
||||
align-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.hero-pills {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-pill {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.info-pill span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.info-pill strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 1rem;
|
||||
color: #f2f9ff;
|
||||
}
|
||||
|
||||
.hero-meters,
|
||||
.feed-stack,
|
||||
.module-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.module-panel,
|
||||
.ssh-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.module-panel, .ssh-panel { grid-column: 1 / -1; }
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
display: flex; justify-content: space-between; align-items: flex-start;
|
||||
gap: 16px; margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 9px 12px;
|
||||
border-radius: 999px;
|
||||
color: #d6effd;
|
||||
font-size: 0.84rem;
|
||||
display: inline-flex; align-items: center; padding: 9px 12px;
|
||||
border-radius: 999px; color: #d6effd; font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.progress-meter {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meter-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meter-head span {
|
||||
color: var(--muted);
|
||||
.info-pill {
|
||||
padding: 14px 16px; border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.info-pill span { display: block; color: var(--muted); font-size: 0.78rem; }
|
||||
.info-pill strong { display: block; margin-top: 8px; font-size: 1rem; color: #f2f9ff; }
|
||||
|
||||
.progress-meter { display: grid; gap: 8px; }
|
||||
.meter-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.meter-head span { color: var(--muted); }
|
||||
.meter-track {
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
height: 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); overflow: hidden;
|
||||
}
|
||||
|
||||
.meter-track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
display: block; height: 100%; border-radius: inherit;
|
||||
background: linear-gradient(90deg, #53bcff, #63e7c2 55%, #ffc86b 100%);
|
||||
transition: width 360ms ease;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.ghost,
|
||||
.text-button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
.primary, .ghost, .text-button {
|
||||
border: 0; cursor: pointer;
|
||||
transition: transform 180ms ease, background 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.ghost {
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.primary, .ghost { border-radius: 16px; padding: 12px 16px; }
|
||||
.primary {
|
||||
background: linear-gradient(135deg, #36d8b0, #1da8f1);
|
||||
color: #031019;
|
||||
background: linear-gradient(135deg, #36d8b0, #1da8f1); color: #031019;
|
||||
box-shadow: 0 14px 28px rgba(45, 168, 241, 0.18);
|
||||
}
|
||||
|
||||
.primary.danger {
|
||||
background: linear-gradient(135deg, #ff9d7e, #ff617f);
|
||||
}
|
||||
|
||||
.primary.danger { background: linear-gradient(135deg, #ff9d7e, #ff617f); }
|
||||
.ghost {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.07); color: var(--text);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.ghost.danger { border-color: rgba(255, 127, 150, 0.3); color: #ffdce3; }
|
||||
.text-button { background: transparent; color: var(--sky); padding: 0; }
|
||||
button:hover { transform: translateY(-1px); }
|
||||
|
||||
.ghost.danger {
|
||||
border-color: rgba(255, 127, 150, 0.3);
|
||||
color: #ffdce3;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
background: transparent;
|
||||
color: var(--sky);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.module-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.module-card,
|
||||
.node-card,
|
||||
.feed-card {
|
||||
border-radius: 22px;
|
||||
padding: 16px;
|
||||
.module-grid { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
.module-card, .node-card, .feed-card {
|
||||
border-radius: 22px; padding: 16px;
|
||||
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
.module-card:hover,
|
||||
.node-card:hover,
|
||||
.feed-card:hover {
|
||||
transform: translateY(-1px);
|
||||
.module-card:hover, .node-card:hover, .feed-card:hover { transform: translateY(-1px); }
|
||||
.module-card p, .node-card p, .feed-card p { margin: 6px 0 0; color: var(--muted); line-height: 1.5; }
|
||||
.module-card.warning, .node-card.selected, .feed-card.warning { border-color: rgba(255, 200, 107, 0.28); }
|
||||
.module-card.danger, .feed-card.danger { border-color: rgba(255, 127, 150, 0.38); }
|
||||
.module-foot, .node-stats {
|
||||
display: flex; justify-content: space-between; gap: 12px;
|
||||
margin-top: 14px; color: #d7ecfb; font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.module-card p,
|
||||
.node-card p,
|
||||
.feed-card p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.node-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
|
||||
.node-card { cursor: pointer; }
|
||||
.node-card.local { background: linear-gradient(135deg, rgba(99, 231, 194, 0.12), rgba(31, 112, 255, 0.08)); }
|
||||
.node-card.selected { box-shadow: inset 0 0 0 1px rgba(255, 200, 107, 0.18); }
|
||||
|
||||
.module-card.warning,
|
||||
.node-card.selected,
|
||||
.feed-card.warning {
|
||||
border-color: rgba(255, 200, 107, 0.28);
|
||||
}
|
||||
|
||||
.module-card.danger,
|
||||
.feed-card.danger {
|
||||
border-color: rgba(255, 127, 150, 0.38);
|
||||
}
|
||||
|
||||
.module-foot,
|
||||
.node-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
color: #d7ecfb;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.node-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node-card.local {
|
||||
background: linear-gradient(135deg, rgba(99, 231, 194, 0.12), rgba(31, 112, 255, 0.08));
|
||||
}
|
||||
|
||||
.node-card.selected {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 200, 107, 0.18);
|
||||
}
|
||||
|
||||
.connect-form,
|
||||
.command-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.connect-form, .command-form { display: grid; gap: 10px; }
|
||||
|
||||
.handshake-banner {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: grid; gap: 6px; margin-bottom: 14px; padding: 14px 16px;
|
||||
border-radius: 18px; border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.handshake-banner strong { font-size: 0.92rem; }
|
||||
.handshake-banner span { color: var(--muted); font-size: 0.86rem; }
|
||||
.handshake-banner.approved { border-color: rgba(99, 231, 194, 0.32); }
|
||||
.handshake-banner.denied { border-color: rgba(255, 127, 150, 0.36); }
|
||||
|
||||
.handshake-banner strong {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.connect-form { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
.command-form { grid-template-columns: 1fr auto; margin-top: 12px; }
|
||||
|
||||
.handshake-banner span {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.handshake-banner.approved {
|
||||
border-color: rgba(99, 231, 194, 0.32);
|
||||
}
|
||||
|
||||
.handshake-banner.denied {
|
||||
border-color: rgba(255, 127, 150, 0.36);
|
||||
}
|
||||
|
||||
.connect-form {
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.command-form {
|
||||
grid-template-columns: 1fr auto;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
color: var(--text);
|
||||
border-radius: 15px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: rgba(96, 203, 255, 0.42);
|
||||
box-shadow: 0 0 0 4px rgba(96, 203, 255, 0.08);
|
||||
input, select, textarea {
|
||||
width: 100%; min-width: 0; padding: 12px 14px; color: var(--text);
|
||||
border-radius: 15px; border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06); outline: none;
|
||||
}
|
||||
input:focus, select:focus { border-color: rgba(96, 203, 255, 0.42); box-shadow: 0 0 0 4px rgba(96, 203, 255, 0.08); }
|
||||
|
||||
.terminal-surface {
|
||||
margin-top: 12px;
|
||||
min-height: 320px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
margin-top: 12px; min-height: 320px; max-height: 420px; overflow: auto;
|
||||
padding: 18px; border-radius: 24px;
|
||||
font-family: "SF Mono", "Cascadia Code", "JetBrains Mono", monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.9rem; line-height: 1.6; white-space: pre-wrap;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(3, 11, 20, 0.92), rgba(6, 18, 30, 0.88)),
|
||||
radial-gradient(circle at top right, rgba(96, 203, 255, 0.08), transparent 30%);
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
max-width: 260px;
|
||||
}
|
||||
.filter-input { max-width: 260px; }
|
||||
|
||||
@keyframes drift {
|
||||
0%,
|
||||
100% { transform: translate3d(0, 0, 0); }
|
||||
0%, 100% { transform: translate3d(0, 0, 0); }
|
||||
50% { transform: translate3d(0, -18px, 0) scale(1.04); }
|
||||
}
|
||||
|
||||
/* Responsive breakpoints */
|
||||
@media (max-width: 1220px) {
|
||||
.nebula-app {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.command-rail {
|
||||
order: 2;
|
||||
position: static;
|
||||
height: auto;
|
||||
}
|
||||
.nebula-app { grid-template-columns: 1fr; }
|
||||
.command-rail { order: 2; position: static; height: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.main-grid,
|
||||
.connect-form,
|
||||
.node-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.hero-pills {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
.main-grid, .connect-form, .node-grid, .spark-grid { grid-template-columns: 1fr; }
|
||||
.hero-panel, .hero-pills { grid-template-columns: 1fr; }
|
||||
.section-head { flex-direction: column; }
|
||||
.health-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user