mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-07-22 07:13:30 +03:00
Add new admin panel, failover, dns fallback, providers, limiters. Update XHTTP
This commit is contained in:
247
service/admin_panel/web/src/pages/BandwidthLimitersPage.tsx
Normal file
247
service/admin_panel/web/src/pages/BandwidthLimitersPage.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import SpeedIcon from "@mui/icons-material/Speed";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
CrudPage,
|
||||
renderOptionChain,
|
||||
renderOptionLabel,
|
||||
type CrudConfig,
|
||||
} from "../components/CrudPage";
|
||||
import { useApi } from "../auth/AuthContext";
|
||||
import type {
|
||||
BandwidthLimiter,
|
||||
BandwidthLimiterCreate,
|
||||
BandwidthLimiterUpdate,
|
||||
BandwidthMode,
|
||||
BandwidthStrategy,
|
||||
ConnectionType,
|
||||
} from "../api/types";
|
||||
import {
|
||||
parseSquadIds,
|
||||
pickSquadIds,
|
||||
renderSquadIds,
|
||||
squadIdsField,
|
||||
useSquadCatalog,
|
||||
} from "./squadField";
|
||||
|
||||
// Display labels mirror service/admin_panel/tables/bandwidth_limiter.go so
|
||||
// the table shows "Global" instead of "global", "Download" instead of
|
||||
// "download", etc.
|
||||
const STRATEGIES: { value: BandwidthStrategy; label: string }[] = [
|
||||
{ value: "global", label: "Global" },
|
||||
{ value: "connection", label: "Connection" },
|
||||
{ value: "bypass", label: "Bypass" },
|
||||
];
|
||||
const MODES: { value: BandwidthMode; label: string }[] = [
|
||||
{ value: "download", label: "Download" },
|
||||
{ value: "upload", label: "Upload" },
|
||||
{ value: "bidirectional", label: "Bidirectional" },
|
||||
];
|
||||
const CONN_TYPES: { value: ConnectionType; label: string }[] = [
|
||||
{ value: "hwid", label: "HWID" },
|
||||
{ value: "mux", label: "Mux" },
|
||||
{ value: "source_ip", label: "Source IP" },
|
||||
{ value: "default", label: "Default" },
|
||||
];
|
||||
const FLOW_KEYS: { value: string; label: string }[] = [
|
||||
{ value: "user", label: "User" },
|
||||
{ value: "destination", label: "Destination" },
|
||||
{ value: "hwid", label: "HWID" },
|
||||
{ value: "mux", label: "Mux" },
|
||||
{ value: "source_ip", label: "Source IP" },
|
||||
];
|
||||
|
||||
export function BandwidthLimitersPage() {
|
||||
const api = useApi();
|
||||
const squads = useSquadCatalog(api);
|
||||
// Memoise the CRUD config so CrudPage's `reload` callback stays stable
|
||||
// across re-renders. Recomputed when the API client flips or a new
|
||||
// squad name is merged into the catalog through observeRows.
|
||||
const config = useMemo<
|
||||
CrudConfig<BandwidthLimiter, BandwidthLimiterCreate, BandwidthLimiterUpdate>
|
||||
>(() => ({
|
||||
title: "Bandwidth limiters",
|
||||
icon: <SpeedIcon />,
|
||||
idKey: "id",
|
||||
onRowsChange: (rows) => squads.observeRows(rows, pickSquadIds),
|
||||
columns: [
|
||||
{ key: "id", label: "ID" },
|
||||
// squad_ids is an array column — `sortable: false` matches the
|
||||
// legacy admin where these columns lacked FieldSortable(). Render
|
||||
// squad names instead of raw IDs.
|
||||
{
|
||||
key: "squad_ids",
|
||||
label: "Squads",
|
||||
sortable: false,
|
||||
render: renderSquadIds<BandwidthLimiter>(squads.names),
|
||||
},
|
||||
{ key: "username", label: "Username" },
|
||||
{ key: "outbound", label: "Outbound" },
|
||||
{ key: "strategy", label: "Strategy", render: renderOptionLabel<BandwidthLimiter>("strategy", STRATEGIES) },
|
||||
{
|
||||
key: "connection_type",
|
||||
label: "Connection type",
|
||||
render: renderOptionLabel<BandwidthLimiter>("connection_type", CONN_TYPES),
|
||||
},
|
||||
{ key: "mode", label: "Mode", render: renderOptionLabel<BandwidthLimiter>("mode", MODES) },
|
||||
{
|
||||
key: "flow_keys",
|
||||
label: "Flow keys",
|
||||
sortable: false,
|
||||
render: renderOptionChain<BandwidthLimiter>("flow_keys", FLOW_KEYS),
|
||||
},
|
||||
{
|
||||
key: "speed",
|
||||
label: "Speed",
|
||||
// bypass disables the limiter's speed cap server-side
|
||||
// (excluded_if=Strategy bypass on the DTO), so the row's `speed`
|
||||
// arrives as an empty string. Render an unambiguous ∞ instead so
|
||||
// the column reads as "no cap" at a glance instead of looking
|
||||
// like missing data.
|
||||
render: (row) => (row.strategy === "bypass" ? "∞" : row.speed),
|
||||
},
|
||||
{ key: "created_at", label: "Created at" },
|
||||
{ key: "updated_at", label: "Updated at" },
|
||||
],
|
||||
filters: [
|
||||
{ name: "username", label: "Username", type: "text" },
|
||||
{ name: "outbound", label: "Outbound", type: "text" },
|
||||
{ name: "strategy", label: "Strategy", type: "select", options: STRATEGIES },
|
||||
{ name: "connection_type", label: "Connection type", type: "select", options: CONN_TYPES },
|
||||
{ name: "mode", label: "Mode", type: "select", options: MODES },
|
||||
{ name: "created_at", label: "Created at", type: "datetime-range" },
|
||||
{ name: "updated_at", label: "Updated at", type: "datetime-range" },
|
||||
],
|
||||
fields: [
|
||||
// Mirror service/admin_panel/tables/bandwidth_limiter.go: squads,
|
||||
// username and outbound are locked once the limiter exists.
|
||||
squadIdsField(squads.loadOptions),
|
||||
{ name: "username", label: "Username", type: "text", only: "create" },
|
||||
{ name: "outbound", label: "Outbound", type: "text", required: true, only: "create" },
|
||||
{
|
||||
name: "strategy",
|
||||
label: "Strategy",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: STRATEGIES,
|
||||
// bypass disables every post-Strategy field server-side
|
||||
// (excluded_if=Strategy bypass on the DTO). connection_type is
|
||||
// additionally only meaningful for the "connection" strategy. Wipe
|
||||
// every dependent field on change so a stale value can't be
|
||||
// smuggled out of a hidden field.
|
||||
clears: ["connection_type", "mode", "flow_keys", "speed"],
|
||||
},
|
||||
{
|
||||
name: "connection_type",
|
||||
label: "Connection type",
|
||||
type: "select",
|
||||
options: CONN_TYPES,
|
||||
visibleWhen: (form) => form.strategy === "connection",
|
||||
},
|
||||
{
|
||||
name: "mode",
|
||||
label: "Mode",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: MODES,
|
||||
visibleWhen: (form) => form.strategy !== "bypass",
|
||||
},
|
||||
{
|
||||
name: "flow_keys",
|
||||
label: "Flow keys",
|
||||
type: "multiselect",
|
||||
options: FLOW_KEYS,
|
||||
// Render the picked values as an ordered pipeline
|
||||
// ("User → Destination → IP") so the form's preview matches the
|
||||
// table column and reads as a queue rather than a flat set.
|
||||
displayAsChain: true,
|
||||
visibleWhen: (form) => form.strategy !== "bypass",
|
||||
},
|
||||
{
|
||||
name: "speed",
|
||||
label: "Speed",
|
||||
type: "text",
|
||||
required: true,
|
||||
helperText: "e.g. 2MB, 100KB, 1GB or 10Mbps",
|
||||
visibleWhen: (form) => form.strategy !== "bypass",
|
||||
},
|
||||
],
|
||||
list: (q) => api.bandwidthLimiters.list(q),
|
||||
count: (q) => api.bandwidthLimiters.count(q),
|
||||
create: (b) => api.bandwidthLimiters.create(b),
|
||||
update: (id, b) => api.bandwidthLimiters.update(Number(id), b),
|
||||
remove: (id) => api.bandwidthLimiters.remove(Number(id)),
|
||||
fromEntity: (e) => ({
|
||||
squad_ids: e.squad_ids,
|
||||
username: e.username ?? "",
|
||||
outbound: e.outbound,
|
||||
strategy: e.strategy,
|
||||
connection_type: e.connection_type ?? "",
|
||||
mode: e.mode,
|
||||
flow_keys: e.flow_keys ?? [],
|
||||
speed: e.speed,
|
||||
}),
|
||||
toCreate: (f) => bodyFor(f) as unknown as BandwidthLimiterCreate,
|
||||
toUpdate: (f, original) => updateBodyFor(f, original) as unknown as BandwidthLimiterUpdate,
|
||||
}), [api, squads]);
|
||||
return (
|
||||
<CrudPage<BandwidthLimiter, BandwidthLimiterCreate, BandwidthLimiterUpdate> config={config} />
|
||||
);
|
||||
}
|
||||
|
||||
// strategyDependentFields returns connection_type / mode / flow_keys /
|
||||
// speed only when the picked strategy is *not* "bypass". The manager-api
|
||||
// DTO carries `excluded_if=Strategy bypass` on each of these, and the
|
||||
// SQL repository unconditionally parses `Speed` via
|
||||
// byteformats.NetworkBytesCompat (see service/manager/repository/sqlite/
|
||||
// repository.go) — so sending `mode: ""` / `speed: ""` with a bypass
|
||||
// payload makes the server reject the request with 400 "invalid
|
||||
// format". Dropping the keys entirely (returned `undefined` is omitted
|
||||
// by `JSON.stringify`) keeps the body shape exactly what the DTO
|
||||
// expects for each strategy branch.
|
||||
function strategyDependentFields(
|
||||
f: Record<string, unknown>,
|
||||
strategy: BandwidthStrategy,
|
||||
) {
|
||||
if (strategy === "bypass") {
|
||||
return {
|
||||
connection_type: undefined,
|
||||
mode: undefined,
|
||||
flow_keys: undefined,
|
||||
speed: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
connection_type: f.connection_type
|
||||
? (String(f.connection_type) as ConnectionType)
|
||||
: undefined,
|
||||
mode: String(f.mode ?? "") as BandwidthMode,
|
||||
flow_keys:
|
||||
Array.isArray(f.flow_keys) && (f.flow_keys as string[]).length > 0
|
||||
? (f.flow_keys as string[])
|
||||
: undefined,
|
||||
speed: String(f.speed ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function bodyFor(f: Record<string, unknown>) {
|
||||
const strategy = String(f.strategy ?? "") as BandwidthStrategy;
|
||||
return {
|
||||
squad_ids: parseSquadIds(f.squad_ids),
|
||||
username: f.username ? String(f.username) : undefined,
|
||||
outbound: String(f.outbound ?? "").trim(),
|
||||
strategy,
|
||||
...strategyDependentFields(f, strategy),
|
||||
};
|
||||
}
|
||||
|
||||
// updateBodyFor reuses the original entity for the create-only fields
|
||||
// (username, outbound) that the API still requires on update.
|
||||
function updateBodyFor(f: Record<string, unknown>, original: BandwidthLimiter) {
|
||||
const strategy = String(f.strategy ?? "") as BandwidthStrategy;
|
||||
return {
|
||||
username: original.username || undefined,
|
||||
outbound: original.outbound,
|
||||
strategy,
|
||||
...strategyDependentFields(f, strategy),
|
||||
};
|
||||
}
|
||||
191
service/admin_panel/web/src/pages/ConnectionLimitersPage.tsx
Normal file
191
service/admin_panel/web/src/pages/ConnectionLimitersPage.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
CrudPage,
|
||||
renderOptionLabel,
|
||||
type CrudConfig,
|
||||
} from "../components/CrudPage";
|
||||
import { useApi } from "../auth/AuthContext";
|
||||
import type {
|
||||
ConnectionLimiter,
|
||||
ConnectionLimiterCreate,
|
||||
ConnectionLimiterUpdate,
|
||||
ConnectionStrategy,
|
||||
ConnectionType,
|
||||
LockType,
|
||||
} from "../api/types";
|
||||
import {
|
||||
parseSquadIds,
|
||||
pickSquadIds,
|
||||
renderSquadIds,
|
||||
squadIdsField,
|
||||
useSquadCatalog,
|
||||
} from "./squadField";
|
||||
|
||||
// Display labels mirror service/admin_panel/tables/connection_limiter.go.
|
||||
const STRATEGIES: { value: ConnectionStrategy; label: string }[] = [
|
||||
{ value: "connection", label: "Connection" },
|
||||
{ value: "bypass", label: "Bypass" },
|
||||
];
|
||||
const CONN_TYPES: { value: ConnectionType; label: string }[] = [
|
||||
{ value: "hwid", label: "HWID" },
|
||||
{ value: "mux", label: "Mux" },
|
||||
{ value: "source_ip", label: "Source IP" },
|
||||
{ value: "default", label: "Default" },
|
||||
];
|
||||
const LOCK_TYPES: { value: LockType; label: string }[] = [
|
||||
{ value: "manager", label: "Manager" },
|
||||
{ value: "default", label: "Default" },
|
||||
];
|
||||
|
||||
export function ConnectionLimitersPage() {
|
||||
const api = useApi();
|
||||
const squads = useSquadCatalog(api);
|
||||
// Memoise the CRUD config so CrudPage's `reload` callback stays stable
|
||||
// across re-renders. Recomputed when the API client flips or a new
|
||||
// squad name is merged into the catalog through observeRows.
|
||||
const config = useMemo<
|
||||
CrudConfig<ConnectionLimiter, ConnectionLimiterCreate, ConnectionLimiterUpdate>
|
||||
>(() => ({
|
||||
title: "Connection limiters",
|
||||
icon: <LinkIcon />,
|
||||
idKey: "id",
|
||||
onRowsChange: (rows) => squads.observeRows(rows, pickSquadIds),
|
||||
columns: [
|
||||
{ key: "id", label: "ID" },
|
||||
{
|
||||
key: "squad_ids",
|
||||
label: "Squads",
|
||||
sortable: false,
|
||||
render: renderSquadIds<ConnectionLimiter>(squads.names),
|
||||
},
|
||||
{ key: "username", label: "Username" },
|
||||
{ key: "outbound", label: "Outbound" },
|
||||
{ key: "strategy", label: "Strategy", render: renderOptionLabel<ConnectionLimiter>("strategy", STRATEGIES) },
|
||||
{
|
||||
key: "connection_type",
|
||||
label: "Connection type",
|
||||
render: renderOptionLabel<ConnectionLimiter>("connection_type", CONN_TYPES),
|
||||
},
|
||||
{ key: "lock_type", label: "Lock type", render: renderOptionLabel<ConnectionLimiter>("lock_type", LOCK_TYPES) },
|
||||
{
|
||||
key: "count",
|
||||
label: "Count",
|
||||
// bypass disables the limiter server-side
|
||||
// (excluded_if=Strategy bypass on the DTO), so `count` arrives
|
||||
// as 0. Render an unambiguous ∞ instead so the column reads as
|
||||
// "no cap" at a glance instead of looking like a real zero.
|
||||
render: (row) => (row.strategy === "bypass" ? "∞" : row.count),
|
||||
},
|
||||
{ key: "created_at", label: "Created at" },
|
||||
{ key: "updated_at", label: "Updated at" },
|
||||
],
|
||||
filters: [
|
||||
{ name: "username", label: "Username", type: "text" },
|
||||
{ name: "outbound", label: "Outbound", type: "text" },
|
||||
{ name: "strategy", label: "Strategy", type: "select", options: STRATEGIES },
|
||||
{ name: "connection_type", label: "Connection type", type: "select", options: CONN_TYPES },
|
||||
{ name: "lock_type", label: "Lock type", type: "select", options: LOCK_TYPES },
|
||||
{ name: "created_at", label: "Created at", type: "datetime-range" },
|
||||
{ name: "updated_at", label: "Updated at", type: "datetime-range" },
|
||||
],
|
||||
fields: [
|
||||
// Mirror service/admin_panel/tables/connection_limiter.go: squads,
|
||||
// username and outbound are locked once the limiter exists.
|
||||
squadIdsField(squads.loadOptions),
|
||||
{ name: "username", label: "Username", type: "text", only: "create" },
|
||||
{ name: "outbound", label: "Outbound", type: "text", required: true, only: "create" },
|
||||
{
|
||||
name: "strategy",
|
||||
label: "Strategy",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: STRATEGIES,
|
||||
// bypass disables every post-Strategy field server-side
|
||||
// (excluded_if=Strategy bypass on the DTO). Wipe their values when
|
||||
// switching so a stale entry can't be smuggled out of a hidden field.
|
||||
clears: ["connection_type", "lock_type", "count"],
|
||||
},
|
||||
{
|
||||
name: "connection_type",
|
||||
label: "Connection type",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: CONN_TYPES,
|
||||
visibleWhen: (form) => form.strategy !== "bypass",
|
||||
},
|
||||
{
|
||||
name: "lock_type",
|
||||
label: "Lock type",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: LOCK_TYPES,
|
||||
visibleWhen: (form) => form.strategy !== "bypass",
|
||||
},
|
||||
{
|
||||
name: "count",
|
||||
label: "Count",
|
||||
type: "number",
|
||||
required: true,
|
||||
visibleWhen: (form) => form.strategy !== "bypass",
|
||||
},
|
||||
],
|
||||
list: (q) => api.connectionLimiters.list(q),
|
||||
count: (q) => api.connectionLimiters.count(q),
|
||||
create: (b) => api.connectionLimiters.create(b),
|
||||
update: (id, b) => api.connectionLimiters.update(Number(id), b),
|
||||
remove: (id) => api.connectionLimiters.remove(Number(id)),
|
||||
fromEntity: (e) => ({
|
||||
username: e.username ?? "",
|
||||
outbound: e.outbound,
|
||||
strategy: e.strategy,
|
||||
connection_type: e.connection_type ?? "",
|
||||
lock_type: e.lock_type,
|
||||
count: e.count,
|
||||
}),
|
||||
// `connection_type` / `lock_type` / `count` carry
|
||||
// `excluded_if=Strategy bypass` on the manager-api DTO; the
|
||||
// validator rejects non-zero values when strategy=bypass and any
|
||||
// empty/zero values still get persisted by the SQL repo, so drop
|
||||
// the keys entirely on bypass (`undefined` → omitted by
|
||||
// `JSON.stringify`).
|
||||
toCreate: (f) => {
|
||||
const strategy = String(f.strategy ?? "") as ConnectionStrategy;
|
||||
const bypass = strategy === "bypass";
|
||||
return {
|
||||
squad_ids: parseSquadIds(f.squad_ids),
|
||||
username: f.username ? String(f.username) : undefined,
|
||||
outbound: String(f.outbound ?? "").trim(),
|
||||
strategy,
|
||||
connection_type: bypass
|
||||
? undefined
|
||||
: f.connection_type
|
||||
? (String(f.connection_type) as ConnectionType)
|
||||
: undefined,
|
||||
lock_type: bypass ? undefined : (String(f.lock_type ?? "") as LockType),
|
||||
count: bypass ? undefined : Number(f.count ?? 0),
|
||||
};
|
||||
},
|
||||
// username and outbound are locked on update, so reuse the original
|
||||
// entity's values to satisfy the API's required-field validation.
|
||||
toUpdate: (f, original) => {
|
||||
const strategy = String(f.strategy ?? "") as ConnectionStrategy;
|
||||
const bypass = strategy === "bypass";
|
||||
return {
|
||||
username: original.username || undefined,
|
||||
outbound: original.outbound,
|
||||
strategy,
|
||||
connection_type: bypass
|
||||
? undefined
|
||||
: f.connection_type
|
||||
? (String(f.connection_type) as ConnectionType)
|
||||
: undefined,
|
||||
lock_type: bypass ? undefined : (String(f.lock_type ?? "") as LockType),
|
||||
count: bypass ? undefined : Number(f.count ?? 0),
|
||||
};
|
||||
},
|
||||
}), [api, squads]);
|
||||
return (
|
||||
<CrudPage<ConnectionLimiter, ConnectionLimiterCreate, ConnectionLimiterUpdate> config={config} />
|
||||
);
|
||||
}
|
||||
276
service/admin_panel/web/src/pages/DashboardPage.tsx
Normal file
276
service/admin_panel/web/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardActionArea,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import Grid from "@mui/material/Grid2";
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import DashboardIcon from "@mui/icons-material/Dashboard";
|
||||
import GroupsIcon from "@mui/icons-material/Groups";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import PeopleIcon from "@mui/icons-material/People";
|
||||
import SpeedIcon from "@mui/icons-material/Speed";
|
||||
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import FilterAltIcon from "@mui/icons-material/FilterAlt";
|
||||
import { useApi } from "../auth/AuthContext";
|
||||
import { notifyApiError, useNotify } from "../notifications/NotificationsProvider";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
interface Tile {
|
||||
label: string;
|
||||
to: string;
|
||||
icon: ReactNode;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const api = useApi();
|
||||
const notify = useNotify();
|
||||
// Every tile renders with `var(--sb-accent)` (see DashboardTile below),
|
||||
// so the order is the only thing that matters here — it mirrors the
|
||||
// navigation in <Layout/> and the table registration order in
|
||||
// service/admin_panel/service.go.
|
||||
const [tiles, setTiles] = useState<Tile[]>([
|
||||
{ label: "Squads", to: "/squads", icon: <GroupsIcon />, value: null },
|
||||
{ label: "Nodes", to: "/nodes", icon: <StorageIcon />, value: null },
|
||||
{ label: "Users", to: "/users", icon: <PeopleIcon />, value: null },
|
||||
{
|
||||
label: "Connection limiters",
|
||||
to: "/connection-limiters",
|
||||
icon: <LinkIcon />,
|
||||
value: null,
|
||||
},
|
||||
{
|
||||
label: "Bandwidth limiters",
|
||||
to: "/bandwidth-limiters",
|
||||
icon: <SpeedIcon />,
|
||||
value: null,
|
||||
},
|
||||
{
|
||||
label: "Traffic limiters",
|
||||
to: "/traffic-limiters",
|
||||
icon: <SwapHorizIcon />,
|
||||
value: null,
|
||||
},
|
||||
{
|
||||
label: "Rate limiters",
|
||||
to: "/rate-limiters",
|
||||
icon: <FilterAltIcon />,
|
||||
value: null,
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
// Counts are fetched in the same positional order as the `tiles`
|
||||
// array above: Squads, Nodes, Users, Connection, Bandwidth, Traffic,
|
||||
// Rate.
|
||||
const values = await Promise.all([
|
||||
api.squads.count(),
|
||||
api.nodes.count(),
|
||||
api.users.count(),
|
||||
api.connectionLimiters.count(),
|
||||
api.bandwidthLimiters.count(),
|
||||
api.trafficLimiters.count(),
|
||||
api.rateLimiters.count(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setTiles((prev) => prev.map((tile, i) => ({ ...tile, value: values[i] ?? 0 })));
|
||||
} catch (e) {
|
||||
// Surface counter-load failures via the global toast stack
|
||||
// instead of an inline Alert above the tiles — the tiles
|
||||
// themselves keep their loading spinners (their `value` stays
|
||||
// null) so the page reads as "not loaded yet" without an
|
||||
// extra red bar competing with the rest of the dashboard
|
||||
// chrome.
|
||||
if (!cancelled) notifyApiError(notify, "Failed to load dashboard counters", e);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [api, notify]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PageHeader
|
||||
icon={<DashboardIcon />}
|
||||
title="Dashboard"
|
||||
/>
|
||||
<Grid container spacing={2}>
|
||||
{tiles.map((t, i) => (
|
||||
<Grid key={t.label} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
|
||||
<DashboardTile tile={t} index={i} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// DashboardTile renders a single counter card. `index` drives the
|
||||
// per-tile delay on the entrance animation so the cards fade in as a
|
||||
// staggered cascade rather than all at once.
|
||||
//
|
||||
// The entrance animation is driven by the Web Animations API (`element.animate`)
|
||||
// rather than a CSS `@keyframes` rule for one specific reason: on a cold
|
||||
// browser reload users reported the dashboard "drawing twice". Emotion
|
||||
// re-serialises the `sx` block on every re-render, and although the
|
||||
// resulting className is content-deterministic, certain edge cases —
|
||||
// CSS bundle attaching after the JS commit, font swap forcing a class
|
||||
// re-application, or `tile.value` going `null → number` causing the
|
||||
// animation property to be re-applied — can re-trigger a CSS keyframe
|
||||
// animation on an already-mounted element. WAAPI plus a `useRef` flag
|
||||
// guarantees the entrance plays exactly once per fiber lifetime, no
|
||||
// matter how many times React commits or how emotion shuffles classes
|
||||
// underneath.
|
||||
function DashboardTile({ tile, index }: { tile: Tile; index: number }) {
|
||||
// Every tile uses the global theme accent (`var(--sb-accent)`) so all
|
||||
// dashboard cards re-tint together when the user picks a new theme
|
||||
// colour. Translucent variants are produced via `color-mix` so they
|
||||
// automatically follow the variable too.
|
||||
const ACCENT = "var(--sb-accent)";
|
||||
const accentMix = (pct: number) =>
|
||||
`color-mix(in srgb, ${ACCENT} ${pct}%, transparent)`;
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const animatedRef = useRef(false);
|
||||
// Run as a layout effect so the keyframe is registered before the
|
||||
// browser paints the first frame; without `useLayoutEffect` the card
|
||||
// would briefly flash at full opacity before the WAAPI animation
|
||||
// captured the `from` state on the first paint after mount.
|
||||
useLayoutEffect(() => {
|
||||
const el = cardRef.current;
|
||||
if (!el || animatedRef.current) return;
|
||||
if (typeof el.animate !== "function") return;
|
||||
animatedRef.current = true;
|
||||
el.animate(
|
||||
[
|
||||
{ opacity: 0, transform: "translateY(12px)" },
|
||||
{ opacity: 1, transform: "translateY(0)" },
|
||||
],
|
||||
{
|
||||
duration: 480,
|
||||
delay: index * 70,
|
||||
easing: "cubic-bezier(0.22, 0.61, 0.36, 1)",
|
||||
fill: "backwards",
|
||||
},
|
||||
);
|
||||
}, [index]);
|
||||
return (
|
||||
<Card
|
||||
ref={cardRef}
|
||||
sx={{
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: `linear-gradient(135deg, ${accentMix(4)} 0%, transparent 70%)`,
|
||||
transition:
|
||||
"transform 0.18s cubic-bezier(0.22, 0.61, 0.36, 1), border-color 0.18s, box-shadow 0.24s, background 0.32s",
|
||||
"&:hover": {
|
||||
borderColor: accentMix(55),
|
||||
transform: "translateY(-2px)",
|
||||
boxShadow: `0 12px 28px ${accentMix(18)}, 0 1px 0 ${accentMix(20)}`,
|
||||
},
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
background: `linear-gradient(90deg, ${ACCENT} 0%, ${accentMix(40)} 100%)`,
|
||||
opacity: 0.85,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardActionArea component={RouterLink} to={tile.to} sx={{ height: "100%" }}>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={2}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 2.5,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
bgcolor: accentMix(16),
|
||||
color: ACCENT,
|
||||
border: `1px solid ${accentMix(30)}`,
|
||||
boxShadow: `inset 0 0 0 1px ${accentMix(6)}`,
|
||||
transition:
|
||||
"background-color 0.32s cubic-bezier(0.4,0,0.2,1), color 0.32s cubic-bezier(0.4,0,0.2,1), border-color 0.32s cubic-bezier(0.4,0,0.2,1)",
|
||||
}}
|
||||
>
|
||||
{tile.icon}
|
||||
</Box>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: 600,
|
||||
fontSize: 10.5,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{tile.label}
|
||||
</Typography>
|
||||
{/* Reserve a fixed-height row for the value so swapping
|
||||
the loading spinner for the final number doesn't move
|
||||
the card by ~10 px when the count fetch resolves. The
|
||||
height matches the line-box of the 32 px number text
|
||||
(font-size × line-height = 32 × 1 = 32). The spinner
|
||||
is centred inside this row instead of forcing its own
|
||||
smaller intrinsic height onto the parent column,
|
||||
which is what made the dashboard read as "animating
|
||||
twice" on a cold browser reload — the tile slid in
|
||||
via `tileIn`, then jumped down a row when the
|
||||
spinner was replaced by the larger number. */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0.75,
|
||||
height: 32,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{tile.value === null ? (
|
||||
<CircularProgress size={22} thickness={5} sx={{ color: ACCENT }} />
|
||||
) : (
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
lineHeight: 1,
|
||||
fontSize: 32,
|
||||
fontWeight: 600,
|
||||
letterSpacing: -0.6,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{tile.value}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
273
service/admin_panel/web/src/pages/LoginPage.tsx
Normal file
273
service/admin_panel/web/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import {
|
||||
ApiError,
|
||||
UnauthorizedError,
|
||||
clearLoginDraft,
|
||||
loadLoginDraft,
|
||||
ping,
|
||||
saveLoginDraft,
|
||||
} from "../api/client";
|
||||
import { useNotify } from "../notifications/NotificationsProvider";
|
||||
import { LoginBackdropMesh } from "../components/LoginBackdropMesh";
|
||||
import { LoginThemeControls } from "../components/LoginThemeControls";
|
||||
import brandIcon from "../assets/icon.svg";
|
||||
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const notify = useNotify();
|
||||
// Pre-fill from the persisted login draft so the user does not have to
|
||||
// retype their URL + key after closing the tab or logging out.
|
||||
const [baseUrl, setBaseUrl] = useState(() => loadLoginDraft().baseUrl);
|
||||
const [apiKey, setApiKey] = useState(() => loadLoginDraft().apiKey);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Toggles whether the API key is rendered as plain text or masked
|
||||
// dots. Defaults to masked so the page never paints a credential in
|
||||
// clear text on first load — the user has to opt in to peek.
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
// Persist the form on every keystroke. `saveLoginDraft` itself swallows
|
||||
// any storage errors (private mode / quota), so the effect is a no-op
|
||||
// there rather than throwing into the React tree. We don't bother with
|
||||
// a `beforeunload` flush — the per-keystroke write already keeps the
|
||||
// saved copy in sync with the latest field value at all times.
|
||||
useEffect(() => {
|
||||
saveLoginDraft({ baseUrl, apiKey });
|
||||
}, [baseUrl, apiKey]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
try {
|
||||
const trimmedUrl = baseUrl.trim();
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (!trimmedUrl || !trimmedKey) throw new Error("API URL and key are required");
|
||||
await ping({ baseUrl: trimmedUrl, apiKey: trimmedKey });
|
||||
// Auth itself is the source of truth once signed in — drop the draft.
|
||||
clearLoginDraft();
|
||||
login({ baseUrl: trimmedUrl, apiKey: trimmedKey });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Errors are surfaced exclusively through the global toast stack so
|
||||
// the form's height stays constant on a rejected attempt. An inline
|
||||
// <Alert> inside the Paper would grow the form and, combined with
|
||||
// the parent's vertical centering, make the whole card "bounce" up
|
||||
// and down each time the user mistypes the API key. The toast still
|
||||
// carries a contextual message ("Authorization failed" /
|
||||
// "Connection error" / generic API failure) so the user sees a
|
||||
// clear cause for the rejected sign-in.
|
||||
// 401s are a normal path here (typo'd API key); we explicitly
|
||||
// map them to the auth-error wording rather than the generic
|
||||
// ApiError formatter from notifyApiError so the message reads
|
||||
// naturally on the login screen.
|
||||
if (err instanceof UnauthorizedError) {
|
||||
notify.error("Authorization failed — check the API key.");
|
||||
} else if (err instanceof ApiError && err.status === 0) {
|
||||
notify.error(
|
||||
`Connection error — could not reach the manager API: ${err.body || err.message}`,
|
||||
);
|
||||
} else if (err instanceof ApiError) {
|
||||
notify.error(`Sign-in failed (HTTP ${err.status}): ${err.body || err.message}`);
|
||||
} else {
|
||||
notify.error(`Sign-in failed: ${message}`);
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: "background.default",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<LoginBackdropMesh />
|
||||
<LoginThemeControls />
|
||||
<Paper
|
||||
component="form"
|
||||
onSubmit={submit}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: { xs: 3, sm: 4.5 },
|
||||
width: "100%",
|
||||
maxWidth: 440,
|
||||
position: "relative",
|
||||
// Bump the form above the absolutely-positioned backdrop so
|
||||
// the animated network stays strictly behind the card. Without
|
||||
// this the Paper (a flex item in normal flow) would paint
|
||||
// before positioned siblings and the SVG would sit on top.
|
||||
zIndex: 1,
|
||||
// Drop shadow removed: the animated backdrop already
|
||||
// gives the page enough depth, and the form's outline
|
||||
// border is enough to detach it from the moving graphics
|
||||
// behind it without needing a shadow on top.
|
||||
boxShadow: "none",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
{/* Brand — icon + two-line wordmark grouped inside a single
|
||||
flex row, mirroring the sidebar header in Layout.tsx.
|
||||
Sizes are scaled up: the sidebar uses 32 px icon /
|
||||
17 px wordmark / 10 px subtitle; here those are
|
||||
multiplied by 44/32 to keep proportions identical
|
||||
while filling the larger card surface. The text
|
||||
wrapper carries a small paddingBottom so
|
||||
`alignItems: center` optically centres the two-line
|
||||
text against the icon without touching any margins
|
||||
on the individual spans. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={brandIcon}
|
||||
alt="sing-box"
|
||||
sx={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
flexShrink: 0,
|
||||
display: "block",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
paddingBottom: "6px",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={(t) => ({
|
||||
display: "block",
|
||||
margin: 0,
|
||||
height: 30,
|
||||
fontFamily: t.typography.fontFamily,
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
letterSpacing: -0.6,
|
||||
lineHeight: "30px",
|
||||
color: t.palette.text.primary,
|
||||
})}
|
||||
>
|
||||
Sing-box
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: "block",
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 2.4,
|
||||
lineHeight: "15px",
|
||||
marginTop: "3px",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--sb-accent)",
|
||||
transition: "color 0.32s cubic-bezier(0.4,0,0.2,1)",
|
||||
}}
|
||||
>
|
||||
extended
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="API base URL"
|
||||
placeholder="http://127.0.0.1:8090"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
// `type` flips between password and text driven by the
|
||||
// visibility toggle below. We keep `autoComplete="off"`
|
||||
// so a browser password manager doesn't autofill the
|
||||
// wrong credential into the API key slot.
|
||||
type={showApiKey ? "text" : "password"}
|
||||
label="API key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
// `onMouseDown` prevent-default keeps the input
|
||||
// focused when the user clicks the toggle —
|
||||
// without it the field would lose focus and any
|
||||
// autofocus logic would have to chase the user.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setShowApiKey((v) => !v)}
|
||||
edge="end"
|
||||
size="small"
|
||||
// Screen-reader label kept (it's not a visible
|
||||
// description), but no Tooltip wrapper — per
|
||||
// request the eye icon stays "quiet" with no
|
||||
// hover hint.
|
||||
aria-label={
|
||||
showApiKey ? "Hide API key" : "Show API key"
|
||||
}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<VisibilityOffIcon fontSize="small" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
disabled={busy}
|
||||
sx={{ py: 1.25 }}
|
||||
>
|
||||
{busy ? "Connecting…" : "Sign in"}
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.secondary" textAlign="center">
|
||||
Credentials are stored locally in your browser only.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
98
service/admin_panel/web/src/pages/NodesPage.tsx
Normal file
98
service/admin_panel/web/src/pages/NodesPage.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Chip } from "@mui/material";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { CrudPage, type CrudConfig } from "../components/CrudPage";
|
||||
import { CopyableId } from "../components/CopyableId";
|
||||
import { useApi } from "../auth/AuthContext";
|
||||
import type { Node, NodeCreate, NodeStatus, NodeUpdate } from "../api/types";
|
||||
import {
|
||||
parseSquadIds,
|
||||
pickSquadIds,
|
||||
renderSquadIds,
|
||||
squadIdsField,
|
||||
useSquadCatalog,
|
||||
} from "./squadField";
|
||||
|
||||
function StatusChip({ uuid }: { uuid: string }) {
|
||||
const api = useApi();
|
||||
const [status, setStatus] = useState<NodeStatus | "loading" | "error">("loading");
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const s = await api.nodes.status(uuid);
|
||||
if (!cancelled) setStatus(s);
|
||||
} catch {
|
||||
if (!cancelled) setStatus("error");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [api, uuid]);
|
||||
const color: "default" | "success" | "warning" | "error" =
|
||||
status === "online" ? "success" : status === "offline" ? "warning" : status === "error" ? "error" : "default";
|
||||
return <Chip size="small" color={color} label={status === "loading" ? "…" : status} />;
|
||||
}
|
||||
|
||||
export function NodesPage() {
|
||||
const api = useApi();
|
||||
const squads = useSquadCatalog(api);
|
||||
// Memoise the CRUD config so CrudPage's `reload` callback stays stable
|
||||
// across re-renders. Recomputed when the API client flips or a new
|
||||
// squad name is merged into the catalog through observeRows.
|
||||
const config = useMemo<CrudConfig<Node, NodeCreate, NodeUpdate>>(() => ({
|
||||
title: "Nodes",
|
||||
icon: <StorageIcon />,
|
||||
idKey: "uuid",
|
||||
rowKey: (r) => r.uuid,
|
||||
onRowsChange: (rows) => squads.observeRows(rows, pickSquadIds),
|
||||
columns: [
|
||||
{
|
||||
key: "uuid",
|
||||
label: "UUID",
|
||||
render: (row) => <CopyableId value={row.uuid} />,
|
||||
},
|
||||
{ key: "name", label: "Name" },
|
||||
{
|
||||
key: "squad_ids",
|
||||
label: "Squads",
|
||||
sortable: false,
|
||||
render: renderSquadIds<Node>(squads.names),
|
||||
},
|
||||
{
|
||||
// Status is computed live per-row via /nodes/:uuid/status, so the
|
||||
// server can't sort by it.
|
||||
key: "status",
|
||||
label: "Status",
|
||||
sortable: false,
|
||||
render: (row) => <StatusChip uuid={row.uuid} />,
|
||||
},
|
||||
{ key: "created_at", label: "Created at" },
|
||||
{ key: "updated_at", label: "Updated at" },
|
||||
],
|
||||
filters: [
|
||||
{ name: "uuid", label: "UUID", type: "text", wide: true },
|
||||
{ name: "name", label: "Name", type: "text" },
|
||||
{ name: "created_at", label: "Created at", type: "datetime-range" },
|
||||
{ name: "updated_at", label: "Updated at", type: "datetime-range" },
|
||||
],
|
||||
fields: [
|
||||
{ name: "uuid", label: "UUID", type: "uuid", required: true, only: "create" },
|
||||
{ name: "name", label: "Name", type: "text", required: true },
|
||||
squadIdsField(squads.loadOptions),
|
||||
],
|
||||
list: (q) => api.nodes.list(q),
|
||||
count: (q) => api.nodes.count(q),
|
||||
create: (b) => api.nodes.create(b),
|
||||
update: (id, b) => api.nodes.update(String(id), b),
|
||||
remove: (id) => api.nodes.remove(String(id)),
|
||||
toCreate: (f) => ({
|
||||
uuid: String(f.uuid ?? "").trim(),
|
||||
name: String(f.name ?? "").trim(),
|
||||
squad_ids: parseSquadIds(f.squad_ids),
|
||||
}),
|
||||
toUpdate: (f) => ({ name: String(f.name ?? "").trim() }),
|
||||
}), [api, squads]);
|
||||
return <CrudPage<Node, NodeCreate, NodeUpdate> config={config} />;
|
||||
}
|
||||
188
service/admin_panel/web/src/pages/RateLimitersPage.tsx
Normal file
188
service/admin_panel/web/src/pages/RateLimitersPage.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import FilterAltIcon from "@mui/icons-material/FilterAlt";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
CrudPage,
|
||||
renderOptionLabel,
|
||||
type CrudConfig,
|
||||
} from "../components/CrudPage";
|
||||
import { useApi } from "../auth/AuthContext";
|
||||
import type {
|
||||
RateConnectionType,
|
||||
RateLimiter,
|
||||
RateLimiterCreate,
|
||||
RateLimiterUpdate,
|
||||
RateStrategy,
|
||||
} from "../api/types";
|
||||
import {
|
||||
parseSquadIds,
|
||||
pickSquadIds,
|
||||
renderSquadIds,
|
||||
squadIdsField,
|
||||
useSquadCatalog,
|
||||
} from "./squadField";
|
||||
|
||||
// Display labels mirror service/admin_panel/tables/rate_limiter.go.
|
||||
const STRATEGIES: { value: RateStrategy; label: string }[] = [
|
||||
{ value: "fixed_window", label: "Fixed window" },
|
||||
{ value: "sliding_window", label: "Sliding window" },
|
||||
{ value: "token_bucket", label: "Token bucket" },
|
||||
{ value: "leaky_bucket", label: "Leaky bucket" },
|
||||
{ value: "bypass", label: "Bypass" },
|
||||
];
|
||||
const CONN_TYPES: { value: RateConnectionType; label: string }[] = [
|
||||
{ value: "hwid", label: "HWID" },
|
||||
{ value: "mux", label: "Mux" },
|
||||
{ value: "source_ip", label: "Source IP" },
|
||||
{ value: "default", label: "Default" },
|
||||
];
|
||||
|
||||
export function RateLimitersPage() {
|
||||
const api = useApi();
|
||||
const squads = useSquadCatalog(api);
|
||||
// Memoise the CRUD config so CrudPage's `reload` callback stays stable
|
||||
// across re-renders. Recomputed when the API client flips or a new
|
||||
// squad name is merged into the catalog through observeRows.
|
||||
const config = useMemo<
|
||||
CrudConfig<RateLimiter, RateLimiterCreate, RateLimiterUpdate>
|
||||
>(() => ({
|
||||
title: "Rate limiters",
|
||||
icon: <FilterAltIcon />,
|
||||
idKey: "id",
|
||||
onRowsChange: (rows) => squads.observeRows(rows, pickSquadIds),
|
||||
columns: [
|
||||
{ key: "id", label: "ID" },
|
||||
{
|
||||
key: "squad_ids",
|
||||
label: "Squads",
|
||||
sortable: false,
|
||||
render: renderSquadIds<RateLimiter>(squads.names),
|
||||
},
|
||||
{ key: "username", label: "Username" },
|
||||
{ key: "outbound", label: "Outbound" },
|
||||
{ key: "strategy", label: "Strategy", render: renderOptionLabel<RateLimiter>("strategy", STRATEGIES) },
|
||||
{
|
||||
key: "connection_type",
|
||||
label: "Connection type",
|
||||
render: renderOptionLabel<RateLimiter>("connection_type", CONN_TYPES),
|
||||
},
|
||||
{
|
||||
key: "count",
|
||||
label: "Count",
|
||||
// bypass disables the limiter server-side
|
||||
// (excluded_if=Strategy bypass on the DTO), so `count` arrives
|
||||
// as 0. Render an unambiguous ∞ instead so the column reads as
|
||||
// "no cap" at a glance instead of looking like a real zero.
|
||||
render: (row) => (row.strategy === "bypass" ? "∞" : row.count),
|
||||
},
|
||||
{
|
||||
key: "interval",
|
||||
label: "Interval",
|
||||
// Same as `count`: `interval` is excluded_if=Strategy bypass and
|
||||
// arrives empty for bypass rows.
|
||||
render: (row) => (row.strategy === "bypass" ? "∞" : row.interval),
|
||||
},
|
||||
{ key: "created_at", label: "Created at" },
|
||||
{ key: "updated_at", label: "Updated at" },
|
||||
],
|
||||
filters: [
|
||||
{ name: "username", label: "Username", type: "text" },
|
||||
{ name: "outbound", label: "Outbound", type: "text" },
|
||||
{ name: "strategy", label: "Strategy", type: "select", options: STRATEGIES },
|
||||
{ name: "connection_type", label: "Connection type", type: "select", options: CONN_TYPES },
|
||||
{ name: "interval", label: "Interval", type: "text", placeholder: "e.g. 1s, 10s, 1m" },
|
||||
{ name: "created_at", label: "Created at", type: "datetime-range" },
|
||||
{ name: "updated_at", label: "Updated at", type: "datetime-range" },
|
||||
],
|
||||
fields: [
|
||||
// Mirror service/admin_panel/tables/rate_limiter.go: squads, username
|
||||
// and outbound are locked once the limiter exists.
|
||||
squadIdsField(squads.loadOptions),
|
||||
{ name: "username", label: "Username", type: "text", only: "create" },
|
||||
{ name: "outbound", label: "Outbound", type: "text", required: true, only: "create" },
|
||||
{
|
||||
name: "strategy",
|
||||
label: "Strategy",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: STRATEGIES,
|
||||
// bypass disables every post-Strategy field server-side
|
||||
// (excluded_if=Strategy bypass on the DTO). Wipe their values when
|
||||
// switching so a stale entry can't be smuggled out of a hidden field.
|
||||
clears: ["connection_type", "count", "interval"],
|
||||
},
|
||||
{
|
||||
name: "connection_type",
|
||||
label: "Connection type",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: CONN_TYPES,
|
||||
visibleWhen: (form) => form.strategy !== "bypass",
|
||||
},
|
||||
{
|
||||
name: "count",
|
||||
label: "Count",
|
||||
type: "number",
|
||||
required: true,
|
||||
visibleWhen: (form) => form.strategy !== "bypass",
|
||||
},
|
||||
{
|
||||
name: "interval",
|
||||
label: "Interval",
|
||||
type: "text",
|
||||
required: true,
|
||||
helperText: "e.g. 1s",
|
||||
visibleWhen: (form) => form.strategy !== "bypass",
|
||||
},
|
||||
],
|
||||
list: (q) => api.rateLimiters.list(q),
|
||||
count: (q) => api.rateLimiters.count(q),
|
||||
create: (b) => api.rateLimiters.create(b),
|
||||
update: (id, b) => api.rateLimiters.update(Number(id), b),
|
||||
remove: (id) => api.rateLimiters.remove(Number(id)),
|
||||
fromEntity: (e) => ({
|
||||
username: e.username ?? "",
|
||||
outbound: e.outbound,
|
||||
strategy: e.strategy,
|
||||
connection_type: e.connection_type,
|
||||
count: e.count,
|
||||
interval: e.interval,
|
||||
}),
|
||||
// `connection_type` / `count` / `interval` carry
|
||||
// `excluded_if=Strategy bypass` on the manager-api DTO and the
|
||||
// SQL repository unconditionally parses `interval` via
|
||||
// time.ParseDuration — sending `interval: ""` with a bypass
|
||||
// payload would make the server reject the request with 400
|
||||
// "invalid format". Drop the keys entirely on bypass so
|
||||
// `JSON.stringify` skips them.
|
||||
toCreate: (f) => {
|
||||
const strategy = String(f.strategy ?? "") as RateStrategy;
|
||||
const bypass = strategy === "bypass";
|
||||
return {
|
||||
squad_ids: parseSquadIds(f.squad_ids),
|
||||
username: f.username ? String(f.username) : undefined,
|
||||
outbound: String(f.outbound ?? "").trim(),
|
||||
strategy,
|
||||
connection_type: bypass
|
||||
? undefined
|
||||
: (String(f.connection_type ?? "") as RateConnectionType),
|
||||
count: bypass ? undefined : Number(f.count ?? 0),
|
||||
interval: bypass ? undefined : String(f.interval ?? "").trim(),
|
||||
};
|
||||
},
|
||||
toUpdate: (f, original) => {
|
||||
const strategy = String(f.strategy ?? "") as RateStrategy;
|
||||
const bypass = strategy === "bypass";
|
||||
return {
|
||||
username: original.username || undefined,
|
||||
outbound: original.outbound,
|
||||
strategy,
|
||||
connection_type: bypass
|
||||
? undefined
|
||||
: (String(f.connection_type ?? "") as RateConnectionType),
|
||||
count: bypass ? undefined : Number(f.count ?? 0),
|
||||
interval: bypass ? undefined : String(f.interval ?? "").trim(),
|
||||
};
|
||||
},
|
||||
}), [api, squads]);
|
||||
return <CrudPage<RateLimiter, RateLimiterCreate, RateLimiterUpdate> config={config} />;
|
||||
}
|
||||
40
service/admin_panel/web/src/pages/SquadsPage.tsx
Normal file
40
service/admin_panel/web/src/pages/SquadsPage.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import GroupsIcon from "@mui/icons-material/Groups";
|
||||
import { useMemo } from "react";
|
||||
import { CrudPage, type CrudConfig } from "../components/CrudPage";
|
||||
import { useApi } from "../auth/AuthContext";
|
||||
import type { Squad, SquadCreate, SquadUpdate } from "../api/types";
|
||||
|
||||
export function SquadsPage() {
|
||||
const api = useApi();
|
||||
// Memoise so CrudPage's `reload` callback (which depends on `config`)
|
||||
// keeps a stable identity across re-renders. Without this every parent
|
||||
// render would invalidate `reload` and refire the list+count requests.
|
||||
const config = useMemo<CrudConfig<Squad, SquadCreate, SquadUpdate>>(
|
||||
() => ({
|
||||
title: "Squads",
|
||||
icon: <GroupsIcon />,
|
||||
idKey: "id",
|
||||
columns: [
|
||||
{ key: "id", label: "ID" },
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "created_at", label: "Created At" },
|
||||
{ key: "updated_at", label: "Updated At" },
|
||||
],
|
||||
filters: [
|
||||
{ name: "name", label: "Name", type: "text" },
|
||||
{ name: "created_at", label: "Created at", type: "datetime-range" },
|
||||
{ name: "updated_at", label: "Updated at", type: "datetime-range" },
|
||||
],
|
||||
fields: [{ name: "name", label: "Name", type: "text", required: true }],
|
||||
list: (q) => api.squads.list(q),
|
||||
count: (q) => api.squads.count(q),
|
||||
create: (b) => api.squads.create(b),
|
||||
update: (id, b) => api.squads.update(Number(id), b),
|
||||
remove: (id) => api.squads.remove(Number(id)),
|
||||
toCreate: (f) => ({ name: String(f.name ?? "") }),
|
||||
toUpdate: (f) => ({ name: String(f.name ?? "") }),
|
||||
}),
|
||||
[api],
|
||||
);
|
||||
return <CrudPage<Squad, SquadCreate, SquadUpdate> config={config} />;
|
||||
}
|
||||
251
service/admin_panel/web/src/pages/TrafficLimitersPage.tsx
Normal file
251
service/admin_panel/web/src/pages/TrafficLimitersPage.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
||||
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
||||
import { Box, LinearProgress, Typography } from "@mui/material";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
CrudPage,
|
||||
renderOptionLabel,
|
||||
type CrudConfig,
|
||||
} from "../components/CrudPage";
|
||||
import { useApi } from "../auth/AuthContext";
|
||||
import { useNotify } from "../notifications/NotificationsProvider";
|
||||
import type {
|
||||
TrafficLimiter,
|
||||
TrafficLimiterCreate,
|
||||
TrafficLimiterUpdate,
|
||||
TrafficMode,
|
||||
TrafficStrategy,
|
||||
} from "../api/types";
|
||||
import {
|
||||
parseSquadIds,
|
||||
pickSquadIds,
|
||||
renderSquadIds,
|
||||
squadIdsField,
|
||||
useSquadCatalog,
|
||||
} from "./squadField";
|
||||
|
||||
// Display labels mirror service/admin_panel/tables/traffic_limiter.go.
|
||||
const STRATEGIES: { value: TrafficStrategy; label: string }[] = [
|
||||
{ value: "global", label: "Global" },
|
||||
{ value: "bypass", label: "Bypass" },
|
||||
];
|
||||
const MODES: { value: TrafficMode; label: string }[] = [
|
||||
{ value: "download", label: "Download" },
|
||||
{ value: "upload", label: "Upload" },
|
||||
{ value: "bidirectional", label: "Bidirectional" },
|
||||
];
|
||||
|
||||
// renderUsage shows the server-computed `usage` percentage (0–100,
|
||||
// floored in SQL) as a horizontal progress bar with a "P %" label next
|
||||
// to it. Computing on the back end keeps the bar's colour and the
|
||||
// number side-by-side: there's no risk of the FE rounding 99.6 → 100
|
||||
// while the colour threshold still sees 99.6.
|
||||
function renderUsage(row: TrafficLimiter) {
|
||||
const pct = Math.min(100, Math.max(0, Number(row.usage ?? 0)));
|
||||
// Hint at the danger zone — bar shifts from primary to warning to error
|
||||
// as the limiter approaches / exceeds its quota.
|
||||
const color = pct >= 100 ? "error" : pct >= 80 ? "warning" : "primary";
|
||||
return (
|
||||
// Capped-width wrapper: on the desktop table the bar always renders
|
||||
// at its 140 px max regardless of how wide the user has stretched
|
||||
// the "Usage" column (without the cap, the inner bar — which has
|
||||
// `flexGrow: 1` — would expand to fill the cell, making every
|
||||
// Usage column visibly different across viewports). On narrow
|
||||
// mobile cards the wrapper shrinks down to the cell's actual
|
||||
// width via `width: 100%` + `minWidth: 0`, so the bar stays on
|
||||
// the same row as its percentage label instead of overflowing.
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "nowrap",
|
||||
gap: 1,
|
||||
width: "100%",
|
||||
maxWidth: 140,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
flexShrink: 0,
|
||||
whiteSpace: "nowrap",
|
||||
// Mobile cards apply `wordBreak: break-word` to value cells,
|
||||
// which would let the browser snap "100%" between digits and
|
||||
// the percent sign on a narrow viewport. Forcing the value to
|
||||
// stay on one glyph row keeps the percent and the bar on the
|
||||
// same line at every screen width.
|
||||
wordBreak: "keep-all",
|
||||
overflowWrap: "normal",
|
||||
}}
|
||||
>
|
||||
{pct}%
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={pct}
|
||||
color={color}
|
||||
sx={{ height: 6, borderRadius: 3 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrafficLimitersPage() {
|
||||
const api = useApi();
|
||||
const notify = useNotify();
|
||||
const squads = useSquadCatalog(api);
|
||||
// Memoise the CRUD config so CrudPage's `reload` callback stays stable
|
||||
// across re-renders. Recomputed when the API client flips or a new
|
||||
// squad name is merged into the catalog through observeRows.
|
||||
const config = useMemo<
|
||||
CrudConfig<TrafficLimiter, TrafficLimiterCreate, TrafficLimiterUpdate>
|
||||
>(() => ({
|
||||
title: "Traffic limiters",
|
||||
icon: <SwapHorizIcon />,
|
||||
idKey: "id",
|
||||
onRowsChange: (rows) => squads.observeRows(rows, pickSquadIds),
|
||||
// Reset traffic — wipes raw_used to 0 via the manager-api
|
||||
// PUT /traffic-limiters/{id}/used endpoint, then reloads the
|
||||
// table so the Usage bar snaps back to 0 %. Hidden for rows that
|
||||
// already have zero usage so the button doesn't read as a no-op.
|
||||
rowActions: [
|
||||
{
|
||||
key: "reset",
|
||||
label: "Reset traffic",
|
||||
icon: <RestartAltIcon fontSize="small" />,
|
||||
visible: (row) => Number(row.raw_used ?? 0) > 0,
|
||||
// Pop a styled MUI confirm dialog (same chrome as the Delete
|
||||
// dialog), not a browser-native window.confirm. The dialog is
|
||||
// tinted "warning" to flag the action without reading as
|
||||
// destructive — usage data is recoverable but the counter
|
||||
// mid-period isn't.
|
||||
confirm: (row) => ({
|
||||
title: `Reset traffic for limiter #${row.id}?`,
|
||||
description:
|
||||
"The used traffic counter will be set back to 0. The limiter's quota and configuration are unchanged.",
|
||||
confirmLabel: "Reset",
|
||||
busyLabel: "Resetting…",
|
||||
color: "warning",
|
||||
}),
|
||||
onClick: async (row, ctx) => {
|
||||
await api.trafficLimiters.updateUsed(row.id, 0);
|
||||
notify.success(`Traffic reset for limiter #${row.id}`);
|
||||
await ctx.reload();
|
||||
},
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: "id", label: "ID" },
|
||||
{
|
||||
key: "squad_ids",
|
||||
label: "Squads",
|
||||
sortable: false,
|
||||
render: renderSquadIds<TrafficLimiter>(squads.names),
|
||||
},
|
||||
{ key: "username", label: "Username" },
|
||||
{ key: "outbound", label: "Outbound" },
|
||||
{ key: "strategy", label: "Strategy", render: renderOptionLabel<TrafficLimiter>("strategy", STRATEGIES) },
|
||||
{ key: "mode", label: "Mode", render: renderOptionLabel<TrafficLimiter>("mode", MODES) },
|
||||
{ key: "usage", label: "Usage", render: renderUsage },
|
||||
{
|
||||
key: "quota",
|
||||
label: "Quota",
|
||||
// bypass disables the limiter's quota server-side
|
||||
// (excluded_if=Strategy bypass on the DTO), so the row's `quota`
|
||||
// arrives as an empty string. Render an unambiguous ∞ instead so
|
||||
// the column reads as "no cap" at a glance instead of looking
|
||||
// like missing data.
|
||||
render: (row) => (row.strategy === "bypass" ? "∞" : row.quota),
|
||||
},
|
||||
{ key: "created_at", label: "Created at" },
|
||||
{ key: "updated_at", label: "Updated at" },
|
||||
],
|
||||
filters: [
|
||||
{ name: "username", label: "Username", type: "text" },
|
||||
{ name: "outbound", label: "Outbound", type: "text" },
|
||||
{ name: "strategy", label: "Strategy", type: "select", options: STRATEGIES },
|
||||
{ name: "mode", label: "Mode", type: "select", options: MODES },
|
||||
{ name: "created_at", label: "Created at", type: "datetime-range" },
|
||||
{ name: "updated_at", label: "Updated at", type: "datetime-range" },
|
||||
],
|
||||
fields: [
|
||||
// Mirror service/admin_panel/tables/traffic_limiter.go: squads,
|
||||
// username and outbound are locked once the limiter exists.
|
||||
squadIdsField(squads.loadOptions),
|
||||
{ name: "username", label: "Username", type: "text", only: "create" },
|
||||
{ name: "outbound", label: "Outbound", type: "text", required: true, only: "create" },
|
||||
{
|
||||
name: "strategy",
|
||||
label: "Strategy",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: STRATEGIES,
|
||||
// bypass disables every post-Strategy field server-side
|
||||
// (excluded_if=Strategy bypass on the DTO). Wipe their values when
|
||||
// switching so a stale entry can't be smuggled out of a hidden field.
|
||||
clears: ["mode", "quota"],
|
||||
},
|
||||
{
|
||||
name: "mode",
|
||||
label: "Mode",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: MODES,
|
||||
visibleWhen: (form) => form.strategy !== "bypass",
|
||||
},
|
||||
{
|
||||
name: "quota",
|
||||
label: "Quota",
|
||||
type: "text",
|
||||
required: true,
|
||||
helperText: "e.g. 10gb",
|
||||
visibleWhen: (form) => form.strategy !== "bypass",
|
||||
},
|
||||
],
|
||||
list: (q) => api.trafficLimiters.list(q),
|
||||
count: (q) => api.trafficLimiters.count(q),
|
||||
create: (b) => api.trafficLimiters.create(b),
|
||||
update: (id, b) => api.trafficLimiters.update(Number(id), b),
|
||||
remove: (id) => api.trafficLimiters.remove(Number(id)),
|
||||
fromEntity: (e) => ({
|
||||
username: e.username ?? "",
|
||||
outbound: e.outbound,
|
||||
strategy: e.strategy,
|
||||
mode: e.mode,
|
||||
quota: e.quota,
|
||||
}),
|
||||
// `mode` / `quota` carry `excluded_if=Strategy bypass` on the
|
||||
// manager-api DTO and the SQL repository unconditionally parses
|
||||
// `quota` via byteformats — sending empty strings with a bypass
|
||||
// payload would make the server reject the request with 400
|
||||
// "invalid format". Drop the keys entirely on bypass so
|
||||
// `JSON.stringify` skips them.
|
||||
toCreate: (f) => {
|
||||
const strategy = String(f.strategy ?? "") as TrafficStrategy;
|
||||
return {
|
||||
squad_ids: parseSquadIds(f.squad_ids),
|
||||
username: f.username ? String(f.username) : undefined,
|
||||
outbound: String(f.outbound ?? "").trim(),
|
||||
strategy,
|
||||
mode: strategy === "bypass" ? undefined : (String(f.mode ?? "") as TrafficMode),
|
||||
quota: strategy === "bypass" ? undefined : String(f.quota ?? "").trim(),
|
||||
};
|
||||
},
|
||||
toUpdate: (f, original) => {
|
||||
const strategy = String(f.strategy ?? "") as TrafficStrategy;
|
||||
return {
|
||||
username: original.username || undefined,
|
||||
outbound: original.outbound,
|
||||
strategy,
|
||||
mode: strategy === "bypass" ? undefined : (String(f.mode ?? "") as TrafficMode),
|
||||
quota: strategy === "bypass" ? undefined : String(f.quota ?? "").trim(),
|
||||
};
|
||||
},
|
||||
}), [api, notify, squads]);
|
||||
return <CrudPage<TrafficLimiter, TrafficLimiterCreate, TrafficLimiterUpdate> config={config} />;
|
||||
}
|
||||
157
service/admin_panel/web/src/pages/UsersPage.tsx
Normal file
157
service/admin_panel/web/src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import PeopleIcon from "@mui/icons-material/People";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
CrudPage,
|
||||
renderOptionLabel,
|
||||
type CrudConfig,
|
||||
} from "../components/CrudPage";
|
||||
import { useApi } from "../auth/AuthContext";
|
||||
import type { User, UserCreate, UserType, UserUpdate } from "../api/types";
|
||||
import {
|
||||
parseSquadIds,
|
||||
pickSquadIds,
|
||||
renderSquadIds,
|
||||
squadIdsField,
|
||||
useSquadCatalog,
|
||||
} from "./squadField";
|
||||
|
||||
// Display labels mirror service/admin_panel/tables/user.go.
|
||||
const USER_TYPES: { value: UserType; label: string }[] = [
|
||||
{ value: "hysteria", label: "Hysteria" },
|
||||
{ value: "hysteria2", label: "Hysteria2" },
|
||||
{ value: "mtproxy", label: "MTProxy" },
|
||||
{ value: "trojan", label: "Trojan" },
|
||||
{ value: "tuic", label: "TUIC" },
|
||||
{ value: "vless", label: "VLESS" },
|
||||
{ value: "vmess", label: "VMess" },
|
||||
];
|
||||
|
||||
const FLOW_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "xtls-rprx-vision", label: "xtls-rprx-vision" },
|
||||
];
|
||||
|
||||
// Per-type field visibility, mirroring FieldOnChooseOptionsHide in
|
||||
// service/admin_panel/tables/user.go and the struct-level validator for
|
||||
// constant.UserCreate in service/manager/service.go. Every field in a
|
||||
// SHOW_* set is also required for that type — the Go validator reports
|
||||
// "required" for each missing credential, so the client enforces the
|
||||
// same rule up-front (required fields invisible for the current type
|
||||
// are filtered out before validateRequired runs).
|
||||
const SHOW_UUID = new Set<UserType>(["vless", "vmess", "tuic"]);
|
||||
const SHOW_PASSWORD = new Set<UserType>(["hysteria", "hysteria2", "trojan", "tuic"]);
|
||||
const SHOW_SECRET = new Set<UserType>(["mtproxy"]);
|
||||
const SHOW_FLOW = new Set<UserType>(["vless"]);
|
||||
const SHOW_ALTER_ID = new Set<UserType>(["vmess"]);
|
||||
|
||||
const showFor = (set: Set<UserType>) => (form: Record<string, unknown>) =>
|
||||
set.has(form.type as UserType);
|
||||
|
||||
export function UsersPage() {
|
||||
const api = useApi();
|
||||
const squads = useSquadCatalog(api);
|
||||
// Memoise the CRUD config so CrudPage's `reload` callback stays stable
|
||||
// across re-renders. Recomputed only when the API client or the squad
|
||||
// catalog actually changes (a new squad name arriving through
|
||||
// observeRows re-renders the table with the fresh chip labels).
|
||||
const config = useMemo<CrudConfig<User, UserCreate, UserUpdate>>(() => ({
|
||||
title: "Users",
|
||||
icon: <PeopleIcon />,
|
||||
idKey: "id",
|
||||
// After each page of users is loaded, fetch the squad names
|
||||
// referenced by those rows (only the ones we haven't cached yet).
|
||||
onRowsChange: (rows) => squads.observeRows(rows, pickSquadIds),
|
||||
columns: [
|
||||
{ key: "id", label: "ID" },
|
||||
{
|
||||
key: "squad_ids",
|
||||
label: "Squads",
|
||||
sortable: false,
|
||||
render: renderSquadIds<User>(squads.names),
|
||||
},
|
||||
{ key: "username", label: "Username" },
|
||||
{ key: "inbound", label: "Inbound" },
|
||||
{ key: "type", label: "Type", render: renderOptionLabel<User>("type", USER_TYPES) },
|
||||
{ key: "created_at", label: "Created at" },
|
||||
{ key: "updated_at", label: "Updated at" },
|
||||
],
|
||||
filters: [
|
||||
{ name: "username", label: "Username", type: "text" },
|
||||
{ name: "inbound", label: "Inbound", type: "text" },
|
||||
{ name: "type", label: "Type", type: "select", options: USER_TYPES },
|
||||
{ name: "created_at", label: "Created at", type: "datetime-range" },
|
||||
{ name: "updated_at", label: "Updated at", type: "datetime-range" },
|
||||
],
|
||||
fields: [
|
||||
// FieldDisableWhenUpdate in service/admin_panel/tables/user.go.
|
||||
// The squad catalog is fetched lazily when the dialog opens —
|
||||
// never on page mount — via CrudDialog's optionsLoader.
|
||||
squadIdsField(squads.loadOptions),
|
||||
{ name: "username", label: "Username", type: "text", required: true, only: "create" },
|
||||
{ name: "inbound", label: "Inbound", type: "text", required: true, only: "create" },
|
||||
{
|
||||
name: "type",
|
||||
label: "Type",
|
||||
type: "select",
|
||||
required: true,
|
||||
only: "create",
|
||||
options: USER_TYPES,
|
||||
// Switching the user type wipes every credential field so the form
|
||||
// matches the legacy admin's behaviour of starting fresh.
|
||||
clears: ["uuid", "password", "secret", "flow", "alter_id"],
|
||||
},
|
||||
// Credential fields: the Go struct validator reports "required" for
|
||||
// whichever of these is missing once the type is chosen, so each one
|
||||
// is marked required AND gated by its SHOW_* set. Invisible fields
|
||||
// are filtered out before validateRequired runs, so e.g. Password is
|
||||
// only enforced for hysteria/hysteria2/trojan/tuic and not for vless.
|
||||
{ name: "uuid", label: "UUID", type: "uuid", required: true, visibleWhen: showFor(SHOW_UUID) },
|
||||
{ name: "password", label: "Password", type: "text", required: true, visibleWhen: showFor(SHOW_PASSWORD) },
|
||||
{ name: "secret", label: "Secret", type: "text", required: true, visibleWhen: showFor(SHOW_SECRET) },
|
||||
{
|
||||
name: "flow",
|
||||
label: "Flow",
|
||||
type: "select",
|
||||
options: FLOW_OPTIONS,
|
||||
visibleWhen: showFor(SHOW_FLOW),
|
||||
},
|
||||
{ name: "alter_id", label: "Alter ID", type: "number", required: true, visibleWhen: showFor(SHOW_ALTER_ID) },
|
||||
],
|
||||
list: (q) => api.users.list(q),
|
||||
count: (q) => api.users.count(q),
|
||||
create: (b) => api.users.create(b),
|
||||
update: (id, b) => api.users.update(Number(id), b),
|
||||
remove: (id) => api.users.remove(Number(id)),
|
||||
// Seed `type` even though the field is create-only; the dialog uses it
|
||||
// for visibleWhen when editing existing users.
|
||||
fromEntity: (u) => ({
|
||||
squad_ids: u.squad_ids,
|
||||
type: u.type,
|
||||
uuid: u.uuid,
|
||||
password: u.password,
|
||||
secret: u.secret,
|
||||
flow: u.flow,
|
||||
alter_id: u.alter_id,
|
||||
}),
|
||||
toCreate: (f) => ({
|
||||
squad_ids: parseSquadIds(f.squad_ids),
|
||||
username: String(f.username ?? "").trim(),
|
||||
inbound: String(f.inbound ?? "").trim(),
|
||||
type: String(f.type ?? "") as UserType,
|
||||
uuid: f.uuid ? String(f.uuid).trim() : undefined,
|
||||
password: f.password ? String(f.password) : undefined,
|
||||
secret: f.secret ? String(f.secret) : undefined,
|
||||
flow: f.flow ? String(f.flow) : undefined,
|
||||
alter_id: f.alter_id !== undefined && f.alter_id !== "" ? Number(f.alter_id) : undefined,
|
||||
}),
|
||||
toUpdate: (f) => {
|
||||
const out: UserUpdate = {};
|
||||
if (f.uuid && String(f.uuid).trim() !== "") out.uuid = String(f.uuid).trim();
|
||||
if (f.password !== undefined && f.password !== "") out.password = String(f.password);
|
||||
if (f.secret !== undefined && f.secret !== "") out.secret = String(f.secret);
|
||||
if (f.flow !== undefined && f.flow !== "") out.flow = String(f.flow);
|
||||
if (f.alter_id !== undefined && f.alter_id !== "") out.alter_id = Number(f.alter_id);
|
||||
return out;
|
||||
},
|
||||
}), [api, squads]);
|
||||
return <CrudPage<User, UserCreate, UserUpdate> config={config} />;
|
||||
}
|
||||
197
service/admin_panel/web/src/pages/squadField.tsx
Normal file
197
service/admin_panel/web/src/pages/squadField.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
// Helpers shared by every page that exposes a `squad_ids` field. Keeps the
|
||||
// FieldSpec and the `string[] → number[]` conversion in one place so the
|
||||
// pages don't repeat themselves.
|
||||
|
||||
import { Box, Chip } from "@mui/material";
|
||||
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import type { Api } from "../api/client";
|
||||
import type { FieldSpec } from "../components/CrudPage";
|
||||
|
||||
export interface SquadCatalog {
|
||||
// Map keyed by squad id → squad name. Populated on demand via
|
||||
// `observeRows`: each refresh of a CRUD table declares which squad
|
||||
// ids appear in the visible page and we fetch names only for the
|
||||
// subset that isn't already cached.
|
||||
names: Map<number, string>;
|
||||
// observeRows takes the freshly loaded page of rows, extracts their
|
||||
// squad ids via `pick`, and fires a GET /squads?id_in=… for the ids
|
||||
// whose names we haven't seen yet. Wired to `CrudConfig.onRowsChange`,
|
||||
// which awaits the returned promise before publishing the rows — that
|
||||
// way the table only paints once both the row data and the squad
|
||||
// names referenced by those rows are in hand, instead of flashing
|
||||
// raw ids before the chip labels resolve. Resolves immediately when
|
||||
// every visible squad id is already cached (or in flight from a
|
||||
// previous refresh).
|
||||
observeRows: <TRow>(rows: TRow[], pick: (row: TRow) => number[] | undefined) => Promise<void>;
|
||||
// loadOptions fetches every squad and returns the multi-select
|
||||
// option list. Intended for CrudDialog's `optionsLoader`, which
|
||||
// fires the first time a create dialog is opened — that way the
|
||||
// full catalog is only downloaded when the user actually needs to
|
||||
// pick squads, not on page mount.
|
||||
loadOptions: () => Promise<{ value: string; label: string }[]>;
|
||||
}
|
||||
|
||||
// useSquadCatalog returns the per-page squad-name cache + option loader.
|
||||
// Unlike the previous `useSquads` the cache starts empty — names are
|
||||
// populated on demand as rows arrive through `observeRows`, and the
|
||||
// create-form options are fetched lazily via `loadOptions`. Result:
|
||||
// visiting e.g. the Users page no longer triggers an unconditional
|
||||
// GET /squads for the entire squad table.
|
||||
export function useSquadCatalog(api: Api): SquadCatalog {
|
||||
const [names, setNames] = useState<Map<number, string>>(() => new Map());
|
||||
// resolvedRef mirrors `names` for synchronous reads inside observeRows
|
||||
// (state reads from the hook closure are stale across renders, and we
|
||||
// need an up-to-date "is this id known?" check on every call). Kept
|
||||
// as a ref because it must never trigger a re-render on its own —
|
||||
// visual updates are driven by `names`.
|
||||
const resolvedRef = useRef<Set<number>>(new Set());
|
||||
// inFlightRef tracks pending GET /squads requests by id so a second
|
||||
// observeRows that arrives while the first is still loading shares
|
||||
// the same promise instead of either (a) re-firing the request or
|
||||
// (b) resolving immediately with names that aren't loaded yet — the
|
||||
// latter would defeat the "wait before painting" contract CrudPage
|
||||
// relies on.
|
||||
const inFlightRef = useRef<Map<number, Promise<void>>>(new Map());
|
||||
|
||||
const observeRows = useCallback(
|
||||
async <TRow,>(rows: TRow[], pick: (row: TRow) => number[] | undefined) => {
|
||||
const need = new Set<number>();
|
||||
for (const row of rows) {
|
||||
const ids = pick(row);
|
||||
if (!Array.isArray(ids)) continue;
|
||||
for (const id of ids) {
|
||||
if (!Number.isFinite(id)) continue;
|
||||
need.add(id);
|
||||
}
|
||||
}
|
||||
if (need.size === 0) return;
|
||||
|
||||
const waiting: Promise<void>[] = [];
|
||||
const fresh: number[] = [];
|
||||
for (const id of need) {
|
||||
if (resolvedRef.current.has(id)) continue;
|
||||
const pending = inFlightRef.current.get(id);
|
||||
if (pending) {
|
||||
waiting.push(pending);
|
||||
continue;
|
||||
}
|
||||
fresh.push(id);
|
||||
}
|
||||
|
||||
if (fresh.length > 0) {
|
||||
const request = (async () => {
|
||||
try {
|
||||
const squads = await api.squads.list({ id_in: fresh });
|
||||
if (squads.length > 0) {
|
||||
for (const s of squads) resolvedRef.current.add(s.id);
|
||||
setNames((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const s of squads) next.set(s.id, s.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// Ids whose name is missing from the response are still
|
||||
// marked resolved — re-asking the server would just
|
||||
// produce the same empty answer, and leaving them in
|
||||
// limbo would make every subsequent refresh re-fetch
|
||||
// them. The chip falls back to the raw id label.
|
||||
for (const id of fresh) resolvedRef.current.add(id);
|
||||
} catch {
|
||||
// Failed fetches stay unresolved so a future refresh can
|
||||
// retry — otherwise the chips would remain "1", "2"
|
||||
// forever.
|
||||
} finally {
|
||||
for (const id of fresh) inFlightRef.current.delete(id);
|
||||
}
|
||||
})();
|
||||
for (const id of fresh) inFlightRef.current.set(id, request);
|
||||
waiting.push(request);
|
||||
}
|
||||
|
||||
if (waiting.length > 0) await Promise.all(waiting);
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
const loadOptions = useCallback(async () => {
|
||||
const squads = await api.squads.list();
|
||||
setNames((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const s of squads) {
|
||||
next.set(s.id, s.name);
|
||||
resolvedRef.current.add(s.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return squads.map((s) => ({ value: String(s.id), label: s.name }));
|
||||
}, [api]);
|
||||
|
||||
return useMemo<SquadCatalog>(
|
||||
() => ({ names, observeRows, loadOptions }),
|
||||
[names, observeRows, loadOptions],
|
||||
);
|
||||
}
|
||||
|
||||
// squadIdsField produces the `FieldSpec` for the squad_ids multi-select
|
||||
// shown in every create form. The option list is supplied via an
|
||||
// `optionsLoader` so the catalog is fetched the first time a create
|
||||
// dialog is opened — not on page mount.
|
||||
export function squadIdsField(
|
||||
loadOptions: () => Promise<{ value: string; label?: string }[]>,
|
||||
overrides?: Partial<FieldSpec>,
|
||||
): FieldSpec {
|
||||
return {
|
||||
name: "squad_ids",
|
||||
label: "Squads",
|
||||
type: "multiselect",
|
||||
required: true,
|
||||
only: "create",
|
||||
optionsLoader: loadOptions,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// parseSquadIds converts whatever the multi-select gave us back into a
|
||||
// number[] suitable for the API.
|
||||
export function parseSquadIds(raw: unknown): number[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return (raw as unknown[])
|
||||
.map((v) => Number(v))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
}
|
||||
|
||||
// renderSquadIds returns a CrudPage column `render` function that turns
|
||||
// a `squad_ids: number[]` field into a wrapping row of name chips,
|
||||
// keyed off the id→name map produced by useSquadCatalog.
|
||||
export function renderSquadIds<TRow extends { squad_ids?: number[] | null }>(
|
||||
names: Map<number, string>,
|
||||
): (row: TRow) => ReactNode {
|
||||
return (row: TRow) => {
|
||||
const ids = row.squad_ids;
|
||||
if (!Array.isArray(ids) || ids.length === 0) return "";
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
rowGap: 0.5,
|
||||
columnGap: 0.5,
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{ids.map((id) => (
|
||||
<Chip key={id} label={names.get(id) ?? String(id)} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// pickSquadIds is the default extractor passed to `observeRows` from
|
||||
// rows whose squad membership sits in a canonical `squad_ids` field.
|
||||
// Exposed so pages don't have to inline the same `(row) => row.squad_ids`.
|
||||
export function pickSquadIds<TRow extends { squad_ids?: number[] | null }>(
|
||||
row: TRow,
|
||||
): number[] | undefined {
|
||||
return row.squad_ids ?? undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user