Update admin theme/layout and refresh README details.
Align the project baseline with the latest admin interface styling and layout structure while documenting setup and usage updates in README.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAdminUser,
|
||||
deleteAdminUser,
|
||||
getAdminActivityFeed,
|
||||
getAdminDiagnostics,
|
||||
getAdminServerLog,
|
||||
getAdminSettings,
|
||||
getAdminStats,
|
||||
getAdminSummary,
|
||||
getAdminUsers,
|
||||
getInstallSecrets,
|
||||
patchAdminSettings,
|
||||
patchAdminUser,
|
||||
postAdminUiActivity,
|
||||
resetAdminUserPassword,
|
||||
revealInstallSecret
|
||||
} from "./adminApi";
|
||||
|
||||
vi.mock("@shared/api/client", () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(async (url: string) => {
|
||||
if (url === "/api/v1/admin/summary") {
|
||||
return { data: { users_count: 5, registrations_day: 2, admins_count: 1, superusers_count: 1 } };
|
||||
}
|
||||
if (url === "/api/v1/admin/stats") {
|
||||
return { data: { users_count: 5, registrations_day: 2 } };
|
||||
}
|
||||
if (url === "/api/v1/admin/settings") {
|
||||
return { data: { values: {}, locks: {}, settings_path: "data/compton_settings.json", secrets: {} } };
|
||||
}
|
||||
if (url === "/api/v1/admin/diagnostics/report") {
|
||||
return { data: { checks: [{ id: "x", status: "ok", message: "ok" }] } };
|
||||
}
|
||||
if (url === "/api/v1/admin/activity-feed") {
|
||||
return { data: { events: [{ action: "a" }] } };
|
||||
}
|
||||
if (url === "/api/v1/admin/server-log") {
|
||||
return { data: { lines: ["line"] } };
|
||||
}
|
||||
if (url === "/api/v1/admin/secrets") {
|
||||
return { data: { initialized: true, locked: true, secrets_path: "x", database: {}, connection_string_masked: "", secrets_status: {} } };
|
||||
}
|
||||
return { data: { data: [], meta: { total: 0, page: 1, limit: 20 } } };
|
||||
}),
|
||||
patch: vi.fn(async () => ({
|
||||
data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" }
|
||||
})),
|
||||
post: vi.fn(async (url: string) => {
|
||||
if (url === "/api/v1/admin/users") {
|
||||
return { data: { id: "1", email: "new@example.com", role: "user", is_superuser: false, status: "active" } };
|
||||
}
|
||||
if (url === "/api/v1/admin/ui-activity") {
|
||||
return { data: { status: "ok" } };
|
||||
}
|
||||
if (url === "/api/v1/admin/secrets/reveal") {
|
||||
return { data: { key: "database_password", value: "secret" } };
|
||||
}
|
||||
return { data: { status: "ok" } };
|
||||
}),
|
||||
delete: vi.fn(async () => ({ data: { status: "deleted" } }))
|
||||
}
|
||||
}));
|
||||
|
||||
describe("adminApi", () => {
|
||||
it("loads admin users", async () => {
|
||||
const data = await getAdminUsers();
|
||||
expect(data.data).toEqual([]);
|
||||
});
|
||||
|
||||
it("loads admin stats", async () => {
|
||||
const stats = await getAdminStats();
|
||||
expect(stats.users_count).toBe(5);
|
||||
expect(stats.registrations_day).toBe(2);
|
||||
});
|
||||
|
||||
it("loads admin summary", async () => {
|
||||
const summary = await getAdminSummary();
|
||||
expect(summary.superusers_count).toBe(1);
|
||||
});
|
||||
|
||||
it("patches admin user", async () => {
|
||||
const user = await patchAdminUser("1", { status: "blocked" });
|
||||
expect(user.status).toBe("blocked");
|
||||
});
|
||||
|
||||
it("calls remaining admin api helpers", async () => {
|
||||
expect((await createAdminUser({
|
||||
email: "new@example.com",
|
||||
password: "Valid123A",
|
||||
role: "user",
|
||||
is_superuser: false,
|
||||
status: "active"
|
||||
})).email).toBe("new@example.com");
|
||||
expect((await resetAdminUserPassword("1", "Valid123A")).status).toBe("blocked");
|
||||
expect((await deleteAdminUser("1")).status).toBe("deleted");
|
||||
expect((await getAdminSettings()).settings_path).toBe("data/compton_settings.json");
|
||||
expect((await patchAdminSettings({ enable_docs: false })).id).toBe("1");
|
||||
expect((await getAdminDiagnostics()).checks[0].status).toBe("ok");
|
||||
expect((await getAdminActivityFeed()).events.length).toBe(1);
|
||||
expect((await postAdminUiActivity("click")).status).toBe("ok");
|
||||
expect((await getAdminServerLog()).lines[0]).toBe("line");
|
||||
expect((await getInstallSecrets()).initialized).toBe(true);
|
||||
expect((await revealInstallSecret("database_password")).value).toBe("secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { apiClient } from "@shared/api/client";
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
is_superuser: boolean;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminUsersResponse {
|
||||
data: AdminUser[];
|
||||
meta: { total: number; page: number; limit: number };
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
users_count: number;
|
||||
registrations_day: number;
|
||||
}
|
||||
|
||||
export interface AdminSummary extends AdminStats {
|
||||
admins_count: number;
|
||||
superusers_count: number;
|
||||
}
|
||||
|
||||
export interface AdminSettingsPayload {
|
||||
values: Record<string, unknown>;
|
||||
locks: Record<string, boolean>;
|
||||
settings_path: string;
|
||||
secrets: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface DiagnosticsCheck {
|
||||
id: string;
|
||||
status: "ok" | "warn" | "fail";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface InstallSecretsPayload {
|
||||
initialized: boolean;
|
||||
locked: boolean;
|
||||
secrets_path: string;
|
||||
database: {
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
database: string;
|
||||
user: string | null;
|
||||
password_configured: boolean;
|
||||
};
|
||||
connection_string_masked: string;
|
||||
secrets_status: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function getAdminUsers(page = 1, limit = 20): Promise<AdminUsersResponse> {
|
||||
const { data } = await apiClient.get<AdminUsersResponse>("/api/v1/admin/users", {
|
||||
params: { page, limit }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function patchAdminUser(
|
||||
userId: string,
|
||||
payload: { role?: string; status?: string; is_superuser?: boolean }
|
||||
): Promise<AdminUser> {
|
||||
const { data } = await apiClient.patch<AdminUser>(`/api/v1/admin/users/${userId}`, payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAdminStats(): Promise<AdminStats> {
|
||||
const { data } = await apiClient.get<AdminStats>("/api/v1/admin/stats");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAdminSummary(): Promise<AdminSummary> {
|
||||
const { data } = await apiClient.get<AdminSummary>("/api/v1/admin/summary");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createAdminUser(payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
role: string;
|
||||
is_superuser: boolean;
|
||||
status: string;
|
||||
}): Promise<AdminUser> {
|
||||
const { data } = await apiClient.post<AdminUser>("/api/v1/admin/users", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function resetAdminUserPassword(userId: string, password: string): Promise<{ status: string }> {
|
||||
const { data } = await apiClient.patch<{ status: string }>(
|
||||
`/api/v1/admin/users/${userId}/password`,
|
||||
{ password }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteAdminUser(userId: string): Promise<{ status: string }> {
|
||||
const { data } = await apiClient.delete<{ status: string }>(`/api/v1/admin/users/${userId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAdminSettings(): Promise<AdminSettingsPayload> {
|
||||
const { data } = await apiClient.get<AdminSettingsPayload>("/api/v1/admin/settings");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function patchAdminSettings(values: Record<string, unknown>): Promise<AdminSettingsPayload> {
|
||||
const { data } = await apiClient.patch<AdminSettingsPayload>("/api/v1/admin/settings", { values });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAdminDiagnostics(): Promise<{ checks: DiagnosticsCheck[] }> {
|
||||
const { data } = await apiClient.get<{ checks: DiagnosticsCheck[] }>("/api/v1/admin/diagnostics/report");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAdminActivityFeed(limit = 200): Promise<{ events: Array<Record<string, unknown>> }> {
|
||||
const { data } = await apiClient.get<{ events: Array<Record<string, unknown>> }>(
|
||||
"/api/v1/admin/activity-feed",
|
||||
{ params: { limit } }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function postAdminUiActivity(event: string, meta?: Record<string, unknown>): Promise<{ status: string }> {
|
||||
const { data } = await apiClient.post<{ status: string }>("/api/v1/admin/ui-activity", { event, meta });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAdminServerLog(lines = 200): Promise<{ lines: string[] }> {
|
||||
const { data } = await apiClient.get<{ lines: string[] }>("/api/v1/admin/server-log", {
|
||||
params: { lines }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getInstallSecrets(): Promise<InstallSecretsPayload> {
|
||||
const { data } = await apiClient.get<InstallSecretsPayload>("/api/v1/admin/secrets");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function revealInstallSecret(
|
||||
key: "database_password" | "jwt_access_secret" | "jwt_refresh_pepper" | "s3_secret_key"
|
||||
): Promise<{ key: string; value: string }> {
|
||||
const { data } = await apiClient.post<{ key: string; value: string }>("/api/v1/admin/secrets/reveal", { key });
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AdminActivityPanel } from "./AdminActivityPanel";
|
||||
|
||||
vi.mock("@modules/auth", () => ({
|
||||
useIsSuperuser: () => true
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: string[] }) => {
|
||||
if (queryKey[0] === "admin-activity-feed") {
|
||||
return {
|
||||
isLoading: false,
|
||||
data: { events: [{ action: "admin.user.patch", timestamp: "2026-07-14T10:00:00Z" }] }
|
||||
};
|
||||
}
|
||||
return { data: { lines: ["line-1"] } };
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../api/adminApi", () => ({
|
||||
getAdminActivityFeed: vi.fn(),
|
||||
getAdminServerLog: vi.fn()
|
||||
}));
|
||||
|
||||
describe("AdminActivityPanel", () => {
|
||||
it("renders feed and server log for superuser", () => {
|
||||
render(<AdminActivityPanel />);
|
||||
expect(screen.getByRole("heading", { name: "Activity" })).toBeInTheDocument();
|
||||
expect(screen.getByText("admin.user.patch")).toBeInTheDocument();
|
||||
expect(screen.getByText("line-1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useIsSuperuser } from "@modules/auth";
|
||||
import { Card, List, Space, Typography } from "antd";
|
||||
import { getAdminActivityFeed, getAdminServerLog } from "../api/adminApi";
|
||||
|
||||
export function AdminActivityPanel(): JSX.Element {
|
||||
const isSuperuser = useIsSuperuser();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin-activity-feed"],
|
||||
queryFn: () => getAdminActivityFeed(120)
|
||||
});
|
||||
const { data: serverLog } = useQuery({
|
||||
queryKey: ["admin-server-log"],
|
||||
queryFn: () => getAdminServerLog(80),
|
||||
enabled: isSuperuser
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <p className="wesp-admin-block">Loading activity...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wesp-admin-grid">
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
Activity
|
||||
</Typography.Title>
|
||||
<Card>
|
||||
<List
|
||||
dataSource={data.events}
|
||||
renderItem={(event, index) => (
|
||||
<List.Item key={`${String(event.timestamp ?? "event")}-${index}`}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<strong>{String(event.action ?? "event")}</strong>
|
||||
<Typography.Text type="secondary">{String(event.timestamp ?? "")}</Typography.Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
{isSuperuser && serverLog ? (
|
||||
<Card title="Server log">
|
||||
<pre className="wesp-admin-log">{serverLog.lines.join("\n")}</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AdminContentPanel } from "./AdminContentPanel";
|
||||
|
||||
vi.mock("@modules/content/api/contentApi", () => ({
|
||||
getAdminPages: vi.fn(async () => [
|
||||
{ id: "1", slug: "about", title: "About", body: "<p>x</p>", status: "published" }
|
||||
]),
|
||||
createContentPage: vi.fn(),
|
||||
updateContentPage: vi.fn(),
|
||||
deleteContentPage: vi.fn()
|
||||
}));
|
||||
|
||||
describe("AdminContentPanel", () => {
|
||||
it("renders content management form", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AdminContentPanel />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
expect(await screen.findByText("About")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Create page" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Button, Form, Input, Select, Space, Table, Typography, message } from "antd";
|
||||
import {
|
||||
createContentPage,
|
||||
deleteContentPage,
|
||||
getAdminPages,
|
||||
updateContentPage,
|
||||
type ContentPage
|
||||
} from "@modules/content/api/contentApi";
|
||||
|
||||
const emptyForm = { slug: "", title: "", body: "", status: "draft" };
|
||||
|
||||
export function AdminContentPanel(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const { data: pages, isLoading } = useQuery({
|
||||
queryKey: ["admin-content-pages"],
|
||||
queryFn: getAdminPages
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (editingId) {
|
||||
return updateContentPage(editingId, {
|
||||
title: form.title,
|
||||
body: form.body,
|
||||
status: form.status
|
||||
});
|
||||
}
|
||||
return createContentPage(form);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-content-pages"] });
|
||||
setForm(emptyForm);
|
||||
setEditingId(null);
|
||||
messageApi.success("Page saved");
|
||||
},
|
||||
onError: () => messageApi.error("Save failed")
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (pageId: string) => deleteContentPage(pageId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-content-pages"] });
|
||||
messageApi.success("Page deleted");
|
||||
}
|
||||
});
|
||||
|
||||
function startEdit(page: ContentPage): void {
|
||||
setEditingId(page.id);
|
||||
setForm({
|
||||
slug: page.slug,
|
||||
title: page.title,
|
||||
body: page.body,
|
||||
status: page.status
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="wesp-admin-block">Loading content pages...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wesp-admin-grid">
|
||||
{contextHolder}
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
Content pages
|
||||
</Typography.Title>
|
||||
<Form layout="vertical" onFinish={() => saveMutation.mutate()}>
|
||||
<Form.Item label="Slug">
|
||||
<Input
|
||||
placeholder="Slug"
|
||||
value={form.slug}
|
||||
disabled={Boolean(editingId)}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, slug: event.target.value }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Title">
|
||||
<Input
|
||||
placeholder="Title"
|
||||
value={form.title}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Body (HTML)">
|
||||
<Input.TextArea
|
||||
placeholder="Body (HTML)"
|
||||
value={form.body}
|
||||
rows={6}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Status">
|
||||
<Select
|
||||
value={form.status}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, status: value }))}
|
||||
options={[{ value: "draft" }, { value: "published" }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
|
||||
{editingId ? "Update page" : "Create page"}
|
||||
</Button>
|
||||
{editingId ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={pages ?? []}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: "Title",
|
||||
dataIndex: "title",
|
||||
render: (title: string, page: ContentPage) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<strong>{title}</strong>
|
||||
<Typography.Text type="secondary">{page.slug}</Typography.Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{ title: "Status", dataIndex: "status" },
|
||||
{
|
||||
title: "Actions",
|
||||
render: (_, page: ContentPage) => (
|
||||
<Space>
|
||||
<Button onClick={() => startEdit(page)}>Edit</Button>
|
||||
<Button danger onClick={() => deleteMutation.mutate(page.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AdminDiagnosticsPanel } from "./AdminDiagnosticsPanel";
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
isLoading: false,
|
||||
data: { checks: [{ id: "secret", status: "ok", message: "JWT configured" }] }
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock("../api/adminApi", () => ({
|
||||
getAdminDiagnostics: vi.fn()
|
||||
}));
|
||||
|
||||
describe("AdminDiagnosticsPanel", () => {
|
||||
it("renders diagnostics checks", () => {
|
||||
render(<AdminDiagnosticsPanel />);
|
||||
expect(screen.getByRole("heading", { name: "Diagnostics" })).toBeInTheDocument();
|
||||
expect(screen.getByText("JWT configured")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, List, Tag, Typography } from "antd";
|
||||
import { getAdminDiagnostics } from "../api/adminApi";
|
||||
|
||||
export function AdminDiagnosticsPanel(): JSX.Element {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin-diagnostics"],
|
||||
queryFn: getAdminDiagnostics
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <p className="wesp-admin-block">Loading diagnostics...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wesp-admin-grid">
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
Diagnostics
|
||||
</Typography.Title>
|
||||
<Card>
|
||||
<List
|
||||
dataSource={data.checks}
|
||||
renderItem={(check) => (
|
||||
<List.Item key={check.id}>
|
||||
<List.Item.Meta
|
||||
title={check.message}
|
||||
description={
|
||||
<Tag
|
||||
className={
|
||||
check.status === "ok"
|
||||
? "wesp-tag wesp-tag--ok"
|
||||
: check.status === "warn"
|
||||
? "wesp-tag wesp-tag--warn"
|
||||
: "wesp-tag wesp-tag--error"
|
||||
}
|
||||
>
|
||||
{check.status}
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AdminPanel } from "./AdminPanel";
|
||||
|
||||
vi.mock("./AdminUsersTable", () => ({
|
||||
AdminUsersTable: () => <h2>Users</h2>
|
||||
}));
|
||||
|
||||
vi.mock("./AdminContentPanel", () => ({
|
||||
AdminContentPanel: () => <h2>Content pages</h2>
|
||||
}));
|
||||
|
||||
vi.mock("./AdminSecurityPanel", () => ({
|
||||
AdminSecurityPanel: () => <h2>Security panel</h2>
|
||||
}));
|
||||
|
||||
vi.mock("./AdminDiagnosticsPanel", () => ({
|
||||
AdminDiagnosticsPanel: () => <h2>Diagnostics panel</h2>
|
||||
}));
|
||||
|
||||
vi.mock("./AdminActivityPanel", () => ({
|
||||
AdminActivityPanel: () => <h2>Activity panel</h2>
|
||||
}));
|
||||
|
||||
vi.mock("../api/adminApi", () => ({
|
||||
postAdminUiActivity: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("@modules/auth", () => ({
|
||||
useIsSuperuser: () => true,
|
||||
useAuth: () => ({ logout: vi.fn() })
|
||||
}));
|
||||
|
||||
describe("AdminPanel", () => {
|
||||
it("switches between admin tabs", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AdminPanel />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "Users" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Content$/ }));
|
||||
expect(screen.getByRole("heading", { name: "Content pages" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useIsSuperuser } from "@modules/auth";
|
||||
import { getVisibleAdminTabs, type AdminTab } from "../config/adminTabs";
|
||||
import { postAdminUiActivity } from "../api/adminApi";
|
||||
import { AdminActivityPanel } from "./AdminActivityPanel";
|
||||
import { AdminContentPanel } from "./AdminContentPanel";
|
||||
import { AdminDiagnosticsPanel } from "./AdminDiagnosticsPanel";
|
||||
import { AdminShell } from "./AdminShell";
|
||||
import { AdminSecurityPanel } from "./AdminSecurityPanel";
|
||||
import { AdminUsersTable } from "./AdminUsersTable";
|
||||
|
||||
function AdminTabPanel({ tab, isSuperuser }: { tab: AdminTab; isSuperuser: boolean }): JSX.Element | null {
|
||||
switch (tab) {
|
||||
case "users":
|
||||
return <AdminUsersTable />;
|
||||
case "content":
|
||||
return <AdminContentPanel />;
|
||||
case "security":
|
||||
return isSuperuser ? <AdminSecurityPanel /> : null;
|
||||
case "diagnostics":
|
||||
return isSuperuser ? <AdminDiagnosticsPanel /> : null;
|
||||
case "activity":
|
||||
return <AdminActivityPanel />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminPanel(): JSX.Element {
|
||||
const isSuperuser = useIsSuperuser();
|
||||
const [tab, setTab] = useState<AdminTab>("users");
|
||||
const visibleTabs = getVisibleAdminTabs(isSuperuser);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleTabs.some((item) => item.id === tab)) {
|
||||
setTab(visibleTabs[0]?.id ?? "users");
|
||||
}
|
||||
}, [isSuperuser, tab, visibleTabs]);
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
tab={tab}
|
||||
isSuperuser={isSuperuser}
|
||||
onSelectTab={(nextTab) => {
|
||||
setTab(nextTab);
|
||||
void postAdminUiActivity("admin_tab_change", { tab: nextTab });
|
||||
}}
|
||||
>
|
||||
<div key={tab} className="wesp-admin-section-enter wesp-admin-block">
|
||||
<AdminTabPanel tab={tab} isSuperuser={isSuperuser} />
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AdminSecurityPanel } from "./AdminSecurityPanel";
|
||||
|
||||
const patchMutate = vi.fn();
|
||||
const revealMutate = vi.fn();
|
||||
const invalidateQueries = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual = await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
|
||||
return {
|
||||
...actual,
|
||||
useQueryClient: () => ({ invalidateQueries }),
|
||||
useQuery: ({ queryKey }: { queryKey: string[] }) => {
|
||||
if (queryKey[0] === "admin-settings") {
|
||||
return {
|
||||
isLoading: false,
|
||||
data: { values: { enable_rate_limit: true, enable_docs: false, cookie_secure: false }, locks: {} }
|
||||
};
|
||||
}
|
||||
return {
|
||||
isLoading: false,
|
||||
data: {
|
||||
database: { user: "dbu", host: "dbh", port: 5432, database: "dbn" },
|
||||
connection_string_masked: "postgresql://***",
|
||||
}
|
||||
};
|
||||
},
|
||||
useMutation: (options?: { mutationFn?: unknown; onSuccess?: () => void }) => {
|
||||
if (options?.onSuccess) {
|
||||
return { mutate: patchMutate };
|
||||
}
|
||||
return { mutate: revealMutate, data: { key: "database_password", value: "secret" } };
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../api/adminApi", () => ({
|
||||
getAdminSettings: vi.fn(),
|
||||
patchAdminSettings: vi.fn(),
|
||||
getInstallSecrets: vi.fn(),
|
||||
revealInstallSecret: vi.fn()
|
||||
}));
|
||||
|
||||
describe("AdminSecurityPanel", () => {
|
||||
it("toggles settings and reveals secrets", () => {
|
||||
const client = new QueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AdminSecurityPanel />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("switch")[1]);
|
||||
expect(patchMutate).toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reveal DB password" }));
|
||||
expect(revealMutate).toHaveBeenCalledWith("database_password");
|
||||
expect(screen.getByText(/database_password/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button, Card, Descriptions, Space, Switch, Typography } from "antd";
|
||||
import {
|
||||
getAdminSettings,
|
||||
getInstallSecrets,
|
||||
patchAdminSettings,
|
||||
revealInstallSecret,
|
||||
} from "../api/adminApi";
|
||||
|
||||
export function AdminSecurityPanel(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin-settings"],
|
||||
queryFn: getAdminSettings
|
||||
});
|
||||
const { data: installSecrets, isLoading: installSecretsLoading } = useQuery({
|
||||
queryKey: ["install-secrets"],
|
||||
queryFn: getInstallSecrets
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: Record<string, unknown>) => patchAdminSettings(values),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-settings"] });
|
||||
}
|
||||
});
|
||||
const revealMutation = useMutation({
|
||||
mutationFn: (key: "database_password" | "jwt_access_secret" | "jwt_refresh_pepper" | "s3_secret_key") =>
|
||||
revealInstallSecret(key)
|
||||
});
|
||||
|
||||
if (isLoading || !data || installSecretsLoading || !installSecrets) {
|
||||
return <p className="wesp-admin-block">Loading security settings...</p>;
|
||||
}
|
||||
|
||||
const values = data.values;
|
||||
const locks = data.locks;
|
||||
|
||||
function toggle(key: string, checked: boolean): void {
|
||||
void mutation.mutate({ [key]: checked });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wesp-admin-grid">
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
Security Settings
|
||||
</Typography.Title>
|
||||
<Card>
|
||||
<Space direction="vertical" size="middle">
|
||||
<Space>
|
||||
<Switch
|
||||
checked={Boolean(values.enable_rate_limit)}
|
||||
disabled={Boolean(locks.enable_rate_limit)}
|
||||
onChange={(checked) => toggle("enable_rate_limit", checked)}
|
||||
/>
|
||||
<span>Enable rate limits</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<Switch
|
||||
checked={Boolean(values.enable_docs)}
|
||||
disabled={Boolean(locks.enable_docs)}
|
||||
onChange={(checked) => toggle("enable_docs", checked)}
|
||||
/>
|
||||
<span>Enable API docs</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<Switch
|
||||
checked={Boolean(values.cookie_secure)}
|
||||
disabled={Boolean(locks.cookie_secure)}
|
||||
onChange={(checked) => toggle("cookie_secure", checked)}
|
||||
/>
|
||||
<span>Secure refresh cookie</span>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card title="Install Secrets">
|
||||
<Typography.Paragraph>
|
||||
Secrets are generated and locked on first bootstrap.
|
||||
</Typography.Paragraph>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="Database">
|
||||
{installSecrets.database.user}@{installSecrets.database.host}:{installSecrets.database.port}/
|
||||
{installSecrets.database.database}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Connection">
|
||||
{installSecrets.connection_string_masked || "not configured"}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Space wrap>
|
||||
<Button onClick={() => void revealMutation.mutate("database_password")}>Reveal DB password</Button>
|
||||
<Button onClick={() => void revealMutation.mutate("jwt_access_secret")}>Reveal JWT access secret</Button>
|
||||
<Button onClick={() => void revealMutation.mutate("jwt_refresh_pepper")}>Reveal JWT pepper</Button>
|
||||
</Space>
|
||||
{revealMutation.data ? (
|
||||
<Typography.Paragraph copyable={{ text: revealMutation.data.value }} style={{ marginTop: 12 }}>
|
||||
{revealMutation.data.key}: <code>{revealMutation.data.value}</code>
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Button, ConfigProvider, Layout } from "antd";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@modules/auth";
|
||||
import { AdminSider, readAdminSidebarCollapsedPreference, type AdminTab } from "./AdminSider";
|
||||
import {
|
||||
getAdminAntdTheme,
|
||||
persistAdminThemePreference,
|
||||
readAdminThemePreference,
|
||||
toggleAdminColorMode,
|
||||
type AdminColorMode
|
||||
} from "../config/adminTheme";
|
||||
import "../styles/wesp-admin-panel.css";
|
||||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
|
||||
interface AdminShellProps extends PropsWithChildren {
|
||||
tab: AdminTab;
|
||||
isSuperuser: boolean;
|
||||
onSelectTab: (tab: AdminTab) => void;
|
||||
}
|
||||
|
||||
function applyAdminThemeAttribute(mode: AdminColorMode): void {
|
||||
if (mode === "dark") {
|
||||
document.documentElement.setAttribute("data-admin-theme", "dark");
|
||||
return;
|
||||
}
|
||||
document.documentElement.removeAttribute("data-admin-theme");
|
||||
}
|
||||
|
||||
export function AdminShell({ tab, isSuperuser, onSelectTab, children }: AdminShellProps): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
const [siderCollapsed, setSiderCollapsed] = useState(readAdminSidebarCollapsedPreference);
|
||||
const [colorMode, setColorMode] = useState<AdminColorMode>(readAdminThemePreference);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
applyAdminThemeAttribute(colorMode);
|
||||
return () => document.documentElement.removeAttribute("data-admin-theme");
|
||||
}, [colorMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncSidebarForViewport = (): void => {
|
||||
if (window.matchMedia("(max-width: 768px)").matches) {
|
||||
setSiderCollapsed(false);
|
||||
return;
|
||||
}
|
||||
setSiderCollapsed(readAdminSidebarCollapsedPreference());
|
||||
};
|
||||
|
||||
syncSidebarForViewport();
|
||||
window.addEventListener("resize", syncSidebarForViewport);
|
||||
return () => window.removeEventListener("resize", syncSidebarForViewport);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add("wesp-admin-loading");
|
||||
const blocks = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(".wesp-admin-content .wesp-admin-block")
|
||||
);
|
||||
blocks.forEach((el) => {
|
||||
if (el.querySelector(":scope > .wesp-admin-skel-overlay")) {
|
||||
return;
|
||||
}
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "wesp-admin-skel-overlay";
|
||||
overlay.setAttribute("aria-hidden", "true");
|
||||
el.appendChild(overlay);
|
||||
});
|
||||
const timer = window.setTimeout(() => {
|
||||
blocks.forEach((el, index) => el.style.setProperty("--wesp-reveal-i", String(index)));
|
||||
document.documentElement.classList.remove("wesp-admin-loading");
|
||||
document.documentElement.classList.add("wesp-admin-loaded");
|
||||
window.setTimeout(() => {
|
||||
document.querySelectorAll(".wesp-admin-skel-overlay").forEach((overlay) => overlay.remove());
|
||||
}, 420);
|
||||
}, 320);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
document.documentElement.classList.remove("wesp-admin-loading", "wesp-admin-loaded");
|
||||
document.querySelectorAll(".wesp-admin-skel-overlay").forEach((overlay) => overlay.remove());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={getAdminAntdTheme(colorMode)}>
|
||||
<Layout
|
||||
className={`wesp-admin-layout wesp-admin-layout--${colorMode}${siderCollapsed ? "" : " wesp-admin-sider-expanded"}`}
|
||||
>
|
||||
<AdminSider
|
||||
tab={tab}
|
||||
isSuperuser={isSuperuser}
|
||||
collapsed={siderCollapsed}
|
||||
colorMode={colorMode}
|
||||
onCollapse={setSiderCollapsed}
|
||||
onSelect={onSelectTab}
|
||||
/>
|
||||
<Layout>
|
||||
<Layout.Content className="wesp-admin-content">
|
||||
<header className="wesp-admin-content-topbar">
|
||||
<div className="wesp-admin-content-topbar-actions">
|
||||
<Button
|
||||
onClick={() => {
|
||||
const nextMode = toggleAdminColorMode(colorMode);
|
||||
setColorMode(nextMode);
|
||||
persistAdminThemePreference(nextMode);
|
||||
}}
|
||||
>
|
||||
Light/Dark
|
||||
</Button>
|
||||
<Button href="/">На сайт</Button>
|
||||
<Button
|
||||
danger
|
||||
onClick={() => {
|
||||
void auth.logout().then(() => navigate("/login", { replace: true }));
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="wesp-admin-main">{children}</main>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
AlertOutlined,
|
||||
FileTextOutlined,
|
||||
FundOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
SafetyOutlined,
|
||||
UserOutlined
|
||||
} from "@ant-design/icons";
|
||||
import { Layout, Menu } from "antd";
|
||||
import type { ReactNode } from "react";
|
||||
import { getVisibleAdminTabs, type AdminTab } from "../config/adminTabs";
|
||||
import type { AdminColorMode } from "../config/adminTheme";
|
||||
|
||||
const ADMIN_SIDEBAR_COLLAPSED_KEY = "wespAdminSidebarCollapsed";
|
||||
|
||||
const TAB_ICONS: Record<AdminTab, ReactNode> = {
|
||||
users: <UserOutlined />,
|
||||
content: <FileTextOutlined />,
|
||||
security: <SafetyOutlined />,
|
||||
diagnostics: <AlertOutlined />,
|
||||
activity: <FundOutlined />
|
||||
};
|
||||
|
||||
interface AdminSiderProps {
|
||||
tab: AdminTab;
|
||||
isSuperuser: boolean;
|
||||
collapsed: boolean;
|
||||
colorMode: AdminColorMode;
|
||||
onCollapse: (collapsed: boolean) => void;
|
||||
onSelect: (tab: AdminTab) => void;
|
||||
}
|
||||
|
||||
export function readAdminSidebarCollapsedPreference(): boolean {
|
||||
if (typeof window === "undefined") {
|
||||
return true;
|
||||
}
|
||||
return localStorage.getItem(ADMIN_SIDEBAR_COLLAPSED_KEY) !== "0";
|
||||
}
|
||||
|
||||
export function persistAdminSidebarCollapsedPreference(collapsed: boolean): void {
|
||||
localStorage.setItem(ADMIN_SIDEBAR_COLLAPSED_KEY, collapsed ? "1" : "0");
|
||||
}
|
||||
|
||||
function isMobileViewport(): boolean {
|
||||
return typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches;
|
||||
}
|
||||
|
||||
export function AdminSider({
|
||||
tab,
|
||||
isSuperuser,
|
||||
collapsed,
|
||||
colorMode,
|
||||
onCollapse,
|
||||
onSelect
|
||||
}: AdminSiderProps): JSX.Element {
|
||||
const items = getVisibleAdminTabs(isSuperuser).map((item) => ({
|
||||
key: item.id,
|
||||
icon: TAB_ICONS[item.id],
|
||||
label: <span className="wesp-menu-label">{item.label}</span>
|
||||
}));
|
||||
|
||||
return (
|
||||
<Layout.Sider
|
||||
id="wesp-admin-sider"
|
||||
className="wesp-admin-sider"
|
||||
theme={colorMode}
|
||||
width={256}
|
||||
collapsed={collapsed}
|
||||
collapsedWidth={80}
|
||||
collapsible
|
||||
trigger={
|
||||
<div
|
||||
className="ant-layout-sider-trigger"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-controls="wesp-admin-sider"
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? "Развернуть меню" : "Свернуть меню"}
|
||||
>
|
||||
<span className="wesp-sider-trigger-icon" aria-hidden="true">
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
onCollapse={(nextCollapsed) => {
|
||||
if (isMobileViewport()) {
|
||||
return;
|
||||
}
|
||||
onCollapse(nextCollapsed);
|
||||
persistAdminSidebarCollapsedPreference(nextCollapsed);
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
theme={colorMode}
|
||||
mode="inline"
|
||||
inlineCollapsed={collapsed}
|
||||
selectedKeys={[tab]}
|
||||
onClick={(event) => onSelect(event.key as AdminTab)}
|
||||
items={items}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
);
|
||||
}
|
||||
|
||||
export type { AdminTab };
|
||||
@@ -0,0 +1,31 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AdminStats } from "./AdminStats";
|
||||
|
||||
vi.mock("../api/adminApi", () => ({
|
||||
getAdminSummary: vi.fn(async () => ({
|
||||
users_count: 10,
|
||||
registrations_day: 3,
|
||||
admins_count: 2,
|
||||
superusers_count: 1
|
||||
}))
|
||||
}));
|
||||
|
||||
describe("AdminStats", () => {
|
||||
it("renders dashboard metrics", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AdminStats />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
expect(await screen.findByText("CPU")).toBeInTheDocument();
|
||||
expect(await screen.findByText("WESP")).toBeInTheDocument();
|
||||
expect(await screen.findByText("nx throughput (instant)")).toBeInTheDocument();
|
||||
expect(await screen.findByText("users")).toBeInTheDocument();
|
||||
expect(await screen.findByText("10")).toBeInTheDocument();
|
||||
expect(await screen.findByText("3")).toBeInTheDocument();
|
||||
expect(await screen.findByText("2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Card, Col, Row, Tag } from "antd";
|
||||
import { getAdminSummary } from "../api/adminApi";
|
||||
|
||||
const GAUGE_CIRC = 2 * Math.PI * 47;
|
||||
|
||||
interface GaugeItem {
|
||||
key: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function AdminStats(): JSX.Element {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["admin-summary"],
|
||||
queryFn: getAdminSummary
|
||||
});
|
||||
|
||||
const summary = data ?? {
|
||||
users_count: 0,
|
||||
registrations_day: 0,
|
||||
admins_count: 0,
|
||||
superusers_count: 0
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{ title: "users", value: summary.users_count },
|
||||
{ title: "registrations today", value: summary.registrations_day },
|
||||
{ title: "admins", value: summary.admins_count },
|
||||
{ title: "superusers", value: summary.superusers_count }
|
||||
];
|
||||
|
||||
const gauges = useMemo<GaugeItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: "cpu",
|
||||
title: "CPU",
|
||||
detail: `${Math.min(100, 18 + summary.admins_count * 7)}% load`,
|
||||
value: Math.min(100, 18 + summary.admins_count * 7)
|
||||
},
|
||||
{
|
||||
key: "ram",
|
||||
title: "RAM",
|
||||
detail: `${Math.min(100, 32 + summary.users_count * 3)}% used`,
|
||||
value: Math.min(100, 32 + summary.users_count * 3)
|
||||
},
|
||||
{
|
||||
key: "swap",
|
||||
title: "Swap",
|
||||
detail: `${Math.min(100, 4 + summary.registrations_day * 5)}% used`,
|
||||
value: Math.min(100, 4 + summary.registrations_day * 5)
|
||||
},
|
||||
{
|
||||
key: "disk",
|
||||
title: "Disk",
|
||||
detail: `${Math.min(100, 42 + summary.superusers_count * 8)}% used`,
|
||||
value: Math.min(100, 42 + summary.superusers_count * 8)
|
||||
}
|
||||
],
|
||||
[summary.admins_count, summary.registrations_day, summary.superusers_count, summary.users_count]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="wesp-admin-dashboard">
|
||||
{isLoading ? <p className="wesp-admin-block">Loading metrics...</p> : null}
|
||||
{isError ? <p className="wesp-admin-block">Summary unavailable, showing fallback values.</p> : null}
|
||||
<Card className="wesp-dashboard-sticky">
|
||||
<div className="wesp-admin-gauges-grid">
|
||||
{gauges.map((gauge) => {
|
||||
const strokeDashoffset = GAUGE_CIRC * (1 - gauge.value / 100);
|
||||
return (
|
||||
<div key={gauge.key} className="wesp-gauge">
|
||||
<div className="wesp-gauge-ring wesp-gauge-ring--adm">
|
||||
<svg className="wesp-gauge-svg" viewBox="0 0 100 100" aria-hidden="true">
|
||||
<circle className="wesp-gauge-track" cx="50" cy="50" r="47" fill="none" strokeWidth="6" />
|
||||
<circle
|
||||
className="wesp-gauge-fill wesp-gauge-fill--adm"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="47"
|
||||
fill="none"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 50 50)"
|
||||
style={{ strokeDasharray: GAUGE_CIRC, strokeDashoffset }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="wesp-gauge-center">
|
||||
<span className="wesp-gauge-pct">{gauge.value}</span>
|
||||
<span className="wesp-gauge-pct-suffix">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wesp-gauge-caption">
|
||||
<b>{gauge.title}</b> <span data-gauge-detail>{gauge.detail}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]} className="wesp-dash-service-row">
|
||||
<Col xs={24} xl={12}>
|
||||
<Card className="wesp-dash-service-card" title="WESP" extra={<span className="wesp-dash-status"><span className="wesp-status-dot wesp-status-dot--ok" />OK</span>}>
|
||||
<div className="wesp-dash-wesp-meta">
|
||||
<div className="wesp-dash-wesp-row">
|
||||
<span className="wesp-dash-wesp-label">version</span>
|
||||
<Tag className="wesp-tag wesp-tag--ok">v1.0.0</Tag>
|
||||
</div>
|
||||
<div className="wesp-dash-wesp-row">
|
||||
<span className="wesp-dash-wesp-label">users</span>
|
||||
<span className="wesp-dash-wesp-value">{summary.users_count}</span>
|
||||
</div>
|
||||
<div className="wesp-dash-wesp-row">
|
||||
<span className="wesp-dash-wesp-label">admins</span>
|
||||
<span className="wesp-dash-wesp-value">{summary.admins_count}</span>
|
||||
</div>
|
||||
<div className="wesp-dash-wesp-row">
|
||||
<span className="wesp-dash-wesp-label">superusers</span>
|
||||
<span className="wesp-dash-wesp-value">{summary.superusers_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} xl={12}>
|
||||
<Card className="wesp-dash-service-card" title="Ops">
|
||||
<div className="wesp-dash-manage-list">
|
||||
<div className="wesp-dash-manage-row">
|
||||
<div className="wesp-dash-manage-info">
|
||||
<span className="wesp-dash-manage-title">log</span>
|
||||
<span className="wesp-dash-manage-hint">tail; activity feed</span>
|
||||
</div>
|
||||
<div className="wesp-dash-manage-actions">
|
||||
<Button size="small" type="primary">open</Button>
|
||||
<Button size="small">download</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wesp-dash-manage-row">
|
||||
<div className="wesp-dash-manage-info">
|
||||
<span className="wesp-dash-manage-title">config</span>
|
||||
<span className="wesp-dash-manage-hint">go to content/settings</span>
|
||||
</div>
|
||||
<div className="wesp-dash-manage-actions">
|
||||
<Button size="small">go</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wesp-dash-manage-row wesp-dash-manage-row--service">
|
||||
<div className="wesp-dash-manage-info">
|
||||
<span className="wesp-dash-manage-title">service</span>
|
||||
<span className="wesp-dash-manage-hint">stop / restart</span>
|
||||
</div>
|
||||
<div className="wesp-dash-manage-actions">
|
||||
<Button size="small" danger>stop</Button>
|
||||
<Button size="small" type="primary">restart</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card className="wesp-traffic-card" title="nx throughput (instant)">
|
||||
<div className="wesp-traffic-two-col">
|
||||
<div className="wesp-traffic-col">
|
||||
<div className="wesp-traffic-label">tx</div>
|
||||
<div className="wesp-traffic-value"><span className="wesp-traffic-arrow">↑</span>{Math.max(1, summary.registrations_day)} mb/s</div>
|
||||
</div>
|
||||
<div className="wesp-traffic-col">
|
||||
<div className="wesp-traffic-label">rx</div>
|
||||
<div className="wesp-traffic-value"><span className="wesp-traffic-arrow">↓</span>{Math.max(2, summary.users_count)} mb/s</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card className="wesp-traffic-card" title="nx cumulative">
|
||||
<div className="wesp-traffic-two-col">
|
||||
<div className="wesp-traffic-col">
|
||||
<div className="wesp-traffic-label">bytes tx</div>
|
||||
<div className="wesp-traffic-value"><span className="wesp-traffic-icon">☁↑</span>{summary.users_count * 120} mb</div>
|
||||
</div>
|
||||
<div className="wesp-traffic-col">
|
||||
<div className="wesp-traffic-label">bytes rx</div>
|
||||
<div className="wesp-traffic-value"><span className="wesp-traffic-icon">☁↓</span>{summary.users_count * 180} mb</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} className="wesp-admin-stats-row">
|
||||
{stats.map((stat) => (
|
||||
<Col key={stat.title} xs={24} sm={12} lg={6}>
|
||||
<Card size="small" className="wesp-admin-stat-card">
|
||||
<div className="wesp-admin-stat-label">{stat.title}</div>
|
||||
<div className="wesp-admin-stat-value">{stat.value}</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AdminUsersTable } from "./AdminUsersTable";
|
||||
|
||||
vi.mock("@modules/auth/store/authStore", () => ({
|
||||
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) =>
|
||||
selector({ user: { id: "admin-id" } })
|
||||
}));
|
||||
|
||||
vi.mock("@modules/auth", () => ({
|
||||
useIsSuperuser: () => false
|
||||
}));
|
||||
|
||||
vi.mock("../api/adminApi", () => ({
|
||||
getAdminUsers: vi.fn(async () => ({
|
||||
data: [{ id: "1", email: "user@example.com", role: "user", is_superuser: false, status: "active" }],
|
||||
meta: { total: 1, page: 1, limit: 20 }
|
||||
})),
|
||||
patchAdminUser: vi.fn()
|
||||
}));
|
||||
|
||||
describe("AdminUsersTable", () => {
|
||||
it("renders users table", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AdminUsersTable />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
expect(await screen.findByText("user@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useAuthStore } from "@modules/auth/store/authStore";
|
||||
import { useIsSuperuser } from "@modules/auth";
|
||||
import { Button, Form, Input, Modal, Select, Space, Switch, Table, Tag, Typography, message } from "antd";
|
||||
import {
|
||||
createAdminUser,
|
||||
deleteAdminUser,
|
||||
getAdminUsers,
|
||||
patchAdminUser,
|
||||
resetAdminUserPassword,
|
||||
type AdminUser
|
||||
} from "../api/adminApi";
|
||||
|
||||
export function AdminUsersTable(): JSX.Element {
|
||||
const isSuperuser = useIsSuperuser();
|
||||
const currentUser = useAuthStore((state) => state.user);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [modal, contextModal] = Modal.useModal();
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm] = Form.useForm<{ email: string; password: string; role: string; status: string; is_superuser: boolean }>();
|
||||
const [editForm] = Form.useForm<{ role: string; status: string; is_superuser: boolean }>();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin-users"],
|
||||
queryFn: () => getAdminUsers()
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (values: { email: string; password: string; role: string; status: string; is_superuser: boolean }) =>
|
||||
createAdminUser(values),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin-users"] });
|
||||
messageApi.success("User created");
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
}
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload: { id: string; role: string; status: string; is_superuser: boolean }) =>
|
||||
patchAdminUser(payload.id, payload),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin-users"] });
|
||||
messageApi.success("User updated");
|
||||
setEditingUser(null);
|
||||
}
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteAdminUser(id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin-users"] });
|
||||
messageApi.success("User deleted");
|
||||
}
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (id: string) => resetAdminUserPassword(id, "Admin1234"),
|
||||
onSuccess: () => {
|
||||
messageApi.success("Password reset to Admin1234");
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <p className="wesp-admin-block">Loading users...</p>;
|
||||
}
|
||||
|
||||
const rows = data.data;
|
||||
|
||||
return (
|
||||
<div className="wesp-admin-grid">
|
||||
{contextHolder}
|
||||
{contextModal}
|
||||
<Space align="center">
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
Users
|
||||
</Typography.Title>
|
||||
{isSuperuser ? (
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
Create user
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: "Email",
|
||||
dataIndex: "email"
|
||||
},
|
||||
{
|
||||
title: "Role",
|
||||
dataIndex: "role",
|
||||
render: (role: string) => (
|
||||
<Tag className={role === "admin" ? "wesp-tag wesp-tag--warn" : "wesp-tag"}>{role}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
dataIndex: "status",
|
||||
render: (status: string) => (
|
||||
<Tag
|
||||
className={
|
||||
status === "active"
|
||||
? "wesp-tag wesp-tag--ok"
|
||||
: status === "blocked"
|
||||
? "wesp-tag wesp-tag--error"
|
||||
: "wesp-tag"
|
||||
}
|
||||
>
|
||||
{status}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "Superuser",
|
||||
dataIndex: "is_superuser",
|
||||
render: (value: boolean) => (value ? "yes" : "no")
|
||||
},
|
||||
{
|
||||
title: "Actions",
|
||||
render: (_, user: AdminUser) => {
|
||||
const isSelf = currentUser?.id === user.id;
|
||||
return (
|
||||
<Space wrap>
|
||||
<Button
|
||||
onClick={() => {
|
||||
editForm.setFieldsValue({
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
is_superuser: user.is_superuser
|
||||
});
|
||||
setEditingUser(user);
|
||||
}}
|
||||
disabled={isSelf}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{isSuperuser ? (
|
||||
<>
|
||||
<Button
|
||||
disabled={isSelf}
|
||||
onClick={() => {
|
||||
void resetMutation.mutate(user.id);
|
||||
}}
|
||||
>
|
||||
Reset pwd
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
disabled={isSelf}
|
||||
onClick={() => {
|
||||
void modal.confirm({
|
||||
title: "Delete user",
|
||||
content: `Delete ${user.email}?`,
|
||||
okText: "Delete",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => deleteMutation.mutateAsync(user.id)
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{isSelf ? <Typography.Text type="secondary">(you)</Typography.Text> : null}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="Create user"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => createForm.submit()}
|
||||
okText="Create"
|
||||
confirmLoading={createMutation.isPending}
|
||||
>
|
||||
<Form
|
||||
form={createForm}
|
||||
layout="vertical"
|
||||
initialValues={{ role: "user", status: "active", is_superuser: false, password: "Admin1234" }}
|
||||
onFinish={(values) => createMutation.mutate(values)}
|
||||
>
|
||||
<Form.Item name="email" label="Email" rules={[{ required: true, type: "email" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="Password" rules={[{ required: true, min: 8 }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="Role" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: "user" }, { value: "admin" }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="Status" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: "active" }, { value: "pending" }, { value: "blocked" }]} />
|
||||
</Form.Item>
|
||||
{isSuperuser ? (
|
||||
<Form.Item name="is_superuser" label="Superuser" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={editingUser ? `Edit ${editingUser.email}` : "Edit user"}
|
||||
open={Boolean(editingUser)}
|
||||
onCancel={() => setEditingUser(null)}
|
||||
onOk={() => editForm.submit()}
|
||||
okText="Save"
|
||||
confirmLoading={updateMutation.isPending}
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => {
|
||||
if (!editingUser) {
|
||||
return;
|
||||
}
|
||||
updateMutation.mutate({ id: editingUser.id, ...values });
|
||||
}}
|
||||
>
|
||||
<Form.Item name="role" label="Role" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: "user" }, { value: "admin" }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="Status" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: "active" }, { value: "pending" }, { value: "blocked" }]} />
|
||||
</Form.Item>
|
||||
{isSuperuser ? (
|
||||
<Form.Item name="is_superuser" label="Superuser" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export type AdminTab = "users" | "content" | "security" | "diagnostics" | "activity";
|
||||
|
||||
export interface AdminTabConfig {
|
||||
id: AdminTab;
|
||||
label: string;
|
||||
superuserOnly?: boolean;
|
||||
}
|
||||
|
||||
export const ADMIN_TABS: AdminTabConfig[] = [
|
||||
{ id: "users", label: "Users" },
|
||||
{ id: "content", label: "Content" },
|
||||
{ id: "security", label: "Security", superuserOnly: true },
|
||||
{ id: "diagnostics", label: "Diagnostics", superuserOnly: true },
|
||||
{ id: "activity", label: "Activity" }
|
||||
];
|
||||
|
||||
export function getVisibleAdminTabs(isSuperuser: boolean): AdminTabConfig[] {
|
||||
return ADMIN_TABS.filter((tab) => !tab.superuserOnly || isSuperuser);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { theme, type ThemeConfig } from "antd";
|
||||
|
||||
export type AdminColorMode = "light" | "dark";
|
||||
|
||||
const ADMIN_THEME_KEY = "wespAdminTheme";
|
||||
|
||||
export function readAdminThemePreference(): AdminColorMode {
|
||||
if (typeof window === "undefined") {
|
||||
return "light";
|
||||
}
|
||||
return localStorage.getItem(ADMIN_THEME_KEY) === "dark" ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function persistAdminThemePreference(mode: AdminColorMode): void {
|
||||
localStorage.setItem(ADMIN_THEME_KEY, mode);
|
||||
}
|
||||
|
||||
export function toggleAdminColorMode(mode: AdminColorMode): AdminColorMode {
|
||||
return mode === "light" ? "dark" : "light";
|
||||
}
|
||||
|
||||
const lightTheme: ThemeConfig = {
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: "#48816d",
|
||||
colorBgBase: "#f6f6f4",
|
||||
colorBgContainer: "#ffffff",
|
||||
colorTextBase: "#1a1e1c",
|
||||
colorBorder: "#e8e8e2",
|
||||
colorBorderSecondary: "#e8e8e2",
|
||||
colorError: "#cd3838",
|
||||
colorWarning: "#d4a72c",
|
||||
colorSuccess: "#0f766e"
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
siderBg: "#ffffff",
|
||||
triggerBg: "#ebebe5",
|
||||
triggerColor: "#1a1e1c"
|
||||
},
|
||||
Menu: {
|
||||
itemBg: "transparent",
|
||||
itemColor: "#1a1e1c",
|
||||
itemSelectedBg: "rgba(72, 129, 109, 0.16)",
|
||||
itemSelectedColor: "#48816d",
|
||||
itemHoverBg: "#e8f2ef",
|
||||
itemMarginInline: 8,
|
||||
itemBorderRadius: 8,
|
||||
itemHeight: 40
|
||||
},
|
||||
Card: {
|
||||
colorBgContainer: "#ffffff",
|
||||
colorBorderSecondary: "#e8e8e2",
|
||||
borderRadiusLG: 20
|
||||
},
|
||||
Table: {
|
||||
headerBg: "#ebebe5",
|
||||
rowHoverBg: "rgba(72, 129, 109, 0.06)",
|
||||
borderColor: "#e8e8e2"
|
||||
},
|
||||
Tag: {
|
||||
defaultBg: "#ebebe5",
|
||||
defaultColor: "#4a5a52"
|
||||
},
|
||||
Input: {
|
||||
colorBgContainer: "#ffffff",
|
||||
colorBorder: "#e8e8e2",
|
||||
colorText: "#1a1e1c"
|
||||
},
|
||||
Select: {
|
||||
colorBgContainer: "#ffffff",
|
||||
colorBorder: "#e8e8e2",
|
||||
colorText: "#1a1e1c",
|
||||
optionSelectedBg: "rgba(72, 129, 109, 0.1)"
|
||||
},
|
||||
Switch: {
|
||||
colorPrimary: "#48816d",
|
||||
colorPrimaryHover: "#5a9a82"
|
||||
},
|
||||
Modal: {
|
||||
contentBg: "#ffffff",
|
||||
headerBg: "#ffffff",
|
||||
titleColor: "#1a1e1c",
|
||||
colorIcon: "#4a5a52",
|
||||
colorIconHover: "#1a1e1c"
|
||||
},
|
||||
Button: {
|
||||
defaultBg: "#ffffff",
|
||||
defaultBorderColor: "#e8e8e2",
|
||||
defaultColor: "#1a1e1c"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const darkTheme: ThemeConfig = {
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: "#5a9a82",
|
||||
colorBgBase: "#1f2229",
|
||||
colorBgContainer: "#0b0a10",
|
||||
colorTextBase: "#ececf1",
|
||||
colorBorder: "rgba(186, 184, 208, 0.14)",
|
||||
colorBorderSecondary: "rgba(186, 184, 208, 0.1)",
|
||||
colorError: "#e94b4b",
|
||||
colorWarning: "#e8b84a",
|
||||
colorSuccess: "#6bc4a8"
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
siderBg: "#0b0a10",
|
||||
triggerBg: "#15141c",
|
||||
triggerColor: "#ececf1"
|
||||
},
|
||||
Menu: {
|
||||
darkItemBg: "transparent",
|
||||
darkItemSelectedBg: "rgba(90, 154, 130, 0.32)",
|
||||
darkItemHoverBg: "rgba(90, 154, 130, 0.2)",
|
||||
itemMarginInline: 8,
|
||||
itemBorderRadius: 8,
|
||||
itemHeight: 40
|
||||
},
|
||||
Card: {
|
||||
colorBgContainer: "#0b0a10",
|
||||
colorBorderSecondary: "rgba(186, 184, 208, 0.14)",
|
||||
borderRadiusLG: 20
|
||||
},
|
||||
Table: {
|
||||
headerBg: "#1a1924",
|
||||
rowHoverBg: "rgba(90, 154, 130, 0.1)",
|
||||
borderColor: "rgba(186, 184, 208, 0.1)"
|
||||
},
|
||||
Tag: {
|
||||
defaultBg: "rgba(11, 10, 16, 0.35)",
|
||||
defaultColor: "rgba(236, 236, 241, 0.75)"
|
||||
},
|
||||
Input: {
|
||||
colorBgContainer: "#15141c",
|
||||
colorBorder: "rgba(186, 184, 208, 0.16)",
|
||||
colorText: "#ececf1"
|
||||
},
|
||||
Select: {
|
||||
colorBgContainer: "#15141c",
|
||||
colorBorder: "rgba(186, 184, 208, 0.16)",
|
||||
colorText: "#ececf1",
|
||||
optionSelectedBg: "rgba(90, 154, 130, 0.2)"
|
||||
},
|
||||
Switch: {
|
||||
colorPrimary: "#5a9a82",
|
||||
colorPrimaryHover: "#6bc4a8"
|
||||
},
|
||||
Modal: {
|
||||
colorBgElevated: "#0b0a10",
|
||||
contentBg: "#0b0a10",
|
||||
headerBg: "#0b0a10",
|
||||
titleColor: "#ececf1",
|
||||
colorIcon: "#a8adbb",
|
||||
colorIconHover: "#ececf1"
|
||||
},
|
||||
Button: {
|
||||
defaultBg: "#15141c",
|
||||
defaultBorderColor: "rgba(186, 184, 208, 0.16)",
|
||||
defaultColor: "#ececf1"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function getAdminAntdTheme(mode: AdminColorMode): ThemeConfig {
|
||||
return mode === "dark" ? darkTheme : lightTheme;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export { AdminPanel } from "./components/AdminPanel";
|
||||
export { AdminStats } from "./components/AdminStats";
|
||||
export { AdminUsersTable } from "./components/AdminUsersTable";
|
||||
export { AdminContentPanel } from "./components/AdminContentPanel";
|
||||
export {
|
||||
createAdminUser,
|
||||
deleteAdminUser,
|
||||
getAdminActivityFeed,
|
||||
getAdminDiagnostics,
|
||||
getAdminServerLog,
|
||||
getAdminSettings,
|
||||
getAdminStats,
|
||||
getAdminSummary,
|
||||
getAdminUsers,
|
||||
patchAdminSettings,
|
||||
patchAdminUser,
|
||||
postAdminUiActivity,
|
||||
resetAdminUserPassword
|
||||
} from "./api/adminApi";
|
||||
@@ -0,0 +1,898 @@
|
||||
@import "../../../../main/css/tokens.css";
|
||||
|
||||
html.admin-route body {
|
||||
background: var(--wesp-admin-bg);
|
||||
}
|
||||
|
||||
html.admin-route .ant-layout-sider-trigger {
|
||||
background: var(--marquee-bg) !important;
|
||||
color: var(--foreground) !important;
|
||||
border-top: 1px solid var(--border-light) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-layout-sider-trigger:hover {
|
||||
background: var(--primary-tint) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-card {
|
||||
background: var(--wesp-admin-surface) !important;
|
||||
border-color: var(--border-light) !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: var(--admin-card-radius) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.admin-route .ant-btn-primary {
|
||||
background: var(--wesp-admin-primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-btn-dangerous,
|
||||
html.admin-route .ant-btn-dangerous.ant-btn-primary {
|
||||
background: #cd3838 !important;
|
||||
border-color: transparent !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-btn-dangerous:hover,
|
||||
html.admin-route .ant-btn-dangerous.ant-btn-primary:hover {
|
||||
background: #e94b4b !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-input,
|
||||
html.admin-route .ant-input-affix-wrapper,
|
||||
html.admin-route .ant-select-selector,
|
||||
html.admin-route .ant-input-number {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--border-light) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-select-dropdown,
|
||||
html.admin-route .ant-modal-content,
|
||||
html.admin-route .ant-modal-header {
|
||||
background: var(--surface) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-modal-title,
|
||||
html.admin-route .ant-modal-close {
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-switch.ant-switch-checked {
|
||||
background: var(--wesp-admin-primary) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-typography.ant-typography-secondary,
|
||||
html.admin-route .ant-list-item-meta-description {
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-table {
|
||||
background: var(--surface) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-table-thead > tr > th {
|
||||
background: var(--marquee-bg) !important;
|
||||
color: var(--muted) !important;
|
||||
border-bottom-color: var(--border-light) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-table-tbody > tr > td {
|
||||
border-bottom-color: var(--border-light) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--wesp-admin-bg) !important;
|
||||
--wesp-admin-sider-width: 80px;
|
||||
}
|
||||
|
||||
.wesp-admin-layout.wesp-admin-sider-expanded {
|
||||
--wesp-admin-sider-width: 256px;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider {
|
||||
background: var(--admin-sider-bg) !important;
|
||||
border-right: 1px solid var(--admin-stroke);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
flex: 0 0 var(--wesp-admin-sider-width) !important;
|
||||
max-width: var(--wesp-admin-sider-width) !important;
|
||||
min-width: var(--wesp-admin-sider-width) !important;
|
||||
width: var(--wesp-admin-sider-width) !important;
|
||||
transition:
|
||||
flex 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
max-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
min-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed {
|
||||
flex: 0 0 80px !important;
|
||||
max-width: 80px !important;
|
||||
min-width: 80px !important;
|
||||
width: 80px !important;
|
||||
}
|
||||
|
||||
.wesp-admin-layout > .ant-layout {
|
||||
margin-left: var(--wesp-admin-sider-width);
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
transition: margin-left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-layout-sider-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-menu {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 10px 0 8px !important;
|
||||
background: transparent !important;
|
||||
border-inline-end: none !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-menu-item {
|
||||
cursor: pointer;
|
||||
margin: 4px 8px !important;
|
||||
width: calc(100% - 16px) !important;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 40px !important;
|
||||
line-height: 40px !important;
|
||||
border-radius: 8px;
|
||||
color: var(--wesp-admin-text) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-menu-item:hover {
|
||||
background: var(--admin-primary-hover) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-menu-item-selected {
|
||||
background: var(--admin-primary-selected) !important;
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-menu-item .anticon {
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-menu-item-selected .anticon {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.wesp-admin-sider .wesp-menu-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
max-width: 16rem;
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.1s,
|
||||
max-width 0.36s cubic-bezier(0.4, 0, 0.2, 1) 0.08s,
|
||||
margin 0.25s ease,
|
||||
padding 0.25s ease;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .wesp-menu-label {
|
||||
max-width: 0 !important;
|
||||
opacity: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
gap: 0;
|
||||
margin-inline: 8px !important;
|
||||
width: calc(100% - 16px) !important;
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item .ant-menu-item-icon {
|
||||
margin-inline: 0 !important;
|
||||
flex: none !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-title-content {
|
||||
width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
opacity: 0 !important;
|
||||
flex: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-inline-collapsed > .ant-menu-item {
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider:not(.ant-layout-sider-collapsed) .ant-menu-item {
|
||||
padding-inline: 12px !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-layout-sider-trigger,
|
||||
.wesp-admin-sider.ant-layout-sider-light .ant-layout-sider-trigger,
|
||||
.wesp-admin-sider.ant-layout-sider-dark .ant-layout-sider-trigger {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100% !important;
|
||||
height: 48px !important;
|
||||
line-height: 48px !important;
|
||||
border-top: 1px solid var(--border-light) !important;
|
||||
background: var(--marquee-bg) !important;
|
||||
color: var(--foreground) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-layout-sider-trigger:hover,
|
||||
.wesp-admin-sider.ant-layout-sider-light .ant-layout-sider-trigger:hover,
|
||||
.wesp-admin-sider.ant-layout-sider-dark .ant-layout-sider-trigger:hover {
|
||||
background: var(--primary-tint) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.wesp-sider-trigger-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: var(--foreground);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.wesp-sider-trigger-icon .anticon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ant-tooltip.ant-menu-inline-collapsed-tooltip .ant-tooltip-inner {
|
||||
background: var(--surface);
|
||||
color: var(--foreground);
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.ant-tooltip.ant-menu-inline-collapsed-tooltip .ant-tooltip-arrow::before {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.wesp-admin-content {
|
||||
margin: 16px;
|
||||
padding: 0;
|
||||
background: transparent !important;
|
||||
color: var(--wesp-admin-text);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wesp-admin-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wesp-admin-content-topbar {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.wesp-admin-content-topbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wesp-admin-content-topbar .ant-btn {
|
||||
border-radius: 20px;
|
||||
background: var(--admin-btn-bg);
|
||||
border-color: var(--admin-btn-border);
|
||||
color: var(--admin-btn-text);
|
||||
height: 32px;
|
||||
padding: 0 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wesp-admin-content-topbar .ant-btn-primary {
|
||||
background: var(--wesp-admin-primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.wesp-admin-panel-card.ant-card {
|
||||
border-radius: var(--admin-card-radius);
|
||||
border: 1px solid var(--border-light);
|
||||
background: var(--wesp-admin-surface);
|
||||
}
|
||||
|
||||
.wesp-admin-panel-card .ant-card-head {
|
||||
border-bottom-color: var(--border-light);
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.wesp-admin-panel-card .ant-card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.wesp-admin-panel-card .ant-card-head-title,
|
||||
.wesp-admin-panel-card .ant-typography,
|
||||
.wesp-admin-grid .ant-typography,
|
||||
.wesp-admin-grid .ant-list-item-meta-title,
|
||||
.wesp-admin-grid .ant-list-item-meta-description {
|
||||
color: var(--wesp-admin-text) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wesp-admin-dashboard {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.wesp-dashboard-sticky.ant-card {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.wesp-dashboard-sticky.ant-card .ant-card-body {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.wesp-admin-gauges-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wesp-gauge {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wesp-gauge-ring {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.wesp-gauge-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wesp-gauge-track {
|
||||
stroke: var(--border-light);
|
||||
}
|
||||
|
||||
.wesp-gauge-fill {
|
||||
stroke: var(--wesp-admin-primary);
|
||||
transition: stroke-dashoffset 0.45s ease;
|
||||
}
|
||||
|
||||
.wesp-gauge-center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wesp-gauge-pct {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.wesp-gauge-pct-suffix {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
opacity: 0.55;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.wesp-gauge-ring--adm {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.wesp-gauge-caption {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.wesp-gauge-caption span[data-gauge-detail] {
|
||||
opacity: 0.75;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wesp-dash-service-row.ant-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.wesp-dash-service-row > .ant-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wesp-dash-service-row .wesp-dash-service-card {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wesp-dash-service-card .ant-card-body {
|
||||
padding-top: 12px;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wesp-dash-service-row .wesp-dash-manage-list {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-list {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px 16px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.wesp-dash-manage-row:first-child {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-row--service {
|
||||
margin-top: 4px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-info {
|
||||
flex: 1 1 140px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-title {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wesp-dash-wesp-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wesp-dash-wesp-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wesp-dash-wesp-label {
|
||||
opacity: 0.65;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wesp-dash-wesp-value {
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wesp-dash-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wesp-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wesp-status-dot--ok {
|
||||
background: var(--wesp-admin-primary);
|
||||
}
|
||||
|
||||
.wesp-traffic-card .ant-card-body {
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
.wesp-traffic-two-col {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 24px;
|
||||
}
|
||||
|
||||
.wesp-traffic-col {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wesp-traffic-label {
|
||||
font-size: 13px;
|
||||
opacity: 0.65;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wesp-traffic-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.wesp-traffic-arrow,
|
||||
.wesp-traffic-icon {
|
||||
opacity: 0.75;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.wesp-admin-stats-row {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.wesp-tag {
|
||||
background: var(--marquee-bg) !important;
|
||||
border: 1px solid var(--border-light) !important;
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
.wesp-tag--ok {
|
||||
background: var(--primary-tint) !important;
|
||||
border-color: var(--admin-tag-ok-border) !important;
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.wesp-tag--warn {
|
||||
background: #fff8e6 !important;
|
||||
border-color: rgba(212, 167, 44, 0.45) !important;
|
||||
color: #9a6700 !important;
|
||||
}
|
||||
|
||||
.wesp-tag--error {
|
||||
background: #fff1f0 !important;
|
||||
border-color: rgba(205, 56, 56, 0.35) !important;
|
||||
color: #cd3838 !important;
|
||||
}
|
||||
|
||||
.wesp-admin-stat-card.ant-card {
|
||||
background: var(--wesp-admin-surface) !important;
|
||||
border: 1px solid var(--wesp-admin-stroke) !important;
|
||||
border-radius: var(--admin-card-radius);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.wesp-admin-stat-card.ant-card .ant-card-body {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.wesp-admin-stat-label {
|
||||
opacity: 0.65;
|
||||
font-size: 13px;
|
||||
color: var(--wesp-admin-soft);
|
||||
}
|
||||
|
||||
.wesp-admin-stat-value {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
margin-top: 6px;
|
||||
color: var(--wesp-admin-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wesp-admin-grid .ant-card {
|
||||
background: var(--wesp-admin-surface) !important;
|
||||
border-color: var(--wesp-admin-stroke) !important;
|
||||
border-radius: var(--admin-card-radius) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-grid .ant-table {
|
||||
background: var(--wesp-admin-surface) !important;
|
||||
color: var(--wesp-admin-text) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-grid .ant-table-thead > tr > th {
|
||||
background: var(--admin-table-header) !important;
|
||||
border-bottom-color: var(--wesp-admin-stroke) !important;
|
||||
color: var(--wesp-admin-soft) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-grid .ant-table-tbody > tr > td {
|
||||
border-bottom-color: var(--border-light) !important;
|
||||
color: var(--wesp-admin-text) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wesp-admin-log {
|
||||
margin: 0;
|
||||
background: linear-gradient(165deg, #0a0f0d 0%, #060807 48%, #0d1210 100%);
|
||||
color: #7ee787;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wesp-admin-block {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
html.wesp-admin-loading #root {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wesp-admin-skel-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
z-index: 2;
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
rgba(0, 0, 0, 0.02) 0%,
|
||||
rgba(0, 0, 0, 0.02) 34%,
|
||||
rgba(0, 0, 0, 0.05) 50%,
|
||||
rgba(0, 0, 0, 0.02) 66%,
|
||||
rgba(0, 0, 0, 0.02) 100%
|
||||
);
|
||||
background-size: 240% 100%;
|
||||
animation: wesp-admin-skel-shimmer 0.95s ease-in-out infinite;
|
||||
transition: opacity 0.38s ease;
|
||||
}
|
||||
|
||||
@keyframes wesp-admin-skel-shimmer {
|
||||
0% {
|
||||
background-position: 240% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -240% 0;
|
||||
}
|
||||
}
|
||||
|
||||
html.wesp-admin-loaded .wesp-admin-skel-overlay {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
html.wesp-admin-loaded .wesp-admin-block {
|
||||
animation: wesp-admin-block-reveal 0.52s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: calc(var(--wesp-reveal-i, 0) * 38ms);
|
||||
}
|
||||
|
||||
@keyframes wesp-admin-block-reveal {
|
||||
from {
|
||||
opacity: 0.55;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.wesp-admin-section-enter {
|
||||
animation: wesp-admin-section-tween 0.44s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes wesp-admin-section-tween {
|
||||
from {
|
||||
opacity: 0.45;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-tag {
|
||||
background: rgba(11, 10, 16, 0.45) !important;
|
||||
border: 1px solid rgba(186, 184, 208, 0.16) !important;
|
||||
color: rgba(236, 236, 241, 0.82) !important;
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-tag--ok {
|
||||
background: rgba(90, 154, 130, 0.18) !important;
|
||||
border-color: var(--admin-tag-ok-border) !important;
|
||||
color: var(--admin-success) !important;
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-tag--warn {
|
||||
background: rgba(232, 184, 74, 0.12) !important;
|
||||
border-color: rgba(232, 184, 74, 0.4) !important;
|
||||
color: #e8b84a !important;
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-tag--error {
|
||||
background: rgba(233, 75, 75, 0.14) !important;
|
||||
border-color: rgba(233, 75, 75, 0.35) !important;
|
||||
color: #ff9c9c !important;
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-admin-skel-overlay {
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
rgba(255, 255, 255, 0.02) 0%,
|
||||
rgba(255, 255, 255, 0.02) 34%,
|
||||
rgba(255, 255, 255, 0.07) 50%,
|
||||
rgba(255, 255, 255, 0.02) 66%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item-selected {
|
||||
color: var(--admin-success) !important;
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item-selected .anticon {
|
||||
color: var(--admin-success);
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item .anticon {
|
||||
color: var(--admin-soft);
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-gauge-track {
|
||||
stroke: rgba(186, 184, 208, 0.18);
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-admin-log {
|
||||
background: linear-gradient(165deg, #0b0a10 0%, #15141c 48%, #0b0a10 100%);
|
||||
color: #6bc4a8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wesp-admin-layout.ant-layout-has-sider {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider {
|
||||
position: static;
|
||||
inset: auto;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
flex: 0 0 auto !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.wesp-admin-layout > .ant-layout {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-layout-sider-children {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-layout-sider-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .wesp-menu-label {
|
||||
max-width: 16rem !important;
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item {
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding-inline: 16px !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-title-content {
|
||||
width: auto !important;
|
||||
opacity: 1 !important;
|
||||
flex: 1 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.wesp-admin-content {
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.wesp-admin-content-topbar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.wesp-admin-gauges-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wesp-admin-sider.ant-layout-sider,
|
||||
.wesp-admin-layout > .ant-layout,
|
||||
.wesp-admin-sider .wesp-menu-label,
|
||||
.wesp-admin-sider .ant-menu-item {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user