still tuning server settings section
This commit is contained in:
parent
66f9c0bc1e
commit
80bc8d352c
11 changed files with 510 additions and 164 deletions
|
|
@ -181,6 +181,7 @@ func (s *server) routes() http.Handler {
|
||||||
mux.Handle("GET /api/server/env", s.serverManage(s.handleServerEnvAPI))
|
mux.Handle("GET /api/server/env", s.serverManage(s.handleServerEnvAPI))
|
||||||
mux.Handle("POST /api/actions/update-server-env", s.serverManage(s.handleUpdateServerEnvAPI))
|
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/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/restart-server", s.serverManage(s.handleRestartServerAPI))
|
||||||
mux.Handle("POST /api/actions/update-server", s.serverManage(s.handleUpdateServerAPI))
|
mux.Handle("POST /api/actions/update-server", s.serverManage(s.handleUpdateServerAPI))
|
||||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,20 @@ func (s *server) handleServerStatusAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, s.serverCtl.Status())
|
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 {
|
type restartServerAPIRequest struct {
|
||||||
CommandID string `json:"command_id"`
|
CommandID string `json:"command_id"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-X1cMLS10.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-X1cMLS10.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-C0CaRjmH.js"></script>
|
<script type="module" crossorigin src="/assets/index-X1cMLS10.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Dx2zWoFE.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-q5XsR54I.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import type {
|
||||||
CollectibleUsernameDetail,
|
CollectibleUsernameDetail,
|
||||||
CollectibleUsernameListResponse,
|
CollectibleUsernameListResponse,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
|
DockerService,
|
||||||
EnvGroup,
|
EnvGroup,
|
||||||
ServerIdentity,
|
ServerIdentity,
|
||||||
ServerStatus,
|
ServerStatus,
|
||||||
|
|
@ -240,6 +241,7 @@ export const api = {
|
||||||
serverIconURL: () => `/api/server/icon?t=${Date.now()}`,
|
serverIconURL: () => `/api/server/icon?t=${Date.now()}`,
|
||||||
serverEnv: () => request<EnvGroup[]>("/api/server/env"),
|
serverEnv: () => request<EnvGroup[]>("/api/server/env"),
|
||||||
serverStatus: () => request<ServerStatus>("/api/server/status"),
|
serverStatus: () => request<ServerStatus>("/api/server/status"),
|
||||||
|
dockerStatus: () => request<DockerService[]>("/api/server/docker-status"),
|
||||||
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
|
|
|
||||||
|
|
@ -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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { ActionButton } from "../components/ActionButton";
|
import { ActionButton } from "../components/ActionButton";
|
||||||
import { Alert, LoadingSurface, Metric, PageFrame, SectionHead } from "../components/ui";
|
import { Alert, LoadingSurface, PageFrame, SectionHead } from "../components/ui";
|
||||||
import type { EnvGroup, ServerIdentity, ServerStatus } from "../types";
|
import type { DockerService, EnvGroup, ServerIdentity, ServerStatus } from "../types";
|
||||||
|
|
||||||
// Server Settings: the web-panel equivalent of tui-panel/server-panel.py's
|
// Server Settings: the web-panel equivalent of tui-panel/server-panel.py's
|
||||||
// menu -- admin-editable server name/description/icon (served to clients
|
// menu -- admin-editable server name/description/icon (served to clients
|
||||||
// over /owpengram/server-info + /owpengram/server-icon), .env editing, and
|
// 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() {
|
export function ServerSettingsPage() {
|
||||||
|
const [tab, setTab] = useState<"settings" | "services">("settings");
|
||||||
return (
|
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">
|
<div className="stacked-sections">
|
||||||
<IdentitySection />
|
<IdentitySection />
|
||||||
<ServerControlSection />
|
|
||||||
<EnvSection />
|
<EnvSection />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="stacked-sections">
|
||||||
|
<ServicesTab />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PageFrame>
|
</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 -----------------------------------------------------
|
// --- .env editor -----------------------------------------------------
|
||||||
|
|
||||||
function EnvSection() {
|
function EnvSection() {
|
||||||
|
|
@ -441,3 +317,272 @@ function EnvSection() {
|
||||||
</section>
|
</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} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1059,3 +1059,125 @@
|
||||||
.restart-overlay-actions {
|
.restart-overlay-actions {
|
||||||
justify-content: center;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -861,3 +861,9 @@ export type ServerStatus = {
|
||||||
AdminPID: number;
|
AdminPID: number;
|
||||||
AdminAlive: boolean;
|
AdminAlive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DockerService = {
|
||||||
|
name: string;
|
||||||
|
state: string;
|
||||||
|
health: string;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 ---------------------------------------------------------
|
// --- build steps ---------------------------------------------------------
|
||||||
|
|
||||||
// GitPull runs `git pull --ff-only`, deliberately never a real merge -- see
|
// GitPull runs `git pull --ff-only`, deliberately never a real merge -- see
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue