Add tabbed settings and system shell
This commit is contained in:
+92
-11
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import {
|
||||
ArrowClockwise,
|
||||
CheckCircle,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Gear,
|
||||
GitBranch,
|
||||
DownloadSimple,
|
||||
Power,
|
||||
SignOut,
|
||||
TerminalWindow,
|
||||
WarningCircle,
|
||||
@@ -29,6 +30,11 @@ import {
|
||||
type UpdateStatus
|
||||
} from "./api";
|
||||
|
||||
const ShellTerminal = lazy(async () => {
|
||||
const module = await import("./ShellTerminal");
|
||||
return { default: module.ShellTerminal };
|
||||
});
|
||||
|
||||
type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal";
|
||||
|
||||
export function App() {
|
||||
@@ -311,12 +317,15 @@ function portLabel(port: number): string {
|
||||
}
|
||||
|
||||
function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
const [tab, setTab] = useState<"network" | "system" | "shell">("network");
|
||||
const [update, setUpdate] = useState<UpdateStatus | null>(null);
|
||||
const [network, setNetwork] = useState<NetworkStatus | null>(null);
|
||||
const [updateError, setUpdateError] = useState("");
|
||||
const [networkError, setNetworkError] = useState("");
|
||||
const [systemNotice, setSystemNotice] = useState("");
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [powerAction, setPowerAction] = useState<"reboot" | "poweroff" | null>(null);
|
||||
const [networkAction, setNetworkAction] = useState<"retry-upstream" | "restore-hotspot" | null>(null);
|
||||
const [hotspotSsid, setHotspotSsid] = useState("MG4-Companion");
|
||||
const [hotspotPassword, setHotspotPassword] = useState("");
|
||||
@@ -372,6 +381,35 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
setRequesting(true);
|
||||
setUpdateError("");
|
||||
try {
|
||||
await post("/api/system/update/check", {}, csrfToken);
|
||||
window.setTimeout(() => void refreshUpdate(), 700);
|
||||
} catch (caught) {
|
||||
setUpdateError(caught instanceof ApiError ? caught.message : "The update check could not be started");
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestPowerAction() {
|
||||
if (!powerAction) return;
|
||||
const action = powerAction;
|
||||
setPowerAction(null);
|
||||
setRequesting(true);
|
||||
setUpdateError("");
|
||||
try {
|
||||
await post("/api/system/power", { action }, csrfToken);
|
||||
setSystemNotice(action === "reboot" ? "Reboot scheduled. The dashboard will disconnect briefly." : "Power off scheduled. The Pi will shut down safely.");
|
||||
} catch (caught) {
|
||||
setUpdateError(caught instanceof ApiError ? caught.message : "The power action could not be scheduled");
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestNetworkAction() {
|
||||
if (!networkAction) return;
|
||||
const action = networkAction;
|
||||
@@ -426,7 +464,7 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
}
|
||||
|
||||
const revision = update?.installedRevision?.slice(0, 12) ?? "Unavailable";
|
||||
const statusLabel = !update ? "Loading" : update.state === "current" ? "Up to date" : update.state === "success" ? "Updated" : update.state === "failed" ? "Needs attention" : running ? "Updating" : "Ready";
|
||||
const statusLabel = !update ? "Loading" : update.state === "current" ? "Up to date" : update.state === "available" ? "Update available" : update.state === "success" ? "Updated" : update.state === "failed" ? "Needs attention" : running ? (update.state === "checking" ? "Checking" : "Updating") : "Ready";
|
||||
const modeLabels: Record<NonNullable<NetworkStatus>["mode"], string> = {
|
||||
disabled: "Not configured",
|
||||
starting: "Starting",
|
||||
@@ -442,11 +480,18 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="settings-layout">
|
||||
<div className="settings-page">
|
||||
<nav className="settings-tabs" aria-label="Settings categories" role="tablist">
|
||||
<button role="tab" aria-selected={tab === "network"} className={tab === "network" ? "active" : ""} onClick={() => setTab("network")}><WifiHigh size={20} />Network</button>
|
||||
<button role="tab" aria-selected={tab === "system"} className={tab === "system" ? "active" : ""} onClick={() => setTab("system")}><Gear size={20} />System</button>
|
||||
<button role="tab" aria-selected={tab === "shell"} className={tab === "shell" ? "active" : ""} onClick={() => setTab("shell")}><TerminalWindow size={20} />Shell</button>
|
||||
</nav>
|
||||
|
||||
{tab === "network" && <div className="settings-layout" role="tabpanel">
|
||||
<section className="settings-panel network-panel">
|
||||
<div className="settings-title">
|
||||
<div className="settings-icon"><WifiHigh size={30} weight="duotone" /></div>
|
||||
<div><h2>Car network</h2><p>Keep the dashboard reachable through the built-in hotspot while preferring the AWUS1900 for upstream internet.</p></div>
|
||||
<div><h2>Network</h2><p>The built-in radio hosts the car network while the AWUS1900 provides upstream internet whenever it is available.</p></div>
|
||||
</div>
|
||||
|
||||
<div className="network-overview">
|
||||
@@ -456,7 +501,11 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
<p>{network?.message ?? "Reading network controller status..."}</p>
|
||||
{probeSeconds !== null && <small>Offline hotspot recovery in at most {probeSeconds} seconds</small>}
|
||||
</div>
|
||||
<NetworkAdapter label="Built-in / car hotspot" adapter={network?.hotspot ?? null} />
|
||||
<div className="network-adapter">
|
||||
<span>Hotspot</span>
|
||||
<strong>{network?.hotspotSsid ?? (network ? "Not active" : "Loading")}</strong>
|
||||
<small>{network?.hotspot.address ?? network?.hotspot.interface ?? "Waiting for status"}</small>
|
||||
</div>
|
||||
<NetworkAdapter label="AWUS1900 / upstream" adapter={network?.upstream ?? null} />
|
||||
<div className="network-adapter">
|
||||
<span>Internet check</span>
|
||||
@@ -500,17 +549,33 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
<p className="settings-note">The password is sent only to the local Pi configuration service and is stored by NetworkManager as a derived WPA key.</p>
|
||||
</form>
|
||||
</section>
|
||||
</div>}
|
||||
|
||||
<section className="settings-panel">
|
||||
{tab === "system" && <div className="system-settings-layout" role="tabpanel">
|
||||
<section className="settings-panel power-panel">
|
||||
<div className="settings-title">
|
||||
<div className="settings-icon"><Power size={30} weight="duotone" /></div>
|
||||
<div><h2>General system</h2><p>Restart or safely shut down the Raspberry Pi.</p></div>
|
||||
</div>
|
||||
{systemNotice && <div className="network-notice" role="status"><CheckCircle size={20} />{systemNotice}</div>}
|
||||
<div className="power-actions">
|
||||
<button className="secondary-button text-button" onClick={() => setPowerAction("reboot")} disabled={requesting}><ArrowClockwise size={22} />Reboot Pi</button>
|
||||
<button className="danger-button" onClick={() => setPowerAction("poweroff")} disabled={requesting}><Power size={22} />Power off</button>
|
||||
</div>
|
||||
<p className="settings-note">Both actions use systemd for a clean shutdown. Power can be removed only after the Pi has stopped.</p>
|
||||
</section>
|
||||
|
||||
<section className="settings-panel update-panel">
|
||||
<div className="settings-title">
|
||||
<div className="settings-icon"><DownloadSimple size={30} weight="duotone" /></div>
|
||||
<div><h2>Software update</h2><p>Fetch, verify, build, and activate the newest release from the configured Git branch.</p></div>
|
||||
<div><h2>Software update</h2><p>Check the configured Git branch, then verify and activate a newer release when you choose.</p></div>
|
||||
</div>
|
||||
|
||||
<dl className="update-facts">
|
||||
<div><dt>Status</dt><dd className={`update-state ${update?.state ?? "idle"}`}>{statusLabel}</dd></div>
|
||||
<div><dt>Installed revision</dt><dd><GitBranch size={18} /> <code>{revision}</code></dd></div>
|
||||
<div><dt>Last activity</dt><dd>{update && update.updatedAt !== new Date(0).toISOString() ? new Date(update.updatedAt).toLocaleString() : "Never"}</dd></div>
|
||||
<div><dt>Available revision</dt><dd><GitBranch size={18} /> <code>{update?.toRevision?.slice(0, 12) ?? "Not checked"}</code></dd></div>
|
||||
<div><dt>Last check</dt><dd>{update && update.updatedAt !== new Date(0).toISOString() ? new Date(update.updatedAt).toLocaleString() : "Never"}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div className="update-message" aria-live="polite">
|
||||
@@ -522,10 +587,12 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
{updateError && <div className="form-error" role="alert"><WarningCircle size={20} />{updateError}</div>}
|
||||
|
||||
<div className="settings-actions">
|
||||
<button className="primary-button" onClick={() => setConfirming(true)} disabled={!update?.supported || running || requesting}>
|
||||
<DownloadSimple size={22} /> {running ? "Updating..." : "Update now"}
|
||||
<button className="secondary-button text-button" onClick={() => void checkForUpdates()} disabled={!update?.supported || running || requesting}>
|
||||
<ArrowClockwise size={22} /> {update?.state === "checking" ? "Checking..." : "Check for updates"}
|
||||
</button>
|
||||
<button className="primary-button" onClick={() => setConfirming(true)} disabled={!update?.supported || running || requesting}>
|
||||
<DownloadSimple size={22} /> {update?.state === "building" ? "Updating..." : "Install update"}
|
||||
</button>
|
||||
<button className="secondary-button" onClick={() => void refreshUpdate()} disabled={requesting}>Refresh status</button>
|
||||
</div>
|
||||
<p className="settings-note">The current release stays active unless the new version passes installation, lint, type checks, tests, and production builds.</p>
|
||||
</section>
|
||||
@@ -538,6 +605,9 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
<div><dt>Update channel</dt><dd>{update?.supported ? "Configured Git branch" : "Not configured"}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>}
|
||||
|
||||
{tab === "shell" && <div role="tabpanel"><Suspense fallback={<div className="shell-loading">Loading terminal…</div>}><ShellTerminal csrfToken={csrfToken} /></Suspense></div>}
|
||||
|
||||
{confirming && <div className="modal-backdrop" role="presentation" onMouseDown={() => setConfirming(false)}>
|
||||
<div className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="update-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
|
||||
@@ -573,6 +643,17 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{powerAction && <div className="modal-backdrop" role="presentation" onMouseDown={() => setPowerAction(null)}>
|
||||
<div className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="power-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<h2 id="power-dialog-title">{powerAction === "reboot" ? "Reboot the Pi?" : "Power off the Pi?"}</h2>
|
||||
<p>{powerAction === "reboot" ? "The dashboard and hotspot will disconnect while the Pi restarts, then return automatically." : "The dashboard and hotspot will disconnect. Physical access may be required to turn the Pi back on."}</p>
|
||||
<div className="dialog-actions">
|
||||
<button className="secondary-button text-button" onClick={() => setPowerAction(null)}>Cancel</button>
|
||||
<button className={powerAction === "poweroff" ? "danger-button" : "primary-button"} onClick={() => void requestPowerAction()}>{powerAction === "reboot" ? "Reboot Pi" : "Power off"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
type ServerMessage =
|
||||
| { type: "ready" }
|
||||
| { type: "output"; data: string }
|
||||
| { type: "exit"; exitCode: number }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
export function ShellTerminal({ csrfToken }: { csrfToken: string }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const [state, setState] = useState<"connecting" | "ready" | "closed" | "error">("connecting");
|
||||
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
fontFamily: 'ui-monospace, "Cascadia Code", "SFMono-Regular", monospace',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.25,
|
||||
scrollback: 3_000,
|
||||
theme: {
|
||||
background: "#0b0e0a",
|
||||
foreground: "#dce4d6",
|
||||
cursor: "#b9e769",
|
||||
selectionBackground: "#43542c",
|
||||
black: "#10130f",
|
||||
green: "#b9e769",
|
||||
brightGreen: "#d0f397",
|
||||
red: "#ff9b8d"
|
||||
}
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
terminal.loadAddon(fit);
|
||||
terminal.open(host.current);
|
||||
fit.fit();
|
||||
terminal.writeln("\x1b[38;2;185;231;105mPi Car Companion shell\x1b[0m");
|
||||
terminal.writeln("Restricted local session running as pi-companion.\r\n");
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/api/system/shell`);
|
||||
const sendSize = () => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "resize", cols: terminal.cols, rows: terminal.rows }));
|
||||
}
|
||||
};
|
||||
socket.addEventListener("open", () => {
|
||||
socket.send(JSON.stringify({ type: "authenticate", csrfToken }));
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
try {
|
||||
const message = JSON.parse(String(event.data)) as ServerMessage;
|
||||
if (message.type === "ready") {
|
||||
setState("ready");
|
||||
sendSize();
|
||||
terminal.focus();
|
||||
} else if (message.type === "output") terminal.write(message.data);
|
||||
else if (message.type === "exit") {
|
||||
terminal.writeln(`\r\n\x1b[90mShell exited with status ${message.exitCode}.\x1b[0m`);
|
||||
setState("closed");
|
||||
} else if (message.type === "error") {
|
||||
terminal.writeln(`\r\n\x1b[31m${message.message}\x1b[0m`);
|
||||
setState("error");
|
||||
}
|
||||
} catch {
|
||||
terminal.writeln("\r\n\x1b[31mInvalid response from the shell service.\x1b[0m");
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", (event) => {
|
||||
if (event.code !== 1000) {
|
||||
terminal.writeln(`\r\n\x1b[31mTerminal disconnected${event.reason ? `: ${event.reason}` : "."}\x1b[0m`);
|
||||
setState("error");
|
||||
} else setState((current) => current === "error" ? "error" : "closed");
|
||||
});
|
||||
socket.addEventListener("error", () => setState("error"));
|
||||
const input = terminal.onData((data) => {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "input", data }));
|
||||
});
|
||||
const resize = new ResizeObserver(() => {
|
||||
fit.fit();
|
||||
sendSize();
|
||||
});
|
||||
resize.observe(host.current);
|
||||
|
||||
return () => {
|
||||
resize.disconnect();
|
||||
input.dispose();
|
||||
socket.close(1000, "Terminal tab closed");
|
||||
terminal.dispose();
|
||||
};
|
||||
}, [csrfToken, generation]);
|
||||
|
||||
return (
|
||||
<section className="shell-panel" aria-label="Interactive Pi shell">
|
||||
<div className="shell-toolbar">
|
||||
<div><span className={`shell-state ${state}`} aria-hidden="true" /><strong>{state === "ready" ? "Connected" : state === "connecting" ? "Connecting" : "Disconnected"}</strong><small>pi-companion@localhost</small></div>
|
||||
{state !== "ready" && <button className="secondary-button text-button" onClick={() => { setState("connecting"); setGeneration((value) => value + 1); }}>Reconnect</button>}
|
||||
</div>
|
||||
<div className="terminal-host" ref={host} />
|
||||
<p className="settings-note">This authenticated terminal runs locally as the restricted <code>pi-companion</code> service account. Sessions close when you leave this tab and expire after 30 minutes.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -57,7 +57,7 @@ export type SystemStatus = {
|
||||
};
|
||||
|
||||
export type UpdateStatus = {
|
||||
state: "idle" | "checking" | "building" | "current" | "success" | "failed";
|
||||
state: "idle" | "checking" | "available" | "building" | "current" | "success" | "failed";
|
||||
message: string;
|
||||
fromRevision: string | null;
|
||||
toRevision: string | null;
|
||||
|
||||
+37
-4
@@ -39,12 +39,14 @@ h2 { letter-spacing: -0.025em; }
|
||||
.error-screen svg { color: var(--danger); margin-bottom: 22px; }
|
||||
.screen-copy { max-width: 520px; color: var(--muted); line-height: 1.6; }
|
||||
|
||||
.primary-button, .secondary-button { min-height: 56px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; border-radius: 10px; padding: 0 24px; border: 0; font-weight: 750; cursor: pointer; transition: transform 120ms ease, background 120ms ease; white-space: nowrap; }
|
||||
.primary-button, .secondary-button, .danger-button { min-height: 56px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; border-radius: 10px; padding: 0 24px; border: 0; font-weight: 750; cursor: pointer; transition: transform 120ms ease, background 120ms ease; white-space: nowrap; }
|
||||
.primary-button { color: var(--accent-ink); background: var(--accent); }
|
||||
.secondary-button { color: var(--text); background: var(--surface-raised); border: 1px solid var(--line); }
|
||||
.danger-button { color: #ffe3df; background: #552820; border: 1px solid #804035; }
|
||||
.primary-button:hover { background: #c8f17f; }
|
||||
.secondary-button:hover { background: #262c24; }
|
||||
.primary-button:active, .secondary-button:active, .nav-item:active { transform: scale(0.98); }
|
||||
.danger-button:hover { background: #683127; }
|
||||
.primary-button:active, .secondary-button:active, .danger-button:active, .nav-item:active { transform: scale(0.98); }
|
||||
button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
.account-layout { min-height: 100dvh; display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(420px, 0.95fr); }
|
||||
@@ -135,7 +137,15 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.activity-empty { min-height: 74px; display: grid; place-items: center; padding: 18px; color: var(--muted); font-size: 0.8rem; text-align: center; }
|
||||
.activity-privacy { margin: 0; color: var(--muted); font-size: 0.72rem; text-align: right; }
|
||||
|
||||
.settings-page { display: grid; gap: 18px; }
|
||||
.settings-tabs { width: fit-content; display: flex; gap: 5px; padding: 5px; background: #121610; border: 1px solid var(--line); border-radius: 12px; }
|
||||
.settings-tabs button { min-height: 48px; display: inline-flex; align-items: center; gap: 9px; padding: 0 19px; color: var(--muted); background: transparent; border: 0; border-radius: 8px; font-weight: 700; cursor: pointer; transition: color 160ms ease, background 160ms ease, transform 120ms ease; }
|
||||
.settings-tabs button:hover { color: var(--text); background: var(--surface-raised); }
|
||||
.settings-tabs button:active { transform: scale(0.98); }
|
||||
.settings-tabs button.active { color: var(--accent-ink); background: var(--accent); }
|
||||
.settings-layout { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.7fr); gap: 18px; align-items: start; }
|
||||
.system-settings-layout { display: grid; grid-template-columns: minmax(280px, 0.65fr) minmax(0, 1.35fr); gap: 18px; align-items: start; }
|
||||
.update-panel { grid-row: span 2; }
|
||||
.settings-panel { padding: 28px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.network-panel { grid-column: 1 / -1; }
|
||||
.network-overview { display: grid; grid-template-columns: 1.3fr repeat(3, minmax(0, 1fr)); gap: 1px; margin: 28px 0 20px; overflow: hidden; border: 1px solid var(--line); border-radius: 11px; background: var(--line); }
|
||||
@@ -162,21 +172,36 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.settings-title h2, .compact-panel h2 { margin-bottom: 8px; font-size: 1.45rem; }
|
||||
.settings-title p { max-width: 620px; margin: 0; color: var(--muted); line-height: 1.5; }
|
||||
.settings-icon { flex: none; width: 56px; height: 56px; display: grid; place-items: center; color: var(--accent); background: rgba(185, 231, 105, 0.08); border-radius: 12px; }
|
||||
.update-facts { display: grid; grid-template-columns: 0.8fr 1fr 1.2fr; gap: 20px; margin: 30px 0 20px; padding: 22px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.update-facts { display: grid; grid-template-columns: 0.7fr 1fr 1fr 1.2fr; gap: 20px; margin: 30px 0 20px; padding: 22px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.update-facts div { display: grid; gap: 7px; }
|
||||
.update-facts dt, .behavior-list dt { color: var(--muted); font-size: 0.8rem; }
|
||||
.update-facts dd, .behavior-list dd { display: flex; align-items: center; gap: 7px; margin: 0; font-weight: 700; }
|
||||
.update-facts code { font-family: ui-monospace, "Cascadia Code", monospace; color: #dce4d6; }
|
||||
.update-state.failed { color: var(--danger); }
|
||||
.update-state.success, .update-state.current, .update-state.building, .update-state.checking { color: var(--accent); }
|
||||
.update-state.success, .update-state.current, .update-state.available, .update-state.building, .update-state.checking { color: var(--accent); }
|
||||
.update-message { min-height: 56px; display: flex; align-items: center; gap: 12px; margin-bottom: 18px; padding: 14px 16px; color: #dce4d6; background: #121610; border-radius: 10px; }
|
||||
.update-message svg { flex: none; color: var(--accent); }
|
||||
.update-message .ph-warning-circle { color: var(--danger); }
|
||||
.settings-actions { display: flex; gap: 12px; margin-top: 22px; }
|
||||
.settings-note { max-width: 680px; margin: 20px 0 0; color: var(--muted); font-size: 0.83rem; line-height: 1.5; }
|
||||
.power-panel .network-notice { margin-top: 24px; }
|
||||
.power-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 30px; }
|
||||
.behavior-list { display: grid; gap: 24px; margin: 26px 0 0; }
|
||||
.behavior-list div { display: grid; gap: 7px; }
|
||||
.behavior-list dd { color: #dce4d6; }
|
||||
.shell-panel { overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.shell-loading { min-height: 420px; display: grid; place-items: center; color: var(--muted); background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.shell-toolbar { min-height: 68px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 10px 14px 10px 20px; border-bottom: 1px solid var(--line); }
|
||||
.shell-toolbar > div { display: grid; grid-template-columns: auto auto; align-items: center; gap: 3px 9px; }
|
||||
.shell-toolbar strong { font-size: 0.87rem; }
|
||||
.shell-toolbar small { grid-column: 2; color: var(--muted); font: 0.7rem/1.2 ui-monospace, "Cascadia Code", monospace; }
|
||||
.shell-state { grid-row: 1 / 3; width: 9px; height: 9px; border-radius: 50%; background: #7c8477; }
|
||||
.shell-state.ready { background: var(--accent); box-shadow: 0 0 0 5px rgba(185, 231, 105, 0.08); }
|
||||
.shell-state.error { background: var(--danger); }
|
||||
.terminal-host { height: min(590px, calc(100dvh - 290px)); min-height: 360px; padding: 16px 12px; background: #0b0e0a; }
|
||||
.terminal-host .xterm { height: 100%; }
|
||||
.shell-panel > .settings-note { margin: 0; padding: 14px 20px 16px; border-top: 1px solid var(--line); }
|
||||
.shell-panel code { color: #dce4d6; }
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 24px; background: rgba(8, 10, 8, 0.78); }
|
||||
.confirm-dialog { width: min(520px, 100%); padding: 28px; background: var(--surface-raised); border: 1px solid #465043; border-radius: var(--radius); box-shadow: 0 24px 80px rgba(3, 5, 3, 0.45); }
|
||||
.confirm-dialog h2 { margin-bottom: 12px; font-size: 1.6rem; }
|
||||
@@ -197,6 +222,8 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.health-summary { grid-template-columns: 1fr; gap: 24px; }
|
||||
.metrics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.settings-layout { grid-template-columns: 1fr; }
|
||||
.system-settings-layout { grid-template-columns: 1fr; }
|
||||
.update-panel { grid-row: auto; }
|
||||
.network-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.hotspot-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.activity-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@@ -231,6 +258,12 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.activity-summary, .client-grid { grid-template-columns: 1fr; }
|
||||
.settings-actions, .dialog-actions { flex-direction: column; }
|
||||
.settings-actions button, .dialog-actions button { width: 100%; }
|
||||
.settings-actions .text-button, .dialog-actions .text-button { font-size: inherit; }
|
||||
.settings-tabs { width: 100%; }
|
||||
.settings-tabs button { flex: 1; justify-content: center; padding: 0 10px; }
|
||||
.power-actions { grid-template-columns: 1fr; }
|
||||
.power-actions .secondary-button { width: 100%; padding: 0 18px; font-size: inherit; }
|
||||
.terminal-host { height: 58dvh; min-height: 320px; }
|
||||
.skeleton-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user