fixes and improvements for new server settings menu

This commit is contained in:
onysd 2026-08-26 00:37:25 +03:00
parent 902f3606c2
commit 66f9c0bc1e
27 changed files with 2244 additions and 40 deletions

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-Bt9UBcEE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
<script type="module" crossorigin src="/assets/index-C0CaRjmH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dx2zWoFE.css">
</head>
<body>
<div id="root"></div>

View file

@ -23,6 +23,9 @@ import type {
CollectibleUsernameDetail,
CollectibleUsernameListResponse,
CommandResult,
EnvGroup,
ServerIdentity,
ServerStatus,
GroupMessageDetail,
GroupMessageListResponse,
MessageDetail,
@ -232,6 +235,11 @@ export const api = {
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
gifCatalog: () => request<GifCatalogListResponse>("/api/gif-catalog"),
createGifCatalogEntry: (form: FormData) => request<CommandResult>("/api/actions/create-gif-catalog-entry", { method: "POST", body: form }),
serverIdentity: () => request<ServerIdentity>("/api/server/identity"),
uploadServerIcon: (form: FormData) => request<CommandResult>("/api/actions/upload-server-icon", { method: "POST", body: form }),
serverIconURL: () => `/api/server/icon?t=${Date.now()}`,
serverEnv: () => request<EnvGroup[]>("/api/server/env"),
serverStatus: () => request<ServerStatus>("/api/server/status"),
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST",
body: JSON.stringify(payload)

View file

@ -9,6 +9,7 @@ import {
LogOut,
Megaphone,
MessageSquareText,
Settings,
ShieldAlert,
ShieldCheck,
Smile,
@ -18,7 +19,7 @@ import {
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
import { permissionBotVerificationReview, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
import { permissionBotVerificationReview, permissionServerManage, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
import { type Navigate, type RouteState, routeTitle } from "../routing";
import { ThemeSwitch } from "../theme";
import { AppLink } from "./AppLink";
@ -57,6 +58,7 @@ export function Shell({
// Same reasoning for the third-party queue, which has its own right: the two
// sections are granted independently, so one entry can be visible without the other.
const canReviewBotVerification = useCan(permissionBotVerificationReview);
const canManageServer = useCan(permissionServerManage);
// Third-party verification is additionally hidden by default (not fully
// finished) regardless of what the session was granted -- see permissions.tsx.
const thirdPartyVerificationHidden = useThirdPartyVerificationHidden();
@ -135,7 +137,13 @@ export function Shell({
</div>
)}
</div>
{canManageServer && (
<NavLink icon={<Settings size={16} />} href="/server-settings" route={route} navigate={navigate}>{"Server Settings"}</NavLink>
)}
</nav>
<div className="sidebar-status">
<span className="sidebar-label">{"Version: O7"}</span>
</div>
</aside>
<div className="workspace">
<header className="topbar">

View file

@ -16,6 +16,7 @@ import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
import { StickerSetsPage } from "./StickerSetsPage";
import { GifCatalogPage } from "./GifCatalogPage";
import { ServerSettingsPage } from "./ServerSettingsPage";
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
import { ModerationCasesPage } from "./ModerationCasesPage";
import { StoragePage } from "./StoragePage";
@ -27,6 +28,7 @@ import {
PermissionGate,
ThirdPartyVerificationHiddenGate,
permissionBotVerificationReview,
permissionServerManage,
permissionVerificationReview
} from "../permissions";
@ -124,6 +126,13 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/gif-catalog") {
return <GifCatalogPage />;
}
if (route.path === "/server-settings") {
return (
<PermissionGate permission={permissionServerManage}>
<ServerSettingsPage />
</PermissionGate>
);
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (
<MessageDetailPage

View file

@ -0,0 +1,443 @@
import { ChevronDown, ImageOff, ImagePlus, Loader2, RefreshCw, 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";
// 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.
export function ServerSettingsPage() {
return (
<PageFrame title={"Server Settings"} eyebrow={"Identity, .env, and process control -- mirrors the TUI panel"}>
<div className="stacked-sections">
<IdentitySection />
<ServerControlSection />
<EnvSection />
</div>
</PageFrame>
);
}
// --- Identity ---------------------------------------------------------
function IdentitySection() {
const [identity, setIdentity] = useState<ServerIdentity | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [iconModalOpen, setIconModalOpen] = useState(false);
const [iconBust, setIconBust] = useState(0);
const [iconFailed, setIconFailed] = useState(false);
const [error, setError] = useState("");
async function load() {
setError("");
try {
const info = await api.serverIdentity();
setIdentity(info);
setName(info.name);
setDescription(info.description);
setIconFailed(false);
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => { void load(); }, []);
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."} />
{error && <Alert>{error}</Alert>}
{!identity ? (
<LoadingSurface label={"Loading identity..."} />
) : (
<div className="card-body">
<div className="entity-head-main">
<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 }}
onError={() => setIconFailed(true)}
/>
) : (
<div className="avatar-fallback server-icon-fallback" style={{ width: 56, height: 56 }}>
<ImageOff size={20} />
</div>
)}
<button
className="icon-btn avatar-edit-btn"
type="button"
aria-label={"Change server icon"}
title={"Change server icon"}
onClick={() => setIconModalOpen(true)}
>
<ImagePlus size={13} />
</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>
</div>
</div>
<div className="gift-table-actions">
<ActionButton
tone="neutral"
label={"Save identity"}
path="/api/actions/set-server-identity"
payload={() => ({ name, description })}
onDone={() => void load()}
/>
</div>
</div>
)}
{iconModalOpen && (
<ServerIconModal
hasIcon={!!identity?.icon_ext}
onClose={() => setIconModalOpen(false)}
onDone={() => { setIconBust((n) => n + 1); setIconFailed(false); void load(); }}
/>
)}
</section>
);
}
function ServerIconModal({ hasIcon, onClose, onDone }: { hasIcon: boolean; onClose: () => void; onDone: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [previewURL, setPreviewURL] = useState("");
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!file) {
setPreviewURL("");
return;
}
const url = URL.createObjectURL(file);
setPreviewURL(url);
return () => URL.revokeObjectURL(url);
}, [file]);
async function submitUpload() {
if (!file) {
setError("Choose an image file first.");
return;
}
if (!reason.trim()) {
setError("Please enter an operation reason");
return;
}
setBusy(true);
setError("");
try {
const form = new FormData();
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true }));
form.set("file", file, file.name);
const result = await api.uploadServerIcon(form);
if (result.error) {
setError(result.error);
return;
}
onDone();
onClose();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
async function submitRemove() {
if (!reason.trim()) {
setError("Please enter an operation reason");
return;
}
setBusy(true);
setError("");
try {
const result = await api.action("/api/actions/remove-server-icon", { command_id: "", reason: reason.trim(), confirm: true });
if (result.error) {
setError(result.error);
return;
}
onDone();
onClose();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Change server icon"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Server identity"}</div>
<h2>{"Change server icon"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept=".png,.jpg,.jpeg,.webp,.gif,image/png,image/jpeg,image/webp,image/gif" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
{previewURL ? <img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} /> : <ImagePlus size={22} />}
<span className="gift-file-copy"><span className="gift-field-label">{"New icon"}</span><strong>{file ? file.name : "Choose a PNG, JPEG, WebP, or GIF image"}</strong></span>
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
</label>
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why the server icon is changing"} onChange={(event) => setReason(event.target.value)} /></label>
{error && <Alert>{error}</Alert>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
{hasIcon && (
<button className="btn danger icon-text" type="button" onClick={() => void submitRemove()} disabled={busy}>
{busy ? <Loader2 className="spin" size={15} /> : <Trash2 size={15} />}
{"Remove icon"}
</button>
)}
<button className="btn primary icon-text" type="button" onClick={() => void submitUpload()} disabled={busy}>
{busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
{"Upload icon"}
</button>
</div>
</section>
</div>,
document.body
);
}
// --- 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() {
const [groups, setGroups] = useState<EnvGroup[]>([]);
const [values, setValues] = useState<Record<string, string>>({});
const [open, setOpen] = useState<Record<string, boolean>>({});
const [error, setError] = useState("");
async function load() {
setError("");
try {
const g = await api.serverEnv();
setGroups(g);
const next: Record<string, string> = {};
for (const group of g) {
for (const field of group.fields) {
next[field.key] = field.value;
}
}
setValues(next);
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => { void load(); }, []);
const fieldCount = useMemo(() => groups.reduce((sum, g) => sum + g.fields.length, 0), [groups]);
return (
<section className="section-block">
<SectionHead title={"Environment (.env)"} text={`${fieldCount} setting(s) across ${groups.length} group(s). Changes take effect on the next Restart/Update.`} />
{error && <Alert>{error}</Alert>}
<div className="env-groups">
{groups.map((group) => {
const isOpen = !!open[group.title];
return (
<div key={group.title} className={`env-group ${isOpen ? "open" : ""}`}>
<button
className="env-group-toggle"
type="button"
aria-expanded={isOpen}
onClick={() => setOpen((prev) => ({ ...prev, [group.title]: !prev[group.title] }))}
>
<span className="env-group-toggle-text">
<span className="env-group-toggle-title">{group.title}</span>
<span className="env-group-toggle-count">{`${group.fields.length} field${group.fields.length === 1 ? "" : "s"}`}</span>
</span>
<ChevronDown size={16} className="env-group-chevron" />
</button>
{isOpen && (
<div className="env-group-body">
{group.description && <p className="env-group-desc">{group.description}</p>}
{group.fields.map((field) => (
<label key={field.key} className="form-field env-field">
<span className="mono">{field.key}</span>
{field.description && <span className="env-field-desc">{field.description}</span>}
<input
type={field.sensitive ? "password" : "text"}
value={values[field.key] ?? ""}
placeholder={field.default_value}
onChange={(event) => setValues((prev) => ({ ...prev, [field.key]: event.target.value }))}
/>
</label>
))}
</div>
)}
</div>
);
})}
</div>
<div className="gift-table-actions env-save-row">
<ActionButton
tone="warn"
label={"Save .env changes"}
path="/api/actions/update-server-env"
payload={() => ({ values })}
onDone={() => void load()}
/>
</div>
</section>
);
}

View file

@ -12,6 +12,9 @@ export const permissionVerificationRevoke = "verification.revoke";
// verifier roster, the icon catalogue and taking a granted mark away.
export const permissionBotVerificationReview = "botverification.review";
export const permissionBotVerificationManage = "botverification.manage";
// Server Settings: identity, .env, restart/update. One right, not
// review/manage -- see the constant's doc comment in security.go.
export const permissionServerManage = "server.manage";
// GET /api/session is read once at boot; the panel keeps the answer here so a
// section the session may not use is hidden instead of rendered into a 403. This

View file

@ -31,5 +31,6 @@ export function routeTitle(pathname: string): string {
if (pathname.startsWith("/messages")) return "Message Audit";
if (pathname.startsWith("/stickers")) return "Stickers";
if (pathname.startsWith("/gif-catalog")) return "GIFs";
if (pathname.startsWith("/server-settings")) return "Server Settings";
return "Operations Console";
}

View file

@ -928,3 +928,134 @@
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* --- Server Settings ---------------------------------------------------- */
/* Bare .card-body (outside .action-groups, which scopes its own flex rules)
just needs a sensible vertical rhythm below a SectionHead -- used as-is by
IdentitySection/ServerControlSection on the Server Settings page. */
.card-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.server-identity-fields {
display: grid;
flex: 1 1 auto;
min-width: 0;
gap: 8px;
}
.server-icon-fallback {
color: var(--muted);
background: var(--panel-subtle);
border: 1px dashed var(--line-strong);
}
.env-groups {
display: grid;
gap: 8px;
}
.env-group {
overflow: hidden;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.env-group-toggle {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 11px 14px;
background: var(--panel-subtle);
border: none;
cursor: pointer;
text-align: left;
transition: background-color 140ms ease;
}
.env-group-toggle:hover {
background: var(--brand-tint);
}
.env-group-toggle-text {
display: flex;
min-width: 0;
align-items: baseline;
gap: 8px;
}
.env-group-toggle-title {
color: var(--heading);
font-size: 13px;
font-weight: 800;
}
.env-group-toggle-count {
flex-shrink: 0;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.env-group-chevron {
flex-shrink: 0;
color: var(--muted);
transition: transform 140ms ease;
}
.env-group.open .env-group-chevron {
transform: rotate(180deg);
}
.env-group-body {
display: grid;
gap: 12px;
padding: 14px;
border-top: 1px solid var(--line);
}
.env-group-desc {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.env-field .env-field-desc {
color: var(--muted);
font-size: 11px;
font-weight: 500;
text-transform: none;
letter-spacing: normal;
}
.env-save-row {
margin-top: 12px;
}
.restart-overlay {
width: min(440px, 100%);
}
.restart-overlay-body {
display: grid;
justify-items: center;
gap: 12px;
padding: 28px 20px;
text-align: center;
}
.restart-overlay-body p {
margin: 0;
color: var(--text-soft);
font-weight: 700;
}
.restart-overlay-actions {
justify-content: center;
}

View file

@ -580,6 +580,10 @@ export type AdminSession = {
// 404, so this is a UI convenience on top of a real enforcement, not the
// enforcement itself.
hide_third_party_verification?: boolean;
// Random per admin-process-start value -- see the Go handler's doc
// comment. Used by Server Settings' Restart/Update flow to detect a
// genuinely new admin process after asking it to bounce.
boot_id?: string;
};
export type AdminLoginResult = AdminSession & {
@ -829,3 +833,31 @@ export type GroupMessageListResponse = {
limit: number;
rows: GroupMessageRow[];
};
export type ServerIdentity = {
name: string;
description: string;
icon_ext?: string;
};
export type EnvField = {
key: string;
default_value: string;
description: string;
enabled_by_default: boolean;
sensitive: boolean;
value: string;
};
export type EnvGroup = {
title: string;
description: string;
fields: EnvField[];
};
export type ServerStatus = {
ServerPID: number;
ServerAlive: boolean;
AdminPID: number;
AdminAlive: boolean;
};