feat: admin sidebar change and system health check view (#48)

This commit is contained in:
Tautvydas Derzinskas authored and GitHub committed 2026-10-16 18:23:26 +02:00
1 parent 731a2950be
commit 92161f8cf8
17 files changed
+1124 -76

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ function envInt(name: string, fallback: number): number {
}
/** cgroup v2, then v1. Unlimited ("max", a near-2^63 sentinel, or >= machine RAM) returns null. */
function cgroupMemoryLimitBytes(): number | null {
export function cgroupMemoryLimitBytes(): number | null {
for (const file of ["/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"]) {
try {
const value = Number(fs.readFileSync(file, "utf-8").trim());
+19
View File
@@ -8,6 +8,7 @@ import { parseBody } from "../utils/validate";
import { deleteAllPrintsForUser, getStorageUsage, listLogs, listUsersWithPrintCounts } from "../services/adminService";
import { createLog } from "../services/auditLog";
import { deleteUser, permissionsOver, setUserRole } from "../services/userAdminService";
import { getHealthReport, HEALTH_COMPONENTS, testHealth } from "../services/systemHealthService";
import { INVITATION_TTL_DAYS, inviteUser } from "../services/invitationService";
import { listJobItems } from "../services/importJobService";
import {
@@ -64,6 +65,24 @@ router.get(
}),
);
// System Health: the last results, without testing anything; testing is the admin's call.
router.get(
"/health",
asyncHandler(async (_req, res) => {
res.json(await getHealthReport());
}),
);
const healthTestSchema = z.object({ component: z.enum(HEALTH_COMPONENTS).optional() });
// One component, or all of them when none is named.
router.post(
"/health/test",
asyncHandler(async (req, res) => {
res.json(await testHealth(parseBody(healthTestSchema, req.body ?? {}).component));
}),
);
router.get(
"/storage",
asyncHandler(async (_req, res) => {
+279
View File
@@ -0,0 +1,279 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import nodemailer from "nodemailer";
import type { Prisma } from "@prisma/client";
import { cgroupMemoryLimitBytes, MODEL_PREVIEW_MAX_MEMORY_MB, STORAGE } from "../config";
import { prisma } from "../db";
import { listAiProviderModels } from "./aiCategorizationService";
import { resolveAiConnection } from "./aiConnectionService";
import { testFlaresolverr } from "./flaresolverr";
import { getFlaresolverrUrl, getSmtpSettings, getThingiverseAccessToken } from "./settingsService";
import { checkThingiverseAccessToken } from "./thingiverseApi";
// Administration's System Health: each component Thingport relies on, tested only when an admin asks. Results are
// kept, with when each was taken, so the page shows the last ones without testing anything on its own.
/** By importance: what everything needs first, what's optional last. */
export const HEALTH_COMPONENTS = [
"database",
"storage",
"memory",
"flaresolverr",
"thingiverse",
"ai",
"smtp",
] as const;
export type HealthComponent = (typeof HEALTH_COMPONENTS)[number];
export type HealthStatus = "ok" | "warning" | "error" | "not_configured";
export type HealthResult = {
component: HealthComponent;
status: HealthStatus;
/** What was found, for the page to explain and say how to fix: "unreachable", "low_space"... */
code: string;
/** The underlying error, as the component gave it. */
detail: string | null;
facts: Record<string, string | number | null>;
latency_ms: number | null;
checked_at: string;
};
export type HealthReport = {
components: { component: HealthComponent; result: HealthResult | null }[];
/** When every component was last tested together. */
full_checked_at: string | null;
};
const SETTING_KEY = "system_health";
// Any number, as long as only the report's writers take it.
const REPORT_LOCK = 74_210_303;
const CHECK_TIMEOUT_MS = 20_000;
const LOW_DISK_BYTES = 2 * 1024 ** 3;
const LOW_MEMORY_BYTES = 256 * 1024 ** 2;
type Found = Omit<HealthResult, "component" | "checked_at" | "latency_ms"> & { latency_ms?: number | null };
const found = (
status: HealthStatus,
code: string,
facts: Found["facts"] = {},
detail: string | null = null,
): Found => ({
status,
code,
facts,
detail,
});
const hostOf = (url: string) => {
try {
return new URL(url).host;
} catch {
return url;
}
};
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
async function checkDatabase(): Promise<Found> {
const start = Date.now();
try {
const [row] = await prisma.$queryRaw<{ version: string }[]>`SELECT version() AS version`;
// "PostgreSQL 16.4 on x86_64-pc-linux-musl, ..." -> "PostgreSQL 16.4"
const version = row?.version.split(" on ")[0] ?? null;
return { ...found("ok", "ok", { version }), latency_ms: Date.now() - start };
} catch (err) {
return found("error", "unreachable", {}, message(err));
}
}
async function checkStorage(): Promise<Found> {
// A real write, as imports and uploads make: a read-only or full volume shows up here.
const probe = path.join(STORAGE, `.health-${process.pid}-${Date.now()}`);
try {
await fs.writeFile(probe, "ok");
await fs.rm(probe, { force: true });
} catch (err) {
return found("error", "not_writable", { path: STORAGE }, message(err));
}
try {
const stats = await fs.statfs(STORAGE);
const free = stats.bavail * stats.bsize;
const total = stats.blocks * stats.bsize;
const facts = { path: STORAGE, free_bytes: free, total_bytes: total };
return free < LOW_DISK_BYTES || free < total * 0.05
? found("warning", "low_space", facts)
: found("ok", "ok", facts);
} catch {
return found("ok", "ok", { path: STORAGE });
}
}
async function cgroupMemoryUsedBytes(): Promise<number | null> {
for (const file of ["/sys/fs/cgroup/memory.current", "/sys/fs/cgroup/memory/memory.usage_in_bytes"]) {
try {
const value = Number((await fs.readFile(file, "utf8")).trim());
if (Number.isFinite(value) && value > 0) return value;
} catch {
// Not in a container, or another cgroup version.
}
}
return null;
}
async function checkMemory(): Promise<Found> {
const limit = cgroupMemoryLimitBytes();
const used = limit ? await cgroupMemoryUsedBytes() : null;
// In a container its own limit is what runs out, whatever the host has left.
const total = limit ?? os.totalmem();
const available = limit && used !== null ? Math.max(0, limit - used) : os.freemem();
const facts = {
total_bytes: total,
available_bytes: available,
process_bytes: process.memoryUsage().rss,
container_limited: limit ? 1 : 0,
preview_limit_mb: MODEL_PREVIEW_MAX_MEMORY_MB,
};
return available < LOW_MEMORY_BYTES || available < total * 0.1
? found("warning", "low_memory", facts)
: found("ok", "ok", facts);
}
async function checkFlaresolverr(): Promise<Found> {
const url = await getFlaresolverrUrl();
if (!url) return found("not_configured", "not_configured");
const result = await testFlaresolverr(url);
return result.ok
? { ...found("ok", "ok", { url, version: result.version }), latency_ms: result.latencyMs }
: found("error", "unreachable", { url }, result.error);
}
async function checkThingiverse(): Promise<Found> {
const token = await getThingiverseAccessToken();
if (!token) return found("not_configured", "not_configured");
const start = Date.now();
const result = await checkThingiverseAccessToken(token);
const latency_ms = Date.now() - start;
if (result === "ok") return { ...found("ok", "ok"), latency_ms };
if (result === "rejected") return found("error", "token_rejected");
// Cloudflare's challenge says nothing about the token; FlareSolverr is the way past it.
if (result === "blocked") return found("warning", "blocked", { flaresolverr: (await getFlaresolverrUrl()) ? 1 : 0 });
return found("error", "unreachable");
}
async function checkAi(): Promise<Found> {
const connection = await resolveAiConnection(null);
if (!connection) return found("not_configured", "not_configured");
const facts = { host: hostOf(connection.baseUrl), model: connection.model };
const start = Date.now();
// The model list, not a test prompt: a prompt takes minutes on a slow CPU, the list comes back at once.
const result = await listAiProviderModels({ baseUrl: connection.baseUrl, apiKey: connection.apiKey });
const latency_ms = Date.now() - start;
if (!result.ok) return found("error", "unreachable", facts, result.error);
// Some providers list nothing they'd still answer for, so a missing model is a warning, not a failure.
if (result.models.length && !result.models.includes(connection.model)) {
return { ...found("warning", "model_missing", facts), latency_ms };
}
return { ...found("ok", "ok", facts), latency_ms };
}
async function checkSmtp(): Promise<Found> {
const smtp = await getSmtpSettings();
if (!smtp.host) return found("not_configured", "not_configured");
const facts = { host: smtp.host, port: smtp.port };
const start = Date.now();
try {
// Connects and logs in, without sending anything.
await nodemailer
.createTransport({
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
auth: smtp.user ? { user: smtp.user, pass: smtp.pass ?? undefined } : undefined,
connectionTimeout: CHECK_TIMEOUT_MS,
})
.verify();
return { ...found("ok", "ok", facts), latency_ms: Date.now() - start };
} catch (err) {
return found("error", "unreachable", facts, message(err));
}
}
const CHECKS: Record<HealthComponent, () => Promise<Found>> = {
database: checkDatabase,
storage: checkStorage,
memory: checkMemory,
flaresolverr: checkFlaresolverr,
thingiverse: checkThingiverse,
ai: checkAi,
smtp: checkSmtp,
};
async function runCheck(component: HealthComponent): Promise<HealthResult> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<Found>((resolve) => {
timer = setTimeout(
() => resolve(found("error", "timeout", {}, `No answer within ${CHECK_TIMEOUT_MS / 1000}s`)),
CHECK_TIMEOUT_MS,
);
});
try {
const result = await Promise.race([
CHECKS[component]().catch((err: unknown) => found("error", "failed", {}, message(err))),
timeout,
]);
return { latency_ms: null, ...result, component, checked_at: new Date().toISOString() };
} finally {
clearTimeout(timer);
}
}
type Stored = { results: Partial<Record<HealthComponent, HealthResult>>; full_checked_at: string | null };
async function readStored(db: Prisma.TransactionClient = prisma): Promise<Stored> {
const row = await db.setting.findUnique({ where: { key: SETTING_KEY } });
const value = row?.value as Partial<Stored> | undefined;
return { results: value?.results ?? {}, full_checked_at: value?.full_checked_at ?? null };
}
function report(stored: Stored): HealthReport {
return {
components: HEALTH_COMPONENTS.map((component) => ({ component, result: stored.results[component] ?? null })),
full_checked_at: stored.full_checked_at,
};
}
export async function getHealthReport(): Promise<HealthReport> {
return report(await readStored());
}
/** Tests one component, or all of them at once when none is named, and keeps the results. */
export async function testHealth(component?: HealthComponent): Promise<HealthReport> {
const components = component ? [component] : [...HEALTH_COMPONENTS];
// Tested outside the lock: checks take seconds, the merge below a moment.
const results = await Promise.all(components.map(runCheck));
const finishedAt = new Date().toISOString();
// Tests from two admins, or a single one during a full test, would each read the report before the other wrote
// it back and drop the other's results. Taken in turn, each merges into what the last one saved; a result only
// replaces an older one, so a slow full test can't overwrite a newer single test.
const stored = await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${REPORT_LOCK})`;
const current = await readStored(tx);
for (const result of results) {
const kept = current.results[result.component];
if (!kept || kept.checked_at <= result.checked_at) current.results[result.component] = result;
}
if (!component && (!current.full_checked_at || current.full_checked_at < finishedAt)) {
current.full_checked_at = finishedAt;
}
await tx.setting.upsert({
where: { key: SETTING_KEY },
create: { key: SETTING_KEY, value: current as unknown as Prisma.InputJsonValue },
update: { value: current as unknown as Prisma.InputJsonValue },
});
return current;
});
return report(stored);
}
+14
View File
@@ -153,6 +153,20 @@ export async function verifyThingiverseAccessToken(accessToken: string): Promise
}
}
/** Like verifyThingiverseAccessToken, but tells a rejected token from Cloudflare's block and an unreachable API. */
export async function checkThingiverseAccessToken(
accessToken: string,
): Promise<"ok" | "rejected" | "blocked" | "unreachable"> {
try {
const { ok, data } = await fetchThingiverseApi("/users/me", accessToken);
return ok && isRecord(data) ? "ok" : "unreachable";
} catch (err) {
if (err instanceof ThingiverseAuthError) return "rejected";
if (err instanceof ThingiverseRateLimitError) return "blocked";
return "unreachable";
}
}
export type ThingiversePlateFile = { name: string; url: string };
export type ThingiverseGalleryImage = { name: string; url: string };
+99
View File
@@ -0,0 +1,99 @@
import { beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
const app = createApp();
let admin: string;
let member: string;
async function register(role: "ADMIN" | "MEMBER") {
const res = await request(app)
.post("/api/register")
.send({ displayName: role, email: `health-${role}-${Date.now()}@example.com`, password: "password123" });
await prisma.user.update({ where: { id: res.body.user.id }, data: { role } });
return res.body.token as string;
}
beforeAll(async () => {
admin = await register("ADMIN");
member = await register("MEMBER");
});
const as = (token: string) => ({ Authorization: `Bearer ${token}` });
type Report = {
components: {
component: string;
result: { status: string; code: string; checked_at: string; facts: object } | null;
}[];
full_checked_at: string | null;
};
const resultOf = (report: Report, component: string) =>
report.components.find((c) => c.component === component)!.result;
describe("system health", () => {
it("lists every component by importance, the database first and SMTP, which is optional, last", async () => {
const res = await request(app).get("/api/admin/health").set(as(admin));
expect(res.status).toBe(200);
expect((res.body as Report).components.map((c) => c.component)).toEqual([
"database",
"storage",
"memory",
"flaresolverr",
"thingiverse",
"ai",
"smtp",
]);
});
it("tests one component when asked, leaving the others' last results as they were", async () => {
const before = (await request(app).get("/api/admin/health").set(as(admin))).body as Report;
const res = await request(app).post("/api/admin/health/test").set(as(admin)).send({ component: "database" });
expect(res.status).toBe(200);
const database = resultOf(res.body, "database")!;
expect(database).toMatchObject({ status: "ok", code: "ok" });
expect(database.facts).toMatchObject({ version: expect.stringMatching(/^PostgreSQL/) });
expect(resultOf(res.body, "smtp")).toEqual(resultOf(before, "smtp"));
expect(res.body.full_checked_at).toBe(before.full_checked_at);
});
it("tests everything in a full test, and says when it ran", async () => {
const res = await request(app).post("/api/admin/health/test").set(as(admin)).send({});
expect(res.status).toBe(200);
const report = res.body as Report;
expect(report.full_checked_at).not.toBeNull();
for (const { component, result } of report.components)
expect({ component, tested: Boolean(result) }).toEqual({ component, tested: true });
expect(resultOf(report, "storage")!.status).toBe("ok");
expect(resultOf(report, "memory")!.facts).toHaveProperty("available_bytes");
// Kept: opening the page again shows the same results, without testing anything.
const again = (await request(app).get("/api/admin/health").set(as(admin))).body as Report;
expect(again).toEqual(report);
});
it("keeps every result when tests overlap, as from two admins at once", async () => {
const before = (await request(app).get("/api/admin/health").set(as(admin))).body as Report;
const started = new Date().toISOString();
await Promise.all(
["database", "storage", "memory"].map((component) =>
request(app).post("/api/admin/health/test").set(as(admin)).send({ component }),
),
);
const after = (await request(app).get("/api/admin/health").set(as(admin))).body as Report;
for (const component of ["database", "storage", "memory"]) {
expect({ component, fresh: resultOf(after, component)!.checked_at >= started }).toEqual({
component,
fresh: true,
});
}
expect(after.full_checked_at).toBe(before.full_checked_at);
});
it("is for admins only, and refuses a component it doesn't know", async () => {
expect((await request(app).get("/api/admin/health").set(as(member))).status).toBe(403);
expect((await request(app).post("/api/admin/health/test").set(as(member)).send({})).status).toBe(403);
expect(
(await request(app).post("/api/admin/health/test").set(as(admin)).send({ component: "toaster" })).status,
).toBe(400);
});
});
+41
View File
@@ -19,6 +19,27 @@ export type AdminUser = {
created_at: string;
};
export type HealthComponent = "database" | "storage" | "memory" | "flaresolverr" | "thingiverse" | "ai" | "smtp";
export type HealthStatus = "ok" | "warning" | "error" | "not_configured";
export type HealthResult = {
component: HealthComponent;
status: HealthStatus;
/** What was found, e.g. "unreachable" or "low_space", for the page to explain. */
code: string;
/** The component's own error, as it gave it. */
detail: string | null;
facts: Record<string, string | number | null>;
latency_ms: number | null;
checked_at: string;
};
export type HealthReport = {
/** By importance; a result is null until that component is first tested. */
components: { component: HealthComponent; result: HealthResult | null }[];
full_checked_at: string | null;
};
export type LogAction =
| "user_logged_in"
| "user_logged_out"
@@ -141,6 +162,26 @@ export const adminApi = {
return res.json();
},
/** The last results; nothing is tested. */
getHealth: async (): Promise<HealthReport> => {
const res = await fetch(`${apiBase()}/admin/health`, { headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to load system health"));
return res.json();
},
/** One component, or every one when none is given. */
testHealth: async (component?: HealthComponent): Promise<HealthReport> => {
const res = await fetch(`${apiBase()}/admin/health/test`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(component ? { component } : {}),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to test"));
return res.json();
},
listUsers: async (): Promise<AdminUser[]> => {
const res = await fetch(`${apiBase()}/admin/users`, { headers: authHeaders() });
assertOk(res, "Failed to load users");
@@ -0,0 +1,123 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import DashboardOutlinedIcon from "@mui/icons-material/DashboardOutlined";
import { ADMIN_SECTIONS } from "../../constants/adminSections";
import { dividerBorderColor } from "../../theme";
import { CollapsedNavIcon, navRowSx, SIDEBAR_COLLAPSED_WIDTH } from "./Sidebar";
const WIDTH = 220;
const COLLAPSED_STORAGE_KEY = "thingport_admin_nav_collapsed";
function readCollapsed(): boolean {
try {
return window.localStorage.getItem(COLLAPSED_STORAGE_KEY) === "true";
} catch {
return false;
}
}
/**
* Administration's own pages, in a second column beside the sidebar while one of them is open. The sidebar folds to
* icons meanwhile, so this one has the collapse button; folded, it's an icon rail too.
*/
export default function AdminSubnav() {
const { t } = useTranslation(["app", "common"]);
const location = useLocation();
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(readCollapsed);
const pages = [
{ path: "/admin", icon: <DashboardOutlinedIcon fontSize="small" />, label: t("sidebar.adminOverview") },
...ADMIN_SECTIONS.map((section) => ({ path: section.path, icon: section.icon, label: t(section.labelKey) })),
];
const toggle = () =>
setCollapsed((previous) => {
const next = !previous;
try {
window.localStorage.setItem(COLLAPSED_STORAGE_KEY, String(next));
} catch {
// Storage blocked: it just won't be remembered.
}
return next;
});
return (
<Box
component="nav"
aria-label={t("sidebar.administration")}
sx={{
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH : WIDTH,
flexShrink: 0,
height: "100vh",
position: "sticky",
top: 0,
display: "flex",
flexDirection: "column",
borderRight: "1px solid",
borderColor: dividerBorderColor,
bgcolor: (theme) => theme.thingport.subnavBackground,
overflowX: "hidden",
overflowY: "auto",
transition: (theme) => theme.transitions.create("width", { duration: theme.transitions.duration.shortest }),
}}
>
{/* As tall as the folded sidebar's logo row beside it, so both columns' lists start level. */}
<Stack
direction="row"
alignItems="center"
justifyContent={collapsed ? "center" : "space-between"}
sx={{ px: collapsed ? 1 : 2, pt: "20px", pb: "20px", minHeight: 72 }}
>
{!collapsed && (
<Typography variant="subtitle2" fontWeight={700} noWrap>
{t("sidebar.administration")}
</Typography>
)}
<Tooltip title={collapsed ? t("sidebar.expandAdminMenu") : t("sidebar.collapseAdminMenu")}>
<IconButton size="small" onClick={toggle} aria-expanded={!collapsed}>
{collapsed ? <ChevronRightIcon fontSize="small" /> : <ChevronLeftIcon fontSize="small" />}
</IconButton>
</Tooltip>
</Stack>
<List disablePadding sx={{ px: collapsed ? 0.5 : 1 }}>
{pages.map((page) => {
// The overview only on itself; each page also on anything under it.
const selected =
page.path === "/admin" ? location.pathname === "/admin" : location.pathname.startsWith(page.path);
return collapsed ? (
<CollapsedNavIcon
key={page.path}
icon={page.icon}
label={page.label}
selected={selected}
onClick={() => navigate(page.path)}
/>
) : (
<ListItemButton
key={page.path}
selected={selected}
onClick={() => navigate(page.path)}
aria-current={selected ? "page" : undefined}
sx={{ borderRadius: 1, mb: 0.5, ...navRowSx(selected) }}
>
<ListItemIcon sx={{ minWidth: 30 }}>{page.icon}</ListItemIcon>
<ListItemText primary={page.label} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
);
})}
</List>
</Box>
);
}
@@ -6,6 +6,7 @@ import CssBaseline from "@mui/material/CssBaseline";
import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert";
import Sidebar from "./Sidebar";
import AdminSubnav from "./AdminSubnav";
import TopBar from "./TopBar";
import ImportProgressBar from "./ImportProgressBar";
import AiRunProgressBar from "./AiRunProgressBar";
@@ -155,6 +156,7 @@ function AppLayoutShell({
}: ShellProps) {
const { t } = useTranslation(["app", "common"]);
const { title: routeTitle, subtitle: routeSubtitle, onBack: routeOnBack } = useRouteChrome();
const { pathname } = useLocation();
const [pageHeader, setPageHeader] = React.useState<PageHeader>(null);
const { refresh: refreshNotifications } = useNotifications();
@@ -174,6 +176,7 @@ function AppLayoutShell({
}}
>
<Sidebar isAdmin={isAdmin} onSelectCategory={onSelectCategory} bookmarksVersion={bookmarksVersion} />
{isAdmin && pathname.startsWith("/admin") && <AdminSubnav />}
<Box component="main" sx={{ flex: 1, minWidth: 0, p: 2, pt: 0 }}>
{apiUp === false && (
<Alert severity="error" sx={{ mb: 1.5 }}>
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink, useLocation, useNavigate } from "react-router-dom";
import type { Theme } from "@mui/material/styles";
import { useTheme, type Theme } from "@mui/material/styles";
import { dividerBorderColor } from "../../../theme";
import Box from "@mui/material/Box";
import Link from "@mui/material/Link";
@@ -44,13 +44,14 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import Wordmark from "../../Wordmark";
import BrandMark from "../../BrandMark";
import { type BookmarkEntry, bookmarksApi } from "../../../api/bookmarks";
const SIDEBAR_WIDTH = 240;
const SIDEBAR_COLLAPSED_WIDTH = 72;
export const SIDEBAR_COLLAPSED_WIDTH = 72;
const SIDEBAR_COLLAPSED_STORAGE_KEY = "thingport_sidebar_collapsed";
function navRowSx(selected: boolean) {
export function navRowSx(selected: boolean) {
const color = (theme: Theme) => (selected ? theme.thingport.selectedNavText : theme.thingport.navInactiveText);
return {
color,
@@ -72,7 +73,7 @@ function navRowSx(selected: boolean) {
};
}
function CollapsedNavIcon({
export function CollapsedNavIcon({
icon,
label,
selected,
@@ -156,7 +157,8 @@ export default function Sidebar({ isAdmin, onSelectCategory, bookmarksVersion }:
const { t } = useTranslation(["app", "common"]);
const location = useLocation();
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(() => {
const muiTheme = useTheme();
const [collapsedByUser, setCollapsedByUser] = useState(() => {
if (typeof window === "undefined") return false;
return window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "true";
});
@@ -228,9 +230,11 @@ export default function Sidebar({ isAdmin, onSelectCategory, bookmarksVersion }:
(location.pathname.startsWith("/models") && !onCollections && !onTags) || location.pathname.startsWith("/authors");
const onDownload = location.pathname.startsWith("/downloads");
const onAdmin = location.pathname.startsWith("/admin");
// Administration opens its own menu beside this one, so this one folds to icons there, whatever the user chose.
const collapsed = collapsedByUser || onAdmin;
const toggleCollapsed = () => {
setCollapsed((prev) => {
setCollapsedByUser((prev) => {
const next = !prev;
try {
window.localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(next));
@@ -281,11 +285,25 @@ export default function Sidebar({ isAdmin, onSelectCategory, bookmarksVersion }:
<Wordmark size="lg" seasonal />
</Link>
)}
<Tooltip title={collapsed ? t("sidebar.expandSidebar") : t("sidebar.collapseSidebar")}>
<IconButton size="small" onClick={toggleCollapsed}>
{collapsed ? <ChevronRightIcon fontSize="small" /> : <ChevronLeftIcon fontSize="small" />}
</IconButton>
</Tooltip>
{onAdmin ? (
// Folded for administration, there's nothing to unfold: the mark leads home instead.
<Tooltip title={t("sidebar.dashboard")} placement="right">
<Link
component={RouterLink}
to="/"
aria-label={t("sidebar.dashboard")}
sx={{ display: "flex", alignItems: "center", lineHeight: 0, p: 0.5 }}
>
<BrandMark theme={muiTheme.palette.mode} size="md" />
</Link>
</Tooltip>
) : (
<Tooltip title={collapsed ? t("sidebar.expandSidebar") : t("sidebar.collapseSidebar")}>
<IconButton size="small" onClick={toggleCollapsed}>
{collapsed ? <ChevronRightIcon fontSize="small" /> : <ChevronLeftIcon fontSize="small" />}
</IconButton>
</Tooltip>
)}
</Stack>
<Box component="nav" sx={{ flex: 1, overflow: "auto", px: collapsed ? 0.5 : 1 }}>
+4 -1
View File
@@ -9,10 +9,12 @@ type Props = {
subtitle: string;
onBack?: () => void;
backLabel?: string;
/** Buttons beside the title, e.g. a section-wide action. */
actions?: React.ReactNode;
};
/** `onBack` only when the section was reached from a menu grid. */
export default function SectionHeader({ title, subtitle, onBack, backLabel }: Props) {
export default function SectionHeader({ title, subtitle, onBack, backLabel, actions }: Props) {
return (
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" gap={2} flexWrap="wrap">
<Box>
@@ -23,6 +25,7 @@ export default function SectionHeader({ title, subtitle, onBack, backLabel }: Pr
{subtitle}
</Typography>
</Box>
{actions}
{onBack && (
<Button variant="outlined" size="small" onClick={onBack}>
{backLabel}
+28
View File
@@ -0,0 +1,28 @@
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
import BoltIcon from "@mui/icons-material/Bolt";
import CableIcon from "@mui/icons-material/Cable";
import HistoryIcon from "@mui/icons-material/History";
import PeopleIcon from "@mui/icons-material/People";
import QueueIcon from "@mui/icons-material/Queue";
import SettingsIcon from "@mui/icons-material/Settings";
import VerifiedUserIcon from "@mui/icons-material/VerifiedUser";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
export type AdminSection = {
path: string;
icon: React.ReactNode;
labelKey: string;
};
/** Administration's pages, under its entry in the sidebar; /admin itself is the overview. */
export const ADMIN_SECTIONS: AdminSection[] = [
{ path: "/admin-settings", icon: <SettingsIcon fontSize="small" />, labelKey: "common:settings" },
{ path: "/admin-rendering", icon: <ViewInArIcon fontSize="small" />, labelKey: "adminSettings.rendering.heading" },
{ path: "/admin-ai", icon: <AutoAwesomeIcon fontSize="small" />, labelKey: "adminSettings.ai.heading" },
{ path: "/admin-users", icon: <PeopleIcon fontSize="small" />, labelKey: "adminSettings.users.heading" },
{ path: "/admin-queue", icon: <QueueIcon fontSize="small" />, labelKey: "adminSettings.importQueue.heading" },
{ path: "/admin-logs", icon: <HistoryIcon fontSize="small" />, labelKey: "adminSettings.logs.heading" },
{ path: "/admin-triggers", icon: <BoltIcon fontSize="small" />, labelKey: "adminSettings.triggers.heading" },
{ path: "/admin-connections", icon: <CableIcon fontSize="small" />, labelKey: "adminSettings.connections.heading" },
{ path: "/admin-captcha", icon: <VerifiedUserIcon fontSize="small" />, labelKey: "adminSettings.captcha.heading" },
];
+102
View File
@@ -73,6 +73,9 @@
}
},
"sidebar": {
"collapseAdminMenu": "Collapse administration menu",
"expandAdminMenu": "Expand administration menu",
"adminOverview": "Overview",
"dashboard": "Dashboard",
"administration": "Administration",
"expandSidebar": "Expand sidebar",
@@ -489,6 +492,105 @@
"hint": "Install guides, provider setup and news about Thingport"
},
"pageTitle": "Admin Settings",
"health": {
"heading": "System Health",
"subtitle": "Everything Thingport relies on, what each is for, and whether it's working. Nothing is tested until you ask.",
"testAll": "Run full test",
"test": "Test",
"settings": "Settings",
"testedAgo": "Tested {{when}}",
"neverTested": "Not tested yet",
"lastFullTest": "Last full test: {{when}} ({{ago}})",
"neverFullTest": "No full test has been run yet.",
"need": {
"required": "Required",
"recommended": "Recommended",
"optional": "Optional"
},
"status": {
"ok": "OK",
"warning": "Warning",
"error": "Error",
"not_configured": "Not configured",
"untested": "Not tested"
},
"facts": {
"diskFree": "{{free}} free of {{total}}",
"memory": "{{available}} available of {{total}}",
"memoryContainer": "{{available}} available of the container's {{total}}",
"process": "Thingport uses {{used}}",
"latency": "answered in {{ms}} ms"
},
"problems": {
"unknown": "This component reported a problem. The details below come from it directly."
},
"components": {
"database": {
"name": "Database",
"description": "PostgreSQL holds every model, collection, user and setting. Without it nothing in Thingport works.",
"problems": {
"unreachable": "Thingport can't reach the database. Check that the database container is running and that DATABASE_URL points at it, then restart the backend.",
"timeout": "The database didn't answer in time. It may be overloaded, or the host it runs on may be out of resources.",
"failed": "The database check failed unexpectedly."
}
},
"storage": {
"name": "File storage",
"description": "Where model files, their pictures and 3D previews are kept. Uploads, imports and downloads all need it writable, with room to spare.",
"problems": {
"not_writable": "Thingport can't write to its storage folder. Check that the volume is mounted, isn't read-only, and that PUID/PGID match the folder's owner.",
"low_space": "Storage is running low. New uploads and imports will fail once it's full: free up space or give the volume more.",
"timeout": "Checking the storage folder took too long. A network share may be slow or unavailable."
}
},
"memory": {
"name": "Memory",
"description": "Building 3D previews of large models and simplifying them take the most memory. With too little, previews fail or the server restarts.",
"problems": {
"low_memory": "Memory is running low. Give the container more memory, or turn on Simplify previews under Rendering so heavy models need less."
}
},
"flaresolverr": {
"name": "FlareSolverr",
"description": "Gets past Cloudflare's checks for providers that use them, mostly Thingiverse and Printables. Without it, imports and syncs from those sites will be blocked.",
"problems": {
"not_configured": "Not set up. Imports work until a provider shows a Cloudflare challenge; add a FlareSolverr URL so they keep working when it does.",
"unreachable": "FlareSolverr isn't answering. Check that its container is running and that the URL in Connections > FlareSolverr is right, including the port (usually 8191).",
"timeout": "FlareSolverr didn't answer in time. It may still be starting, or be busy."
}
},
"thingiverse": {
"name": "Thingiverse",
"description": "The instance's Thingiverse Access Token, used to import models, sync collections and liked models, and link authors from Thingiverse.",
"problems": {
"not_configured": "No Access Token set. Thingiverse imports and syncs won't work until one is added in Settings (create one at thingiverse.com/apps/create).",
"token_rejected": "Thingiverse rejected the Access Token. Generate a new one at thingiverse.com/apps/create and save it in Settings.",
"blocked": "Thingiverse answered with a Cloudflare challenge, which says nothing about the token. Set up FlareSolverr, or wait: it usually clears after a while.",
"unreachable": "Thingport couldn't reach Thingiverse. Check the server's internet access, or try again later.",
"timeout": "Thingiverse didn't answer in time. Try again later."
}
},
"ai": {
"name": "AI provider",
"description": "The default AI model, behind AI categorization and improved descriptions. Everything else works without it.",
"problems": {
"not_configured": "No AI model added. AI features stay off until you add one under AI.",
"unreachable": "The AI provider isn't answering. Check that it's running and that its API base URL and key under AI are right.",
"model_missing": "The provider answered, but doesn't list the chosen model. Check the model name under AI, or pull the model on the provider.",
"timeout": "The AI provider didn't answer in time. A local one may still be loading a model."
}
},
"smtp": {
"name": "Email (SMTP)",
"description": "Sends invitations, email address confirmations and password resets. Optional: without it, invite people by link and reset passwords as an admin.",
"problems": {
"not_configured": "Not set up. Thingport sends no email: invitation links are shown to copy instead, and new accounts don't need to confirm their address.",
"unreachable": "Thingport couldn't connect or log in to the mail server. Check the host, port, security setting and login under Connections > SMTP.",
"timeout": "The mail server didn't answer in time. Check the host and port, and that the server's network allows outgoing mail."
}
}
}
},
"updateCheck": {
"heading": "Update Checker",
"subtitle": "Checks whether a newer Thingport version is available for this backend and frontend.",
+102
View File
@@ -73,6 +73,9 @@
}
},
"sidebar": {
"collapseAdminMenu": "Suskleisti administravimo meniu",
"expandAdminMenu": "Išskleisti administravimo meniu",
"adminOverview": "Apžvalga",
"dashboard": "Valdymo skydas",
"administration": "Administravimas",
"expandSidebar": "Išskleisti šoninę juostą",
@@ -490,6 +493,105 @@
"hint": "Diegimo gidai, šaltinių nustatymas ir Thingport naujienos"
},
"pageTitle": "Administravimo nustatymai",
"health": {
"heading": "Sistemos būklė",
"subtitle": "Viskas, kuo remiasi Thingport, kam to reikia ir ar veikia. Niekas netikrinama, kol nepaprašote.",
"testAll": "Patikrinti viską",
"test": "Patikrinti",
"settings": "Nustatymai",
"testedAgo": "Patikrinta {{when}}",
"neverTested": "Dar netikrinta",
"lastFullTest": "Paskutinis pilnas patikrinimas: {{when}} ({{ago}})",
"neverFullTest": "Pilnas patikrinimas dar nebuvo atliktas.",
"need": {
"required": "Būtina",
"recommended": "Rekomenduojama",
"optional": "Neprivaloma"
},
"status": {
"ok": "Veikia",
"warning": "Įspėjimas",
"error": "Klaida",
"not_configured": "Nesukonfigūruota",
"untested": "Netikrinta"
},
"facts": {
"diskFree": "laisva {{free}} iš {{total}}",
"memory": "laisva {{available}} iš {{total}}",
"memoryContainer": "laisva {{available}} iš konteineriui skirtų {{total}}",
"process": "Thingport naudoja {{used}}",
"latency": "atsakė per {{ms}} ms"
},
"problems": {
"unknown": "Šis komponentas pranešė apie problemą. Žemiau – jo paties pateikta informacija."
},
"components": {
"database": {
"name": "Duomenų bazė",
"description": "PostgreSQL saugo visus modelius, kolekcijas, naudotojus ir nustatymus. Be jos Thingport neveikia.",
"problems": {
"unreachable": "Thingport nepasiekia duomenų bazės. Patikrinkite, ar veikia jos konteineris ir ar DATABASE_URL nurodo į ją, tada paleiskite serverį iš naujo.",
"timeout": "Duomenų bazė laiku neatsakė. Ji gali būti perkrauta arba jos serveriui trūksta išteklių.",
"failed": "Duomenų bazės patikrinimas netikėtai nepavyko."
}
},
"storage": {
"name": "Failų saugykla",
"description": "Čia laikomi modelių failai, paveikslėliai ir peržiūros. Įkėlimams, importams ir atsisiuntimams reikia, kad į ją būtų galima rašyti ir joje būtų vietos.",
"problems": {
"not_writable": "Thingport negali rašyti į saugyklos aplanką. Patikrinkite, ar tomas prijungtas, ar jis ne tik skaitymui ir ar PUID/PGID atitinka aplanko savininką.",
"low_space": "Saugykloje mažai vietos. Kai ji užsipildys, įkėlimai ir importai nepavyks: atlaisvinkite vietos arba padidinkite tomą.",
"timeout": "Saugyklos patikrinimas užtruko per ilgai. Tinklo diskas gali būti lėtas arba nepasiekiamas."
}
},
"memory": {
"name": "Atmintis",
"description": "Daugiausia atminties reikia didelių modelių 3D peržiūroms kurti ir supaprastinti. Jos trūkstant peržiūros nepavyksta arba serveris persikrauna.",
"problems": {
"low_memory": "Atminties mažai. Skirkite konteineriui daugiau atminties arba skiltyje „Atvaizdavimas“ įjunkite peržiūrų supaprastinimą."
}
},
"flaresolverr": {
"name": "FlareSolverr",
"description": "Įveikia Cloudflare patikras tiekėjuose, kurie jas naudoja – dažniausiai Thingiverse ir Printables. Be jo importai ir sinchronizacijos iš šių svetainių bus blokuojami.",
"problems": {
"not_configured": "Nesukonfigūruota. Importai veikia, kol tiekėjas neparodo Cloudflare patikros; nurodykite FlareSolverr adresą, kad jie veiktų ir tada.",
"unreachable": "FlareSolverr neatsako. Patikrinkite, ar veikia jo konteineris ir ar adresas skiltyje „Ryšiai > FlareSolverr“ teisingas, įskaitant prievadą (dažniausiai 8191).",
"timeout": "FlareSolverr laiku neatsakė. Jis gali dar tik pasileisti arba būti užimtas."
}
},
"thingiverse": {
"name": "Thingiverse",
"description": "Sistemos Thingiverse prieigos raktas – modeliams importuoti, kolekcijoms ir patiktiems modeliams sinchronizuoti bei autoriams susieti.",
"problems": {
"not_configured": "Prieigos raktas nenurodytas. Thingiverse importai ir sinchronizacijos neveiks, kol jo nepridėsite nustatymuose (sukurkite thingiverse.com/apps/create).",
"token_rejected": "Thingiverse atmetė prieigos raktą. Sukurkite naują thingiverse.com/apps/create ir išsaugokite nustatymuose.",
"blocked": "Thingiverse atsakė Cloudflare patikra – tai nieko nesako apie raktą. Įdiekite FlareSolverr arba palaukite: dažniausiai praeina po kurio laiko.",
"unreachable": "Thingport nepasiekė Thingiverse. Patikrinkite serverio interneto ryšį arba bandykite vėliau.",
"timeout": "Thingiverse laiku neatsakė. Bandykite vėliau."
}
},
"ai": {
"name": "DI tiekėjas",
"description": "Numatytasis DI modelis, naudojamas DI kategorizavimui ir aprašymų tobulinimui. Visa kita veikia ir be jo.",
"problems": {
"not_configured": "DI modelis nepridėtas. DI funkcijos išjungtos, kol jo nepridėsite skiltyje „DI“.",
"unreachable": "DI tiekėjas neatsako. Patikrinkite, ar jis veikia ir ar jo API adresas bei raktas skiltyje „DI“ teisingi.",
"model_missing": "Tiekėjas atsakė, bet pasirinkto modelio jo sąraše nėra. Patikrinkite modelio pavadinimą skiltyje „DI“ arba atsisiųskite modelį tiekėjui.",
"timeout": "DI tiekėjas laiku neatsakė. Vietinis tiekėjas gali dar įkelti modelį."
}
},
"smtp": {
"name": "El. paštas (SMTP)",
"description": "Siunčia kvietimus, el. pašto patvirtinimus ir slaptažodžių atkūrimo laiškus. Neprivaloma: be jo kvieskite nuoroda, o slaptažodžius atkurkite kaip administratorius.",
"problems": {
"not_configured": "Nesukonfigūruota. Thingport nesiunčia laiškų: kvietimų nuorodos rodomos nukopijuoti, o naujoms paskyroms nereikia patvirtinti adreso.",
"unreachable": "Thingport nepavyko prisijungti prie pašto serverio. Patikrinkite adresą, prievadą, saugumo nustatymą ir prisijungimo duomenis skiltyje „Ryšiai > SMTP“.",
"timeout": "Pašto serveris laiku neatsakė. Patikrinkite adresą ir prievadą bei ar serverio tinklas leidžia siųsti laiškus."
}
}
}
},
"updateCheck": {
"heading": "Atnaujinimų tikrinimas",
"subtitle": "Patikrina, ar yra naujesnė Thingport versija šiam backend ir frontend.",
@@ -0,0 +1,261 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import CircularProgress from "@mui/material/CircularProgress";
import Paper from "@mui/material/Paper";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import MailOutlineIcon from "@mui/icons-material/MailOutline";
import MemoryIcon from "@mui/icons-material/Memory";
import PlaylistPlayIcon from "@mui/icons-material/PlaylistPlay";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import StorageIcon from "@mui/icons-material/Storage";
import { UnauthorizedError } from "../../api/client";
import {
adminApi,
type HealthComponent,
type HealthReport,
type HealthResult,
type HealthStatus,
} from "../../api/admin";
import SectionHeader from "../../components/SectionHeader";
import { formatFileSize } from "../../utils/fileSize";
import { relativeTime } from "../../utils/relativeTime";
type Props = {
onUnauthorized?: () => void;
};
type Need = "required" | "recommended" | "optional";
/** How much Thingport needs each one, and where it's set up. Order comes from the server: by importance. */
const COMPONENTS: Record<HealthComponent, { icon: React.ReactNode; need: Need; settings?: string }> = {
database: { icon: <StorageIcon />, need: "required", settings: "/admin-connections?tab=database" },
storage: { icon: <FolderOutlinedIcon />, need: "required" },
memory: { icon: <MemoryIcon />, need: "required", settings: "/admin-rendering" },
flaresolverr: { icon: <ShieldOutlinedIcon />, need: "recommended", settings: "/admin-connections?tab=flaresolverr" },
thingiverse: { icon: <CloudOutlinedIcon />, need: "recommended", settings: "/admin-settings" },
ai: { icon: <AutoAwesomeOutlinedIcon />, need: "optional", settings: "/admin-ai" },
smtp: { icon: <MailOutlineIcon />, need: "optional", settings: "/admin-connections?tab=smtp" },
};
const STATUS_COLOR: Record<HealthStatus, "success" | "warning" | "error" | "default"> = {
ok: "success",
warning: "warning",
error: "error",
not_configured: "default",
};
/** The facts worth a glance, in words; the rest stay out of the way. */
function factsLine(result: HealthResult, t: (key: string, options?: Record<string, unknown>) => string): string[] {
const { facts } = result;
const lines: string[] = [];
const bytes = (key: string) => (typeof facts[key] === "number" ? formatFileSize(facts[key] as number) : null);
if (typeof facts.version === "string" && facts.version) lines.push(facts.version);
if (typeof facts.model === "string") lines.push(`${facts.model} @ ${facts.host}`);
if (typeof facts.host === "string" && result.component === "smtp") lines.push(`${facts.host}:${facts.port}`);
if (bytes("free_bytes")) {
lines.push(t("adminSettings.health.facts.diskFree", { free: bytes("free_bytes"), total: bytes("total_bytes") }));
}
if (bytes("available_bytes")) {
lines.push(
t(facts.container_limited ? "adminSettings.health.facts.memoryContainer" : "adminSettings.health.facts.memory", {
available: bytes("available_bytes"),
total: bytes("total_bytes"),
}),
);
lines.push(t("adminSettings.health.facts.process", { used: bytes("process_bytes") }));
}
if (result.latency_ms !== null) lines.push(t("adminSettings.health.facts.latency", { ms: result.latency_ms }));
return lines;
}
function ComponentRow({
component,
result,
testing,
disabled,
onTest,
}: {
component: HealthComponent;
result: HealthResult | null;
testing: boolean;
disabled: boolean;
onTest: () => void;
}) {
const { t, i18n } = useTranslation("app");
const info = COMPONENTS[component];
const base = `adminSettings.health.components.${component}`;
const problem = result && result.status !== "ok";
return (
<Box sx={{ p: 2, "&:not(:last-of-type)": { borderBottom: 1, borderColor: "divider" } }}>
<Stack direction="row" spacing={1.5} alignItems="flex-start">
<Box sx={{ color: "text.secondary", pt: 0.25, "& svg": { fontSize: 22 } }}>{info.icon}</Box>
<Stack spacing={0.75} sx={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Typography variant="body1" fontWeight={600}>
{t(`${base}.name`)}
</Typography>
<Chip size="small" variant="outlined" label={t(`adminSettings.health.need.${info.need}`)} />
<Chip
size="small"
color={result ? STATUS_COLOR[result.status] : "default"}
variant={result && result.status !== "not_configured" ? "filled" : "outlined"}
label={t(`adminSettings.health.status.${result?.status ?? "untested"}`)}
/>
</Stack>
<Typography variant="body2" color="text.secondary">
{t(`${base}.description`)}
</Typography>
{result && result.status !== "not_configured" && factsLine(result, t).length > 0 && (
<Typography variant="caption" color="text.secondary">
{factsLine(result, t).join(" · ")}
</Typography>
)}
{problem && (
<Alert
severity={result.status === "error" ? "error" : result.status === "warning" ? "warning" : "info"}
sx={{ py: 0.25 }}
>
{t(`${base}.problems.${result.code}`, { defaultValue: t("adminSettings.health.problems.unknown") })}
{result.detail && (
<Typography
component="div"
variant="caption"
sx={{ mt: 0.5, fontFamily: "ui-monospace, Menlo, Consolas, monospace", wordBreak: "break-word" }}
>
{result.detail}
</Typography>
)}
</Alert>
)}
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Button
size="small"
variant="outlined"
onClick={onTest}
disabled={disabled}
startIcon={testing ? <CircularProgress size={14} /> : undefined}
>
{t("adminSettings.health.test")}
</Button>
{info.settings && (
<Button size="small" component={RouterLink} to={info.settings}>
{t("adminSettings.health.settings")}
</Button>
)}
<Typography variant="caption" color="text.secondary">
{result
? t("adminSettings.health.testedAgo", { when: relativeTime(result.checked_at, i18n.language) })
: t("adminSettings.health.neverTested")}
</Typography>
</Stack>
</Stack>
</Stack>
</Box>
);
}
/**
* Everything Thingport relies on, with the last test of each and how to fix what's wrong. Nothing is tested on
* opening: each component on its own, or all of them in a full test, when the admin asks.
*/
export default function SystemHealthSection({ onUnauthorized }: Props) {
const { t, i18n } = useTranslation("app");
const [report, setReport] = React.useState<HealthReport | null>(null);
const [error, setError] = React.useState<string | null>(null);
// The one being tested, or "all" during a full test.
const [testing, setTesting] = React.useState<HealthComponent | "all" | null>(null);
const fail = React.useCallback(
(err: unknown) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : String(err));
},
[onUnauthorized],
);
React.useEffect(() => {
let active = true;
adminApi.getHealth().then(
(loaded) => active && setReport(loaded),
(err: unknown) => active && fail(err),
);
return () => {
active = false;
};
}, [fail]);
const test = async (component?: HealthComponent) => {
setTesting(component ?? "all");
setError(null);
try {
setReport(await adminApi.testHealth(component));
} catch (err) {
fail(err);
} finally {
setTesting(null);
}
};
return (
<Stack spacing={3}>
<SectionHeader
title={t("adminSettings.health.heading")}
subtitle={t("adminSettings.health.subtitle")}
actions={
<Button
variant="contained"
onClick={() => void test()}
disabled={testing !== null || !report}
startIcon={testing === "all" ? <CircularProgress size={16} color="inherit" /> : <PlaylistPlayIcon />}
>
{t("adminSettings.health.testAll")}
</Button>
}
/>
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
<Paper variant="outlined" sx={{ borderRadius: "12px", overflow: "hidden" }}>
{report
? report.components.map(({ component, result }) => (
<ComponentRow
key={component}
component={component}
result={result}
testing={testing === component || testing === "all"}
disabled={testing !== null}
onTest={() => void test(component)}
/>
))
: Array.from({ length: 7 }, (_, index) => (
<Box key={index} sx={{ p: 2, "&:not(:last-of-type)": { borderBottom: 1, borderColor: "divider" } }}>
<Skeleton width="40%" />
<Skeleton width="85%" />
</Box>
))}
</Paper>
{report && (
<Typography variant="body2" color="text.secondary" sx={{ px: 0.5 }}>
{report.full_checked_at
? t("adminSettings.health.lastFullTest", {
when: new Date(report.full_checked_at).toLocaleString(i18n.language),
ago: relativeTime(report.full_checked_at, i18n.language),
})
: t("adminSettings.health.neverFullTest")}
</Typography>
)}
</Stack>
);
}
+3 -61
View File
@@ -1,58 +1,24 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Skeleton from "@mui/material/Skeleton";
import Typography from "@mui/material/Typography";
import Link from "@mui/material/Link";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import StorageIcon from "@mui/icons-material/Storage";
import SettingsIcon from "@mui/icons-material/Settings";
import PeopleIcon from "@mui/icons-material/People";
import HistoryIcon from "@mui/icons-material/History";
import BoltIcon from "@mui/icons-material/Bolt";
import CableIcon from "@mui/icons-material/Cable";
import PublicIcon from "@mui/icons-material/Public";
import VerifiedUserIcon from "@mui/icons-material/VerifiedUser";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
import QueueIcon from "@mui/icons-material/Queue";
import UpdateCheckSection from "./UpdateCheckSection";
import SystemHealthSection from "./SystemHealthSection";
import { adminApi, type StorageUsage } from "../../api/admin";
import { UnauthorizedError } from "../../api/client";
import { formatFileSize } from "../../utils/fileSize";
import { THINGPORT_WEBSITE_URL } from "../../constants/website";
type Section = {
path: string;
icon: React.ReactNode;
labelKey: string;
};
const SECTIONS: Section[] = [
{ path: "/admin-settings", icon: <SettingsIcon fontSize="small" />, labelKey: "common:settings" },
{ path: "/admin-rendering", icon: <ViewInArIcon fontSize="small" />, labelKey: "adminSettings.rendering.heading" },
{ path: "/admin-ai", icon: <AutoAwesomeIcon fontSize="small" />, labelKey: "adminSettings.ai.heading" },
{ path: "/admin-users", icon: <PeopleIcon fontSize="small" />, labelKey: "adminSettings.users.heading" },
{ path: "/admin-queue", icon: <QueueIcon fontSize="small" />, labelKey: "adminSettings.importQueue.heading" },
{ path: "/admin-logs", icon: <HistoryIcon fontSize="small" />, labelKey: "adminSettings.logs.heading" },
{ path: "/admin-triggers", icon: <BoltIcon fontSize="small" />, labelKey: "adminSettings.triggers.heading" },
{ path: "/admin-connections", icon: <CableIcon fontSize="small" />, labelKey: "adminSettings.connections.heading" },
{ path: "/admin-captcha", icon: <VerifiedUserIcon fontSize="small" />, labelKey: "adminSettings.captcha.heading" },
];
type Props = {
onUnauthorized?: () => void;
};
export default function AdminPage({ onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const navigate = useNavigate();
// undefined = loading, null = failed (the footer hides).
const [storage, setStorage] = useState<StorageUsage | null | undefined>(undefined);
@@ -74,33 +40,9 @@ export default function AdminPage({ onUnauthorized }: Props) {
}, [onUnauthorized]);
return (
<Stack spacing={3} sx={{ maxWidth: 480 }}>
<Stack spacing={4} sx={{ maxWidth: 960 }}>
<UpdateCheckSection onUnauthorized={onUnauthorized} />
<Paper
variant="outlined"
sx={{
borderRadius: "12px",
borderColor: (theme) => (theme.palette.mode === "dark" ? "transparent" : "divider"),
}}
>
<List disablePadding>
{SECTIONS.map((section, idx) => (
<ListItemButton
key={section.path}
onClick={() => navigate(section.path)}
divider={idx < SECTIONS.length - 1}
sx={{ py: 1.5 }}
>
<ListItemIcon sx={{ minWidth: 36 }}>{section.icon}</ListItemIcon>
<ListItemText
primary={t(section.labelKey)}
primaryTypographyProps={{ variant: "body2", fontWeight: 600 }}
/>
<ChevronRightIcon fontSize="small" sx={{ color: "text.disabled" }} />
</ListItemButton>
))}
</List>
</Paper>
<SystemHealthSection onUnauthorized={onUnauthorized} />
{storage !== null && (
<Stack
+7 -2
View File
@@ -1,5 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import Stack from "@mui/material/Stack";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
@@ -11,11 +12,15 @@ type Props = {
onUnauthorized?: () => void;
};
type TabKey = "smtp" | "database" | "flaresolverr";
const TABS = ["smtp", "database", "flaresolverr"] as const;
type TabKey = (typeof TABS)[number];
export default function ConnectionsPage({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [tab, setTab] = React.useState<TabKey>("smtp");
// ?tab=... opens one directly, as System Health's links do.
const [searchParams] = useSearchParams();
const asked = searchParams.get("tab");
const [tab, setTab] = React.useState<TabKey>(TABS.find((key) => key === asked) ?? "smtp");
return (
<Stack spacing={3}>
+9
View File
@@ -14,6 +14,8 @@ declare module "@mui/material/styles" {
interface Theme {
thingport: {
pageBackground: string;
/** A second-level menu beside the sidebar: unlike both it and the page, so the tiers read apart. */
subnavBackground: string;
modelColor: string;
modelEmissive: string;
/** A secondary accent that doesn't compete with the primary green. */
@@ -30,6 +32,7 @@ declare module "@mui/material/styles" {
interface ThemeOptions {
thingport: {
pageBackground: string;
subnavBackground: string;
modelColor: string;
modelEmissive: string;
altText: string;
@@ -44,6 +47,7 @@ declare module "@mui/material/styles" {
type ThemeDef = {
mode: "light" | "dark";
pageBackground: string;
subnavBackground: string;
panel: string;
panelStrong: string;
border: string;
@@ -69,6 +73,8 @@ const THEME_DEFS: Record<ResolvedTheme, ThemeDef> = {
light: {
mode: "light",
pageBackground: "#f7f7f7",
// A shade under the page, faintly green: neither the white sidebar nor the content.
subnavBackground: "#ecf1ec",
panel: "#ffffff",
panelStrong: "#ffffff",
border: "#ebebeb",
@@ -92,6 +98,8 @@ const THEME_DEFS: Record<ResolvedTheme, ThemeDef> = {
dark: {
mode: "dark",
pageBackground: "#181f39",
// A step above the sidebar, which is a step above the page.
subnavBackground: "#26315a",
panel: "#1e2746",
panelStrong: "#1e2746",
border: "#333a54",
@@ -188,6 +196,7 @@ export function buildTheme(id: ResolvedTheme): Theme {
},
thingport: {
pageBackground: d.pageBackground,
subnavBackground: d.subnavBackground,
modelColor: d.modelColor,
modelEmissive: d.modelEmissive,
altText: d.altText,