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("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) {
|
||||
|
|
|
|||
|
|
@ -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
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 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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -861,3 +861,9 @@ export type ServerStatus = {
|
|||
AdminPID: number;
|
||||
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 ---------------------------------------------------------
|
||||
|
||||
// GitPull runs `git pull --ff-only`, deliberately never a real merge -- see
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue