improved settings window

This commit is contained in:
onysd 2026-08-26 02:44:59 +03:00
parent 80bc8d352c
commit 1c9a192a96
13 changed files with 271 additions and 89 deletions

View file

@ -182,6 +182,7 @@ func (s *server) routes() http.Handler {
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("GET /api/server/check-updates", s.serverManage(s.handleCheckServerUpdatesAPI))
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

@ -228,6 +228,18 @@ func (s *server) handleServerStatusAPI(w http.ResponseWriter, r *http.Request) {
// 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".
// handleCheckServerUpdatesAPI backs the Update button's "Check updates"
// state -- a plain git fetch + rev-list count, no pull/build/restart. See
// procctl.Manager.CheckUpdates.
func (s *server) handleCheckServerUpdatesAPI(w http.ResponseWriter, r *http.Request) {
behind, err := s.serverCtl.CheckUpdates(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"commits_behind": behind})
}
func (s *server) handleDockerStatusAPI(w http.ResponseWriter, r *http.Request) {
services, err := s.serverCtl.DockerStatus(r.Context())
if err != nil {

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-X1cMLS10.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-q5XsR54I.css">
<script type="module" crossorigin src="/assets/index-qfdwgPZE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dc3900m_.css">
</head>
<body>
<div id="root"></div>

View file

@ -242,6 +242,7 @@ export const api = {
serverEnv: () => request<EnvGroup[]>("/api/server/env"),
serverStatus: () => request<ServerStatus>("/api/server/status"),
dockerStatus: () => request<DockerService[]>("/api/server/docker-status"),
checkServerUpdates: () => request<{ commits_behind: number }>("/api/server/check-updates"),
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST",
body: JSON.stringify(payload)

View file

@ -6,7 +6,7 @@ import { api, errorMessage } from "../api";
import type { CommandResult } from "../types";
import { Alert, JsonBlock } from "./ui";
type ActionTone = "neutral" | "warn" | "danger";
type ActionTone = "neutral" | "warn" | "danger" | "primary";
export function ActionButton({
label,
@ -77,7 +77,7 @@ export function ActionButton({
}
const canConfirm = result?.dry_run && !result.error;
const triggerClass = `btn ${tone === "danger" ? "danger" : tone === "warn" ? "warn" : ""} ${compact ? "compact-btn" : ""}`;
const triggerClass = `btn ${tone === "danger" ? "danger" : tone === "warn" ? "warn" : tone === "primary" ? "primary" : ""} ${compact ? "compact-btn" : ""}`;
const previewPayload = useMemo(() => {
try {
return payload();

View file

@ -1,4 +1,4 @@
import { ChevronDown, CircleCheck, CircleOff, CircleX, Database, HardDrive, ImageOff, ImagePlus, Layers, Loader2, RefreshCw, Server, ShieldCheck, Trash2, Upload, X } from "lucide-react";
import { ChevronDown, CircleCheck, CircleOff, CircleX, Database, Download, 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";
@ -69,25 +69,25 @@ function IdentitySection() {
return (
<section className="section-block">
<SectionHead title={"Server identity"} text={"Shown to clients auto-fetching this server's key -- takes effect immediately, no restart needed."} />
<SectionHead title={"Server identity"} />
{error && <Alert>{error}</Alert>}
{!identity ? (
<LoadingSurface label={"Loading identity..."} />
) : (
<div className="card-body">
<div className="entity-head-main">
<div className="card-body identity-card">
<div className="identity-layout">
<div className="avatar-edit-slot">
{identity.icon_ext && !iconFailed ? (
<img
className="avatar-photo-img"
src={api.serverIconURL() + `&b=${iconBust}`}
alt=""
style={{ width: 56, height: 56 }}
style={{ width: 88, height: 88 }}
onError={() => setIconFailed(true)}
/>
) : (
<div className="avatar-fallback server-icon-fallback" style={{ width: 56, height: 56 }}>
<ImageOff size={20} />
<div className="avatar-fallback server-icon-fallback" style={{ width: 88, height: 88 }}>
<ImageOff size={26} />
</div>
)}
<button
@ -97,15 +97,15 @@ function IdentitySection() {
title={"Change server icon"}
onClick={() => setIconModalOpen(true)}
>
<ImagePlus size={13} />
<ImagePlus size={14} />
</button>
</div>
<div className="server-identity-fields">
<label className="form-field"><span>{"Name"}</span><input value={name} maxLength={128} onChange={(event) => setName(event.target.value)} /></label>
<label className="form-field"><span>{"Description"}</span><input value={description} maxLength={512} onChange={(event) => setDescription(event.target.value)} /></label>
<label className="form-field"><span>{"Description"}</span><textarea rows={4} value={description} maxLength={512} onChange={(event) => setDescription(event.target.value)} /></label>
</div>
</div>
<div className="gift-table-actions">
<div className="gift-table-actions identity-save-row">
<ActionButton
tone="neutral"
label={"Save identity"}
@ -471,6 +471,75 @@ function dockerStatusLabel(service: DockerService): string {
// couple seconds for no reason.
const LIVE_POLL_MS = 4000;
// UpdateButton is a two-state control: "Check updates" (a plain git fetch +
// commit count, no side effects) until a check finds the branch behind its
// upstream, at which point it becomes "Update (<N>)" -- a second click runs
// the real git pull + rebuild + restart (via the existing update-server
// action, called directly with confirm:true since the check step already
// serves as the "are you sure" gate). Auto-checks once on mount so the
// button reflects reality without an operator having to click twice.
function UpdateButton({ onUpdateStarted }: { onUpdateStarted: (overlayLabel: string) => void }) {
const [behind, setBehind] = useState<number | null>(null);
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const check = useCallback(async () => {
setBusy(true);
setError("");
setMessage("");
try {
const result = await api.checkServerUpdates();
setBehind(result.commits_behind);
setMessage(result.commits_behind > 0
? `${result.commits_behind} new commit${result.commits_behind === 1 ? "" : "s"} pulled from GitHub.`
: "Already up to date.");
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}, []);
useEffect(() => { void check(); }, [check]);
async function runUpdate() {
const commits = behind ?? 0;
if (!window.confirm(`Pull ${commits} commit(s), rebuild, and restart owpengram-server and the admin panel?`)) {
return;
}
setBusy(true);
setError("");
try {
const result = await api.action("/api/actions/update-server", { command_id: "", reason: "Update via Services tab", confirm: true });
if (result.error) {
setError(result.error);
setBusy(false);
return;
}
onUpdateStarted(`Pulled ${commits} commit${commits === 1 ? "" : "s"} -- rebuilding and restarting owpengram-server and the admin panel...`);
} catch (err) {
setError(errorMessage(err));
setBusy(false);
}
}
const hasUpdates = (behind ?? 0) > 0;
return (
<button
className={`btn compact-btn icon-text ${hasUpdates ? "danger" : ""}`}
type="button"
disabled={busy}
title={error || message || undefined}
onClick={() => void (hasUpdates ? runUpdate() : check())}
>
{busy ? <Loader2 className="spin" size={15} /> : hasUpdates ? <Download size={15} /> : <RefreshCw size={15} />}
{hasUpdates ? `Update (${behind})` : "Check updates"}
</button>
);
}
function ServicesTab() {
const [status, setStatus] = useState<ServerStatus | null>(null);
const [statusError, setStatusError] = useState("");
@ -503,20 +572,60 @@ function ServicesTab() {
return () => window.clearInterval(id);
}, [load]);
const loading = status === null && docker === null && !statusError && !dockerError;
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>}
title={"Services"}
action={
<div className="services-header-actions">
<UpdateButton
onUpdateStarted={(label) => {
setOverlayLabel(label);
void restartWatcher.watch();
}}
/>
<ActionButton
compact
tone="primary"
label={"Restart"}
path="/api/actions/restart-server"
payload={() => ({})}
onDone={() => {
setOverlayLabel("Restarting owpengram-server and the admin panel...");
void restartWatcher.watch();
}}
/>
</div>
}
/>
{statusError && <Alert>{statusError}</Alert>}
{dockerError && <Alert>{dockerError}</Alert>}
{docker === null && !dockerError ? (
<LoadingSurface label={"Loading Docker status..."} />
) : docker && docker.length > 0 ? (
{loading ? (
<LoadingSurface label={"Loading service status..."} />
) : (
<div className="service-grid">
{docker.map((service) => (
{status && (
<>
<ServiceCard
icon={<Server size={18} />}
name={"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}
/>
</>
)}
{docker?.map((service) => (
<ServiceCard
key={service.name}
icon={dockerServiceIcon[service.name] ?? <Database size={18} />}
@ -527,59 +636,7 @@ function ServicesTab() {
/>
))}
</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

@ -947,6 +947,35 @@
gap: 8px;
}
/* Server identity card: icon+upload fixed on the left, name+description
filling the rest of the section's width on the right, full-width Save
button spanning the whole section beneath. */
.identity-card {
width: 100%;
}
.identity-layout {
display: flex;
align-items: flex-start;
gap: 24px;
}
.identity-layout .server-identity-fields {
flex: 1 1 auto;
gap: 12px;
}
.identity-layout .form-field textarea {
min-height: 92px;
resize: vertical;
}
.identity-save-row .btn {
width: 100%;
justify-content: center;
min-height: 40px;
}
.server-icon-fallback {
color: var(--muted);
background: var(--panel-subtle);
@ -1181,3 +1210,9 @@
background: var(--surface-soft);
border: 1px solid var(--line);
}
.services-header-actions {
display: flex;
align-items: center;
gap: 8px;
}