mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: admin sidebar change and system health check view (#48)
This commit is contained in:
1 parent
731a2950be
commit
92161f8cf8
17 files changed
+1124
-76
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user