improved settings window
This commit is contained in:
parent
80bc8d352c
commit
1c9a192a96
13 changed files with 271 additions and 89 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
9
cmd/telesrv-admin/web/dist/assets/index-qfdwgPZE.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-qfdwgPZE.js
vendored
Normal file
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-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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue