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

View file

@ -181,6 +181,7 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/server/env", s.serverManage(s.handleServerEnvAPI))
mux.Handle("POST /api/actions/update-server-env", s.serverManage(s.handleUpdateServerEnvAPI))
mux.Handle("GET /api/server/status", s.serverManage(s.handleServerStatusAPI))
mux.Handle("GET /api/server/docker-status", s.serverManage(s.handleDockerStatusAPI))
mux.Handle("POST /api/actions/restart-server", s.serverManage(s.handleRestartServerAPI))
mux.Handle("POST /api/actions/update-server", s.serverManage(s.handleUpdateServerAPI))
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {

View file

@ -223,6 +223,20 @@ func (s *server) handleServerStatusAPI(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.serverCtl.Status())
}
// handleDockerStatusAPI backs the Services tab's live container list
// (postgres/redis/minio) -- see procctl.Manager.DockerStatus. A "docker
// compose ps" failure (daemon not running, compose file missing) is
// reported as an API error rather than an empty list, so the frontend can
// tell "no services" apart from "couldn't ask Docker".
func (s *server) handleDockerStatusAPI(w http.ResponseWriter, r *http.Request) {
services, err := s.serverCtl.DockerStatus(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, services)
}
type restartServerAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`

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"}>
<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 />
<ServerControlSection />
<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;
};

View file

@ -247,6 +247,62 @@ func (m *Manager) ensureDocker(ctx context.Context, st State) (string, error) {
}
}
// DockerService is one container's live status, as reported by
// `docker compose ps`. State is Docker's raw container state ("running",
// "exited", ...); Health is the healthcheck status ("healthy", "starting",
// "unhealthy") or "" for a container/image with no healthcheck defined --
// all three services in deploy/docker-compose.yml (postgres/redis/minio)
// declare one, so "" in practice means Docker hasn't reported yet.
type DockerService struct {
Name string `json:"name"` // compose service name, e.g. "postgres"
State string `json:"state"`
Health string `json:"health"`
}
// dockerComposePsRow mirrors the fields `docker compose ps --format json`
// emits (one JSON object per line, Compose v2's ndjson convention -- NOT a
// single JSON array).
type dockerComposePsRow struct {
Service string `json:"Service"`
State string `json:"State"`
Health string `json:"Health"`
}
// DockerStatus reports the live state of every service in
// deploy/docker-compose.yml, for the admin panel's "Services" tab. Returns
// an empty slice (not an error) when the compose file doesn't exist, same
// convention as ensureDocker.
func (m *Manager) DockerStatus(ctx context.Context) ([]DockerService, error) {
composeFile := filepath.Join(m.Root, "deploy", "docker-compose.yml")
if _, err := os.Stat(composeFile); os.IsNotExist(err) {
return nil, nil
}
st := m.loadState()
cmd := exec.CommandContext(ctx, "docker", "compose", "-f", composeFile, "ps", "--all", "--format", "json")
cmd.Dir = m.Root
cmd.Env = append(os.Environ(),
"TELESRV_DOCKER_PROJECT="+st.DockerProject,
"TELESRV_DOCKER_PREFIX="+st.DockerPrefix,
)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("docker compose ps failed: %w", err)
}
var services []DockerService
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var row dockerComposePsRow
if err := json.Unmarshal([]byte(line), &row); err != nil {
continue
}
services = append(services, DockerService{Name: row.Service, State: row.State, Health: row.Health})
}
return services, nil
}
// --- build steps ---------------------------------------------------------
// GitPull runs `git pull --ff-only`, deliberately never a real merge -- see