feat: add server settings (toggle on/off, port selection, start on system start) to Settings UI and backend
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { createUlid, type AuditLogEntry, type Role, type User } from "@pubscript/core";
|
||||
import { useSettings } from "./store/settings.js";
|
||||
|
||||
const BASE = "/api";
|
||||
const TOKEN_KEY = "pubscript.admin.token";
|
||||
@@ -138,6 +139,10 @@ async function bootstrapAdminSession(): Promise<string | null> {
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit, retried = false): Promise<T> {
|
||||
const { serverEnabled } = useSettings.getState();
|
||||
if (!serverEnabled) {
|
||||
throw new Error("Server disabled in settings (Local Storage Mode)");
|
||||
}
|
||||
const token = getStoredToken();
|
||||
const headers: Record<string, string> = {
|
||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||
@@ -361,4 +366,96 @@ export const api = {
|
||||
}),
|
||||
|
||||
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
|
||||
|
||||
getServerConfig: async (portHint?: number): Promise<{
|
||||
online: boolean;
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
activePort?: number;
|
||||
startOnBoot: boolean;
|
||||
}> => {
|
||||
const endpoints = [
|
||||
"/api/system/server-config",
|
||||
...(portHint ? [`http://localhost:${portHint}/api/system/server-config`] : []),
|
||||
];
|
||||
for (const url of endpoints) {
|
||||
try {
|
||||
const res = await fetch(url, { credentials: "include" });
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (res.ok && ct.includes("application/json")) {
|
||||
const data = (await res.json()) as {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
activePort?: number;
|
||||
startOnBoot: boolean;
|
||||
};
|
||||
return {
|
||||
online: true,
|
||||
enabled: data.enabled,
|
||||
port: data.port,
|
||||
...(data.activePort !== undefined ? { activePort: data.activePort } : {}),
|
||||
startOnBoot: data.startOnBoot,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* try next endpoint */
|
||||
}
|
||||
}
|
||||
return {
|
||||
online: false,
|
||||
enabled: true,
|
||||
port: portHint ?? 8081,
|
||||
startOnBoot: false,
|
||||
};
|
||||
},
|
||||
|
||||
updateServerConfig: async (
|
||||
patch: { enabled?: boolean; port?: number; startOnBoot?: boolean },
|
||||
portHint?: number,
|
||||
): Promise<{
|
||||
online: boolean;
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
activePort?: number;
|
||||
startOnBoot: boolean;
|
||||
}> => {
|
||||
const endpoints = [
|
||||
"/api/system/server-config",
|
||||
...(portHint ? [`http://localhost:${portHint}/api/system/server-config`] : []),
|
||||
];
|
||||
for (const url of endpoints) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (res.ok && ct.includes("application/json")) {
|
||||
const data = (await res.json()) as {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
activePort?: number;
|
||||
startOnBoot: boolean;
|
||||
};
|
||||
return {
|
||||
online: true,
|
||||
enabled: data.enabled,
|
||||
port: data.port,
|
||||
...(data.activePort !== undefined ? { activePort: data.activePort } : {}),
|
||||
startOnBoot: data.startOnBoot,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* try next endpoint */
|
||||
}
|
||||
}
|
||||
return {
|
||||
online: false,
|
||||
enabled: patch.enabled ?? true,
|
||||
port: patch.port ?? portHint ?? 8081,
|
||||
startOnBoot: patch.startOnBoot ?? false,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
useSettings,
|
||||
DEFAULT_AI_AGENTS,
|
||||
type Density,
|
||||
type AppSettings,
|
||||
} from "../store/settings.js";
|
||||
import { api } from "../api.js";
|
||||
import {
|
||||
Select,
|
||||
Button,
|
||||
@@ -35,6 +36,82 @@ export function Settings() {
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string>(s.activeAgentId);
|
||||
const [probing, setProbing] = useState(false);
|
||||
|
||||
// Server settings state
|
||||
const [portDraft, setPortDraft] = useState<string>(String(s.serverPort));
|
||||
const [serverOnline, setServerOnline] = useState<boolean>(false);
|
||||
const [activeRunningPort, setActiveRunningPort] = useState<number | null>(null);
|
||||
const [savingServerCfg, setSavingServerCfg] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void api.getServerConfig(s.serverPort).then((res) => {
|
||||
if (cancelled) return;
|
||||
setServerOnline(res.online);
|
||||
setActiveRunningPort(res.activePort ?? res.port);
|
||||
if (res.online) {
|
||||
setPortDraft(String(res.port));
|
||||
set({
|
||||
serverEnabled: res.enabled,
|
||||
serverPort: res.port,
|
||||
serverStartOnBoot: res.startOnBoot,
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToggleServerEnabled = async (enabled: boolean) => {
|
||||
set({ serverEnabled: enabled });
|
||||
setSavingServerCfg(true);
|
||||
try {
|
||||
const res = await api.updateServerConfig({ enabled }, s.serverPort);
|
||||
setServerOnline(res.online && enabled);
|
||||
toast.success(
|
||||
enabled
|
||||
? `Server enabled (port ${s.serverPort})`
|
||||
: "Server toggled off — using Local Offline Storage mode",
|
||||
);
|
||||
} finally {
|
||||
setSavingServerCfg(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyServerPort = async () => {
|
||||
const parsed = parseInt(portDraft.trim(), 10);
|
||||
if (Number.isNaN(parsed) || parsed < 1024 || parsed > 65535) {
|
||||
toast.error("Please enter a valid port between 1024 and 65535");
|
||||
return;
|
||||
}
|
||||
set({ serverPort: parsed });
|
||||
setSavingServerCfg(true);
|
||||
try {
|
||||
const res = await api.updateServerConfig({ port: parsed }, s.serverPort);
|
||||
setServerOnline(res.online);
|
||||
setActiveRunningPort(res.activePort ?? parsed);
|
||||
toast.success(`Server port saved (${parsed})`);
|
||||
} finally {
|
||||
setSavingServerCfg(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStartOnBoot = async (startOnBoot: boolean) => {
|
||||
set({ serverStartOnBoot: startOnBoot });
|
||||
setSavingServerCfg(true);
|
||||
try {
|
||||
const res = await api.updateServerConfig({ startOnBoot }, s.serverPort);
|
||||
setServerOnline(res.online);
|
||||
toast.success(
|
||||
startOnBoot
|
||||
? "Enabled Start on System Start (systemd user service & desktop autostart)"
|
||||
: "Disabled Start on System Start",
|
||||
);
|
||||
} finally {
|
||||
setSavingServerCfg(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentProvider =
|
||||
s.aiProviders.find((p) => p.id === selectedProviderId) ?? s.aiProviders[0]!;
|
||||
const currentAgent =
|
||||
@@ -429,6 +506,91 @@ export function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SERVER & SYSTEM STARTUP */}
|
||||
<div className="ps-settings-group">
|
||||
<h3>Server & System Startup</h3>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span>PubScript Server</span>
|
||||
<Badge
|
||||
variant={!s.serverEnabled ? "neutral" : serverOnline ? "success" : "warning"}
|
||||
>
|
||||
{!s.serverEnabled
|
||||
? "Off (Local Mode)"
|
||||
: serverOnline
|
||||
? `Online · Port ${activeRunningPort ?? s.serverPort}`
|
||||
: `Standby · Port ${s.serverPort}`}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="ps-settings-desc">
|
||||
Toggle the PubScript API & SQLite server on or off. When toggled off, PubScript runs in local offline storage mode.
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.serverEnabled}
|
||||
disabled={savingServerCfg}
|
||||
onChange={(e) => void handleToggleServerEnabled(e.target.checked)}
|
||||
/>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
{s.serverEnabled ? "Server On" : "Server Off"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Server port</div>
|
||||
<div className="ps-settings-desc">
|
||||
TCP port for the local/self-hosted PubScript server (1024–65535). Default: 8081 (web) / 18081 (desktop).
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<input
|
||||
type="number"
|
||||
className="ps-input"
|
||||
min={1024}
|
||||
max={65535}
|
||||
style={{ width: 110 }}
|
||||
value={portDraft}
|
||||
onChange={(e) => setPortDraft(e.target.value)}
|
||||
aria-label="Server port"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={savingServerCfg}
|
||||
onClick={() => void handleApplyServerPort()}
|
||||
>
|
||||
Apply Port
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Start on system start</div>
|
||||
<div className="ps-settings-desc">
|
||||
Automatically launch the PubScript server when you log in or boot the system (configures systemd user service & XDG autostart).
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.serverStartOnBoot}
|
||||
disabled={savingServerCfg}
|
||||
onChange={(e) => void handleToggleStartOnBoot(e.target.checked)}
|
||||
/>
|
||||
<span style={{ fontSize: 13 }}>
|
||||
{s.serverStartOnBoot ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WRITING & PROOFING */}
|
||||
<div className="ps-settings-group">
|
||||
<h3>Writing & Proofing</h3>
|
||||
|
||||
@@ -263,6 +263,10 @@ export interface AppSettings {
|
||||
activeModel: string;
|
||||
aiAgents: AIAgentConfig[];
|
||||
activeAgentId: string;
|
||||
// Local / Self-Hosted Server & Startup Settings
|
||||
serverEnabled: boolean;
|
||||
serverPort: number;
|
||||
serverStartOnBoot: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -292,6 +296,10 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
activeModel: DEFAULT_AI_PROVIDERS[0]!.defaultModel,
|
||||
aiAgents: DEFAULT_AI_AGENTS,
|
||||
activeAgentId: DEFAULT_AI_AGENTS[0]!.id,
|
||||
// Local / Self-Hosted Server & Startup Settings
|
||||
serverEnabled: true,
|
||||
serverPort: 8081,
|
||||
serverStartOnBoot: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "pubscript.settings.v1";
|
||||
|
||||
Reference in New Issue
Block a user