Add new admin panel, failover, dns fallback, providers, limiters. Update XHTTP

This commit is contained in:
Shtorm
2026-05-11 00:59:35 +03:00
parent 764ae8107c
commit 34b09a8ef3
241 changed files with 36409 additions and 4086 deletions

View 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),
};
}

View 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} />
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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} />;
}

View 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} />;
}

View 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 (0100,
// 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} />;
}

View 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} />;
}

View 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;
}