still tuning server settings section

This commit is contained in:
onysd 2026-08-26 01:54:19 +03:00
parent 66f9c0bc1e
commit 80bc8d352c
11 changed files with 510 additions and 164 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-C0CaRjmH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dx2zWoFE.css">
<script type="module" crossorigin src="/assets/index-X1cMLS10.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-q5XsR54I.css">
</head>
<body>
<div id="root"></div>

View file

@ -23,6 +23,7 @@ import type {
CollectibleUsernameDetail,
CollectibleUsernameListResponse,
CommandResult,
DockerService,
EnvGroup,
ServerIdentity,
ServerStatus,
@ -240,6 +241,7 @@ export const api = {
serverIconURL: () => `/api/server/icon?t=${Date.now()}`,
serverEnv: () => request<EnvGroup[]>("/api/server/env"),
serverStatus: () => request<ServerStatus>("/api/server/status"),
dockerStatus: () => request<DockerService[]>("/api/server/docker-status"),
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST",
body: JSON.stringify(payload)

View file

@ -1,23 +1,42 @@
import { ChevronDown, ImageOff, ImagePlus, Loader2, RefreshCw, Trash2, Upload, X } from "lucide-react";
import { ChevronDown, CircleCheck, CircleOff, CircleX, Database, HardDrive, ImageOff, ImagePlus, Layers, Loader2, RefreshCw, Server, ShieldCheck, Trash2, Upload, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, LoadingSurface, Metric, PageFrame, SectionHead } from "../components/ui";
import type { EnvGroup, ServerIdentity, ServerStatus } from "../types";
import { Alert, LoadingSurface, PageFrame, SectionHead } from "../components/ui";
import type { DockerService, EnvGroup, ServerIdentity, ServerStatus } from "../types";
// Server Settings: the web-panel equivalent of tui-panel/server-panel.py's
// menu -- admin-editable server name/description/icon (served to clients
// over /owpengram/server-info + /owpengram/server-icon), .env editing, and
// Restart/Update. See cmd/telesrv-admin/serversettings.go for the backend.
// live process/Docker status + Restart/Update. See
// cmd/telesrv-admin/serversettings.go for the backend.
//
// Split into two tabs: "Settings" (identity + .env, rarely touched, no live
// state) and "Services" (live process/container status + restart/update,
// the operational side someone actually watches while things are moving).
export function ServerSettingsPage() {
const [tab, setTab] = useState<"settings" | "services">("settings");
return (
<PageFrame title={"Server Settings"} eyebrow={"Identity, .env, and process control -- mirrors the TUI panel"}>
<div className="stacked-sections">
<IdentitySection />
<ServerControlSection />
<EnvSection />
<PageFrame title={"Server Settings"} eyebrow={"Identity, .env, and live process/service control"}>
<div className="tab-bar" role="tablist" aria-label={"Server Settings sections"}>
<button className={`tab-btn ${tab === "settings" ? "active" : ""}`} type="button" role="tab" aria-selected={tab === "settings"} onClick={() => setTab("settings")}>
{"Settings"}
</button>
<button className={`tab-btn ${tab === "services" ? "active" : ""}`} type="button" role="tab" aria-selected={tab === "services"} onClick={() => setTab("services")}>
{"Services"}
</button>
</div>
{tab === "settings" ? (
<div className="stacked-sections">
<IdentitySection />
<EnvSection />
</div>
) : (
<div className="stacked-sections">
<ServicesTab />
</div>
)}
</PageFrame>
);
}
@ -215,149 +234,6 @@ function ServerIconModal({ hasIcon, onClose, onDone }: { hasIcon: boolean; onClo
);
}
// --- Server control (status / restart / update) --------------------------
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// useAdminRestartWatcher backs the "the admin panel is bouncing itself"
// flow after Restart/Update: those actions ask owpengram-server to relaunch
// the admin process once *it* is back up (see internal/procctl's
// PendingAdminRestart), so from the browser's side this just means polling
// /api/session until a *different* boot_id answers -- proof a genuinely new
// process is up, not just that the old one is still slow -- then reloading
// the page. A timeout surfaces as a message with a manual reload button
// instead of spinning forever if something went wrong server-side.
function useAdminRestartWatcher() {
const [waiting, setWaiting] = useState(false);
const [timedOut, setTimedOut] = useState(false);
const cancelled = useRef(false);
const watch = useCallback(async (timeoutMs = 150000) => {
cancelled.current = false;
setTimedOut(false);
setWaiting(true);
let baseline = "";
try {
baseline = (await api.session()).boot_id ?? "";
} catch {
// Falls through to polling anyway -- worst case it reloads on the
// first boot_id it manages to read, which is still correct.
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (cancelled.current) return;
await sleep(1500);
try {
const session = await api.session();
if (session.boot_id && session.boot_id !== baseline) {
window.location.reload();
return;
}
} catch {
// Expected mid-bounce: the old process is dying or the new one
// hasn't opened its listener yet. Keep polling.
}
}
setWaiting(false);
setTimedOut(true);
}, []);
const dismiss = useCallback(() => {
cancelled.current = true;
setWaiting(false);
setTimedOut(false);
}, []);
return { waiting, timedOut, watch, dismiss };
}
function RestartOverlay({ label, timedOut, onDismiss }: { label: string; timedOut: boolean; onDismiss: () => void }) {
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal restart-overlay" role="dialog" aria-modal="true" aria-label={label}>
{timedOut ? (
<div className="command-body restart-overlay-body">
<Alert>{"The admin panel did not come back within the expected time. It may still be building/restarting -- reload manually in a bit, or check the server logs."}</Alert>
<div className="gift-table-actions restart-overlay-actions">
<button className="btn" type="button" onClick={onDismiss}>{"Dismiss"}</button>
<button className="btn primary" type="button" onClick={() => window.location.reload()}>{"Reload now"}</button>
</div>
</div>
) : (
<div className="command-body restart-overlay-body">
<Loader2 className="spin" size={28} />
<p>{label}</p>
</div>
)}
</section>
</div>,
document.body
);
}
function ServerControlSection() {
const [status, setStatus] = useState<ServerStatus | null>(null);
const [error, setError] = useState("");
const [overlayLabel, setOverlayLabel] = useState("");
const restartWatcher = useAdminRestartWatcher();
async function load() {
setError("");
try {
setStatus(await api.serverStatus());
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => { void load(); }, []);
return (
<section className="section-block">
<SectionHead
title={"Process control"}
text={"Restart rebuilds and relaunches owpengram-server. Update also runs git pull first and rebuilds both binaries. Either way the admin panel bounces onto its (possibly rebuilt) binary too, a few seconds after the server comes back -- this page reloads itself once that's done."}
action={<button className="btn icon-text" type="button" onClick={() => void load()}><RefreshCw size={15} /> {"Refresh status"}</button>}
/>
{error && <Alert>{error}</Alert>}
<div className="card-body">
{status && (
<div className="metric-row">
<Metric label={"owpengram-server"} value={status.ServerAlive ? `running (pid ${status.ServerPID})` : "stopped"} tone={status.ServerAlive ? "good" : "danger"} />
<Metric label={"admin panel"} value={status.AdminAlive ? `running (pid ${status.AdminPID})` : "stopped"} tone={status.AdminAlive ? "good" : "danger"} />
</div>
)}
<div className="gift-table-actions">
<ActionButton
tone="warn"
label={"Restart server"}
path="/api/actions/restart-server"
payload={() => ({})}
onDone={() => {
setOverlayLabel("Restarting owpengram-server and the admin panel...");
void restartWatcher.watch();
}}
/>
<ActionButton
tone="danger"
label={"Update (git pull + rebuild + restart)"}
path="/api/actions/update-server"
payload={() => ({})}
onDone={() => {
setOverlayLabel("Pulling, rebuilding, and restarting owpengram-server and the admin panel...");
void restartWatcher.watch();
}}
/>
</div>
</div>
{restartWatcher.waiting && <RestartOverlay label={overlayLabel} timedOut={false} onDismiss={restartWatcher.dismiss} />}
{restartWatcher.timedOut && <RestartOverlay label={overlayLabel} timedOut={true} onDismiss={restartWatcher.dismiss} />}
</section>
);
}
// --- .env editor -----------------------------------------------------
function EnvSection() {
@ -441,3 +317,272 @@ function EnvSection() {
</section>
);
}
// --- Services tab (live Docker + process status, restart/update) --------
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// useAdminRestartWatcher backs the "the admin panel is bouncing itself"
// flow after Restart/Update: those actions ask owpengram-server to relaunch
// the admin process once *it* is back up (see internal/procctl's
// PendingAdminRestart), so from the browser's side this just means polling
// /api/session until a *different* boot_id answers -- proof a genuinely new
// process is up, not just that the old one is still slow -- then reloading
// the page. A timeout surfaces as a message with a manual reload button
// instead of spinning forever if something went wrong server-side.
function useAdminRestartWatcher() {
const [waiting, setWaiting] = useState(false);
const [timedOut, setTimedOut] = useState(false);
const cancelled = useRef(false);
const watch = useCallback(async (timeoutMs = 150000) => {
cancelled.current = false;
setTimedOut(false);
setWaiting(true);
let baseline = "";
try {
baseline = (await api.session()).boot_id ?? "";
} catch {
// Falls through to polling anyway -- worst case it reloads on the
// first boot_id it manages to read, which is still correct.
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (cancelled.current) return;
await sleep(1500);
try {
const session = await api.session();
if (session.boot_id && session.boot_id !== baseline) {
window.location.reload();
return;
}
} catch {
// Expected mid-bounce: the old process is dying or the new one
// hasn't opened its listener yet. Keep polling.
}
}
setWaiting(false);
setTimedOut(true);
}, []);
const dismiss = useCallback(() => {
cancelled.current = true;
setWaiting(false);
setTimedOut(false);
}, []);
return { waiting, timedOut, watch, dismiss };
}
function RestartOverlay({ label, timedOut, onDismiss }: { label: string; timedOut: boolean; onDismiss: () => void }) {
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal restart-overlay" role="dialog" aria-modal="true" aria-label={label}>
{timedOut ? (
<div className="command-body restart-overlay-body">
<Alert>{"The admin panel did not come back within the expected time. It may still be building/restarting -- reload manually in a bit, or check the server logs."}</Alert>
<div className="gift-table-actions restart-overlay-actions">
<button className="btn" type="button" onClick={onDismiss}>{"Dismiss"}</button>
<button className="btn primary" type="button" onClick={() => window.location.reload()}>{"Reload now"}</button>
</div>
</div>
) : (
<div className="command-body restart-overlay-body">
<Loader2 className="spin" size={28} />
<p>{label}</p>
</div>
)}
</section>
</div>,
document.body
);
}
type LiveTone = "good" | "warn" | "danger" | "idle";
function liveDotIcon(tone: LiveTone) {
switch (tone) {
case "good": return <CircleCheck size={15} />;
case "warn": return <Loader2 className="spin" size={15} />;
case "danger": return <CircleX size={15} />;
default: return <CircleOff size={15} />;
}
}
// ServiceCard renders one live status tile -- a Docker container or a local
// process -- with a status pill (dot + label) and up to one detail line.
// Shared between the Docker services grid and the process-control grid so
// both read the same way at a glance instead of two different layouts.
function ServiceCard({
icon,
name,
tone,
statusLabel,
detail
}: {
icon: React.ReactNode;
name: string;
tone: LiveTone;
statusLabel: string;
detail?: string;
}) {
return (
<div className={`service-card tone-${tone}`}>
<div className="service-card-icon">{icon}</div>
<div className="service-card-body">
<div className="service-card-name">{name}</div>
<div className="service-card-detail">{detail ?? " "}</div>
</div>
<div className="service-card-status">
{liveDotIcon(tone)}
<span>{statusLabel}</span>
</div>
</div>
);
}
const dockerServiceIcon: Record<string, React.ReactNode> = {
postgres: <Database size={18} />,
redis: <Layers size={18} />,
minio: <HardDrive size={18} />
};
function dockerTone(service: DockerService): LiveTone {
const state = service.state.toLowerCase();
const health = service.health.toLowerCase();
if (state !== "running") return "danger";
if (health === "unhealthy") return "danger";
if (health === "starting") return "warn";
return "good";
}
function dockerStatusLabel(service: DockerService): string {
const state = service.state.toLowerCase();
if (state !== "running") return service.state || "stopped";
if (service.health) return service.health;
return "running";
}
// Live polling cadence for the Services tab. Fast enough that a
// Restart/Update's effect on the process/container cards feels immediate,
// slow enough not to hammer `docker compose ps` (which shells out) every
// couple seconds for no reason.
const LIVE_POLL_MS = 4000;
function ServicesTab() {
const [status, setStatus] = useState<ServerStatus | null>(null);
const [statusError, setStatusError] = useState("");
const [docker, setDocker] = useState<DockerService[] | null>(null);
const [dockerError, setDockerError] = useState("");
const [overlayLabel, setOverlayLabel] = useState("");
const restartWatcher = useAdminRestartWatcher();
const pausedRef = useRef(false);
pausedRef.current = restartWatcher.waiting;
const load = useCallback(async () => {
if (pausedRef.current) return;
try {
setStatus(await api.serverStatus());
setStatusError("");
} catch (err) {
setStatusError(errorMessage(err));
}
try {
setDocker(await api.dockerStatus());
setDockerError("");
} catch (err) {
setDockerError(errorMessage(err));
}
}, []);
useEffect(() => {
void load();
const id = window.setInterval(() => void load(), LIVE_POLL_MS);
return () => window.clearInterval(id);
}, [load]);
return (
<>
<section className="section-block">
<SectionHead
title={"Docker services"}
text={"postgres / redis / minio, from deploy/docker-compose.yml -- refreshes automatically."}
action={<button className="btn icon-text" type="button" onClick={() => void load()}><RefreshCw size={15} /> {"Refresh"}</button>}
/>
{dockerError && <Alert>{dockerError}</Alert>}
{docker === null && !dockerError ? (
<LoadingSurface label={"Loading Docker status..."} />
) : docker && docker.length > 0 ? (
<div className="service-grid">
{docker.map((service) => (
<ServiceCard
key={service.name}
icon={dockerServiceIcon[service.name] ?? <Database size={18} />}
name={service.name}
tone={dockerTone(service)}
statusLabel={dockerStatusLabel(service)}
detail={service.state}
/>
))}
</div>
) : docker && docker.length === 0 ? (
<Alert>{"No Docker Compose services found (deploy/docker-compose.yml missing, or nothing has been started yet)."}</Alert>
) : null}
</section>
<section className="section-block">
<SectionHead
title={"Process control"}
text={"Restart rebuilds and relaunches owpengram-server. Update also runs git pull first and rebuilds both binaries. Either way the admin panel bounces onto its (possibly rebuilt) binary too, a few seconds after the server comes back -- this page reloads itself once that's done."}
/>
{statusError && <Alert>{statusError}</Alert>}
{status === null && !statusError ? (
<LoadingSurface label={"Loading process status..."} />
) : status ? (
<div className="service-grid">
<ServiceCard
icon={<Server size={18} />}
name={"owpengram-server"}
tone={status.ServerAlive ? "good" : "danger"}
statusLabel={status.ServerAlive ? "running" : "stopped"}
detail={status.ServerAlive ? `pid ${status.ServerPID}` : undefined}
/>
<ServiceCard
icon={<ShieldCheck size={18} />}
name={"admin panel"}
tone={status.AdminAlive ? "good" : "danger"}
statusLabel={status.AdminAlive ? "running" : "stopped"}
detail={status.AdminAlive ? `pid ${status.AdminPID}` : undefined}
/>
</div>
) : null}
<div className="gift-table-actions">
<ActionButton
tone="warn"
label={"Restart server"}
path="/api/actions/restart-server"
payload={() => ({})}
onDone={() => {
setOverlayLabel("Restarting owpengram-server and the admin panel...");
void restartWatcher.watch();
}}
/>
<ActionButton
tone="danger"
label={"Update (git pull + rebuild + restart)"}
path="/api/actions/update-server"
payload={() => ({})}
onDone={() => {
setOverlayLabel("Pulling, rebuilding, and restarting owpengram-server and the admin panel...");
void restartWatcher.watch();
}}
/>
</div>
</section>
{restartWatcher.waiting && <RestartOverlay label={overlayLabel} timedOut={false} onDismiss={restartWatcher.dismiss} />}
{restartWatcher.timedOut && <RestartOverlay label={overlayLabel} timedOut={true} onDismiss={restartWatcher.dismiss} />}
</>
);
}

View file

@ -1059,3 +1059,125 @@
.restart-overlay-actions {
justify-content: center;
}
/* Server Settings' Settings/Services tab bar. */
.tab-bar {
display: flex;
gap: 4px;
padding: 4px;
margin-bottom: 18px;
background: var(--surface-soft);
border: 1px solid var(--line);
border-radius: var(--radius);
width: fit-content;
}
.tab-btn {
appearance: none;
border: none;
background: transparent;
color: var(--text-soft);
font-size: 13px;
font-weight: 600;
padding: 7px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.tab-btn:hover {
color: var(--text);
}
.tab-btn.active {
background: var(--panel);
color: var(--text);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
/* Live Docker/process status cards -- Services tab. */
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.service-card {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
}
.service-card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
flex: none;
border-radius: var(--radius-sm);
background: var(--surface-soft);
color: var(--text-soft);
}
.service-card-body {
flex: 1;
min-width: 0;
}
.service-card-name {
font-weight: 700;
font-size: 13px;
color: var(--text);
text-transform: capitalize;
}
.service-card-detail {
font-size: 11.5px;
color: var(--muted);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
margin-top: 1px;
}
.service-card-status {
display: flex;
align-items: center;
gap: 5px;
flex: none;
font-size: 12px;
font-weight: 700;
text-transform: capitalize;
padding: 4px 9px;
border-radius: 999px;
}
.service-card.tone-good .service-card-icon { color: var(--good); }
.service-card.tone-good .service-card-status {
color: var(--good);
background: var(--good-tint);
border: 1px solid var(--good-border);
}
.service-card.tone-warn .service-card-icon { color: var(--warn); }
.service-card.tone-warn .service-card-status {
color: var(--warn);
background: var(--warn-tint);
border: 1px solid var(--warn-border);
}
.service-card.tone-danger .service-card-icon { color: var(--danger); }
.service-card.tone-danger .service-card-status {
color: var(--danger);
background: var(--danger-tint);
border: 1px solid var(--danger-border);
}
.service-card.tone-idle .service-card-status {
color: var(--muted);
background: var(--surface-soft);
border: 1px solid var(--line);
}

View file

@ -861,3 +861,9 @@ export type ServerStatus = {
AdminPID: number;
AdminAlive: boolean;
};
export type DockerService = {
name: string;
state: string;
health: string;
};