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

This commit is contained in:
Sergei Maklagin
2026-05-11 00:59:35 +03:00
parent 652e0baf57
commit 3bd162ed6f
241 changed files with 36409 additions and 4086 deletions

View File

@@ -0,0 +1,111 @@
# admin_panel
A TypeScript (Vite + React + Material UI, dark theme) admin panel that talks to
the `manager_api/http` server.
The compiled SPA is **embedded into the Go binary via `//go:embed`** — the
post-processed `dist/` directory is checked into the repo, so
`go build -tags with_admin_panel ./cmd/sing-box` produces a fully
self-contained binary with no Node.js required at compile time.
The `web/` directory holds the original TypeScript sources, which are only
needed when the panel itself is being modified. `make admin_panel_regen`
rebuilds `dist/` from those sources via `npm run build` followed by an
in-place post-processing step (`cmd/internal/admin_panel_pack`).
## Layout
```
service/admin_panel/
├── service.go # Go service (build tag: with_admin_panel, //go:embed dist)
├── service_stub.go # Stub when the tag is missing
├── service_test.go # Tests for the SPA handler
├── dist/ # Embedded SPA bytes (committed to git)
│ ├── index.html
│ ├── index.html.gz
│ └── assets/
│ ├── index-*.js
│ ├── index-*.js.gz
│ ├── index-*.css
│ ├── index-*.css.gz
│ └── inter-*.woff2
└── web/ # Vite + React TypeScript source (only needed to regen)
├── package.json
├── vite.config.ts
├── tsconfig.json
├── index.html
└── src/
├── main.tsx
├── App.tsx
├── theme.ts
├── api/{client,types}.ts
├── auth/AuthContext.tsx
├── components/{Layout,CrudPage}.tsx
└── pages/*.tsx
cmd/internal/admin_panel_pack/
└── main.go # Post-processor: drops .woff, rewrites CSS, pre-gzips text
```
## Building sing-box with the panel
`dist/` is committed, so any developer or CI machine can build a
self-contained binary with no Node.js / npm available:
```bash
go build -tags with_admin_panel ./cmd/sing-box
# or
make build_admin_panel
```
If the tag is omitted the service registers a stub that errors out on start.
## Regenerating dist/
Whenever the SPA source under `web/` is changed, run:
```bash
make admin_panel_regen
```
That target performs two steps:
1. `make admin_panel_web``npm install` + `npm run build` in `web/`,
producing `service/admin_panel/dist/`.
2. `make admin_panel_pack` — runs the
`cmd/internal/admin_panel_pack` post-processor, which:
- deletes the legacy `*.woff` (WOFF 1.0) fallback fonts; every browser
since 2014 reads WOFF2 natively, and shipping both formats roughly
doubles the embedded font payload;
- rewrites the bundled `*.css` to drop `,url(...).woff) format("woff")`
references so the @font-face rules don't point at files that aren't
shipped;
- drops a gzip-compressed `*.gz` companion next to every text-like
asset (.html, .css, .js, .svg, .json) at `gzip.BestCompression`. The
runtime serves those bytes verbatim with `Content-Encoding: gzip`
when the client advertises gzip support, and falls back to the raw
file otherwise.
After the post-processing pass, commit the resulting `dist/` directory
along with your source changes.
## Configuring sing-box
Add a service entry of type `admin-panel`:
```json
{
"services": [
{
"type": "admin-panel",
"tag": "admin",
"listen": "127.0.0.1",
"listen_port": 8081
}
]
}
```
The panel itself does not require any back-end auth: at sign-in the user
enters the URL and bearer key of a `manager-api` instance. Both are stored
only in the browser's `localStorage`.

View File

@@ -1,400 +0,0 @@
package migration
import (
"database/sql"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/sagernet/sing-box/common/migrate/source"
)
var migrations = map[string]string{
"1_initialize_schema.up.sql": `
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
CREATE SEQUENCE public.goadmin_menu_myid_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
MAXVALUE 99999999
CACHE 1;
SET default_tablespace = '';
SET default_table_access_method = heap;
CREATE TABLE public.goadmin_menu (
id integer DEFAULT nextval('public.goadmin_menu_myid_seq'::regclass) NOT NULL,
parent_id integer DEFAULT 0 NOT NULL,
type integer DEFAULT 0,
"order" integer DEFAULT 0 NOT NULL,
title character varying(50) NOT NULL,
header character varying(100),
plugin_name character varying(100) NOT NULL,
icon character varying(50) NOT NULL,
uri character varying(3000) NOT NULL,
uuid character varying(100),
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE SEQUENCE public.goadmin_operation_log_myid_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
MAXVALUE 99999999
CACHE 1;
CREATE TABLE public.goadmin_operation_log (
id integer DEFAULT nextval('public.goadmin_operation_log_myid_seq'::regclass) NOT NULL,
user_id integer NOT NULL,
path character varying(255) NOT NULL,
method character varying(10) NOT NULL,
ip character varying(15) NOT NULL,
input text NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE SEQUENCE public.goadmin_permissions_myid_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
MAXVALUE 99999999
CACHE 1;
CREATE TABLE public.goadmin_permissions (
id integer DEFAULT nextval('public.goadmin_permissions_myid_seq'::regclass) NOT NULL,
name character varying(50) NOT NULL,
slug character varying(50) NOT NULL,
http_method character varying(255),
http_path text NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE TABLE public.goadmin_role_menu (
role_id integer NOT NULL,
menu_id integer NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE TABLE public.goadmin_role_permissions (
role_id integer NOT NULL,
permission_id integer NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE TABLE public.goadmin_role_users (
role_id integer NOT NULL,
user_id integer NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE SEQUENCE public.goadmin_roles_myid_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
MAXVALUE 99999999
CACHE 1;
CREATE TABLE public.goadmin_roles (
id integer DEFAULT nextval('public.goadmin_roles_myid_seq'::regclass) NOT NULL,
name character varying NOT NULL,
slug character varying NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE SEQUENCE public.goadmin_session_myid_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
MAXVALUE 99999999
CACHE 1;
CREATE TABLE public.goadmin_session (
id integer DEFAULT nextval('public.goadmin_session_myid_seq'::regclass) NOT NULL,
sid character varying(50) NOT NULL,
"values" character varying(3000) NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE SEQUENCE public.goadmin_site_myid_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
MAXVALUE 99999999
CACHE 1;
CREATE TABLE public.goadmin_site (
id integer DEFAULT nextval('public.goadmin_site_myid_seq'::regclass) NOT NULL,
key character varying(100) NOT NULL,
value text NOT NULL,
type integer DEFAULT 0,
description character varying(3000),
state integer DEFAULT 0,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE TABLE public.goadmin_user_permissions (
user_id integer NOT NULL,
permission_id integer NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE SEQUENCE public.goadmin_users_myid_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
MAXVALUE 99999999
CACHE 1;
CREATE TABLE public.goadmin_users (
id integer DEFAULT nextval('public.goadmin_users_myid_seq'::regclass) NOT NULL,
username character varying(100) NOT NULL,
password character varying(100) NOT NULL,
name character varying(100) NOT NULL,
avatar character varying(255),
remember_token character varying(100),
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (1, 0, 1, 1, 'Dashboard', NULL, '', 'fa-bar-chart', '/', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (2, 0, 1, 2, 'Admin', NULL, '', 'fa-tasks', '', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (3, 2, 1, 2, 'Users', NULL, '', 'fa-users', '/info/manager', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (4, 2, 1, 3, 'Roles', NULL, '', 'fa-user', '/info/roles', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (5, 2, 1, 4, 'Permission', NULL, '', 'fa-ban', '/info/permission', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (7, 2, 1, 6, 'Operation log', NULL, '', 'fa-history', '/info/op', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (9, 0, 0, 9, 'Users', '', '', 'fa-users', '/info/users', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (14, 0, 0, 12, 'Github', 'Miscellaneous', '', 'fa-github', 'https://github.com/shtorm-7/sing-box-extended', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (15, 0, 0, 13, 'Donate', '', '', 'fa-heart', 'https://github.com/shtorm-7/sing-box-extended#support-the-project', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (13, 0, 0, 7, 'Squads', 'General', '', 'fa-gg', '/info/squads', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (11, 0, 0, 8, 'Nodes', '', '', 'fa-sitemap', '/info/nodes', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (10, 0, 0, 10, 'Connection limiters', 'Limiters', '', 'fa-plug', '/info/connection_limiters', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (8, 0, 0, 11, 'Bandwidth limiters', '', '', 'fa-dashboard', '/info/bandwidth_limiters', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_permissions (id, name, slug, http_method, http_path, created_at, updated_at) VALUES (1, 'All permission', '*', '', '*', '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_permissions (id, name, slug, http_method, http_path, created_at, updated_at) VALUES (2, 'Dashboard', 'dashboard', 'GET,PUT,POST,DELETE', '/', '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (1, 7, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (2, 7, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (1, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_users (role_id, user_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_users (role_id, user_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_roles (id, name, slug, created_at, updated_at) VALUES (1, 'Administrator', 'administrator', '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_roles (id, name, slug, created_at, updated_at) VALUES (2, 'Operator', 'operator', '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (6, 'site_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.436501', '2026-02-15 09:57:02.436501');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (7, 'prohibit_config_modification', 'false', 0, NULL, 1, '2026-02-15 09:57:02.441183', '2026-02-15 09:57:02.441183');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (11, 'login_url', '/login', 0, NULL, 1, '2026-02-15 09:57:02.459525', '2026-02-15 09:57:02.459525');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (16, 'open_admin_api', 'false', 0, NULL, 1, '2026-02-15 09:57:02.483908', '2026-02-15 09:57:02.483908');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (18, 'domain', '', 0, NULL, 1, '2026-02-15 09:57:02.493151', '2026-02-15 09:57:02.493151');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (23, 'asset_root_path', './public/', 0, NULL, 1, '2026-02-15 09:57:02.517213', '2026-02-15 09:57:02.517213');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (24, 'url_prefix', 'admin', 0, NULL, 1, '2026-02-15 09:57:02.521815', '2026-02-15 09:57:02.521815');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (33, 'exclude_theme_components', 'null', 0, NULL, 1, '2026-02-15 09:57:02.565725', '2026-02-15 09:57:02.565725');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (39, 'app_id', 'Qn0eh7HQsrt9', 0, NULL, 1, '2026-02-15 09:57:02.592551', '2026-02-15 09:57:02.592551');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (41, 'auth_user_table', 'goadmin_users', 0, NULL, 1, '2026-02-15 09:57:02.601496', '2026-02-15 09:57:02.601496');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (53, 'bootstrap_file_path', '', 0, NULL, 1, '2026-02-15 09:57:02.658984', '2026-02-15 09:57:02.658984');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (55, 'index_url', '/', 0, NULL, 1, '2026-02-15 09:57:02.668457', '2026-02-15 09:57:02.668457');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (66, 'login_logo', '', 0, NULL, 1, '2026-02-15 09:57:02.719608', '2026-02-15 09:57:02.719608');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (67, 'hide_visitor_user_center_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.724307', '2026-02-15 09:57:02.724307');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (68, 'go_mod_file_path', '', 0, NULL, 1, '2026-02-15 09:57:02.728694', '2026-02-15 09:57:02.728694');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (3, 'logger_encoder_caller', 'full', 0, NULL, 1, '2026-02-15 09:57:02.420312', '2026-02-15 09:57:02.420312');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (60, 'logger_encoder_caller_key', 'caller', 0, NULL, 1, '2026-02-15 09:57:02.692189', '2026-02-15 09:57:02.692189');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (34, 'logo', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.570594', '2026-02-15 09:57:02.570594');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (69, 'env', 'prod', 0, NULL, 1, '2026-02-15 09:57:02.733059', '2026-02-15 09:57:02.733059');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (29, 'color_scheme', 'skin-black', 0, NULL, 1, '2026-02-15 09:57:02.545599', '2026-02-15 09:57:02.545599');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (17, 'allow_del_operation_log', 'false', 0, NULL, 1, '2026-02-15 09:57:02.488458', '2026-02-15 09:57:02.488458');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (35, 'info_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.574649', '2026-02-15 09:57:02.574649');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (22, 'operation_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.512394', '2026-02-15 09:57:02.512394');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (42, 'hide_app_info_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.606071', '2026-02-15 09:57:02.606071');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (12, 'access_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.464612', '2026-02-15 09:57:02.464612');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (32, 'logger_rotate_max_age', '30', 0, NULL, 1, '2026-02-15 09:57:02.560801', '2026-02-15 09:57:02.560801');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (40, 'custom_foot_html', '', 0, NULL, 1, '2026-02-15 09:57:02.597285', '2026-02-15 09:57:02.597285');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (62, 'logger_encoder_duration', 'string', 0, NULL, 1, '2026-02-15 09:57:02.701522', '2026-02-15 09:57:02.701522');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (65, 'logger_encoder_level_key', 'level', 0, NULL, 1, '2026-02-15 09:57:02.715108', '2026-02-15 09:57:02.715108');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (64, 'debug', 'false', 0, NULL, 1, '2026-02-15 09:57:02.710705', '2026-02-15 09:57:02.710705');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (43, 'hide_plugin_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.610825', '2026-02-15 09:57:02.610825');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (54, 'animation_type', '', 0, NULL, 1, '2026-02-15 09:57:02.663713', '2026-02-15 09:57:02.663713');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (48, 'theme', 'sword', 0, NULL, 1, '2026-02-15 09:57:02.634039', '2026-02-15 09:57:02.634039');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (45, 'info_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.620165', '2026-02-15 09:57:02.620165');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (31, 'error_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.555798', '2026-02-15 09:57:02.555798');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (5, 'asset_url', '', 0, NULL, 1, '2026-02-15 09:57:02.431855', '2026-02-15 09:57:02.431855');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (36, 'logger_encoder_encoding', 'console', 0, NULL, 1, '2026-02-15 09:57:02.579052', '2026-02-15 09:57:02.579052');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (27, 'login_title', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.536102', '2026-02-15 09:57:02.536102');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (51, 'animation_duration', '0.00', 0, NULL, 1, '2026-02-15 09:57:02.64867', '2026-02-15 09:57:02.64867');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (19, 'file_upload_engine', '{"name":"local"}', 0, NULL, 1, '2026-02-15 09:57:02.49794', '2026-02-15 09:57:02.49794');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (26, 'logger_encoder_time', 'iso8601', 0, NULL, 1, '2026-02-15 09:57:02.531365', '2026-02-15 09:57:02.531365');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (10, 'custom_404_html', '', 0, NULL, 1, '2026-02-15 09:57:02.454777', '2026-02-15 09:57:02.454777');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (58, 'sql_log', 'false', 0, NULL, 1, '2026-02-15 09:57:02.682567', '2026-02-15 09:57:02.682567');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (2, 'logger_encoder_message_key', 'msg', 0, NULL, 1, '2026-02-15 09:57:02.415189', '2026-02-15 09:57:02.415189');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (46, 'logger_encoder_stacktrace_key', 'stacktrace', 0, NULL, 1, '2026-02-15 09:57:02.624977', '2026-02-15 09:57:02.624977');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (63, 'mini_logo', 'SBE', 0, NULL, 1, '2026-02-15 09:57:02.706145', '2026-02-15 09:57:02.706145');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (38, 'custom_403_html', '', 0, NULL, 1, '2026-02-15 09:57:02.588062', '2026-02-15 09:57:02.588062');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (30, 'language', 'en', 0, NULL, 1, '2026-02-15 09:57:02.550466', '2026-02-15 09:57:02.550466');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (15, 'hide_config_center_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.479097', '2026-02-15 09:57:02.479097');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (59, 'logger_rotate_max_backups', '5', 0, NULL, 1, '2026-02-15 09:57:02.687429', '2026-02-15 09:57:02.687429');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (57, 'custom_head_html', '', 0, NULL, 1, '2026-02-15 09:57:02.677723', '2026-02-15 09:57:02.677723');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (52, 'custom_500_html', '', 0, NULL, 1, '2026-02-15 09:57:02.654236', '2026-02-15 09:57:02.654236');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (44, 'title', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.615471', '2026-02-15 09:57:02.615471');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (47, 'session_life_time', '7200', 0, NULL, 1, '2026-02-15 09:57:02.629619', '2026-02-15 09:57:02.629619');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (8, 'access_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.445593', '2026-02-15 09:57:02.445593');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (49, 'error_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.6385', '2026-02-15 09:57:02.6385');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (50, 'logger_rotate_max_size', '10', 0, NULL, 1, '2026-02-15 09:57:02.643733', '2026-02-15 09:57:02.643733');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (14, 'logger_rotate_compress', 'false', 0, NULL, 1, '2026-02-15 09:57:02.474296', '2026-02-15 09:57:02.474296');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (13, 'logger_encoder_time_key', 'ts', 0, NULL, 1, '2026-02-15 09:57:02.469396', '2026-02-15 09:57:02.469396');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (37, 'animation_delay', '0.00', 0, NULL, 1, '2026-02-15 09:57:02.583815', '2026-02-15 09:57:02.583815');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (20, 'extra', '', 0, NULL, 1, '2026-02-15 09:57:02.50276', '2026-02-15 09:57:02.50276');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (25, 'access_assets_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.526618', '2026-02-15 09:57:02.526618');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (4, 'logger_level', '0', 0, NULL, 1, '2026-02-15 09:57:02.426736', '2026-02-15 09:57:02.426736');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (9, 'footer_info', '', 0, NULL, 1, '2026-02-15 09:57:02.450409', '2026-02-15 09:57:02.450409');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (21, 'no_limit_login_ip', 'false', 0, NULL, 1, '2026-02-15 09:57:02.507609', '2026-02-15 09:57:02.507609');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (28, 'hide_tool_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.540813', '2026-02-15 09:57:02.540813');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (61, 'logger_encoder_level', 'capitalColor', 0, NULL, 1, '2026-02-15 09:57:02.696859', '2026-02-15 09:57:02.696859');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (56, 'logger_encoder_name_key', 'logger', 0, NULL, 1, '2026-02-15 09:57:02.672962', '2026-02-15 09:57:02.672962');
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_users (id, username, password, name, avatar, remember_token, created_at, updated_at) VALUES (2, 'operator', '$2a$10$rVqkOzHjN2MdlEprRflb1eGP0oZXuSrbJLOmJagFsCd81YZm0bsh.', 'Operator', '', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_users (id, username, password, name, avatar, remember_token, created_at, updated_at) VALUES (1, 'admin', '$2a$10$ilNHHnX5S6EMw.Ffc1Y1JezYCyquFIO.7Z0vLr1eHJUXnGy4cdrtq', 'admin', '', 'tlNcBVK9AvfYH7WEnwB1RKvocJu8FfRy4um3DJtwdHuJy0dwFsLOgAc0xUfh', '2019-09-10 00:00:00', '2019-09-10 00:00:00');
SELECT pg_catalog.setval('public.goadmin_menu_myid_seq', 12, true);
SELECT pg_catalog.setval('public.goadmin_operation_log_myid_seq', 11, true);
SELECT pg_catalog.setval('public.goadmin_permissions_myid_seq', 2, true);
SELECT pg_catalog.setval('public.goadmin_roles_myid_seq', 2, true);
SELECT pg_catalog.setval('public.goadmin_session_myid_seq', 7, true);
SELECT pg_catalog.setval('public.goadmin_site_myid_seq', 69, true);
SELECT pg_catalog.setval('public.goadmin_users_myid_seq', 2, true);
ALTER TABLE ONLY public.goadmin_menu
ADD CONSTRAINT goadmin_menu_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.goadmin_operation_log
ADD CONSTRAINT goadmin_operation_log_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.goadmin_permissions
ADD CONSTRAINT goadmin_permissions_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.goadmin_roles
ADD CONSTRAINT goadmin_roles_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.goadmin_session
ADD CONSTRAINT goadmin_session_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.goadmin_site
ADD CONSTRAINT goadmin_site_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.goadmin_users
ADD CONSTRAINT goadmin_users_pkey PRIMARY KEY (id);
`,
"1_initialize_schema.down.sql": ``,
}
func MigratePostgreSQL(db *sql.DB) error {
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return err
}
sourceDriver := source.NewRawDriver(migrations)
if err := sourceDriver.Init(); err != nil {
return err
}
m, err := migrate.NewWithInstance(
"raw",
sourceDriver,
"postgres",
driver,
)
if err != nil {
return err
}
return m.Up()
}

View File

@@ -1,13 +0,0 @@
package pages
import (
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/template/types"
)
func DashboardPage(ctx *context.Context) (types.Panel, error) {
return types.Panel{
Title: "Dashboard",
}, nil
}

View File

@@ -4,24 +4,16 @@ package admin_panel
import (
"context"
"database/sql"
"embed"
"encoding/json"
"errors"
"io/fs"
"mime"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/golang-migrate/migrate/v4"
_ "github.com/lib/pq"
"golang.org/x/net/http2"
_ "github.com/GoAdminGroup/go-admin/adapter/chi"
"github.com/GoAdminGroup/go-admin/engine"
"github.com/GoAdminGroup/go-admin/modules/config"
_ "github.com/GoAdminGroup/go-admin/modules/db/drivers/sqlite"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template"
"github.com/GoAdminGroup/go-admin/template/chartjs"
_ "github.com/GoAdminGroup/themes/adminlte"
_ "github.com/GoAdminGroup/themes/sword"
"path"
"strconv"
"strings"
"time"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
@@ -30,17 +22,45 @@ import (
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/service/admin_panel/migration"
"github.com/sagernet/sing-box/service/admin_panel/pages"
"github.com/sagernet/sing-box/service/admin_panel/tables"
CM "github.com/sagernet/sing-box/service/manager/constant"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
N "github.com/sagernet/sing/common/network"
aTLS "github.com/sagernet/sing/common/tls"
"github.com/sagernet/sing/service"
sHTTP "github.com/sagernet/sing/protocol/http"
"github.com/go-chi/chi/v5"
"golang.org/x/net/http2"
)
// distFS holds the SPA bytes produced by `npm run build` (Vite) and then
// post-processed by `cmd/internal/admin_panel_pack`. The directory
// is checked into the repo so a plain `go build -tags with_admin_panel`
// produces a self-contained binary — no Node.js required at compile time.
//
// The post-processor:
// - deletes the legacy *.woff fonts (every browser since 2014 reads
// WOFF2 natively) and removes their references from the bundled CSS;
// - drops a gzip-compressed `*.gz` companion next to every compressible
// text asset (.html, .css, .js, …) using BestCompression. We pass
// those bytes through verbatim with Content-Encoding: gzip when the
// client advertises gzip, and fall back to the raw file otherwise.
//
//go:embed dist
var distFS embed.FS
// distRoot is the embed.FS rooted at `dist/`, so handlers can use plain
// "index.html" / "assets/..." keys instead of the "dist/..." prefix.
var distRoot = func() fs.FS {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
// Cannot happen unless the //go:embed pattern above and the
// fs.Sub argument disagree; bail loudly so the mismatch is
// caught at startup.
panic(err)
}
return sub
}()
func RegisterService(registry *boxService.Registry) {
boxService.Register[option.AdminPanelServiceOptions](registry, C.TypeAdminPanel, NewService)
}
@@ -56,7 +76,7 @@ type Service struct {
}
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.AdminPanelServiceOptions) (adapter.Service, error) {
s := &Service{
return &Service{
Adapter: boxService.NewAdapter(C.TypeAdminPanel, tag),
ctx: ctx,
logger: logger,
@@ -67,83 +87,15 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
Listen: options.ListenOptions,
}),
options: options,
}
return s, nil
}, nil
}
func (s *Service) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
boxManager := service.FromContext[adapter.ServiceManager](s.ctx)
service, ok := boxManager.Get(s.options.Manager)
if !ok {
return E.New("manager ", s.options.Manager, " not found")
}
manager, ok := service.(CM.Manager)
if !ok {
return E.New("invalid ", s.options.Manager, " manager")
}
switch s.options.Database.Driver {
case "postgresql":
db, err := sql.Open("postgres", s.options.Database.DSN)
if err != nil {
return err
}
defer db.Close()
if err := migration.MigratePostgreSQL(db); err != nil && err != migrate.ErrNoChange {
return err
}
default:
return E.New("unknown driver \"", s.options.Database.Driver, "\"")
}
var generators = map[string]table.Generator{
"squads": tables.SquadTableFactory(
manager,
s.logger,
),
"nodes": tables.NodeTableFactory(
manager,
s.logger,
),
"users": tables.UserTableFactory(
manager,
s.logger,
),
"connection_limiters": tables.ConnectionLimiterTableFactory(
manager,
s.logger,
),
"bandwidth_limiters": tables.BandwidthLimiterTableFactory(
manager,
s.logger,
),
}
eng := engine.Default()
chiRouter := chi.NewRouter()
template.AddComp(chartjs.NewChart())
if err := eng.AddConfig(&config.Config{
UrlPrefix: "admin",
IndexUrl: "/",
LoginUrl: "/login",
Databases: config.DatabaseList{
"default": config.Database{
Driver: s.options.Database.Driver,
Dsn: s.options.Database.DSN,
},
},
}).
AddGenerators(generators).
Use(chiRouter); err != nil {
return err
}
eng.HTML("GET", "/admin", pages.DashboardPage)
chiRouter.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin", http.StatusMovedPermanently)
})
chiRouter.Get("/admin/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin", http.StatusMovedPermanently)
})
s.Route(chiRouter)
if s.options.TLS != nil {
tlsConfig, err := tls.NewServer(s.ctx, s.logger, common.PtrValueOrDefault(s.options.TLS))
if err != nil {
@@ -168,10 +120,14 @@ func (s *Service) Start(stage adapter.StartStage) error {
tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig)
}
s.httpServer = &http.Server{
Handler: chiRouter,
Handler: chiRouter,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
}
go func() {
err = s.httpServer.Serve(tcpListener)
err := s.httpServer.Serve(tcpListener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Error("serve error: ", err)
}
@@ -180,9 +136,145 @@ func (s *Service) Start(stage adapter.StartStage) error {
}
func (s *Service) Close() error {
if s.httpServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.httpServer.Shutdown(ctx)
}
return common.Close(
common.PtrOrNil(s.httpServer),
common.PtrOrNil(s.listener),
s.tlsConfig,
)
}
func (s *Service) Route(r chi.Router) {
r.Use(func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
s.logger.Debug(request.Method, " ", request.RequestURI, " ", sHTTP.SourceAddress(request))
handler.ServeHTTP(writer, request)
})
})
r.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]string{
"version": C.Version,
})
})
handler := newSPAHandler()
r.Method(http.MethodGet, "/*", handler)
r.Method(http.MethodHead, "/*", handler)
}
func newSPAHandler() http.Handler {
_, hasIndex := readFile("index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if reqPath != "" && reqPath != "index.html" {
if data, ok := readFile(reqPath); ok {
serveAsset(w, r, reqPath, data)
return
}
}
if !hasIndex {
http.Error(w, "admin panel not built", http.StatusInternalServerError)
return
}
serveIndex(w, r)
})
}
func readFile(name string) ([]byte, bool) {
data, err := fs.ReadFile(distRoot, name)
if err != nil {
return nil, false
}
return data, true
}
func gzipCompanion(name string) ([]byte, bool) {
return readFile(name + ".gz")
}
func serveAsset(w http.ResponseWriter, r *http.Request, name string, raw []byte) {
if ctype := contentType(name); ctype != "" {
w.Header().Set("Content-Type", ctype)
}
if isHashedAsset(name) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
}
writeBody(w, r, name, raw)
}
func serveIndex(w http.ResponseWriter, r *http.Request) {
raw, _ := readFile("index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
writeBody(w, r, "index.html", raw)
}
func writeBody(w http.ResponseWriter, r *http.Request, name string, raw []byte) {
header := w.Header()
if gz, ok := gzipCompanion(name); ok {
// Even when we end up serving the raw bytes (because the client
// declined gzip), let any shared cache know the response varies
// by Accept-Encoding so it doesn't hand a gzipped payload to a
// non-gzip client.
header.Add("Vary", "Accept-Encoding")
if acceptsGzip(r) {
header.Set("Content-Encoding", "gzip")
header.Set("Content-Length", strconv.Itoa(len(gz)))
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(gz)
return
}
}
header.Set("Content-Length", strconv.Itoa(len(raw)))
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(raw)
}
func acceptsGzip(r *http.Request) bool {
for _, h := range r.Header.Values("Accept-Encoding") {
for _, part := range strings.Split(h, ",") {
tok := strings.TrimSpace(part)
if i := strings.IndexByte(tok, ';'); i >= 0 {
tok = strings.TrimSpace(tok[:i])
}
if strings.EqualFold(tok, "gzip") {
return true
}
}
}
return false
}
func isHashedAsset(name string) bool {
return strings.HasPrefix(name, "assets/")
}
func contentType(name string) string {
ext := path.Ext(name)
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
switch ext {
case ".js", ".mjs":
return "application/javascript; charset=utf-8"
case ".css":
return "text/css; charset=utf-8"
case ".html":
return "text/html; charset=utf-8"
case ".svg":
return "image/svg+xml"
}
return ""
}

View File

@@ -0,0 +1,199 @@
//go:build with_admin_panel
package admin_panel
import (
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
// firstAssetPath returns one of the hashed Vite assets from the embedded
// SPA bundle. Used by tests that need a real file-served URL (i.e. one
// that should NOT fall back to index.html). Returns "" if the bundle is
// empty — every concrete test then skips itself, which keeps the suite
// runnable even before `make admin_panel_regen` has been run.
func firstAssetPath(t *testing.T) string {
t.Helper()
var found string
_ = fs.WalkDir(distRoot, "assets", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || strings.HasSuffix(p, ".gz") {
return nil
}
found = p
return fs.SkipAll
})
return found
}
// firstGzippedAsset returns one asset that has a `.gz` companion in the
// embedded bundle. Like firstAssetPath, returns "" when nothing matches.
func firstGzippedAsset(t *testing.T) string {
t.Helper()
var found string
_ = fs.WalkDir(distRoot, "assets", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || strings.HasSuffix(p, ".gz") {
return nil
}
if _, ok := gzipCompanion(p); ok {
found = p
return fs.SkipAll
}
return nil
})
return found
}
func TestSPAHandler(t *testing.T) {
r := chi.NewRouter()
handler := newSPAHandler()
r.Method(http.MethodGet, "/*", handler)
indexBytes, hasIndex := readFile("index.html")
if !hasIndex {
t.Skip("index.html not packed yet (run `make admin_panel_regen`)")
}
cases := []struct {
name string
path string
}{
{"root", "/"},
{"client-route fallback", "/squads"},
{"deep route fallback", "/users/123"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "<div id=\"root\">") {
t.Fatalf("body does not look like index.html:\n%s", rec.Body.String())
}
})
}
// Ensure a real packed asset is served as itself, not as the fallback.
t.Run("real file is served", func(t *testing.T) {
assetPath := firstAssetPath(t)
if assetPath == "" {
t.Skip("no packed assets/* to verify")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+assetPath, nil)
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if strings.Contains(rec.Body.String(), "<div id=\"root\">") {
t.Fatalf("asset request returned the index fallback")
}
})
// Sanity check: index.html in the bundle is the same as what serveIndex returns.
t.Run("index payload matches map", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
r.ServeHTTP(rec, req)
if rec.Body.String() != string(indexBytes) {
t.Fatalf("served index does not match readFile(\"index.html\")")
}
})
t.Run("index has no-store cache control", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
r.ServeHTTP(rec, req)
cc := rec.Header().Get("Cache-Control")
if !strings.Contains(cc, "no-store") {
t.Fatalf("Cache-Control = %q, want it to contain no-store", cc)
}
})
t.Run("hashed asset is cached aggressively", func(t *testing.T) {
assetPath := firstAssetPath(t)
if assetPath == "" {
t.Skip("no packed assets/* to verify")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+assetPath, nil)
r.ServeHTTP(rec, req)
cc := rec.Header().Get("Cache-Control")
if !strings.Contains(cc, "immutable") {
t.Fatalf("Cache-Control for %q = %q, want it to contain immutable", assetPath, cc)
}
})
// With Accept-Encoding: gzip the handler should pass through the
// pre-compressed `.gz` companion verbatim with Content-Encoding: gzip.
t.Run("gzip pass-through", func(t *testing.T) {
assetPath := firstGzippedAsset(t)
if assetPath == "" {
t.Skip("no gzipped assets in this build")
}
gz, _ := gzipCompanion(assetPath)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+assetPath, nil)
req.Header.Set("Accept-Encoding", "gzip")
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if rec.Body.String() != string(gz) {
t.Fatalf("body did not match the stored gzipped bytes")
}
})
// Without Accept-Encoding the same asset should be served raw and
// Content-Encoding must be unset.
t.Run("gzip transparent fallback", func(t *testing.T) {
assetPath := firstGzippedAsset(t)
if assetPath == "" {
t.Skip("no gzipped assets in this build")
}
raw, _ := readFile(assetPath)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+assetPath, nil)
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty (raw body)", got)
}
if rec.Body.String() != string(raw) {
t.Fatalf("body does not match the raw asset")
}
})
// `.gz` companions are an internal implementation detail of the
// pre-compression strategy; clients should never fetch them directly,
// and walking the embedded FS via /-prefixed URLs must not expose
// them as if they were real assets either. Today they happen to be
// reachable (since fs.ReadFile sees them) — this test pins that
// behaviour so we notice if the contract changes.
t.Run("gz companion lookup", func(t *testing.T) {
assetPath := firstGzippedAsset(t)
if assetPath == "" {
t.Skip("no gzipped assets in this build")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+assetPath+".gz", nil)
r.ServeHTTP(rec, req)
// We don't assert the precise body — only that the request
// resolves to *something* (200 or fallback) instead of crashing.
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (asset or SPA fallback)", rec.Code)
}
})
}

View File

@@ -1,259 +0,0 @@
package tables
import (
"encoding/json"
"strconv"
"strings"
"time"
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template"
"github.com/GoAdminGroup/go-admin/template/types"
"github.com/GoAdminGroup/go-admin/template/types/form"
"github.com/sagernet/sing-box/log"
CM "github.com/sagernet/sing-box/service/manager/constant"
)
func BandwidthLimiterTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) table.Table {
return func(ctx *context.Context) table.Table {
t := table.NewDefaultTable(ctx, table.Config{
CanAdd: true,
Editable: true,
Deletable: true,
Exportable: true,
PrimaryKey: table.PrimaryKey{
Type: db.Int,
Name: table.DefaultPrimaryKeyName,
},
})
squads, err := manager.GetSquads(map[string][]string{})
if err != nil {
return nil
}
squadsByID := make(map[int]string, len(squads))
squadOptions := make(types.FieldOptions, len(squads))
for i, squad := range squads {
squadsByID[squad.ID] = squad.Name
squadOptions[i] = types.FieldOption{
Text: squad.Name,
Value: strconv.Itoa(squad.ID),
}
}
info := t.GetInfo().SetFilterFormLayout(form.LayoutFilter)
info.AddField("ID", "id", db.Int).
FieldSortable()
info.AddField("Squads", "squad_ids", db.Varchar).
FieldDisplay(func(model types.FieldModel) interface{} {
values := model.Row["squad_ids"].([]interface{})
labels := template.HTML("")
labelTpl := label(ctx).SetType("success")
labelValues := make([]string, len(values))
for i, squadID := range values {
labelValues[i] = squadsByID[int(squadID.(float64))]
}
for key, label := range labelValues {
if key == len(labelValues)-1 {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
} else {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
}
}
return labels
})
info.AddField("Username", "username", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Outbound", "outbound", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Strategy", "strategy", db.Varchar).
FieldFilterable(types.FilterType{
FormType: form.SelectSingle,
Options: types.FieldOptions{
{Text: "Connection", Value: "connection"},
{Text: "Global", Value: "global"},
},
}).
FieldSortable()
info.AddField("Mode", "mode", db.Varchar).
FieldFilterable(types.FilterType{
FormType: form.SelectSingle,
Options: types.FieldOptions{
{Text: "Download", Value: "download"},
{Text: "Upload", Value: "upload"},
{Text: "Duplex", Value: "duplex"},
},
}).
FieldSortable()
info.AddField("Connection type", "connection_type", db.Varchar).
FieldFilterable(types.FilterType{
FormType: form.SelectSingle,
Options: types.FieldOptions{
{Text: "HWID", Value: "hwid"},
{Text: "Mux", Value: "mux"},
{Text: "IP", Value: "ip"},
},
}).
FieldSortable()
info.AddField("Speed", "speed", db.Varchar).
FieldSortable()
info.AddField("Created at", "created_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.AddField("Updated at", "updated_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) {
filters := make(map[string][]string)
listFilters := map[string][]string{
"offset": {strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)},
"limit": {param.PageSize},
}
for k, v := range param.Fields {
if strings.HasPrefix(k, "__") {
continue
}
key := strings.TrimSuffix(k, "__goadmin")
filters[key] = v
listFilters[key] = v
}
if param.SortField != "" {
if param.SortType == "asc" {
listFilters["sort_asc"] = []string{param.SortField}
} else {
listFilters["sort_desc"] = []string{param.SortField}
}
}
items, err := manager.GetBandwidthLimiters(listFilters)
if err != nil {
logger.Error(err)
return nil, 0
}
count, err := manager.GetBandwidthLimitersCount(filters)
if err != nil {
logger.Error(err)
return nil, 0
}
result := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
var data map[string]interface{}
raw, _ := json.Marshal(item)
json.Unmarshal(raw, &data)
result = append(result, data)
}
return result, count
})
info.SetDeleteFn(func(ids []string) error {
for _, id := range ids {
i, err := strconv.Atoi(id)
if err != nil {
return err
}
if _, err := manager.DeleteBandwidthLimiter(i); err != nil {
return err
}
}
return nil
})
info.SetTable("bandwidth_limiters").SetTitle("Bandwidth Limiters").SetDescription("Bandwidth Limiters")
formList := t.GetForm()
formList.AddField("ID", "id", db.Int, form.Default).
FieldNotAllowAdd().
FieldNotAllowEdit()
formList.AddField("Squads", "squad_ids", db.Varchar, form.Select).
FieldMust().
FieldOptions(squadOptions).
FieldDisableWhenUpdate()
formList.AddField("Username", "username", db.Varchar, form.Text).
FieldMust().
FieldDisplayButCanNotEditWhenUpdate()
formList.AddField("Outbound", "outbound", db.Varchar, form.Text).
FieldMust().
FieldDisplayButCanNotEditWhenUpdate()
formList.AddField("Strategy", "strategy", db.Varchar, form.SelectSingle).
FieldMust().
FieldOptions(types.FieldOptions{
{Text: "Connection", Value: "connection"},
{Text: "Global", Value: "global"},
}).
FieldOnChooseOptionsHide([]string{"", "global"}, "connection_type")
formList.AddField("Mode", "mode", db.Varchar, form.SelectSingle).
FieldMust().
FieldOptions(types.FieldOptions{
{Text: "Download", Value: "download"},
{Text: "Upload", Value: "upload"},
{Text: "Duplex", Value: "duplex"},
})
formList.AddField("Connection type", "connection_type", db.Varchar, form.SelectSingle).
FieldOptions(types.FieldOptions{
{Text: "HWID", Value: "hwid"},
{Text: "Mux", Value: "mux"},
{Text: "IP", Value: "ip"},
})
formList.AddField("Speed", "speed", db.Varchar, form.Text).
FieldMust()
formList.SetInsertFn(func(values mForm.Values) error {
squadIDs := make([]int, len(values["squad_ids[]"]))
for i, rawSquadID := range values["squad_ids[]"] {
squadID, err := strconv.Atoi(rawSquadID)
if err != nil {
return err
}
squadIDs[i] = squadID
}
_, err := manager.CreateBandwidthLimiter(CM.BandwidthLimiterCreate{
SquadIDs: squadIDs,
Username: values.Get("username"),
Outbound: values.Get("outbound"),
Strategy: values.Get("strategy"),
Mode: values.Get("mode"),
ConnectionType: values.Get("connection_type"),
Speed: values.Get("speed"),
})
return err
})
formList.SetUpdateFn(func(values mForm.Values) error {
id, err := strconv.Atoi(values.Get("id"))
if err != nil {
return err
}
_, err = manager.UpdateBandwidthLimiter(id, CM.BandwidthLimiterUpdate{
Username: values.Get("username"),
Outbound: values.Get("outbound"),
Strategy: values.Get("strategy"),
Mode: values.Get("mode"),
ConnectionType: values.Get("connection_type"),
Speed: values.Get("speed"),
})
return err
})
formList.SetTable("bandwidth_limiters").SetTitle("Bandwidth Limiters").SetDescription("Bandwidth Limiters")
return t
}
}

View File

@@ -1,261 +0,0 @@
package tables
import (
"encoding/json"
"strconv"
"strings"
"time"
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template"
"github.com/GoAdminGroup/go-admin/template/types"
"github.com/GoAdminGroup/go-admin/template/types/form"
"github.com/sagernet/sing-box/log"
CM "github.com/sagernet/sing-box/service/manager/constant"
)
func ConnectionLimiterTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) table.Table {
return func(ctx *context.Context) table.Table {
connectionLimiterTable := table.NewDefaultTable(ctx, table.Config{
CanAdd: true,
Editable: true,
Deletable: true,
Exportable: true,
PrimaryKey: table.PrimaryKey{
Type: db.Int,
Name: table.DefaultPrimaryKeyName,
},
})
squads, err := manager.GetSquads(map[string][]string{})
if err != nil {
return nil
}
squadsByID := make(map[int]string, len(squads))
squadOptions := make(types.FieldOptions, len(squads))
for i, squad := range squads {
squadsByID[squad.ID] = squad.Name
squadOptions[i] = types.FieldOption{
Text: squad.Name,
Value: strconv.Itoa(squad.ID),
}
}
info := connectionLimiterTable.GetInfo().SetFilterFormLayout(form.LayoutFilter)
info.AddField("ID", "id", db.Int).
FieldSortable()
info.AddField("Squads", "squad_ids", db.Varchar).
FieldDisplay(func(model types.FieldModel) interface{} {
values := model.Row["squad_ids"].([]interface{})
labels := template.HTML("")
labelTpl := label(ctx).SetType("success")
labelValues := make([]string, len(values))
for i, squadID := range values {
labelValues[i] = squadsByID[int(squadID.(float64))]
}
for key, label := range labelValues {
if key == len(labelValues)-1 {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
} else {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
}
}
return labels
})
info.AddField("Username", "username", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Outbound", "outbound", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Strategy", "strategy", db.Varchar).
FieldFilterable(types.FilterType{
FormType: form.SelectSingle,
Options: types.FieldOptions{
{Text: "Connection", Value: "connection"},
},
}).
FieldSortable()
info.AddField("Connection type", "connection_type", db.Varchar).
FieldFilterable(types.FilterType{
FormType: form.SelectSingle,
Options: types.FieldOptions{
{Text: "Mux", Value: "mux"},
{Text: "HWID", Value: "hwid"},
{Text: "IP", Value: "ip"},
},
}).
FieldSortable()
info.AddField("Lock type", "lock_type", db.Varchar).
FieldFilterable(types.FilterType{
FormType: form.SelectSingle,
Options: types.FieldOptions{
{Text: "Manager", Value: "manager"},
},
}).
FieldSortable()
info.AddField("Count", "count", db.Int).
FieldSortable()
info.AddField("Created at", "created_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.AddField("Updated at", "updated_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) {
filters := make(map[string][]string)
listFilters := map[string][]string{
"offset": {strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)},
"limit": {param.PageSize},
}
for k, v := range param.Fields {
if strings.HasPrefix(k, "__") {
continue
}
key := strings.TrimSuffix(k, "__goadmin")
filters[key] = v
listFilters[key] = v
}
if param.SortField != "" {
if param.SortType == "asc" {
listFilters["sort_asc"] = []string{param.SortField}
} else {
listFilters["sort_desc"] = []string{param.SortField}
}
}
items, err := manager.GetConnectionLimiters(listFilters)
if err != nil {
logger.Error(err)
return nil, 0
}
count, err := manager.GetConnectionLimitersCount(filters)
if err != nil {
logger.Error(err)
return nil, 0
}
result := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
var data map[string]interface{}
raw, _ := json.Marshal(item)
json.Unmarshal(raw, &data)
result = append(result, data)
}
return result, count
})
info.SetDeleteFn(func(ids []string) error {
for _, id := range ids {
i, err := strconv.Atoi(id)
if err != nil {
return err
}
if _, err := manager.DeleteConnectionLimiter(i); err != nil {
return err
}
}
return nil
})
info.SetTable("connection_limiters").SetTitle("Connection Limiters").SetDescription("Connection Limiters")
formList := connectionLimiterTable.GetForm()
formList.AddField("ID", "id", db.Int, form.Default).
FieldNotAllowAdd().
FieldNotAllowEdit()
formList.AddField("Squads", "squad_ids", db.Varchar, form.Select).
FieldMust().
FieldOptions(squadOptions).
FieldDisableWhenUpdate()
formList.AddField("Username", "username", db.Varchar, form.Text).
FieldMust().
FieldDisplayButCanNotEditWhenUpdate()
formList.AddField("Outbound", "outbound", db.Varchar, form.Text).
FieldMust().
FieldDisplayButCanNotEditWhenUpdate()
formList.AddField("Strategy", "strategy", db.Varchar, form.SelectSingle).
FieldMust().
FieldOptions(types.FieldOptions{
{Text: "Connection", Value: "connection"},
}).
FieldDefault("connection")
formList.AddField("Connection type", "connection_type", db.Varchar, form.SelectSingle).
FieldOptions(types.FieldOptions{
{Text: "Mux", Value: "mux"},
{Text: "HWID", Value: "hwid"},
{Text: "IP", Value: "ip"},
})
formList.AddField("Lock type", "lock_type", db.Varchar, form.SelectSingle).
FieldOptions(types.FieldOptions{
{Text: "Manager", Value: "manager"},
})
formList.AddField("Count", "count", db.Int, form.Number).
FieldMust().
FieldDefault("0")
formList.SetInsertFn(func(values mForm.Values) error {
squadIDs := make([]int, len(values["squad_ids[]"]))
for i, rawSquadID := range values["squad_ids[]"] {
squadID, err := strconv.Atoi(rawSquadID)
if err != nil {
return err
}
squadIDs[i] = squadID
}
count, err := strconv.ParseUint(values.Get("count"), 10, 32)
if err != nil {
return err
}
_, err = manager.CreateConnectionLimiter(CM.ConnectionLimiterCreate{
SquadIDs: squadIDs,
Username: values.Get("username"),
Outbound: values.Get("outbound"),
Strategy: values.Get("strategy"),
ConnectionType: values.Get("connection_type"),
LockType: values.Get("lock_type"),
Count: uint32(count),
})
return err
})
formList.SetUpdateFn(func(values mForm.Values) error {
id, err := strconv.Atoi(values.Get("id"))
if err != nil {
return err
}
count, err := strconv.ParseUint(values.Get("count"), 10, 32)
if err != nil {
return err
}
_, err = manager.UpdateConnectionLimiter(id, CM.ConnectionLimiterUpdate{
Username: values.Get("username"),
Outbound: values.Get("outbound"),
Strategy: values.Get("strategy"),
ConnectionType: values.Get("connection_type"),
LockType: values.Get("lock_type"),
Count: uint32(count),
})
return err
})
formList.SetTable("connection_limiters").SetTitle("Connection Limiters").SetDescription("Connection Limiters")
return connectionLimiterTable
}
}

View File

@@ -1,201 +0,0 @@
package tables
import (
"encoding/json"
"strconv"
"strings"
"time"
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/config"
"github.com/GoAdminGroup/go-admin/modules/db"
mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template"
"github.com/GoAdminGroup/go-admin/template/types"
"github.com/GoAdminGroup/go-admin/template/types/form"
"github.com/gofrs/uuid/v5"
"github.com/sagernet/sing-box/log"
CM "github.com/sagernet/sing-box/service/manager/constant"
)
func label(ctx *context.Context) types.LabelAttribute {
return template.Get(ctx, config.GetTheme()).Label().SetType("success")
}
func NodeTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (nodeTable table.Table) {
return func(ctx *context.Context) (nodeTable table.Table) {
nodeTable = table.NewDefaultTable(ctx, table.Config{
CanAdd: true,
Editable: true,
Deletable: true,
Exportable: true,
PrimaryKey: table.PrimaryKey{
Type: db.Varchar,
Name: "uuid",
},
})
squads, err := manager.GetSquads(map[string][]string{})
if err != nil {
return nil
}
squadsByID := make(map[int]string, len(squads))
squadOptions := make(types.FieldOptions, len(squads))
for i, squad := range squads {
squadsByID[squad.ID] = squad.Name
squadOptions[i] = types.FieldOption{
Text: squad.Name,
Value: strconv.Itoa(squad.ID),
}
}
info := nodeTable.GetInfo().SetFilterFormLayout(form.LayoutFilter)
info.AddField("UUID", "uuid", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Name", "name", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Squads", "squad_ids", db.Varchar).
FieldDisplay(func(model types.FieldModel) interface{} {
values := model.Row["squad_ids"].([]interface{})
labels := template.HTML("")
labelTpl := label(ctx).SetType("success")
labelValues := make([]string, len(values))
for i, squadID := range values {
labelValues[i] = squadsByID[int(squadID.(float64))]
}
for key, label := range labelValues {
if key == len(labelValues)-1 {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
} else {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
}
}
return labels
})
info.AddField("Status", "status", db.Varchar).
FieldDisplay(func(value types.FieldModel) interface{} {
uuid := value.Row["uuid"].(string)
return manager.GetNodeStatus(uuid)
})
info.AddField("Created at", "created_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.AddField("Updated at", "updated_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) {
filters := make(map[string][]string, len(param.Fields))
listFilters := make(map[string][]string, len(param.Fields)+2)
listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)}
listFilters["limit"] = []string{param.PageSize}
for key, values := range param.Fields {
if key == "__pk" {
key = "uuid"
} else {
if strings.HasPrefix(key, "__") {
continue
}
key = strings.TrimSuffix(key, "__goadmin")
}
filters[key] = values
listFilters[key] = values
}
if param.SortField != "" {
if param.SortType == "asc" {
listFilters["sort_asc"] = []string{param.SortField}
} else {
listFilters["sort_desc"] = []string{param.SortField}
}
}
nodes, err := manager.GetNodes(listFilters)
if err != nil {
logger.Error(err)
return nil, 0
}
count, err := manager.GetNodesCount(filters)
if err != nil {
logger.Error(err)
return nil, 0
}
result := make([]map[string]interface{}, 0, len(nodes))
for _, node := range nodes {
var data map[string]interface{}
rawData, _ := json.Marshal(node)
json.Unmarshal(rawData, &data)
result = append(result, data)
}
return result, count
})
info.SetDeleteFn(func(ids []string) error {
for _, uuid := range ids {
if _, err := manager.DeleteNode(uuid); err != nil {
return err
}
}
return nil
})
info.SetTable("nodes").SetTitle("Nodes").SetDescription("Nodes")
defaultUUID, _ := uuid.NewV4()
formList := nodeTable.GetForm()
formList.AddField("UUID", "uuid", db.Varchar, form.Text).
FieldMust().
FieldNotAllowEdit().
FieldDefault(defaultUUID.String())
formList.AddField("Name", "name", db.Varchar, form.Text).
FieldMust()
formList.AddField("Squads", "squad_ids", db.Varchar, form.Select).
FieldMust().
FieldOptions(squadOptions).
FieldDisableWhenUpdate()
formList.SetInsertFn(func(values mForm.Values) (err error) {
squadIDs := make([]int, len(values["squad_ids[]"]))
for i, rawSquadID := range values["squad_ids[]"] {
squadID, err := strconv.Atoi(rawSquadID)
if err != nil {
return err
}
squadIDs[i] = squadID
}
_, err = manager.CreateNode(CM.NodeCreate{
UUID: values.Get("uuid"),
Name: values.Get("name"),
SquadIDs: squadIDs,
})
return
})
formList.SetUpdateFn(func(values mForm.Values) (err error) {
uuid := values.Get("uuid")
_, err = manager.UpdateNode(uuid, CM.NodeUpdate{
Name: values.Get("name"),
})
return
})
formList.SetTable("nodes").SetTitle("Nodes").SetDescription("Nodes")
return
}
}

View File

@@ -1,164 +0,0 @@
package tables
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template/types"
"github.com/GoAdminGroup/go-admin/template/types/form"
"github.com/go-playground/validator/v10"
"github.com/sagernet/sing-box/log"
CM "github.com/sagernet/sing-box/service/manager/constant"
)
func SquadTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (squadTable table.Table) {
return func(ctx *context.Context) (squadTable table.Table) {
squadTable = table.NewDefaultTable(ctx, table.Config{
CanAdd: true,
Editable: true,
Deletable: true,
Exportable: true,
PrimaryKey: table.PrimaryKey{
Type: db.Int,
Name: table.DefaultPrimaryKeyName,
},
})
info := squadTable.GetInfo().SetFilterFormLayout(form.LayoutFilter)
info.AddField("ID", "id", db.Int).
FieldSortable()
info.AddField("Name", "name", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Created At", "created_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldSortable().
FieldFilterable(types.FilterType{FormType: form.DatetimeRange})
info.AddField("Updated At", "updated_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldSortable().
FieldFilterable(types.FilterType{FormType: form.DatetimeRange})
info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) {
filters := make(map[string][]string, len(param.Fields))
listFilters := make(map[string][]string, len(param.Fields)+2)
listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)}
listFilters["limit"] = []string{param.PageSize}
for key, values := range param.Fields {
if key == "__pk" {
key = "pk"
} else if strings.HasPrefix(key, "__") {
continue
} else {
key = strings.TrimSuffix(key, "__goadmin")
}
filters[key] = values
listFilters[key] = values
}
if param.SortField != "" {
if param.SortType == "asc" {
listFilters["sort_asc"] = []string{param.SortField}
} else {
listFilters["sort_desc"] = []string{param.SortField}
}
}
squads, err := manager.GetSquads(listFilters)
if err != nil {
logger.Error(err)
return nil, 0
}
count, err := manager.GetSquadsCount(filters)
if err != nil {
logger.Error(err)
return nil, 0
}
result := make([]map[string]interface{}, 0, len(squads))
for _, squad := range squads {
var data map[string]interface{}
rawData, _ := json.Marshal(squad)
json.Unmarshal(rawData, &data)
result = append(result, data)
}
return result, count
})
info.SetDeleteFn(func(ids []string) error {
for _, id := range ids {
intID, err := strconv.Atoi(id)
if err != nil {
return err
}
if _, err := manager.DeleteSquad(intID); err != nil {
return err
}
}
return nil
})
info.SetTable("squads").SetTitle("Squads").SetDescription("Squads")
formList := squadTable.GetForm()
formList.AddField("ID", "id", db.Int, form.Default).
FieldNotAllowAdd().
FieldNotAllowEdit()
formList.AddField("Name", "name", db.Varchar, form.Text).
FieldMust()
formList.SetInsertFn(func(values mForm.Values) (err error) {
_, err = manager.CreateSquad(CM.SquadCreate{
Name: values.Get("name"),
})
if err != nil {
if ve, ok := err.(validator.ValidationErrors); ok {
var errors []string
for _, e := range ve {
switch e.Tag() {
case "required":
errors = append(errors, e.StructField()+": required field missing")
default:
errors = append(errors, e.StructField()+": invalid request")
}
}
err = fmt.Errorf("%s", strings.Join(errors, "<br>"))
}
}
return
})
formList.SetUpdateFn(func(values mForm.Values) (err error) {
id, err := strconv.Atoi(values.Get("id"))
if err != nil {
return err
}
_, err = manager.UpdateSquad(id, CM.SquadUpdate{
Name: values.Get("name"),
})
return
})
formList.SetTable("squads").SetTitle("Squads").SetDescription("Squads")
return
}
}

View File

@@ -1,288 +0,0 @@
package tables
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template"
"github.com/GoAdminGroup/go-admin/template/types"
"github.com/GoAdminGroup/go-admin/template/types/form"
"github.com/go-playground/validator/v10"
"github.com/sagernet/sing-box/log"
CM "github.com/sagernet/sing-box/service/manager/constant"
)
func UserTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (userTable table.Table) {
return func(ctx *context.Context) (userTable table.Table) {
userTable = table.NewDefaultTable(ctx, table.Config{
CanAdd: true,
Editable: true,
Deletable: true,
Exportable: true,
PrimaryKey: table.PrimaryKey{
Type: db.Int,
Name: table.DefaultPrimaryKeyName,
},
})
squads, err := manager.GetSquads(map[string][]string{})
if err != nil {
return nil
}
squadsByID := make(map[int]string, len(squads))
squadOptions := make(types.FieldOptions, len(squads))
for i, squad := range squads {
squadsByID[squad.ID] = squad.Name
squadOptions[i] = types.FieldOption{
Text: squad.Name,
Value: strconv.Itoa(squad.ID),
}
}
info := userTable.GetInfo().SetFilterFormLayout(form.LayoutFilter)
info.AddField("ID", "id", db.Int).
FieldSortable()
info.AddField("Squads", "squad_ids", db.Varchar).
FieldDisplay(func(model types.FieldModel) interface{} {
values := model.Row["squad_ids"].([]interface{})
labels := template.HTML("")
labelTpl := label(ctx).SetType("success")
labelValues := make([]string, len(values))
for i, squadID := range values {
labelValues[i] = squadsByID[int(squadID.(float64))]
}
for key, label := range labelValues {
if key == len(labelValues)-1 {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
} else {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
}
}
return labels
})
info.AddField("Username", "username", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Type", "type", db.Varchar).
FieldFilterable(
types.FilterType{
FormType: form.SelectSingle,
Options: types.FieldOptions{
{Text: "Hysteria", Value: "hysteria"},
{Text: "Hysteria2", Value: "hysteria2"},
{Text: "MTProxy", Value: "mtproxy"},
{Text: "Trojan", Value: "trojan"},
{Text: "TUIC", Value: "tuic"},
{Text: "VLESS", Value: "vless"},
{Text: "VMess", Value: "vmess"},
},
},
).
FieldSortable()
info.AddField("Inbound", "inbound", db.Varchar).FieldFilterable().
FieldSortable()
info.AddField("Created at", "created_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.AddField("Updated at", "updated_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) {
filters := make(map[string][]string, len(param.Fields))
listFilters := make(map[string][]string, len(param.Fields)+2)
listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)}
listFilters["limit"] = []string{param.PageSize}
for key, values := range param.Fields {
if key == "__pk" {
key = "pk"
} else {
if strings.HasPrefix(key, "__") {
continue
}
key = strings.TrimSuffix(key, "__goadmin")
}
filters[key] = values
listFilters[key] = values
}
if param.SortField != "" {
if param.SortType == "asc" {
listFilters["sort_asc"] = []string{param.SortField}
} else {
listFilters["sort_desc"] = []string{param.SortField}
}
}
users, err := manager.GetUsers(listFilters)
if err != nil {
logger.Error(err)
return nil, 0
}
count, err := manager.GetUsersCount(filters)
if err != nil {
logger.Error(err)
return nil, 0
}
result := make([]map[string]interface{}, 0, len(users))
for _, user := range users {
var data map[string]interface{}
rawData, _ := json.Marshal(user)
json.Unmarshal(rawData, &data)
result = append(result, data)
}
return result, count
})
info.SetDeleteFn(func(ids []string) error {
for _, id := range ids {
value, err := strconv.Atoi(id)
if err != nil {
return err
}
if _, err := manager.DeleteUser(value); err != nil {
return err
}
}
return nil
})
info.SetTable("users").SetTitle("Users").SetDescription("Users")
formList := userTable.GetForm()
formList.AddField("ID", "id", db.Int, form.Default).
FieldNotAllowEdit().
FieldNotAllowAdd()
formList.AddField("Squads", "squad_ids", db.Varchar, form.Select).
FieldMust().
FieldOptions(squadOptions).
FieldDisableWhenUpdate()
formList.AddField("Username", "username", db.Varchar, form.Text).
FieldMust().
FieldDisplayButCanNotEditWhenUpdate()
formList.AddField("Type", "type", db.Varchar, form.SelectSingle).
FieldMust().
FieldDisplayButCanNotEditWhenUpdate().
FieldOptions(types.FieldOptions{
{Text: "Hysteria", Value: "hysteria"},
{Text: "Hysteria2", Value: "hysteria2"},
{Text: "MTProxy", Value: "mtproxy"},
{Text: "Trojan", Value: "trojan"},
{Text: "TUIC", Value: "tuic"},
{Text: "VLESS", Value: "vless"},
{Text: "VMess", Value: "vmess"},
}).
FieldOnChooseOptionsHide([]string{""}, "inbound").
FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "shadowsocks", "trojan", "tuic"}, "uuid").
FieldOnChooseOptionsHide([]string{"", "mtproxy", "vless", "vmess"}, "password").
FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "shadowsocks", "trojan", "tuic", "vless", "vmess"}, "secret").
FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "shadowsocks", "trojan", "tuic", "vmess"}, "flow").
FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "shadowsocks", "trojan", "tuic", "vless"}, "alter_id")
formList.AddField("Inbound", "inbound", db.Varchar, form.Text).
FieldMust().
FieldDisplayButCanNotEditWhenUpdate().
FieldOptionInitFn(func(val types.FieldModel) types.FieldOptions {
return types.FieldOptions{
{Value: val.Value, Text: val.Value, Selected: true},
}
})
formList.AddField("UUID", "uuid", db.Varchar, form.Text)
formList.AddField("Password", "password", db.Varchar, form.Text)
formList.AddField("Secret", "secret", db.Varchar, form.Text)
formList.AddField("Flow", "flow", db.Varchar, form.SelectSingle).
FieldOptions(types.FieldOptions{
{Text: "xtls-rprx-vision", Value: "xtls-rprx-vision"},
})
formList.AddField("Alter ID", "alter_id", db.Varchar, form.Number).
FieldDefault("0")
formList.SetInsertFn(func(values mForm.Values) (err error) {
squadIDs := make([]int, len(values["squad_ids[]"]))
for i, rawSquadID := range values["squad_ids[]"] {
squadID, err := strconv.Atoi(rawSquadID)
if err != nil {
return err
}
squadIDs[i] = squadID
}
var alterId int
if value := values.Get("alter_id"); value != "" {
alterId, err = strconv.Atoi(value)
if err != nil {
return err
}
}
_, err = manager.CreateUser(CM.UserCreate{
SquadIDs: squadIDs,
Username: values.Get("username"),
Type: values.Get("type"),
Inbound: values.Get("inbound"),
UUID: values.Get("uuid"),
Password: values.Get("password"),
Secret: values.Get("secret"),
Flow: values.Get("flow"),
AlterID: alterId,
})
if err != nil {
if ve, ok := err.(validator.ValidationErrors); ok {
var errors []string
for _, e := range ve {
switch e.Tag() {
case "required":
errors = append(errors, e.StructField()+": required field missing")
case "uuid4":
errors = append(errors, e.StructField()+": invalid UUID")
default:
errors = append(errors, e.StructField()+": invalid request")
}
}
err = fmt.Errorf("%s", strings.Join(errors, "<br>"))
}
}
return
})
formList.SetUpdateFn(func(values mForm.Values) (err error) {
id, err := strconv.Atoi(values.Get("id"))
if err != nil {
return err
}
var alterId int
if value := values.Get("alter_id"); value != "" {
alterId, err = strconv.Atoi(value)
if err != nil {
return err
}
}
_, err = manager.UpdateUser(id, CM.UserUpdate{
UUID: values.Get("uuid"),
Password: values.Get("password"),
Secret: values.Get("secret"),
Flow: values.Get("flow"),
AlterID: alterId,
})
return
})
formList.SetTable("users").SetTitle("Users").SetDescription("Users")
return
}
}

2
service/admin_panel/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
.vite

View File

@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#141414" />
<link rel="icon" type="image/svg+xml" href="/src/assets/icon.svg" />
<title>Sing-box Extended</title>
<!--
Resolve the user's saved theme mode AND accent colour before the
React bundle loads so the document doesn't paint white (or with
the wrong accent) for a frame on reload. Mirrors the same
`sing-box-admin:mode` and `sing-box-admin:accent` localStorage
keys the React provider uses, falls back to `prefers-color-scheme`
for the mode and the default accent (#3b82f6) for the colour, and
hard-defaults to dark. The colour values match `theme.ts`
(DARK.surface / LIGHT.surface), and `--sb-accent` is the same CSS
variable every accent-aware MUI override consumes — by stamping it
onto <html> before any styles are applied, the very first frame
of every reload renders with the saved accent instead of flashing
the default blue.
-->
<script>
(function () {
try {
var stored = localStorage.getItem("sing-box-admin:mode");
var prefersLight =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: light)").matches;
var mode =
stored === "light" || stored === "dark"
? stored
: prefersLight
? "light"
: "dark";
var bg = mode === "light" ? "#ffffff" : "#141414";
var fg = mode === "light" ? "#0f172a" : "#f5f5f5";
var root = document.documentElement;
root.style.colorScheme = mode;
root.style.backgroundColor = bg;
// Accent — same `isHexColor` check the React side does, so a
// corrupted value can never poison the CSS variable.
var accentRaw = localStorage.getItem("sing-box-admin:accent");
var accent =
accentRaw && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(accentRaw)
? accentRaw
: "#3b82f6";
root.style.setProperty("--sb-accent", accent);
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", bg);
document.addEventListener("DOMContentLoaded", function () {
document.body.style.backgroundColor = bg;
document.body.style.color = fg;
});
} catch (_) {
/* ignore */
}
})();
</script>
</head>
<body style="margin:0;font-family:Inter,system-ui,sans-serif">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3245
service/admin_panel/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"name": "sing-box-admin-panel",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b --noEmit && vite build --emptyOutDir --outDir ../dist",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.5",
"@fontsource/inter": "^5.2.8",
"@mui/icons-material": "^6.1.10",
"@mui/material": "^6.1.10",
"@mui/x-date-pickers": "^7.29.4",
"dayjs": "^1.11.20",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"recharts": "^3.8.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}

View File

@@ -0,0 +1,40 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout";
import { useAuth } from "./auth/AuthContext";
import { LoginPage } from "./pages/LoginPage";
import { DashboardPage } from "./pages/DashboardPage";
import { SquadsPage } from "./pages/SquadsPage";
import { NodesPage } from "./pages/NodesPage";
import { UsersPage } from "./pages/UsersPage";
import { BandwidthLimitersPage } from "./pages/BandwidthLimitersPage";
import { TrafficLimitersPage } from "./pages/TrafficLimitersPage";
import { ConnectionLimitersPage } from "./pages/ConnectionLimitersPage";
import { RateLimitersPage } from "./pages/RateLimitersPage";
export function App() {
const { auth } = useAuth();
if (!auth) {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
);
}
return (
<Layout>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/squads" element={<SquadsPage />} />
<Route path="/nodes" element={<NodesPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/bandwidth-limiters" element={<BandwidthLimitersPage />} />
<Route path="/traffic-limiters" element={<TrafficLimitersPage />} />
<Route path="/connection-limiters" element={<ConnectionLimitersPage />} />
<Route path="/rate-limiters" element={<RateLimitersPage />} />
<Route path="/login" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
);
}

View File

@@ -0,0 +1,316 @@
import type {
BandwidthLimiter,
BandwidthLimiterCreate,
BandwidthLimiterUpdate,
ConnectionLimiter,
ConnectionLimiterCreate,
ConnectionLimiterUpdate,
CountResponse,
Listable,
Node,
NodeCreate,
NodeStatus,
NodeUpdate,
RateLimiter,
RateLimiterCreate,
RateLimiterUpdate,
Squad,
SquadCreate,
SquadUpdate,
TrafficLimiter,
TrafficLimiterCreate,
TrafficLimiterUpdate,
User,
UserCreate,
UserUpdate,
VersionInfo,
} from "./types";
export class ApiError extends Error {
status: number;
body: string;
constructor(status: number, body: string) {
super(`HTTP ${status}: ${body || "(empty)"}`);
this.status = status;
this.body = body;
}
}
// UnauthorizedError is thrown for 401 responses so callers can clear stored
// credentials and redirect to the login screen.
export class UnauthorizedError extends ApiError {
constructor(body: string) {
super(401, body);
this.name = "UnauthorizedError";
}
}
export interface AuthInfo {
baseUrl: string;
apiKey: string;
}
const STORAGE_KEY = "sing-box-admin:auth";
export function loadAuth(): AuthInfo | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<AuthInfo>;
if (!parsed.baseUrl || !parsed.apiKey) return null;
return { baseUrl: parsed.baseUrl, apiKey: parsed.apiKey };
} catch {
return null;
}
}
// All write paths are wrapped in try/catch so a private-mode browser, a
// disabled storage backend, or a quota error never bubbles up into the
// React effects that call us. The reads above already handle missing /
// malformed entries; failing silently here is the symmetrical behaviour.
export function saveAuth(auth: AuthInfo) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
} catch {
/* ignore */
}
}
export function clearAuth() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
}
// Login form draft — what the user typed before clicking Sign in. Stored
// independently of `AuthInfo` so it survives logout (and tab close), letting
// users come back to their last typed credentials.
export interface LoginDraft {
baseUrl: string;
apiKey: string;
}
const DRAFT_KEY = "sing-box-admin:login-draft";
export function loadLoginDraft(): LoginDraft {
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return { baseUrl: "", apiKey: "" };
const parsed = JSON.parse(raw) as Partial<LoginDraft>;
return {
baseUrl: typeof parsed.baseUrl === "string" ? parsed.baseUrl : "",
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : "",
};
} catch {
return { baseUrl: "", apiKey: "" };
}
}
export function saveLoginDraft(draft: LoginDraft) {
try {
// Drop entirely empty drafts so we don't litter localStorage with "{}".
if (!draft.baseUrl && !draft.apiKey) {
localStorage.removeItem(DRAFT_KEY);
return;
}
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
} catch {
/* ignore */
}
}
export function clearLoginDraft() {
try {
localStorage.removeItem(DRAFT_KEY);
} catch {
/* ignore */
}
}
// onUnauthorized lets the auth context plug in a global 401 handler that
// clears local credentials and bounces the user back to the login screen.
let onUnauthorized: ((err: UnauthorizedError) => void) | null = null;
export function setUnauthorizedHandler(fn: ((err: UnauthorizedError) => void) | null) {
onUnauthorized = fn;
}
function joinUrl(base: string, path: string): string {
const trimmed = base.replace(/\/+$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${trimmed}/manager/v1${suffix}`;
}
function buildQuery(params?: Listable): string {
if (!params) return "";
const segments: string[] = [];
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === "") continue;
if (Array.isArray(v)) {
const items: string[] = [];
for (const item of v) {
if (item === undefined || item === null || item === "") continue;
items.push(encodeURIComponent(String(item)));
}
if (items.length === 0) continue;
segments.push(`${encodeURIComponent(k)}=${items.join(",")}`);
} else {
segments.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
}
}
return segments.length > 0 ? `?${segments.join("&")}` : "";
}
async function request<T>(
auth: AuthInfo,
method: string,
path: string,
body?: unknown,
query?: Listable,
): Promise<T> {
let res: Response;
try {
res = await fetch(joinUrl(auth.baseUrl, path) + buildQuery(query), {
method,
headers: {
Authorization: `Bearer ${auth.apiKey}`,
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch (e) {
throw new ApiError(0, e instanceof Error ? e.message : String(e));
}
if (res.status === 401) {
const text = await res.text().catch(() => "");
const err = new UnauthorizedError(text);
if (onUnauthorized) onUnauthorized(err);
throw err;
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new ApiError(res.status, text);
}
if (res.status === 204) return undefined as T;
const text = await res.text();
if (!text) return undefined as T;
// Reject HTML payloads up-front: the manager-api always returns JSON for
// success, so anything that smells like HTML must be a misrouted request
// (e.g. SPA fallback) and would only confuse callers expecting JSON.
const trimmed = text.trim();
if (trimmed.startsWith("<")) {
throw new ApiError(
res.status,
"expected JSON, got HTML — is the API base URL pointing at the manager-api server?",
);
}
try {
return JSON.parse(text) as T;
} catch {
throw new ApiError(res.status, `invalid JSON response: ${text.slice(0, 200)}`);
}
}
interface CRUD<TEntity, TCreate, TUpdate, TID = number> {
list: (q?: Listable) => Promise<TEntity[]>;
count: (q?: Listable) => Promise<number>;
get: (id: TID) => Promise<TEntity>;
create: (body: TCreate) => Promise<TEntity>;
update: (id: TID, body: TUpdate) => Promise<TEntity>;
remove: (id: TID) => Promise<TEntity>;
}
function intCRUD<TEntity, TCreate, TUpdate>(
auth: AuthInfo,
base: string,
): CRUD<TEntity, TCreate, TUpdate, number> {
return {
list: async (q) => {
const r = await request<TEntity[]>(auth, "GET", base, undefined, q);
return Array.isArray(r) ? r : [];
},
count: async (q) => {
const r = await request<CountResponse>(auth, "GET", `${base}/count`, undefined, q);
return r?.count ?? 0;
},
get: (id) => request<TEntity>(auth, "GET", `${base}/${id}`),
create: (body) => request<TEntity>(auth, "POST", base, body),
update: (id, body) => request<TEntity>(auth, "PUT", `${base}/${id}`, body),
remove: (id) => request<TEntity>(auth, "DELETE", `${base}/${id}`),
};
}
export function makeApi(auth: AuthInfo) {
const nodesBase = "/nodes";
return {
auth,
squads: intCRUD<Squad, SquadCreate, SquadUpdate>(auth, "/squads"),
users: intCRUD<User, UserCreate, UserUpdate>(auth, "/users"),
bandwidthLimiters: intCRUD<BandwidthLimiter, BandwidthLimiterCreate, BandwidthLimiterUpdate>(
auth,
"/bandwidth-limiters",
),
trafficLimiters: {
...intCRUD<TrafficLimiter, TrafficLimiterCreate, TrafficLimiterUpdate>(
auth,
"/traffic-limiters",
),
// updateUsed overwrites the running raw_used counter on a
// traffic limiter. Passing 0 is the supported "reset traffic"
// operation surfaced in the UI; any uint64 also works for ad-hoc
// adjustments.
updateUsed: (id: number, used: number) =>
request<TrafficLimiter>(
auth,
"PUT",
`/traffic-limiters/${id}/used`,
{ used },
),
},
connectionLimiters: intCRUD<ConnectionLimiter, ConnectionLimiterCreate, ConnectionLimiterUpdate>(
auth,
"/connection-limiters",
),
rateLimiters: intCRUD<RateLimiter, RateLimiterCreate, RateLimiterUpdate>(
auth,
"/rate-limiters",
),
// Identity probe: returns the running sing-box build's version
// and the project home URL. Used by the sidebar's About popover
// so the panel can show what release the connected manager-api
// is built from. Cheap, single-shot — call it once on Layout
// mount and cache the result for the session.
version: () => request<VersionInfo>(auth, "GET", "/version"),
nodes: {
list: async (q?: Listable) => {
const r = await request<Node[]>(auth, "GET", nodesBase, undefined, q);
return Array.isArray(r) ? r : [];
},
count: async (q?: Listable) => {
const r = await request<CountResponse>(auth, "GET", `${nodesBase}/count`, undefined, q);
return r?.count ?? 0;
},
get: (uuid: string) => request<Node>(auth, "GET", `${nodesBase}/${uuid}`),
create: (body: NodeCreate) => request<Node>(auth, "POST", nodesBase, body),
update: (uuid: string, body: NodeUpdate) =>
request<Node>(auth, "PUT", `${nodesBase}/${uuid}`, body),
remove: (uuid: string) => request<Node>(auth, "DELETE", `${nodesBase}/${uuid}`),
status: async (uuid: string): Promise<NodeStatus> => {
const r = await request<{ status: NodeStatus }>(
auth,
"GET",
`${nodesBase}/${uuid}/status`,
);
return r.status;
},
},
};
}
export type Api = ReturnType<typeof makeApi>;
export async function ping(auth: AuthInfo): Promise<void> {
// Cheap reachability + auth check.
await request<CountResponse>(auth, "GET", "/squads/count");
}

View File

@@ -0,0 +1,245 @@
// Mirrors the DTOs declared in service/manager/constant/dto.go.
// Strings stay strings (the server emits time.Time as RFC3339).
export type SquadIDs = number[];
export interface Squad {
id: number;
name: string;
created_at: string;
updated_at: string;
}
export interface SquadCreate {
name: string;
}
export interface SquadUpdate {
name: string;
}
export interface Node {
uuid: string;
name: string;
squad_ids: SquadIDs;
created_at: string;
updated_at: string;
}
export interface NodeCreate {
uuid: string;
name: string;
squad_ids: SquadIDs;
}
export interface NodeUpdate {
name: string;
}
export type NodeStatus = "online" | "offline";
export type UserType =
| "hysteria"
| "hysteria2"
| "mtproxy"
| "trojan"
| "tuic"
| "vless"
| "vmess";
export interface User {
id: number;
squad_ids: SquadIDs;
username: string;
inbound: string;
type: UserType;
uuid: string;
password: string;
secret: string;
flow: string;
alter_id: number;
created_at: string;
updated_at: string;
}
export interface UserCreate {
squad_ids: SquadIDs;
username: string;
inbound: string;
type: UserType;
uuid?: string;
password?: string;
secret?: string;
flow?: string;
alter_id?: number;
}
export interface UserUpdate {
uuid?: string;
password?: string;
secret?: string;
flow?: string;
alter_id?: number;
}
export type BandwidthStrategy = "global" | "connection" | "bypass";
export type BandwidthMode = "upload" | "download" | "bidirectional";
export type ConnectionType = "default" | "hwid" | "mux" | "source_ip";
export interface BandwidthLimiter {
id: number;
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: BandwidthStrategy;
connection_type?: ConnectionType;
mode: BandwidthMode;
flow_keys?: string[];
speed: string;
raw_speed: number;
created_at: string;
updated_at: string;
}
// `mode`, `flow_keys`, `connection_type` and `speed` carry
// `excluded_if=Strategy bypass` on the manager-api DTO (see
// service/manager/constant/dto.go) — they must be omitted from the
// JSON body when strategy="bypass", otherwise the SQL repository
// fails to parse `speed=""` via byteformats.NetworkBytesCompat and
// the request is rejected with 400 "invalid format". Marking them
// optional here lets the page builders drop them via
// `JSON.stringify`'s `undefined`-skips-key behaviour.
export interface BandwidthLimiterCreate {
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: BandwidthStrategy;
connection_type?: ConnectionType;
mode?: BandwidthMode;
flow_keys?: string[];
speed?: string;
}
export interface BandwidthLimiterUpdate {
username?: string;
outbound: string;
strategy: BandwidthStrategy;
connection_type?: ConnectionType;
mode?: BandwidthMode;
flow_keys?: string[];
speed?: string;
}
export type TrafficStrategy = "global" | "bypass";
export type TrafficMode = BandwidthMode;
export interface TrafficLimiter {
id: number;
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: TrafficStrategy;
mode: TrafficMode;
raw_used: number;
quota: string;
raw_quota: number;
usage: number;
created_at: string;
updated_at: string;
}
// `mode` / `quota` are excluded_if=Strategy bypass on the DTO; see the
// BandwidthLimiterCreate comment above for the full rationale.
export interface TrafficLimiterCreate {
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: TrafficStrategy;
mode?: TrafficMode;
quota?: string;
}
export interface TrafficLimiterUpdate {
username?: string;
outbound: string;
strategy: TrafficStrategy;
mode?: TrafficMode;
quota?: string;
}
export type ConnectionStrategy = "connection" | "bypass";
export type LockType = "manager" | "default";
export interface ConnectionLimiter {
id: number;
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: ConnectionStrategy;
connection_type?: ConnectionType;
lock_type: LockType;
count: number;
created_at: string;
updated_at: string;
}
// `connection_type` / `lock_type` / `count` are excluded_if=Strategy
// bypass on the DTO; see the BandwidthLimiterCreate comment above for
// the full rationale.
export interface ConnectionLimiterCreate {
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: ConnectionStrategy;
connection_type?: ConnectionType;
lock_type?: LockType;
count?: number;
}
export interface ConnectionLimiterUpdate {
username?: string;
outbound: string;
strategy: ConnectionStrategy;
connection_type?: ConnectionType;
lock_type?: LockType;
count?: number;
}
export type RateStrategy =
| "fixed_window"
| "sliding_window"
| "token_bucket"
| "leaky_bucket"
| "bypass";
export type RateConnectionType = "hwid" | "mux" | "source_ip" | "default";
export interface RateLimiter {
id: number;
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: RateStrategy;
connection_type: RateConnectionType;
count: number;
interval: string;
created_at: string;
updated_at: string;
}
// `connection_type` / `count` / `interval` are excluded_if=Strategy
// bypass on the DTO; see the BandwidthLimiterCreate comment above for
// the full rationale.
export interface RateLimiterCreate {
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: RateStrategy;
connection_type?: RateConnectionType;
count?: number;
interval?: string;
}
export interface RateLimiterUpdate {
username?: string;
outbound: string;
strategy: RateStrategy;
connection_type?: RateConnectionType;
count?: number;
interval?: string;
}
export interface CountResponse {
count: number;
}
// Mirrors the JSON shape returned by `GET /manager/v1/version` —
// see service/manager_api/http/server/server.go.
export interface VersionInfo {
version: string;
website: string;
}
export type Listable = Record<string, string | number | boolean | string[] | number[] | undefined | null>;

View File

@@ -0,0 +1,37 @@
<svg width="1027" height="1109" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" overflow="hidden">
<defs>
<filter id="fx0" x="-10%" y="-10%" width="120%" height="120%" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse">
<feComponentTransfer color-interpolation-filters="sRGB">
<feFuncR type="discrete" tableValues="0 0" />
<feFuncG type="discrete" tableValues="0 0" />
<feFuncB type="discrete" tableValues="0 0" />
<feFuncA type="linear" slope="0.4" intercept="0" />
</feComponentTransfer>
<feGaussianBlur stdDeviation="4.58333 4.58333" />
</filter>
<clipPath id="clip1">
<rect x="692" y="855" width="1027" height="1109" />
</clipPath>
<clipPath id="clip2">
<rect x="-2" y="-2" width="541" height="786" />
</clipPath>
<clipPath id="clip3">
<rect x="0" y="0" width="535" height="782" />
</clipPath>
</defs>
<g clip-path="url(#clip1)" transform="translate(-692 -855)">
<path d="M692 1191 692 1575.69C692 1640.41 731.499 1651.19 731.499 1651.19L1148.03 1931.62C1212.66 1974.77 1194.71 1881.29 1194.71 1881.29L1194.71 1528.96 692 1191Z" fill="#37474F" fill-rule="evenodd" />
<g clip-path="url(#clip2)" filter="url(#fx0)" transform="translate(1184 1182)">
<g clip-path="url(#clip3)">
<path d="M520.482 15.4819 520.482 400.176C520.482 464.89 480.983 475.676 480.983 475.676 480.983 475.676 129.086 712.963 64.4523 756.106-0.181814 799.25 17.7721 705.773 17.7721 705.773L17.7721 353.437 520.482 15.4819Z" fill="#455A64" fill-rule="evenodd" />
</g>
</g>
<path d="M1698 1191 1698 1575.69C1698 1640.41 1658.5 1651.19 1658.5 1651.19 1658.5 1651.19 1306.6 1888.48 1241.97 1931.62 1177.34 1974.77 1195.29 1881.29 1195.29 1881.29L1195.29 1528.96 1698 1191Z" fill="#455A64" fill-rule="evenodd" />
<path d="M1241.71 868.473C1212.96 850.509 1169.85 850.509 1144.7 868.473L713.557 1163.07C684.814 1181.04 684.814 1213.37 713.557 1231.33L1144.7 1529.53C1173.44 1547.49 1216.56 1547.49 1241.71 1529.53L1676.44 1227.74C1705.19 1209.78 1705.19 1177.44 1676.44 1159.48L1241.71 868.473Z" fill="#546E7A" fill-rule="evenodd" />
<path d="M1195 1949C1173.4 1949 1159 1935.19 1159 1917.92L1159 1531.08C1159 1513.82 1173.4 1500 1195 1500 1216.6 1500 1231 1513.82 1231 1531.08L1231 1914.46C1231 1935.19 1216.6 1949 1195 1949Z" fill="#546E7A" fill-rule="evenodd" />
<path d="M1553.92 1435.92C1553.92 1471.89 1557.5 1486.27 1518.03 1511.45L1428.32 1568.99C1388.85 1594.17 1374.5 1572.59 1374.5 1540.22L1374.5 1446.71C1374.5 1439.52 1374.5 1435.92 1363.73 1428.73 1270.43 1363.99 911.591 1115.84 847 1069.09L1012.07 954C1058.72 982.772 1399.61 1209.35 1539.56 1306.45 1546.74 1310.05 1550.33 1317.24 1550.33 1320.84L1550.33 1435.92Z" fill="#99AAB5" fill-rule="evenodd" />
<path d="M1543.41 1310.21C1399.82 1213.17 1058.79 986.752 1015.72 958L951.103 997.534 847 1069.41C911.615 1116.14 1270.59 1360.53 1363.92 1425.22 1371.1 1428.81 1371.1 1432.41 1371.1 1436L1547 1313.8C1547 1313.8 1547 1310.21 1543.41 1310.21Z" fill="#CCD6DD" fill-rule="evenodd" />
<path d="M1554.9 1435.48 1554.9 1324.19C1554.9 1317.01 1551.3 1313.42 1544.11 1309.83 1400.28 1212.89 1058.67 986.721 1015.51 958L940 1008.26C1062.26 1090.83 1389.49 1306.24 1475.79 1367.27 1486.58 1374.45 1486.58 1381.63 1486.58 1385.22L1486.58 1536 1522.54 1510.87C1558.5 1485.74 1554.9 1467.79 1554.9 1435.48Z" fill="#CCD6DD" fill-rule="evenodd" />
<path d="M1543.23 1309.95C1399.6 1212.98 1058.49 986.731 1015.4 958L940 1008.28C1062.08 1090.88 1388.83 1306.36 1475.01 1367.41 1475.01 1367.41 1478.6 1371 1478.6 1371L1554 1317.13C1546.82 1313.54 1546.82 1309.95 1543.23 1309.95Z" fill="#E1E8ED" fill-rule="evenodd" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,107 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
type Api,
type AuthInfo,
clearAuth,
loadAuth,
makeApi,
saveAuth,
saveLoginDraft,
setUnauthorizedHandler,
} from "../api/client";
import { useNotify } from "../notifications/NotificationsProvider";
interface AuthContextValue {
auth: AuthInfo | null;
api: Api | null;
login: (auth: AuthInfo) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [auth, setAuth] = useState<AuthInfo | null>(() => loadAuth());
const notify = useNotify();
// keep tabs in sync
useEffect(() => {
const handler = (e: StorageEvent) => {
if (e.key === null || e.key === "sing-box-admin:auth") setAuth(loadAuth());
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, []);
const login = useCallback((next: AuthInfo) => {
saveAuth(next);
setAuth(next);
}, []);
const logout = useCallback(() => {
// Remember the URL the user was just connected to so the login form is
// pre-filled on the way back. The key is *not* preserved.
setAuth((prev) => {
if (prev?.baseUrl) {
saveLoginDraft({ baseUrl: prev.baseUrl, apiKey: "" });
}
return null;
});
clearAuth();
}, []);
// Globally trap 401 → kick back to the login screen.
//
// Two flavours, distinguished by whether the user *was* signed in
// when the 401 hit:
// - prev !== null → an active session got rejected (key revoked,
// server restarted with a different secret, …). We surface a
// toast so the user understands why they're suddenly back at
// the login screen.
// - prev === null → the failure happened *during* a login attempt
// (the LoginPage's `ping` probe). The login form already shows
// an inline error and emits its own focused toast, so we stay
// quiet here to avoid double-announcing the same failure.
useEffect(() => {
setUnauthorizedHandler(() => {
setAuth((prev) => {
if (prev?.baseUrl) {
saveLoginDraft({ baseUrl: prev.baseUrl, apiKey: "" });
}
if (prev) {
notify.error("Session expired — please sign in again.");
}
return null;
});
clearAuth();
});
return () => setUnauthorizedHandler(null);
}, [notify]);
const value = useMemo<AuthContextValue>(
() => ({ auth, api: auth ? makeApi(auth) : null, login, logout }),
[auth, login, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
export function useApi(): Api {
const { api } = useAuth();
if (!api) throw new Error("API used without auth");
return api;
}

View File

@@ -0,0 +1,231 @@
import {
Box,
Button,
IconButton,
Popover,
Stack,
TextField,
Tooltip,
} from "@mui/material";
import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined";
import { useEffect, useRef, useState } from "react";
import { isHexColor } from "../theme";
import { useAccent } from "../theme/AppThemeProvider";
// A handful of curated presets so the user can pick a tasteful color in one
// click. They're roughly the Tailwind "500" values for vivid hues.
const PRESETS = [
"#3b82f6", // blue (default)
"#7c83ff", // indigo
"#00ffff", // cyan
"#22c55e", // green
"#f59e0b", // amber
"#ef4444", // red
"#ec4899", // pink
"#a855f7", // purple
"#14b8a6", // teal
];
export function ColorPickerButton() {
const { accent, setAccent, resetAccent } = useAccent();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const [draftHex, setDraftHex] = useState(accent);
const colorInputRef = useRef<HTMLInputElement | null>(null);
// Keep the text input in sync when the accent changes (e.g. preset clicked
// or another tab updated localStorage).
useEffect(() => setDraftHex(accent), [accent]);
const open = Boolean(anchor);
const apply = (hex: string) => {
if (isHexColor(hex)) setAccent(hex);
};
return (
<>
<IconButton
aria-label="Choose theme color"
onClick={(e) => setAnchor(e.currentTarget)}
size="medium"
sx={{
color: "text.primary",
borderRadius: 2,
width: 40,
height: 40,
"&:hover": { backgroundColor: "action.hover" },
}}
>
<Box
sx={{
position: "relative",
width: 22,
height: 22,
display: "grid",
placeItems: "center",
}}
>
<PaletteOutlinedIcon fontSize="small" />
<Box
sx={{
position: "absolute",
bottom: -2,
right: -2,
width: 8,
height: 8,
borderRadius: "50%",
bgcolor: accent,
// Theme-aware ring around the accent dot so it reads
// cleanly against either the dark or light topbar.
border: "1.5px solid",
borderColor: "background.default",
}}
/>
</Box>
</IconButton>
<Popover
open={open}
anchorEl={anchor}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
slotProps={{ paper: { sx: { p: 2, width: 260 } } }}
>
<Stack spacing={1.5}>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(5, 1fr)",
gap: 1,
}}
>
{PRESETS.map((c, i) => (
<Swatch
key={c}
color={c}
index={i}
selected={c.toLowerCase() === accent.toLowerCase()}
onPick={() => apply(c)}
/>
))}
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<Box
onClick={() => colorInputRef.current?.click()}
sx={{
width: 36,
height: 36,
flexShrink: 0,
borderRadius: 1.5,
bgcolor: accent,
cursor: "pointer",
outline: "1px solid",
outlineColor: "divider",
transition:
"background-color 0.32s cubic-bezier(0.4,0,0.2,1), transform 0.12s cubic-bezier(0.4,0,0.2,1)",
"&:hover": { transform: "scale(1.04)" },
"&:active": { transform: "scale(0.94)" },
}}
/>
<input
ref={colorInputRef}
type="color"
value={accent}
onChange={(e) => apply(e.target.value)}
style={{ display: "none" }}
/>
<TextField
size="small"
fullWidth
value={draftHex}
onChange={(e) => {
const v = e.target.value;
setDraftHex(v);
if (isHexColor(v)) setAccent(v);
}}
placeholder="#7c83ff"
inputProps={{ maxLength: 7, "aria-label": "Custom hex color" }}
error={draftHex.length > 0 && !isHexColor(draftHex)}
/>
</Stack>
<Button
size="small"
variant="outlined"
color="inherit"
onClick={() => {
resetAccent();
setAnchor(null);
}}
>
Reset to default
</Button>
</Stack>
</Popover>
</>
);
}
// Swatch — a single colour cell. The entry fade/grow animation still plays
// when the popover opens, but picking a colour no longer triggers an extra
// pop bounce: that second animation used `composite: "add"` layered on top
// of the CSS hover/active transform transitions, which caused a visible
// twitch as the WAAPI effect finished and the element snapped back to its
// hover-scaled baseline.
function Swatch({
color,
index,
selected,
onPick,
}: {
color: string;
index: number;
selected: boolean;
onPick: () => void;
}) {
return (
<Tooltip title={color.toUpperCase()}>
<Box
role="button"
tabIndex={0}
onClick={onPick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onPick();
}}
sx={{
position: "relative",
width: 36,
height: 36,
borderRadius: 1.5,
bgcolor: color,
cursor: "pointer",
outline: selected ? "2px solid" : "1px solid",
outlineColor: selected ? "text.primary" : "divider",
outlineOffset: selected ? 2 : 0,
zIndex: 1,
transition:
"transform 0.18s cubic-bezier(0.4,0,0.2,1), outline-offset 0.18s cubic-bezier(0.4,0,0.2,1), box-shadow 0.18s cubic-bezier(0.4,0,0.2,1), outline-color 0.2s cubic-bezier(0.4,0,0.2,1)",
animation: `sb-swatch-enter 0.32s ${index * 32}ms cubic-bezier(0.34, 1.4, 0.64, 1) backwards`,
"@keyframes sb-swatch-enter": {
from: { opacity: 0, transform: "scale(0.6)" },
to: { opacity: 1, transform: "scale(1)" },
},
"&:hover": {
transform: "scale(1.08)",
zIndex: 5,
boxShadow: `0 0 0 4px color-mix(in srgb, ${color} 22%, transparent)`,
},
"&:active": {
transform: "scale(0.92)",
transition:
"transform 0.08s cubic-bezier(0.4,0,0.2,1), outline-offset 0.18s cubic-bezier(0.4,0,0.2,1)",
},
"&:focus-visible": {
outline: "2px solid",
outlineColor: "text.primary",
outlineOffset: 2,
},
}}
/>
</Tooltip>
);
}

View File

@@ -0,0 +1,216 @@
import { Box, IconButton, Tooltip } from "@mui/material";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import CheckIcon from "@mui/icons-material/Check";
import {
useCallback,
useEffect,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from "react";
// copyToClipboard writes `text` to the OS clipboard, preferring the modern
// async Clipboard API and falling back to a hidden textarea + execCommand
// for non-secure contexts (HTTP, older browsers) where `navigator.clipboard`
// is undefined.
async function copyToClipboard(text: string): Promise<boolean> {
try {
if (
typeof navigator !== "undefined" &&
navigator.clipboard &&
typeof navigator.clipboard.writeText === "function"
) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
/* fall through to legacy path */
}
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.top = "0";
ta.style.left = "0";
ta.style.opacity = "0";
ta.style.pointerEvents = "none";
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
interface CopyableIdProps {
// The string written to the clipboard. Also rendered inline as the
// visible label — the caller doesn't pass it twice.
value: string;
// Optional override for the tooltip's idle title ("Copy UUID" by default).
// Used to keep the hint accurate when the same component is reused for
// non-UUID identifiers (numeric IDs, IPs, etc.).
label?: string;
}
// CopyableId — inline value + click-to-copy with a small always-visible
// icon and a brief "Copied!" confirmation. Designed to drop into table
// cells styled by `ID_CELL_SX`: long values clip with an ellipsis on the
// left side, while the icon stays pinned to the right.
//
// The whole component is one click target: clicking either the text or
// the explicit icon button copies the value, so users don't have to aim
// for the tiny icon. After a successful copy the icon flips to a green
// checkmark for ~1.2 s so users get visual confirmation regardless of
// the cursor position.
export function CopyableId({ value, label }: CopyableIdProps) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<number | null>(null);
// Cancel any pending "Copied!" reset on unmount so a fast row remount
// (pagination, filter apply) doesn't run setState on a dead component.
useEffect(
() => () => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
},
[],
);
const handleCopy = useCallback(
async (e?: ReactMouseEvent<HTMLElement>) => {
// Stop the click from bubbling to ancestors that might react to row
// clicks (selection toggles, navigation), and from triggering text
// selection on double-click.
e?.stopPropagation();
e?.preventDefault();
const ok = await copyToClipboard(value);
if (!ok) return;
setCopied(true);
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
setCopied(false);
timeoutRef.current = null;
}, 1200);
},
[value],
);
const idleTitle = label ?? "Copy UUID";
return (
<Box
onClick={handleCopy}
role="button"
aria-label={idleTitle}
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.5,
maxWidth: "100%",
cursor: "pointer",
userSelect: "text",
// Subtle hover affordance — the cell text shifts toward the
// primary text colour so it reads as interactive without painting
// a button-like background that would feel heavy in a dense table.
transition: "color 0.14s ease",
"&:hover": { color: "text.primary" },
}}
>
<Box
component="span"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minWidth: 0,
}}
>
{value}
</Box>
<Tooltip
// Suppress the tooltip while the "Copied!" feedback is on screen —
// the icon + colour change is enough confirmation, and a tooltip
// whose title flips mid-animation was reading as the source of the
// jitter (MUI re-measures + re-positions the popper on every text
// change, and that work landed on the same frames as the icon
// cross-fade below).
title={copied ? "" : idleTitle}
placement="top"
arrow
disableInteractive
>
<IconButton
size="small"
className="copy-affordance"
onClick={handleCopy}
aria-label={idleTitle}
sx={{
width: 22,
height: 22,
p: 0,
flexShrink: 0,
// The IconButton itself doesn't carry a `color` any more —
// each stacked icon paints itself directly so the cross-fade
// doesn't have to also animate `currentColor`. Without this
// separation the incoming `ContentCopy` icon would briefly
// render green (inheriting the still-mid-transition success
// colour) and then "snap" to grey, which is the small jitter
// that was visible at the end of the 1.2 s window.
"&:hover": { backgroundColor: "transparent" },
}}
>
{/* Two icons stacked on the same 14×14 spot, cross-faded by
opacity + scale. Each icon owns its own colour so the
transition is a pure visual swap with no `currentColor`
re-flow. The slight scale gives the swap a designed
"pop / dismiss" feel instead of a hard cut, which is what
the eye reads as a glitch when only opacity changes. */}
<Box
component="span"
sx={{
position: "relative",
width: 14,
height: 14,
display: "inline-block",
"& > svg": {
position: "absolute",
top: 0,
left: 0,
fontSize: 14,
transformOrigin: "center",
transition:
"opacity 0.22s cubic-bezier(0.4, 0, 0.2, 1), transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)",
// Force the icon's transform onto its own compositor
// layer so the scale animates on the GPU and never
// shares a frame budget with the surrounding row's
// hover / table re-renders.
willChange: "opacity, transform",
},
}}
>
<ContentCopyIcon
sx={{
color: "text.secondary",
opacity: copied ? 0 : 1,
transform: copied ? "scale(0.7)" : "scale(1)",
}}
/>
<CheckIcon
sx={{
color: "success.main",
opacity: copied ? 1 : 0,
transform: copied ? "scale(1)" : "scale(0.7)",
}}
/>
</Box>
</IconButton>
</Tooltip>
</Box>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,958 @@
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Drawer,
IconButton,
List,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
Popover,
Tooltip,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import LogoutIcon from "@mui/icons-material/Logout";
import LightModeIcon from "@mui/icons-material/LightMode";
import DarkModeIcon from "@mui/icons-material/DarkMode";
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 LinkIcon from "@mui/icons-material/Link";
import SpeedIcon from "@mui/icons-material/Speed";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import FilterAltIcon from "@mui/icons-material/FilterAlt";
import GitHubIcon from "@mui/icons-material/GitHub";
import FavoriteIcon from "@mui/icons-material/Favorite";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import MenuIcon from "@mui/icons-material/Menu";
import InfoIcon from "@mui/icons-material/Info";
import brandIcon from "../assets/icon.svg";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { useEffect, useState, type ReactNode, type MouseEvent } from "react";
import { useApi, useAuth } from "../auth/AuthContext";
import type { VersionInfo } from "../api/types";
import { useAccent } from "../theme/AppThemeProvider";
import { ColorPickerButton } from "./ColorPickerButton";
const leftDrawerWidthExpanded = 240;
const leftDrawerWidthCollapsed = 56;
const headerHeight = 60;
const navItemSize = 40; // square size of each menu button when collapsed
// Smooth, snappy animation for the sidebar collapse/expand.
const drawerTransition = "width 0.22s cubic-bezier(0.4, 0, 0.2, 1)";
const COLLAPSED_KEY = "sing-box-admin:nav-collapsed";
// NavItem is one of three flavours, mutually exclusive:
// - `to` — internal route, navigated via react-router; the item
// lights up as "selected" when the URL matches.
// - `href` — external URL; opens in a new tab, never selected.
// - `action` — ad-hoc onClick (e.g. opens a popover); never
// selected, doesn't dismiss the mobile drawer because
// the click target is meant to remain anchored.
interface NavItem {
label: string;
icon: ReactNode;
to?: string;
href?: string;
action?: (e: MouseEvent<HTMLElement>) => void;
}
interface NavGroup {
header?: string;
items: NavItem[];
}
const NAV_GROUPS: NavGroup[] = [
{
header: "General",
items: [
{ to: "/", label: "Dashboard", icon: <DashboardIcon fontSize="small" /> },
{ to: "/squads", label: "Squads", icon: <GroupsIcon fontSize="small" /> },
{ to: "/nodes", label: "Nodes", icon: <StorageIcon fontSize="small" /> },
{ to: "/users", label: "Users", icon: <PeopleIcon fontSize="small" /> },
],
},
{
header: "Limiters",
items: [
// Order matches the table-registration order in service/admin_panel:
// connection → bandwidth → traffic → rate.
{
to: "/connection-limiters",
label: "Connection limiters",
icon: <LinkIcon fontSize="small" />,
},
{
to: "/bandwidth-limiters",
label: "Bandwidth limiters",
icon: <SpeedIcon fontSize="small" />,
},
{
to: "/traffic-limiters",
label: "Traffic limiters",
icon: <SwapHorizIcon fontSize="small" />,
},
{
to: "/rate-limiters",
label: "Rate limiters",
icon: <FilterAltIcon fontSize="small" />,
},
],
},
{
// Mirrors the "Miscellaneous" menu entries from
// service/admin_panel/migration/postgresql.go.
header: "Miscellaneous",
items: [
{
href: "https://github.com/shtorm-7/sing-box-extended",
label: "GitHub",
icon: <GitHubIcon fontSize="small" />,
},
{
href: "https://github.com/shtorm-7/sing-box-extended#support-the-project",
label: "Donate",
icon: <FavoriteIcon fontSize="small" />,
},
],
},
];
export function Layout({ children }: { children: ReactNode }) {
const { logout } = useAuth();
const api = useApi();
const location = useLocation();
const { palette, mode, toggleMode } = useAccent();
const theme = useTheme();
// Version info shown in the bottom-of-sidebar About popover.
//
// Two loads on mount, each from a different service — the panel
// is reachable via two HTTP endpoints, and we surface both so a
// mismatch is visible at a glance:
//
// - "Server" — `versionInfo` from manager-api's authenticated
// `/version` (fetched through the API client).
// - "Web" — `webVersion` from admin_panel's own
// `/version`, which serves `constant.Version` of the running
// sing-box binary (the same string the Makefile injects via
// `-ldflags -X` at link time). Fetched with a relative URL so
// a sub-path deployment (admin panel behind a reverse proxy)
// keeps working without configuration.
//
// Both loads are fire-and-forget; failures fall through to a
// placeholder rather than disrupting the layout — the sidebar
// should never break because of a /version response.
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
const [webVersion, setWebVersion] = useState<string | null>(null);
const [aboutAnchor, setAboutAnchor] = useState<HTMLElement | null>(null);
// Sign-out confirmation: the bottom-bar Logout button used to call
// `logout` directly, which made it trivial to drop the session by
// accident (the icon sits one row below the colour-picker / theme
// toggle that users poke at constantly). Gate it behind a confirm
// dialog instead — same shape as the delete-confirmation dialog in
// CrudPage so the two reads as a coherent pair.
const [signOutOpen, setSignOutOpen] = useState(false);
useEffect(() => {
let cancelled = false;
api
.version()
.then((info) => {
if (!cancelled) setVersionInfo(info);
})
.catch(() => {
/* keep `null`; popover renders a placeholder. */
});
// The web version comes from the admin_panel service that
// served us, NOT manager-api. `./version` resolves relative to
// the document URL so a sub-path deployment still hits the
// right origin without us having to compute a base URL.
fetch("./version", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((data: { version?: string } | null) => {
if (!cancelled && data && typeof data.version === "string") {
setWebVersion(data.version);
}
})
.catch(() => {
/* keep `null`; popover renders a placeholder. */
});
return () => {
cancelled = true;
};
}, [api]);
const openAbout = (e: MouseEvent<HTMLElement>) => setAboutAnchor(e.currentTarget);
const closeAbout = () => setAboutAnchor(null);
// Augment the static NAV_GROUPS with the About entry as the last
// item of "Miscellaneous". Done here (rather than at module scope)
// because the action callback closes over `openAbout`, which only
// exists once the component is rendering.
const navGroups: NavGroup[] = NAV_GROUPS.map((group) =>
group.header === "Miscellaneous"
? {
...group,
items: [
...group.items,
{
label: "About",
icon: <InfoIcon fontSize="small" />,
action: openAbout,
},
],
}
: group,
);
// Single breakpoint governs the "mobile" treatment: below `md`
// (< 900 px) the permanent sidebar becomes a temporary drawer
// opened by a hamburger. Portrait tablets + phones all fall under
// this threshold; desktop (≥ 900 px) keeps the original collapsible
// sidebar layout. Topbar buttons (theme toggle, accent picker,
// sign-out) are icon-only at every viewport so the row's geometry
// doesn't shift across the breakpoint.
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const [mobileOpen, setMobileOpen] = useState(false);
const [collapsed, setCollapsed] = useState<boolean>(() => {
try {
return localStorage.getItem(COLLAPSED_KEY) === "1";
} catch {
return false;
}
});
useEffect(() => {
try {
localStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0");
} catch {
/* ignore — write may fail in private-mode / quota-exceeded; the
in-memory state still holds for the session. */
}
}, [collapsed]);
// Auto-close the mobile drawer whenever the viewport crosses back
// over the breakpoint — otherwise `mobileOpen` could stay `true` and
// leave the desktop layout with an invisible Modal backdrop blocking
// clicks on the main content.
useEffect(() => {
if (!isMobile && mobileOpen) setMobileOpen(false);
}, [isMobile, mobileOpen]);
// Collapse/expand is a desktop-only affordance. On mobile the drawer
// is either shown in full (mobileOpen = true) or hidden — we never
// render it at the 56 px collapsed width.
const effectiveCollapsed = isMobile ? false : collapsed;
const drawerWidth = effectiveCollapsed
? leftDrawerWidthCollapsed
: leftDrawerWidthExpanded;
// Shared nav content (brand + grouped items) is rendered inside both
// the permanent and the temporary drawer so the two variants don't
// have to duplicate markup. `effectiveCollapsed` drives whether the
// brand caption + item labels are visible.
const drawerContent = (
<>
<Box
sx={{
// Same triple-height-lock as the topbar in the main column:
// explicit `height` + matching min/max + `flexShrink: 0`
// makes the brand strip a fixed-size brick so the sliding
// sidebar's content never reflows the brand row when the
// nav list grows past the drawer's available height. The
// nav list below this Box already has its own `overflow:
// auto`, so any leftover items scroll inside it instead
// of compressing the brand strip out from underneath.
height: headerHeight,
minHeight: headerHeight,
maxHeight: headerHeight,
flexShrink: 0,
boxSizing: "border-box",
position: "relative",
}}
>
{/* Single brand block: icon + wordmark grouped inside one
absolutely-positioned flex row. The wrapper's left edge
is a literal pixel value, its width is content-based,
and its inner flex layout is static (no values that
change between collapsed / expanded), so the whole
group sits at a fixed position relative to the drawer
paper. When the drawer's `width` animates, the brand
block doesn't move, recompute, or jitter — the
drawer's `overflowX: hidden` simply clips off the
trailing edge of the wordmark as the paper narrows. */}
<Box
sx={{
position: "absolute",
left: `${(leftDrawerWidthCollapsed - 32) / 2}px`,
top: 0,
bottom: 0,
display: "flex",
alignItems: "center",
gap: 1.5,
}}
>
<Box
component="img"
src={brandIcon}
alt="sing-box"
sx={{
width: 32,
height: 32,
flexShrink: 0,
display: "block",
objectFit: "contain",
}}
/>
<Box
sx={{
// Only `opacity` is animated. Opacity can't perturb
// layout, so the wordmark's glyphs never shift.
opacity: effectiveCollapsed ? 0 : 1,
transition: "opacity 0.18s ease",
whiteSpace: "nowrap",
// Stop hovering / focusing the fading text from
// getting in the way when the drawer is collapsed.
pointerEvents: effectiveCollapsed ? "none" : "auto",
}}
>
{/* Wordmark — solid text-primary. We previously layered an
accent-tinted gradient on top via `background-clip: text`,
but the gradient's bottom edge sat right above the
accent-coloured "extended" subtitle, and the two
accent-tinted bands fused into a soft halo that read as
a glow around the lower row. Dropping the gradient
removes that fused band entirely while leaving the
accent visible only where it was always intended — on
"extended" itself.
Both rows are kept as block-level boxes with explicit
heights so the `g` descender stays inside the wordmark's
row and never spills onto the subtitle. */}
<Box
component="span"
sx={(t) => ({
display: "block",
margin: 0,
height: 22,
fontFamily: t.typography.fontFamily,
fontWeight: 700,
fontSize: 17,
letterSpacing: -0.45,
lineHeight: "22px",
color: t.palette.text.primary,
})}
>
Sing-box
</Box>
{/* "EXTENDED" subtitle — a plain accent-coloured wordmark
rather than a chip-style pill. The previous boxed
version looked off-balance because a 1 px border around
~9 px uppercase text reads as too heavy at this small
a font size. A clean text label keeps the brand quiet
and lets the wordmark above carry the visual weight. */}
<Box
component="span"
sx={{
display: "block",
margin: 0,
fontSize: 10,
fontWeight: 700,
letterSpacing: 1.8,
lineHeight: "12px",
marginTop: "2px",
textTransform: "uppercase",
color: "var(--sb-accent)",
transition: "color 0.32s cubic-bezier(0.4,0,0.2,1)",
}}
>
extended
</Box>
</Box>
</Box>
</Box>
<Box sx={{ overflow: "auto", overflowX: "hidden", flexGrow: 1, py: 1 }}>
{navGroups.map((group, idx) => (
<List
key={group.header ?? `group-${idx}`}
dense
disablePadding
// No inter-group spacing when collapsed — without their headers
// the groups should read as a single column of icons.
sx={{ mb: effectiveCollapsed ? 0 : 1 }}
subheader={
group.header ? (
// Pure-CSS show/hide: when the sidebar collapses we just
// squeeze the subheader's height to 0 and drop its
// opacity. No JS animation runs per frame — that's what
// was causing the freeze with three subheader Collapses
// animating simultaneously alongside the drawer-width
// CSS transition.
<ListSubheader
disableSticky
sx={{
bgcolor: "transparent",
color: "text.secondary",
fontSize: 10.5,
letterSpacing: 1.4,
lineHeight: effectiveCollapsed ? "0px" : "30px",
height: effectiveCollapsed ? 0 : "30px",
opacity: effectiveCollapsed ? 0 : 1,
overflow: "hidden",
textTransform: "uppercase",
// Align horizontally with the *visible* icon glyph.
// The icon column starts at 12 px from the drawer's
// left edge (8 px marginInline + 4 px pl on each
// ListItemButton) and is 32 px wide; small icons
// (~20 px) are centred inside that column, so their
// visible left edge sits at 12 + (32 20) / 2 = 18.
pl: "18px",
pr: 1,
transition:
"height 0.18s ease, opacity 0.18s ease, line-height 0.18s ease",
}}
>
{group.header}
</ListSubheader>
) : undefined
}
>
{group.items.map((item) => {
// External links + action items never highlight as
// "selected" — only internal routes do.
const selected = item.to
? item.to === "/"
? location.pathname === "/"
: location.pathname.startsWith(item.to)
: false;
// Three flavours of nav item — pick the right element /
// event wiring per type:
// - `action`: plain ListItemButton (renders a div/
// button) with onClick fired straight at the
// callback.
// - `href`: anchor opening in a new tab.
// - `to`: react-router internal navigation.
const linkProps = item.action
? { onClick: item.action }
: item.href
? {
component: "a" as const,
href: item.href,
target: "_blank",
rel: "noopener noreferrer",
}
: {
component: RouterLink,
to: item.to!,
};
const button = (
<ListItemButton
{...linkProps}
selected={selected}
sx={{
// The theme gives every ListItemButton marginInline: 8,
// so the button's outer width is `drawerWidth 16`.
// With a 56 px collapsed drawer this leaves a 40 px
// button wide; horizontal padding splits the leftover
// 8 px around the 32 px icon column, keeping the icon
// centered both when collapsed and when expanded.
pl: "4px",
pr: "4px",
// No vertical margin: spacing between items is provided
// by the wrapper <Box> below via padding. Padding
// doesn't collapse, so the gap stays constant whether
// a Collapse-wrapped subheader is mounted or not —
// killing the small "freeze" that used to happen when
// the subheader unmounted at the end of the close
// animation.
my: 0,
// Fixed height equal to the collapsed button width so
// the button is a 48 × 48 square when collapsed and
// exactly the same height (just wider) when expanded.
// Replaces the previous `aspect-ratio` toggle, which
// was the source of the bounce on the expand/collapse
// transition.
minHeight: navItemSize,
height: navItemSize,
justifyContent: "flex-start",
}}
>
<ListItemIcon
sx={{
width: 32,
minWidth: 32,
justifyContent: "center",
mr: 1.5,
}}
>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{ fontSize: 15, noWrap: true }}
sx={{
opacity: effectiveCollapsed ? 0 : 1,
maxWidth: effectiveCollapsed ? 0 : 1000,
transition: "opacity 0.18s ease, max-width 0.22s ease",
overflow: "hidden",
}}
/>
</ListItemButton>
);
// Each item lives in a wrapping Box that supplies the
// vertical spacing via *padding*. Padding doesn't collapse,
// so adjacent items always have an 8 px gap (4 + 4) whether
// a Collapse-wrapped subheader sits between groups or is in
// the middle of unmounting.
//
// The Tooltip is rendered unconditionally; toggling
// `collapsed` only flips its title + listener flags. That
// way React keeps the same DOM tree across collapse/expand
// and doesn't unmount + remount every ListItemButton on
// every toggle — which was causing a visible freeze with
// ~10 menu items.
return (
<Box
key={item.to ?? item.href ?? item.label}
sx={{ py: 0.5 }}
// Closing the mobile drawer on nav-item click is what
// makes the temporary drawer feel native — users
// don't have to dismiss it manually after picking a
// destination. External `href` items don't navigate
// in-app, but we still dismiss the drawer so the
// main layout isn't left with an open backdrop.
//
// Action items (e.g. About) are the exception: they
// open a popover anchored to the ListItemButton, so
// dismissing the drawer would yank the anchor off-
// screen. Skip the close for those — the user can
// tap the backdrop or hamburger to dismiss.
onClick={
isMobile && !item.action
? () => setMobileOpen(false)
: undefined
}
>
<Tooltip
title={effectiveCollapsed ? item.label : ""}
placement="right"
disableHoverListener={!effectiveCollapsed}
disableFocusListener={!effectiveCollapsed}
disableTouchListener={!effectiveCollapsed}
>
{button}
</Tooltip>
</Box>
);
})}
</List>
))}
</Box>
</>
);
return (
// On desktop (md+) the root is locked to exactly the viewport
// height so the page-content column can flex-fill it and any
// CrudPage table card inside can claim "viewport topbar"
// height without page-level scroll. Mobile keeps `minHeight:
// 100vh` so long card lists scroll naturally on phones.
//
// `overflow: hidden` on md+ is the safety net that keeps the
// sticky topbar pinned to the viewport top no matter what
// internal flex calculation goes on. Without it, any one-frame
// overflow (a row mounting at its full content height before
// its `flex: 1` parent has settled, an emotion class swap,
// a font-metrics shift) lets the body become scrollable, and
// a `position: sticky` topbar inside a once-scrollable body is
// free to ride up *with* the scroll instead of clamping at
// top: 0 — which is exactly what users were seeing as "topbar
// and PageHeader jump higher right when the rows arrive". The
// sole legitimate scroll on desktop is the table's own
// `TableContainer` (`overflow-y: auto`), so clipping the root
// is harmless: there is nothing the user could ever want to
// scroll *outside* the table card.
<Box
sx={{
display: "flex",
height: { md: "100vh" },
minHeight: "100vh",
overflow: { md: "hidden" },
bgcolor: "background.default",
}}
>
{/* Left bar: on desktop this is a permanent, collapsible sidebar.
On mobile it turns into a temporary drawer that slides in from
the left, dismissed by tapping the backdrop or any nav item. */}
{isMobile ? (
<Drawer
anchor="left"
variant="temporary"
open={mobileOpen}
onClose={() => setMobileOpen(false)}
// Keep the drawer mounted so opening it doesn't cost a tree
// rebuild on every tap — the brand + nav items animate in
// from the left instead of re-rendering.
ModalProps={{ keepMounted: true }}
sx={{
[`& .MuiDrawer-paper`]: {
width: leftDrawerWidthExpanded,
boxSizing: "border-box",
backgroundColor: palette.elevated,
overflowX: "hidden",
},
}}
>
{drawerContent}
</Drawer>
) : (
<Drawer
anchor="left"
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
transition: drawerTransition,
[`& .MuiDrawer-paper`]: {
width: drawerWidth,
boxSizing: "border-box",
backgroundColor: palette.elevated,
overflowX: "hidden",
transition: drawerTransition,
// Hint the compositor that the drawer's width animates so the
// browser can lift the paper to its own layer and avoid a full
// reflow of every nav item on every animation frame.
willChange: "width",
},
}}
>
{drawerContent}
</Drawer>
)}
{/* Main column: thin top toolbar + page content. `minHeight: 0`
(desktop) lets the column shrink below its natural content
height so a fixed-height root + a flex-fill child can co-exist
without overflowing the viewport — that's what enables the
CrudPage table card to claim the leftover vertical space and
scroll its rows internally. */}
<Box
component="main"
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
minWidth: 0,
minHeight: { md: 0 },
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
// Mirror the responsive horizontal padding used by the
// page-content Box below (`px: { xs: 1.5, md: 2 }`). With
// a flat `px: 2` here the topbar's leading IconButton
// (hamburger on mobile, chevron on desktop) sat 4 px
// further to the right than the PageHeader icon badge
// on phones — the two icons share an "icon column" by
// design and need the same starting offset to actually
// line up vertically.
px: { xs: 1.5, md: 2 },
// Triple-locked height: explicit `height` + matching
// `min-height` / `max-height` make sure no descendant
// mounting at an unexpected natural height (e.g. an
// accent-color picker briefly painting taller during
// its enter transition, an icon button growing a
// halo) and no flex-shrink pressure from the
// surrounding column can ever resize the topbar.
// `flexShrink: 0` belt-and-braces the same guarantee
// against the flex algorithm itself: even if the
// main column's content overflows the viewport for
// one frame, the topbar is treated as a fixed-size
// brick the algorithm is not allowed to compress.
height: headerHeight,
minHeight: headerHeight,
maxHeight: headerHeight,
flexShrink: 0,
boxSizing: "border-box",
position: "sticky",
top: 0,
// Topbar has to paint above any sticky element rendered
// inside the page content — most importantly CrudPage's
// table, whose `stickyHeader` cells sit at z-index 2 and
// whose pinned Actions column tops out at z-index 3. The
// page-content Box wrapping `{children}` is `position:
// relative` without a `z-index`, so it does NOT create a
// stacking context: those table z-indices live in the
// main-column stacking context alongside the topbar.
//
// On mobile / tablet (xssm) the body itself is the
// scrolling container — `Layout`'s `height: { md: "100vh" }`
// only kicks in at md+, where instead the table scrolls
// its rows internally inside a `TableContainer` and the
// sticky table cells stay clipped to that scroll viewport.
// Below md, the table's sticky cells try to glue to the
// same `top: 0` as the topbar, and the higher z-indices
// would let them paint over the topbar (visually + for
// hit-testing). Concretely: the pinned Actions column is
// sticky to the *right* edge, so on a tablet a tap on
// the rightmost topbar buttons (Sign out / colour picker
// / theme toggle) used to fire the table's Edit/Delete
// IconButton sitting underneath instead.
//
// `theme.zIndex.appBar` (1100) is the standard MUI layer
// for top-level chrome — above any in-page sticky
// content but still below the temporary mobile drawer
// (`zIndex.drawer` = 1200), so opening the hamburger
// still slides the drawer over the topbar the way it
// always has.
zIndex: theme.zIndex.appBar,
backgroundColor: "background.default",
}}
>
{/* Leading button: chevron (collapse/expand) on desktop,
hamburger (open mobile drawer) on mobile. Sized at 46 × 46
on desktop so it visually shares a column with the
PageHeader icon badge; mobile uses the same size for a
generous tap target. */}
<IconButton
onClick={
isMobile
? () => setMobileOpen(true)
: () => setCollapsed((c) => !c)
}
size="medium"
aria-label={
isMobile
? "Open navigation"
: collapsed
? "Show menu"
: "Hide menu"
}
sx={{
color: "text.primary",
borderRadius: 2.5,
width: 46,
height: 46,
"&:hover": { backgroundColor: "action.hover" },
}}
>
{isMobile ? (
<MenuIcon />
) : (
// On desktop the same button toggles the sidebar between
// expanded (`ChevronLeftIcon`) and collapsed
// (`ChevronRightIcon`) states. The two glyphs are mirror
// images of each other but render with subtly different
// optical centres — swapping them via a conditional
// render shifted the icon a couple of pixels left/right
// each click. Both icons are now stacked in the same
// 24×24 box with `position: absolute` and only their
// opacity flips, so the visual centre stays put no
// matter which direction the chevron is pointing.
<Box
component="span"
sx={{
position: "relative",
display: "inline-block",
width: 24,
height: 24,
"& > svg": {
position: "absolute",
top: 0,
left: 0,
fontSize: 24,
},
}}
>
<ChevronLeftIcon sx={{ opacity: collapsed ? 0 : 1 }} />
<ChevronRightIcon sx={{ opacity: collapsed ? 1 : 0 }} />
</Box>
)}
</IconButton>
<Box sx={{ flexGrow: 1 }} />
<IconButton
onClick={(e) => toggleMode(e)}
size="medium"
aria-label="Toggle color theme"
sx={{
color: "text.primary",
borderRadius: 2,
width: 40,
height: 40,
"&:hover": { backgroundColor: "action.hover" },
}}
>
{mode === "light" ? (
<DarkModeIcon fontSize="small" />
) : (
<LightModeIcon fontSize="small" />
)}
</IconButton>
<ColorPickerButton />
{/* Sign out is icon-only at every viewport — same treatment
the theme/colour-picker buttons get sitting next to it.
Tooltip carries the label for hover/focus, `aria-label`
keeps the action discoverable for screen readers. */}
<Tooltip title="Sign out">
<IconButton
onClick={() => setSignOutOpen(true)}
size="medium"
aria-label="Sign out"
sx={{
color: "text.primary",
borderRadius: 2,
width: 40,
height: 40,
"&:hover": { backgroundColor: "action.hover" },
}}
>
<LogoutIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{/* Page-content slot. On desktop the box itself is a flex
column so a child like CrudPage can `flex: 1` and fill the
leftover vertical space below the topbar; on mobile the box
falls back to block layout so dashboards / phone-friendly
card lists scroll the page naturally. `minHeight: 0` is
required for the flex-fill child to be allowed to shrink
below its content height (so the table card scrolls its
rows internally instead of pushing the topbar off-screen). */}
<Box
sx={{
flexGrow: 1,
px: { xs: 1.5, md: 2 },
pb: 3,
position: "relative",
display: { md: "flex" },
flexDirection: { md: "column" },
minHeight: { md: 0 },
}}
>
{children}
</Box>
</Box>
{/* About popover — anchored to the sidebar Info button. Mounted
at the layout root (outside the drawer paper) so the popover
can escape the drawer's `overflowX: hidden` clip without us
having to relax that for the rest of the sidebar. */}
<Popover
open={Boolean(aboutAnchor)}
anchorEl={aboutAnchor}
onClose={closeAbout}
// Anchor on the right edge of the About row, vertically
// centred — so the popover unfolds out to the side, sharing
// the same baseline as the menu item instead of floating up
// above it. Mirrors the placement of the collapsed-sidebar
// tooltip so the popover reads as a slightly heavier version
// of the same affordance.
anchorOrigin={{ vertical: "center", horizontal: "right" }}
transformOrigin={{ vertical: "center", horizontal: "left" }}
slotProps={{
paper: {
sx: {
ml: 1,
p: 2,
minWidth: 240,
border: "1px solid",
borderColor: "divider",
},
},
}}
>
<Typography
variant="overline"
sx={{
display: "block",
color: "text.secondary",
letterSpacing: 1.4,
lineHeight: 1.4,
mb: 1,
}}
>
Sing-box Extended
</Typography>
{/* Two-row version table: server (running sing-box build,
fetched from /version) and web (this SPA bundle's own
version, baked in at build time). Labels in regular text,
values in monospace + word-break so a long build hash
wraps gracefully instead of pushing the popover wider. */}
{[
{ label: "Server", value: versionInfo?.version ?? "loading…" },
{ label: "Web", value: webVersion ?? "loading…" },
].map((row) => (
<Box
key={row.label}
sx={{
display: "flex",
alignItems: "baseline",
gap: 1.5,
"& + &": { mt: 0.5 },
}}
>
<Typography
sx={{
fontSize: 12,
color: "text.secondary",
minWidth: 50,
}}
>
{row.label}
</Typography>
<Typography
sx={{
fontFamily:
'"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: 13,
color: "text.primary",
wordBreak: "break-all",
}}
>
{row.value}
</Typography>
</Box>
))}
</Popover>
{/* Sign-out confirmation. Mirrors the delete-confirmation dialog
in CrudPage (compact xs Dialog, body2 explanatory copy,
Cancel + danger-coloured primary action) so the two read as
a single style of "are you sure?" prompt across the app. */}
<Dialog
open={signOutOpen}
onClose={() => setSignOutOpen(false)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Sign out?</DialogTitle>
<DialogContent dividers>
<Typography variant="body2" color="text.secondary">
You will be returned to the login screen and will need to
sign in again to continue.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setSignOutOpen(false)}>Cancel</Button>
<Button
variant="contained"
color="error"
startIcon={<LogoutIcon fontSize="small" />}
onClick={() => {
setSignOutOpen(false);
logout();
}}
>
Sign out
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,364 @@
import { Box } from "@mui/material";
import { useEffect, useMemo, useRef, useState } from "react";
// LoginBackdropMesh renders a stable network mesh: a fixed set of
// nodes connected by edges that are always faintly visible. Every
// few seconds one or two independent "traces" start somewhere in
// the mesh and travel hop-by-hop along their own random walk
// through the graph — each edge in a trace lights up in turn,
// followed by the destination node briefly pulsing as the trace
// "arrives" there.
//
// Each trace looks like a single data flow finding its way across
// multiple hops; concurrent traces look like several independent
// flows sharing the same network.
//
// Implementation notes:
// - Each trace is computed at spawn time as a sequence of edge
// hops (a random walk that avoids revisiting nodes). The
// sequence is rendered as overlay <line>/<circle> elements
// with per-element CSS `animation-delay`, so the browser
// handles the timing — no per-frame JS work after mount.
// - Multiple traces can be in flight at the same time. The
// spawner produces 12 traces per cycle and waits ~1.52.5 s
// between cycles, so the mesh always feels alive but never
// drowns the form behind it.
// - The accent colour comes from `var(--sb-accent)` so the mesh
// instantly retints when the user picks a new accent.
// - `prefers-reduced-motion` keeps the static mesh visible but
// suppresses the trace spawner.
interface Node {
x: number;
y: number;
r: number;
}
interface Edge {
a: number;
b: number;
}
// One hop of a trace: the edge being traversed, the node the
// trace lands on at the end of the hop, and how many milliseconds
// after the trace's start that hop fires.
interface TraceStep {
edgeIdx: number;
fromNodeIdx: number;
toNodeIdx: number;
delay: number;
}
interface Trace {
id: number;
originIdx: number;
steps: TraceStep[];
totalMs: number;
}
const VB_W = 1600;
const VB_H = 1000;
const EDGE_FIRE_MS = 600;
const NODE_FIRE_MS = 700;
// Time between successive hops. The actual edge highlight lasts
// EDGE_FIRE_MS, so stepping a touch faster than that produces
// overlap between adjacent hops — the trace reads as a continuous
// flow rather than a discrete dot-dot-dot.
const STEP_MS = 480;
// Build a stable mesh: a 7×5 grid of nodes (35 total) with per-cell
// jitter, connected by an edge wherever two nodes are nearer than
// ~1 cell diagonal. The grid is intentionally laid out across an
// area larger than the viewBox on each axis (`OVERSHOOT`) so the
// outermost nodes sit just past the visible edge of the page —
// traces can start in view and visibly continue off-screen, which
// gives the impression that the network extends beyond the form
// rather than being neatly framed by it.
const OVERSHOOT_X = 130;
const OVERSHOOT_Y = 90;
function buildGraph(): { nodes: Node[]; edges: Edge[] } {
const cols = 7;
const rows = 5;
const startX = -OVERSHOOT_X;
const startY = -OVERSHOOT_Y;
const spanX = VB_W + OVERSHOOT_X * 2;
const spanY = VB_H + OVERSHOOT_Y * 2;
const cellW = spanX / cols;
const cellH = spanY / rows;
const nodes: Node[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
nodes.push({
x: startX + cellW * (c + 0.5) + (Math.random() - 0.5) * cellW * 0.5,
y: startY + cellH * (r + 0.5) + (Math.random() - 0.5) * cellH * 0.5,
r: 2.4 + Math.random() * 1.2,
});
}
}
const threshold = Math.hypot(cellW, cellH) * 1.0;
const edges: Edge[] = [];
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i];
const b = nodes[j];
if (Math.hypot(a.x - b.x, a.y - b.y) < threshold) {
edges.push({ a: i, b: j });
}
}
}
return { nodes, edges };
}
// Build adjacency: for every node, the list of (edgeIdx,
// neighbourIdx) pairs incident on it. Used by the random-walk
// trace builder to step from node to node along real edges.
function buildAdjacency(
nodes: Node[],
edges: Edge[],
): { edgeIdx: number; nodeIdx: number }[][] {
const adj: { edgeIdx: number; nodeIdx: number }[][] = nodes.map(() => []);
edges.forEach((e, idx) => {
adj[e.a].push({ edgeIdx: idx, nodeIdx: e.b });
adj[e.b].push({ edgeIdx: idx, nodeIdx: e.a });
});
return adj;
}
// Walk a random path through the graph starting at `originIdx`,
// avoiding already-visited nodes so the trace doesn't double back
// on itself. Returns the chain of hops as an array of TraceStep
// records, ready to be rendered with staggered animation delays.
function buildTrace(
id: number,
originIdx: number,
adj: { edgeIdx: number; nodeIdx: number }[][],
desiredLength: number,
): Trace {
const visited = new Set<number>([originIdx]);
const steps: TraceStep[] = [];
let current = originIdx;
for (let i = 0; i < desiredLength; i++) {
// `adj` carries an entry for every node index, populated by
// buildAdjacency, so we can index into it without a fallback.
const neighbours = adj[current];
const candidates = neighbours.filter((n) => !visited.has(n.nodeIdx));
if (candidates.length === 0) break;
const pick = candidates[Math.floor(Math.random() * candidates.length)];
steps.push({
edgeIdx: pick.edgeIdx,
fromNodeIdx: current,
toNodeIdx: pick.nodeIdx,
delay: i * STEP_MS,
});
visited.add(pick.nodeIdx);
current = pick.nodeIdx;
}
// Total time = last hop's start + the longest individual fire
// animation (edge fire vs trailing node fire), plus a small
// safety margin so we never yank the overlay mid-animation.
const lastDelay = steps.length > 0 ? steps[steps.length - 1].delay : 0;
const totalMs = lastDelay + Math.max(EDGE_FIRE_MS, NODE_FIRE_MS) + 80;
return { id, originIdx, steps, totalMs };
}
export function LoginBackdropMesh() {
const reducedMotion = useMemo(
() =>
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches,
[],
);
const { nodes, edges } = useMemo(buildGraph, []);
const adjacency = useMemo(() => buildAdjacency(nodes, edges), [nodes, edges]);
const [traces, setTraces] = useState<Trace[]>([]);
const idCounter = useRef(0);
useEffect(() => {
if (reducedMotion) return;
let mounted = true;
const timeouts = new Set<number>();
const schedule = (cb: () => void, delay: number) => {
const t = window.setTimeout(() => {
timeouts.delete(t);
if (mounted) cb();
}, delay);
timeouts.add(t);
};
// Restrict trace origins to nodes that sit inside (or only
// just past) the canonical viewBox, so the user actually
// sees the very first hop fire instead of the trace entering
// mid-flight from off-screen.
const visibleOriginIdxs: number[] = [];
nodes.forEach((n, i) => {
if (n.x >= 80 && n.x <= VB_W - 80 && n.y >= 80 && n.y <= VB_H - 80) {
visibleOriginIdxs.push(i);
}
});
const originPool =
visibleOriginIdxs.length > 0
? visibleOriginIdxs
: nodes.map((_, i) => i);
const spawn = () => {
if (!mounted) return;
// 12 traces fired together so the mesh almost always has at
// least one independent flow visible, and occasionally two
// crossing each other — but never enough to flood the page.
const burst = 1 + Math.floor(Math.random() * 2);
for (let k = 0; k < burst; k++) {
const originIdx =
originPool[Math.floor(Math.random() * originPool.length)];
// Randomise hop count per trace so flows feel different
// each time (some short, some longer).
const length = 5 + Math.floor(Math.random() * 5);
const id = ++idCounter.current;
const trace = buildTrace(id, originIdx, adjacency, length);
if (trace.steps.length === 0) continue;
setTraces((prev) => [...prev, trace]);
schedule(
() => setTraces((prev) => prev.filter((t) => t.id !== trace.id)),
trace.totalMs + 60,
);
}
// Stagger the next burst so the mesh has quiet beats between
// bursts rather than one continuous drumbeat.
schedule(spawn, 1500 + Math.random() * 1200);
};
schedule(spawn, 400);
return () => {
mounted = false;
timeouts.forEach((t) => window.clearTimeout(t));
timeouts.clear();
};
}, [reducedMotion, nodes, adjacency]);
return (
<Box
aria-hidden
sx={{
position: "absolute",
inset: 0,
pointerEvents: "none",
overflow: "hidden",
}}
>
{/* Keyframes live in a plain <style> tag so Emotion does not
rename them — the elements below reference these names from
raw `style={{ animation: ... }}` props rather than through
Emotion's `sx`/`styled`, so the names must stay verbatim. */}
<style>{`
@keyframes lb-mesh-edge {
0% { opacity: 0; }
18% { opacity: 0.95; }
70% { opacity: 0.55; }
100% { opacity: 0; }
}
@keyframes lb-mesh-node {
0% { opacity: 0; r: 2; }
25% { opacity: 1; r: 5; }
100% { opacity: 0; r: 7; }
}
`}</style>
<svg
viewBox={`0 0 ${VB_W} ${VB_H}`}
preserveAspectRatio="xMidYMid slice"
style={{ width: "100%", height: "100%", display: "block" }}
>
{/* Persistent base mesh — visible regardless of any
trace, so the topology is always readable as the
background of the page. */}
<g>
{edges.map((e, i) => {
const a = nodes[e.a];
const b = nodes[e.b];
return (
<line
key={`e-${i}`}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke="var(--sb-accent)"
strokeWidth={0.7}
opacity={0.14}
/>
);
})}
{nodes.map((n, i) => (
<circle
key={`n-${i}`}
cx={n.x}
cy={n.y}
r={n.r}
fill="var(--sb-accent)"
opacity={0.45}
/>
))}
</g>
{/* Active traces — one overlay group per live trace, with
per-element animation delays staggered hop-by-hop. */}
{traces.map((t) => {
const origin = nodes[t.originIdx];
return (
<g key={t.id}>
{/* Initial ping at the trace's origin node, so the
user can see where each flow starts. */}
<circle
cx={origin.x}
cy={origin.y}
r={2}
fill="var(--sb-accent)"
opacity={0}
style={{
filter: "drop-shadow(0 0 6px var(--sb-accent))",
animation: `lb-mesh-node ${NODE_FIRE_MS}ms ease-out 0ms forwards`,
}}
/>
{t.steps.map((s, i) => {
const a = nodes[s.fromNodeIdx];
const b = nodes[s.toNodeIdx];
return (
<g key={`step-${i}`}>
<line
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke="var(--sb-accent)"
strokeWidth={1.7}
strokeLinecap="round"
opacity={0}
style={{
animation: `lb-mesh-edge ${EDGE_FIRE_MS}ms ease-out ${s.delay}ms forwards`,
}}
/>
{/* Trace "arrives" at the destination node a
touch before the edge fully fades, which
reads as the packet landing one hop down
the line. */}
<circle
cx={b.x}
cy={b.y}
r={2}
fill="var(--sb-accent)"
opacity={0}
style={{
filter: "drop-shadow(0 0 6px var(--sb-accent))",
animation: `lb-mesh-node ${NODE_FIRE_MS}ms ease-out ${
s.delay + Math.round(EDGE_FIRE_MS * 0.55)
}ms forwards`,
}}
/>
</g>
);
})}
</g>
);
})}
</svg>
</Box>
);
}

View File

@@ -0,0 +1,85 @@
import { Box, IconButton, Tooltip } from "@mui/material";
import LightModeIcon from "@mui/icons-material/LightMode";
import DarkModeIcon from "@mui/icons-material/DarkMode";
import { useAccent } from "../theme/AppThemeProvider";
import { ColorPickerButton } from "./ColorPickerButton";
// Small toolbar pinned to the top-right of the login page. It
// surfaces the same appearance controls the main Layout exposes in
// its top bar — theme mode toggle (light ↔ dark) and the accent
// colour picker — so users can configure the UI's look without
// having to sign in first.
//
// Style-wise it uses a frosted-pill treatment that floats above the
// animated backdrop, matching the visual language the rest of the
// login page uses.
export function LoginThemeControls() {
const { mode, toggleMode } = useAccent();
return (
<Box
sx={{
position: "absolute",
top: { xs: 12, sm: 20 },
right: { xs: 12, sm: 20 },
// Above the animated backdrop (auto z-index) and at the same
// level as the auth form card so the pill is always
// clickable no matter how the viewport stacks.
zIndex: 2,
display: "flex",
alignItems: "center",
gap: 0.5,
p: 0.5,
borderRadius: 999,
bgcolor: (t) =>
t.palette.mode === "light"
? "rgba(255,255,255,0.72)"
: "rgba(20,20,20,0.55)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: (t) => `1px solid ${t.palette.divider}`,
boxShadow: (t) =>
t.palette.mode === "light"
? "0 6px 18px rgba(15,23,42,0.10)"
: "0 6px 18px rgba(0,0,0,0.45)",
// Normalise every IconButton inside the pill (including the
// one the ColorPickerButton component renders for its palette
// popover trigger) to a compact 34 px round button. Without
// this override the ColorPickerButton's default-medium
// IconButton would sit a few pixels taller than the theme
// toggle, making the pill look lopsided.
"& .MuiIconButton-root": {
width: 34,
height: 34,
borderRadius: 999,
transition:
"color 0.2s cubic-bezier(0.4,0,0.2,1), background-color 0.2s cubic-bezier(0.4,0,0.2,1)",
"&:hover": { color: "var(--sb-accent)" },
},
}}
>
<Tooltip
title={
mode === "light" ? "Switch to dark theme" : "Switch to light theme"
}
placement="top"
>
<IconButton
// Passing the click event along so the theme change's
// View Transitions API circular reveal radiates from the
// button's exact click point, not the screen centre.
onClick={(e) => toggleMode(e)}
aria-label="Toggle color theme"
sx={{ color: "text.secondary" }}
>
{mode === "light" ? (
<DarkModeIcon fontSize="small" />
) : (
<LightModeIcon fontSize="small" />
)}
</IconButton>
</Tooltip>
<ColorPickerButton />
</Box>
);
}

View File

@@ -0,0 +1,158 @@
import { Box, Stack, Typography } from "@mui/material";
import { type ReactNode } from "react";
import { useAccent } from "../theme/AppThemeProvider";
export interface PageHeaderProps {
title: string;
subtitle?: string;
icon?: ReactNode;
actions?: ReactNode;
}
// PageHeader is the visual top of every page: an accent-colored icon badge,
// a title, an optional subtitle, and a right-aligned actions slot. A thin
// hairline below visually separates the header from the page content.
//
// Responsive behaviour: on viewports ≥ sm the classic row layout stays
// (icon + title on the left, actions on the right). On xs viewports the
// actions wrap below the title row so the toolbar never pushes the
// title off-screen on a narrow phone.
//
// HEIGHT is explicitly locked. The header used to size to its content
// (icon 46 + pt + pb), which let any small content shift — a font
// metrics swap when Inter finishes loading, a transient inline `height:
// Xpx` from CrudPage's WAAPI tween, an actions row briefly wrapping —
// reflow the entire flex column it sits in and visibly slide the
// topbar / page title up by a few pixels right as fetched rows arrive.
// Pinning a `height` (matched by `min/maxHeight` for belt-and-braces)
// plus `flexShrink: 0` makes the box a fixed-size brick: nothing inside
// or above can change its outer height regardless of state. Tuned at
// 75 px so the existing `pt: 1` + 46 px icon + `pb: 2.5` + 1 px hairline
// fits the inside of the box without trimming.
export const PAGE_HEADER_HEIGHT = 75;
export function PageHeader({ title, subtitle, icon, actions }: PageHeaderProps) {
const { palette } = useAccent();
return (
<Box
sx={{
mb: 3,
pt: 1,
pb: 2.5,
height: PAGE_HEADER_HEIGHT,
minHeight: PAGE_HEADER_HEIGHT,
maxHeight: PAGE_HEADER_HEIGHT,
flexShrink: 0,
boxSizing: "border-box",
borderBottom: `1px solid ${palette.border}`,
}}
>
<Stack
// Always keep title and actions on the same row, with the
// actions pinned to the right edge — even on phones. The
// previous `xs: column` stack pushed the action toolbar
// below the title, which made the icon-only Filters/Refresh/
// New buttons appear left-aligned under the heading instead
// of in their familiar top-right corner.
direction="row"
alignItems="center"
spacing={2}
>
<Stack
direction="row"
alignItems="center"
spacing={2}
sx={{ flexGrow: 1, minWidth: 0 }}
>
{icon && (
<Box
sx={{
width: 46,
height: 46,
display: "grid",
placeItems: "center",
borderRadius: 2.5,
flexShrink: 0,
// CSS-variable accent so the badge smoothly crossfades to a
// new colour when the user picks a different accent. Using a
// literal `palette.accent` here would cause the className to
// change on every accent update and cancel any transition.
bgcolor:
"color-mix(in srgb, var(--sb-accent) 14%, transparent)",
color: "var(--sb-accent)",
border:
"1px solid color-mix(in srgb, var(--sb-accent) 32%, transparent)",
// Stacked box-shadows so the halo is the sum of several
// overlapping soft drops at decreasing alpha — that
// gives Chromium enough intermediate samples to land
// between to avoid the visible step bands a single
// `0 6px 18px @ 18%` shadow produced on the dark
// background (the alpha channel quantises to ~256
// levels, and a single low-alpha shadow doesn't have
// enough headroom across the blur radius to dither
// cleanly).
boxShadow: [
"inset 0 0 0 1px color-mix(in srgb, var(--sb-accent) 8%, transparent)",
"0 1px 2px color-mix(in srgb, var(--sb-accent) 26%, transparent)",
"0 3px 6px color-mix(in srgb, var(--sb-accent) 18%, transparent)",
"0 6px 14px color-mix(in srgb, var(--sb-accent) 12%, transparent)",
"0 10px 28px color-mix(in srgb, var(--sb-accent) 7%, transparent)",
].join(", "),
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), box-shadow 0.32s cubic-bezier(0.4,0,0.2,1)",
}}
>
{icon}
</Box>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography
variant="h5"
sx={{
lineHeight: 1.15,
// Slightly smaller heading on xs so long page titles
// like "Bandwidth limiters" fit the narrower column.
fontSize: { xs: 19, sm: 22 },
fontWeight: 600,
letterSpacing: -0.4,
}}
>
{title}
</Typography>
{subtitle && (
<Typography
variant="body2"
color="text.secondary"
sx={{
mt: 0.5,
fontSize: { xs: 12.5, sm: 13.5 },
// Keep the subtitle readable on narrow screens without
// forcing an overflow ellipsis — wrapping is preferable
// to truncation for description text.
whiteSpace: "normal",
}}
>
{subtitle}
</Typography>
)}
</Box>
</Stack>
{actions && (
<Box
sx={{
flexShrink: 0,
display: "flex",
alignItems: "center",
// Always pinned to the right of the row regardless of
// viewport width.
justifyContent: "flex-end",
flexWrap: "wrap",
rowGap: 1,
}}
>
{actions}
</Box>
)}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,50 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
// Bundle Inter locally — Vite hashes the woff/woff2 files into the build
// so the SPA serves them from the same origin and never reaches out to
// fonts.gstatic.com. Each weight import is the only side-effect path that
// registers an @font-face rule for that weight.
import "@fontsource/inter/400.css";
import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
import "@fontsource/inter/700.css";
import { App } from "./App";
import { AuthProvider } from "./auth/AuthContext";
import { AppThemeProvider } from "./theme/AppThemeProvider";
import { NotificationsProvider } from "./notifications/NotificationsProvider";
const container = document.getElementById("root");
if (!container) throw new Error("missing #root");
createRoot(container).render(
<StrictMode>
<AppThemeProvider>
{/* NotificationsProvider sits between the theme and the rest of
the app so toast Alerts pick up the active palette, and so
AuthProvider can call `useNotify` from its global 401 handler
(clearing credentials + announcing "Session expired" in one
shot). */}
<NotificationsProvider>
{/* LocalizationProvider feeds the MUI X date/time pickers used by the
datetime-range filter. dayjs is small (~7 KB gzipped) and matches
the format strings the manager API accepts. */}
<LocalizationProvider dateAdapter={AdapterDayjs}>
{/* BrowserRouter so deep routes (e.g. /users) are real URLs
shareable across users / bookmarkable / discoverable by
copy-paste. The Go backend serves the SPA's index.html for
every unknown path (see service/admin_panel/service.go), so
the browser always boots the bundle and react-router takes
over client-side. */}
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</LocalizationProvider>
</NotificationsProvider>
</AppThemeProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,346 @@
import { Alert, Box, Collapse, IconButton, Slide } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { ApiError, UnauthorizedError } from "../api/client";
// NotificationsProvider drives the small stack of "toast"-style alerts
// that flash in from the top-right whenever the panel completes (or
// fails) a user-visible action: a record was created / deleted, a CRUD
// request hit a network error, the manager-api returned 401, etc. The
// alerts auto-dismiss after a short delay and de-duplicate so a flurry
// of identical errors (e.g. a paginated reload that retries) shows up as
// a single chip.
//
// Two consumption styles are exposed via `useNotify`:
// - `notify({ severity, message })` for the full payload
// - convenience shorthands `notify.success(msg) / .error(msg) / …`
//
// `notifyApiError` is a pre-canned error-toast helper used by every
// CRUD callsite — it picks a useful description based on the error
// type (connection vs. unauthorized vs. generic API) so callers don't
// have to hand-format messages.
export type NotificationSeverity = "success" | "info" | "warning" | "error";
export interface NotificationOptions {
severity: NotificationSeverity;
message: string;
// Auto-dismiss timer (ms). `null` keeps the toast on screen until the
// user dismisses it manually. Defaults to ~4.5 s, long enough to read
// a sentence-length message without parking on screen.
duration?: number | null;
}
interface QueuedNotification extends NotificationOptions {
id: number;
// Drives the enter/exit transitions: items start with `open: true`,
// flip to `false` when the user clicks × or the auto-timer fires, and
// are finally removed from the array when the Collapse exit transition
// completes (via its `onExited` callback). Keeping the entry alive in
// state for the duration of the exit transition is what gives the
// close animation room to play instead of snapping the chip out.
open: boolean;
}
export interface NotificationsApi {
notify: (n: NotificationOptions) => number;
success: (message: string, options?: Partial<NotificationOptions>) => number;
info: (message: string, options?: Partial<NotificationOptions>) => number;
warning: (message: string, options?: Partial<NotificationOptions>) => number;
error: (message: string, options?: Partial<NotificationOptions>) => number;
dismiss: (id: number) => void;
}
const Ctx = createContext<NotificationsApi | undefined>(undefined);
const DEFAULT_DURATION = 4500;
// Cap on the number of toasts shown at once. Anything over this limit
// drops the oldest entry — a misbehaving callsite that fires hundreds
// of notifications in a row should not be able to fill the viewport.
const MAX_VISIBLE = 5;
// Transition timings (ms). Both Slide (horizontal motion) and Collapse
// (height collapse) share the same timeouts so the chip's slide-out and
// the stack's reflow finish together — a mismatch here would leave a
// phantom gap or a half-collapsed shadow visible for a frame after the
// chip itself has gone.
//
// 250 ms matches MUI's "standard" duration: the toast slides in /
// out at a familiar pace, neither lingering on screen nor flashing
// past too fast to register.
const TRANSITION_MS = 250;
const TRANSITION_TIMEOUT = { enter: TRANSITION_MS, exit: TRANSITION_MS } as const;
// Easing is intentionally left to MUI's defaults — Slide and Collapse
// fall back to `theme.transitions.easing.easeOut` on enter and
// `easeIn` on exit, which gives a clean "fade out to the right" feel
// without any overshoot or bounce.
export function NotificationsProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<QueuedNotification[]>([]);
const idRef = useRef(0);
// Track the active auto-dismiss timers so unmounting the provider (or a
// very stale toast) cleans them up rather than firing into a torn-down
// tree.
const timers = useRef<Map<number, number>>(new Map());
// dismiss flips the toast's `open` flag to false, kicking off its exit
// transition (Slide back out to the right + Collapse height to 0). The
// entry stays in `items` until `finalize` removes it once the Collapse
// `onExited` callback fires — that is what gives the close animation
// time to play instead of snapping the chip out of the DOM. Calling
// `dismiss` more than once for the same id (auto-timer + user click
// race) is a safe no-op.
const dismiss = useCallback((id: number) => {
setItems((prev) =>
prev.map((n) => (n.id === id && n.open ? { ...n, open: false } : n)),
);
const handle = timers.current.get(id);
if (handle !== undefined) {
window.clearTimeout(handle);
timers.current.delete(id);
}
}, []);
// finalize is invoked by the Collapse `onExited` callback once the exit
// transition has fully played out, removing the entry from state and
// letting React unmount the underlying DOM nodes.
const finalize = useCallback((id: number) => {
setItems((prev) => prev.filter((n) => n.id !== id));
}, []);
const notify = useCallback(
(n: NotificationOptions): number => {
const id = ++idRef.current;
setItems((prev) => {
// De-duplicate: if the most recent *visible* toast carries the
// same severity and message, swallow this one. A failed reload
// that retries shouldn't stack identical chips on top of each
// other. Items already in their exit transition (`open: false`)
// are skipped so a quickly-cleared duplicate doesn't suppress a
// legitimate repeat.
for (let i = prev.length - 1; i >= 0; i--) {
const p = prev[i];
if (!p.open) continue;
if (p.severity === n.severity && p.message === n.message) {
return prev;
}
break;
}
const next: QueuedNotification[] = [...prev, { ...n, id, open: true }];
// Cap the number of *visible* (still-open) toasts. Any overflow
// gets gracefully dismissed — flipped to `open: false` — so the
// overflowing oldest entry plays its close animation instead of
// popping out of existence. `finalize` will remove it from the
// array when its transition finishes.
let visible = 0;
for (const p of next) if (p.open) visible++;
let toClose = visible - MAX_VISIBLE;
for (let i = 0; i < next.length && toClose > 0; i++) {
if (next[i].open) {
next[i] = { ...next[i], open: false };
const stale = timers.current.get(next[i].id);
if (stale !== undefined) {
window.clearTimeout(stale);
timers.current.delete(next[i].id);
}
toClose--;
}
}
return next;
});
const duration = n.duration === undefined ? DEFAULT_DURATION : n.duration;
if (duration !== null && duration > 0) {
const handle = window.setTimeout(() => dismiss(id), duration);
timers.current.set(id, handle);
}
return id;
},
[dismiss],
);
// Cleanup pending timers on unmount.
useEffect(() => {
const map = timers.current;
return () => {
for (const handle of map.values()) window.clearTimeout(handle);
map.clear();
};
}, []);
const value = useMemo<NotificationsApi>(
() => ({
notify,
success: (message, options) => notify({ severity: "success", message, ...options }),
info: (message, options) => notify({ severity: "info", message, ...options }),
warning: (message, options) => notify({ severity: "warning", message, ...options }),
error: (message, options) => notify({ severity: "error", message, ...options }),
dismiss,
}),
[notify, dismiss],
);
return (
<Ctx.Provider value={value}>
{children}
{/* Stack of toasts pinned to the top-right of the viewport. A
high `zIndex` keeps them above MUI Dialog (1300) and Drawer
(1200) so a notification triggered from inside a modal
(e.g. CRUD form submission error) is still visible without
having to close the dialog first.
`pointerEvents: "none"` on the container lets clicks pass
through to the page underneath the empty space between
alerts; each Alert re-enables pointer events for itself so
its own close button still works. */}
<Box
sx={{
position: "fixed",
top: { xs: 4, sm: 8 },
right: { xs: 12, sm: 16 },
zIndex: 2000,
display: "flex",
flexDirection: "column",
// No `gap` here on purpose — the inter-toast spacing lives
// inside each Collapse below (as a top padding). That way
// the spacing height animates *together* with the toast
// instead of leaving a stranded gap during the close
// animation, and the stack reflows smoothly when an entry
// disappears.
maxWidth: "min(92vw, 420px)",
pointerEvents: "none",
}}
aria-live="polite"
aria-atomic="false"
>
{items.map((n) => (
// Collapse owns the *vertical* exit motion: when `open` flips
// to false it shrinks the chip's height to 0, pulling the
// toasts below it up smoothly. Its `onExited` callback then
// hands off to `finalize`, which removes the entry from
// state once the animation has fully played out.
<Collapse
key={n.id}
in={n.open}
appear
timeout={TRANSITION_TIMEOUT}
onExited={() => finalize(n.id)}
>
{/* Padding-top on the inner wrapper — not a parent flex gap
— so the spacing collapses together with the toast on
exit. The first toast inherits the same 8 px top
padding; combined with the container's `top: 8` it
lines up at 16 px from the viewport edge, which reads
cleanly without an explicit "no padding for index 0"
special case (which would jump-snap when the topmost
toast is dismissed). */}
<Box sx={{ pt: 1 }}>
{/* Slide owns the *horizontal* motion: enters by sliding
left from off-screen on the right, exits by sliding
back out to the right. Sharing the timeout shape with
Collapse keeps the two transitions in lockstep so the
chip lands / leaves cleanly. */}
<Slide
in={n.open}
direction="left"
appear
timeout={TRANSITION_TIMEOUT}
>
<Alert
severity={n.severity}
variant="filled"
onClose={() => dismiss(n.id)}
action={
<IconButton
aria-label="Dismiss notification"
size="small"
color="inherit"
onClick={() => dismiss(n.id)}
>
<CloseIcon fontSize="inherit" />
</IconButton>
}
sx={(theme) => {
// Force the toast contents to follow the active
// theme rather than MUI's default "always white on
// a coloured background" rule for the filled Alert
// variant: white text in dark mode, black text in
// light mode. Applied to the message body, the
// severity icon, and the dismiss button so all
// three follow the same contrast rule and read as
// a single tonal pair.
const fg = theme.palette.mode === "dark" ? "#ffffff" : "#000000";
return {
pointerEvents: "auto",
boxShadow: 6,
alignItems: "center",
color: fg,
"& .MuiAlert-icon": { color: fg },
"& .MuiAlert-action": { color: fg },
"& .MuiAlert-action .MuiIconButton-root": { color: fg },
// Long error bodies (e.g. an HTTP response excerpt)
// should wrap rather than overflow the chip and steal
// the close button.
"& .MuiAlert-message": {
color: fg,
wordBreak: "break-word",
overflowWrap: "anywhere",
},
};
}}
>
{n.message}
</Alert>
</Slide>
</Box>
</Collapse>
))}
</Box>
</Ctx.Provider>
);
}
export function useNotify(): NotificationsApi {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useNotify must be used within NotificationsProvider");
return ctx;
}
// notifyApiError emits a "toast"-style error notification for an exception
// thrown by the API client, picking a useful description based on the
// error type:
//
// - `UnauthorizedError` (HTTP 401) — silently skipped: the global
// handler in AuthContext already surfaces a "session expired"
// toast and redirects to the login screen, so emitting another
// here would double up.
// - `ApiError` with `status === 0` — connection/network error
// (fetch itself failed before a response arrived). Surfaced as a
// "Connection error" toast so the user understands the panel
// didn't reach the manager-api at all.
// - any other `ApiError` — formatted with the HTTP status and
// server-provided body excerpt.
// - anything else — message of the underlying Error / String fallback.
export function notifyApiError(
notify: NotificationsApi,
prefix: string,
e: unknown,
): void {
if (e instanceof UnauthorizedError) return;
if (e instanceof ApiError && e.status === 0) {
notify.error(`${prefix}: connection error — ${e.body || e.message}`);
return;
}
if (e instanceof ApiError) {
notify.error(`${prefix} (HTTP ${e.status}): ${e.body || e.message}`);
return;
}
notify.error(`${prefix}: ${e instanceof Error ? e.message : String(e)}`);
}

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

View File

@@ -0,0 +1,589 @@
import Fade from "@mui/material/Fade";
import { alpha, createTheme, type Theme } from "@mui/material/styles";
// Two layered palettes, one per mode. Surface and elevated tones differ
// by ~5 luminance steps so the eye can pick out cards/dialogs from the
// page; the rest of the UI references this set so flipping `mode` flips
// every component override at once.
export type ThemeMode = "dark" | "light";
const DARK = {
surface: "#141414", // page / cards / paper / tables
elevated: "#191919", // app bar replacement, table heads, side bar
elevated2: "#1f1f1f", // hover states for table rows
// Borders bumped a touch (0.06 → 0.10 / 0.14 → 0.20) so outlined
// cards and table cell borders are actually visible against the
// very dark surface.
border: "rgba(255,255,255,0.10)",
borderStrong: "rgba(255,255,255,0.20)",
textPrimary: "#f5f5f5",
// Secondary / caption text was washing out at 0.66 — bumped to 0.80
// so subtitles, helper text and uppercase labels stay legible.
textSecondary: "rgba(255,255,255,0.80)",
scrollbar: "rgba(255,255,255,0.10)",
scrollbarHover: "rgba(255,255,255,0.18)",
iconButtonHover: "rgba(255,255,255,0.07)",
inputBg: "rgba(255,255,255,0.02)",
chipBg: "rgba(255,255,255,0.06)",
listItemHover: "rgba(255,255,255,0.04)",
tableHeadColor: "rgba(255,255,255,0.78)",
tooltipBg: "#222",
tooltipText: "#fff",
dialogShadow: "0 24px 48px rgba(0,0,0,0.55)",
} as const;
const LIGHT = {
surface: "#ffffff",
elevated: "#f5f7fa",
elevated2: "#e9edf3",
// Borders deep enough that outlined cards / table rows read clearly
// on pure-white surfaces.
border: "rgba(15,23,42,0.18)",
borderStrong: "rgba(15,23,42,0.32)",
textPrimary: "#0f172a",
// Secondary / caption text — pushed up to 0.82 so helper text,
// subtitles and chips stop looking washed out on white.
textSecondary: "rgba(15,23,42,0.82)",
scrollbar: "rgba(15,23,42,0.26)",
scrollbarHover: "rgba(15,23,42,0.40)",
iconButtonHover: "rgba(15,23,42,0.07)",
inputBg: "#ffffff",
chipBg: "rgba(15,23,42,0.10)",
listItemHover: "rgba(15,23,42,0.06)",
tableHeadColor: "rgba(15,23,42,0.82)",
tooltipBg: "#1e293b",
tooltipText: "#fff",
dialogShadow: "0 24px 48px rgba(15,23,42,0.18)",
} as const;
function colors(mode: ThemeMode) {
return mode === "light" ? LIGHT : DARK;
}
// Default accent. The user can override via the in-app color picker.
export const DEFAULT_ACCENT = "#3b82f6";
export const DEFAULT_MODE: ThemeMode = "dark";
// isHexColor reports whether the string looks like a 3- or 6-digit hex CSS
// color, e.g. "#fff" / "#7c83ff". Used to validate user-supplied accent
// values from localStorage / the picker before feeding them to MUI.
export function isHexColor(s: string): boolean {
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(s);
}
// pickContrastingTextColor returns "#0c0c0c" or "#ffffff" depending on which
// has better contrast against the given background. Used so primary buttons
// stay readable regardless of the chosen accent.
export function pickContrastingTextColor(hex: string): string {
const norm =
hex.length === 4
? "#" +
hex
.slice(1)
.split("")
.map((c) => c + c)
.join("")
: hex;
const r = parseInt(norm.slice(1, 3), 16);
const g = parseInt(norm.slice(3, 5), 16);
const b = parseInt(norm.slice(5, 7), 16);
// Relative luminance per WCAG, rough approximation.
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return lum > 0.55 ? "#0c0c0c" : "#ffffff";
}
// ThemePalette exposes only the tokens callers actually read directly.
// MUI's own theme covers the rest (accent goes through `var(--sb-accent)`,
// borderStrong / elevated2 / accentSoft are only consumed inside the
// MUI theme overrides below, never via this struct).
export interface ThemePalette {
mode: ThemeMode;
surface: string;
elevated: string;
border: string;
}
// buildTheme produces a fresh MUI theme using the given accent color and
// theme mode. All component overrides reference the dynamic accent + mode
// palette so the entire UI updates the moment either changes.
export function buildTheme(
accentInput: string = DEFAULT_ACCENT,
modeInput: ThemeMode = DEFAULT_MODE,
): Theme {
const accent = isHexColor(accentInput) ? accentInput : DEFAULT_ACCENT;
const mode: ThemeMode = modeInput === "light" ? "light" : "dark";
const M = colors(mode);
const accentContrast = pickContrastingTextColor(accent);
return createTheme({
palette: {
mode,
background: { default: M.surface, paper: M.surface },
primary: { main: accent, contrastText: accentContrast },
secondary: { main: mode === "light" ? "#475569" : "#9aa0a6" },
divider: M.border,
text: {
primary: M.textPrimary,
secondary: M.textSecondary,
},
success: { main: "#22c55e" },
warning: { main: "#f59e0b" },
error: { main: "#ef4444" },
},
shape: { borderRadius: 10 },
typography: {
fontFamily:
'Inter, "SF Pro Display", Roboto, system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
h4: { fontWeight: 600, letterSpacing: -0.4 },
h5: { fontWeight: 600, letterSpacing: -0.3 },
h6: { fontWeight: 600, letterSpacing: -0.2 },
button: { fontWeight: 500 },
},
components: {
MuiCssBaseline: {
styleOverrides: {
":root": {
// Default accent CSS variable. AppThemeProvider overrides this
// on <html> at runtime; declaring a fallback here means the
// very first paint never renders with `var(--sb-accent)`
// pointing at nothing.
"--sb-accent": DEFAULT_ACCENT,
// Companion variable holding the readable foreground colour
// for content sitting on top of `--sb-accent` (e.g. the "+"
// create IconButton on the CrudPage toolbar). Computed via
// `pickContrastingTextColor`, kept in lockstep with
// `--sb-accent` by AppThemeProvider on every accent change.
"--sb-accent-contrast": accentContrast,
// Mode-aware surface tones, exposed so component sx blocks can
// pin sticky / overlapping elements (e.g. CrudPage's pinned
// Actions column) to exactly the same colour the surrounding
// Table / TableHead / hovered TableRow renders. Keeping them
// here means flipping the mode re-emits the trio in lockstep
// with every other component override.
"--sb-surface": M.surface,
"--sb-elevated": M.elevated,
"--sb-elevated2": M.elevated2,
},
html: {
// Reserve room for the vertical scrollbar even when it isn't
// present. Without this, anything that briefly hides the
// scrollbar (autofill, modals, even some browser quirks) shifts
// the entire viewport horizontally by ~17px and you see a
// "flick" in the sidebar / topbar text.
scrollbarGutter: "stable",
},
body: {
backgroundColor: M.surface,
color: M.textPrimary,
colorScheme: mode,
},
"*::-webkit-scrollbar": { width: 10, height: 10 },
"*::-webkit-scrollbar-track": { background: "transparent" },
"*::-webkit-scrollbar-thumb": {
background: M.scrollbar,
borderRadius: 8,
border: "2px solid transparent",
backgroundClip: "padding-box",
},
"*::-webkit-scrollbar-thumb:hover": {
background: M.scrollbarHover,
backgroundClip: "padding-box",
},
// Theme switching: View Transitions API circular reveal from the
// toggle's click point. Falls back to instant change in browsers
// without `document.startViewTransition`.
//
// Both pseudo-elements are static images (DOM snapshots), so we
// override the default cross-fade animation with our own clip-path
// reveal on the new state. The old snapshot stays at full opacity
// beneath the new one and is wiped away by the expanding circle.
// We deliberately do NOT disable transitions on the live DOM
// during the view-transition — that caused a "bounce" at the end
// when in-flight ripples / row-hover transitions snapped back.
"::view-transition-old(root), ::view-transition-new(root)": {
animation: "none",
mixBlendMode: "normal",
},
"::view-transition-old(root)": {
zIndex: 1,
},
"::view-transition-new(root)": {
zIndex: 2,
animation: "theme-reveal 520ms cubic-bezier(0.4, 0, 0.2, 1) forwards",
},
"@keyframes theme-reveal": {
from: {
clipPath:
"circle(0px at var(--theme-cx, 50%) var(--theme-cy, 50%))",
},
to: {
clipPath:
"circle(var(--theme-r, 150vmax) at var(--theme-cx, 50%) var(--theme-cy, 50%))",
},
},
},
},
MuiPaper: {
defaultProps: { elevation: 0 },
styleOverrides: {
root: { backgroundImage: "none", backgroundColor: M.surface },
outlined: {
borderColor: M.border,
borderRadius: 12,
},
},
},
MuiCard: {
styleOverrides: {
root: {
backgroundColor: M.surface,
backgroundImage: "none",
border: `1px solid ${M.border}`,
borderRadius: 12,
transition:
"transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease",
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundImage: "none",
backgroundColor: M.elevated,
boxShadow: "none",
borderBottom: `1px solid ${M.border}`,
},
},
},
MuiDrawer: {
styleOverrides: {
paper: {
backgroundImage: "none",
backgroundColor: M.elevated,
borderRight: `1px solid ${M.border}`,
},
},
},
MuiDialog: {
defaultProps: {
// Don't lock body scroll — MUI's default behaviour adds
// `overflow:hidden` + a `padding-right` to <body> equal to the
// scrollbar width while the modal is open, which shifts every
// `position:fixed` element (sidebar, topbar) horizontally by
// ~17px. Keeping the scrollbar avoids that flick.
disableScrollLock: true,
},
styleOverrides: {
paper: {
backgroundColor: M.surface,
backgroundImage: "none",
border: `1px solid ${M.border}`,
boxShadow: M.dialogShadow,
},
},
},
// Same scroll-lock fix for every other Modal-based opener so popping
// a colour picker, filter dropdown or autocomplete list doesn't yank
// the layout sideways.
//
// We also override the default `Grow` transition with `Fade` for
// every overlay element. `Grow` scales the element with
// `scale(0.75, 0.5625) → scale(1, 1)` — an *asymmetric* stretch in
// which the Y-axis grows from 56% to 100%. With most placements that
// means the top edge of the popup rises noticeably as the animation
// ends, which reads as the inner text "snapping upward" at the end
// of the open animation. A pure opacity fade has no transform at
// all, so the contents simply appear in their final position.
MuiPopover: {
defaultProps: {
disableScrollLock: true,
slots: { transition: Fade },
slotProps: { transition: { timeout: 160 } },
},
},
MuiMenu: {
defaultProps: {
disableScrollLock: true,
slots: { transition: Fade },
slotProps: { transition: { timeout: 160 } },
},
},
MuiModal: {
defaultProps: { disableScrollLock: true },
},
MuiTableContainer: {
styleOverrides: { root: { backgroundColor: M.surface } },
},
MuiTable: {
styleOverrides: {
root: {
backgroundColor: M.surface,
width: "100%",
},
},
},
MuiTableHead: {
styleOverrides: { root: { backgroundColor: M.elevated } },
},
MuiTableRow: {
styleOverrides: {
root: {
transition: "background-color 0.14s ease",
"&:hover": { backgroundColor: `${M.elevated2} !important` },
// Tone down the very last row's bottom border so the table edge
// doesn't read as a hard double line against the Paper outline.
"&:last-of-type td": { borderBottom: 0 },
},
},
},
MuiTableCell: {
styleOverrides: {
root: {
color: M.textPrimary,
borderBottomColor: M.border,
backgroundColor: "transparent",
fontSize: 13.5,
paddingTop: 16,
paddingBottom: 16,
},
sizeSmall: {
paddingTop: 16,
paddingBottom: 16,
},
head: {
fontWeight: 600,
fontSize: 11.5,
letterSpacing: 0.6,
textTransform: "uppercase",
color: M.tableHeadColor,
backgroundColor: M.elevated,
borderBottomColor: M.borderStrong,
// Slimmer header strip — the column-name bar shouldn't take
// as much vertical space as a data row, just enough to read
// the label comfortably.
paddingTop: 8,
paddingBottom: 8,
},
},
},
MuiButton: {
defaultProps: { disableElevation: true },
styleOverrides: {
root: {
textTransform: "none",
borderRadius: 8,
paddingInline: 14,
fontWeight: 500,
transition:
"background-color 0.16s ease, border-color 0.16s ease, transform 0.08s ease",
"&:active": { transform: "translateY(0.5px)" },
},
// Accent-coloured rules below reference `var(--sb-accent)` so the
// emotion class hash stays stable when the user picks a new
// accent. With a stable class, the declared `transition` actually
// animates the colour change instead of being voided by a class
// swap.
containedPrimary: {
backgroundColor: "var(--sb-accent)",
color: accentContrast,
transition:
"background-color 0.32s cubic-bezier(0.4,0,0.2,1), color 0.32s cubic-bezier(0.4,0,0.2,1)",
"&:hover": {
backgroundColor:
"color-mix(in srgb, var(--sb-accent) 92%, transparent)",
},
},
outlinedInherit: { borderColor: M.borderStrong },
},
},
MuiIconButton: {
styleOverrides: {
root: {
borderRadius: 8,
transition: "background-color 0.14s ease, color 0.14s ease",
// Desktop hover tint — gated on a real hover-capable
// pointer AND a desktop-width viewport so it never
// fires on touch devices or in Chrome DevTools' mobile
// preset (which keeps `(hover: hover)` true because
// the host machine still has a mouse, but does shrink
// the viewport, so `(min-width: 600px)` catches it).
// No companion `:active` / `:focus` rule on the mobile
// breakpoint by design: per the user's request the
// button gets zero post-tap visuals on a phone.
"@media (hover: hover) and (min-width: 600px)": {
"&:hover": { backgroundColor: M.iconButtonHover },
},
},
},
},
// Hide MUI's TouchRipple visual on the mobile / touch
// breakpoint for every ButtonBase-derived component
// (IconButton, Button, MenuItem, ListItemButton, …). The
// ripple is the growing circle that, on phones, can outlive
// the touch when the touchend event isn't delivered cleanly
// (drag-off, scroll, sluggish hardware) — that's the halo
// the user kept seeing after every tap. Desktop pointers
// fire mouseup / mouseleave reliably, so the ripple animation
// always completes its exit phase there and we leave it
// alone. The same `(hover: none), (max-width: 599.95px)` pair
// is used everywhere else in the theme so the rule fires
// both on real phones and inside Chrome DevTools'
// mobile-emulation preset.
MuiTouchRipple: {
styleOverrides: {
root: {
"@media (hover: none), (max-width: 599.95px)": {
display: "none",
},
},
},
},
MuiOutlinedInput: {
styleOverrides: {
root: {
backgroundColor: M.inputBg,
"& .MuiOutlinedInput-notchedOutline": {
borderColor: M.border,
transition: "border-color 0.32s cubic-bezier(0.4,0,0.2,1)",
},
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: M.borderStrong },
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "var(--sb-accent)",
borderWidth: 1,
},
// Suppress the browser's autofill highlight (Firefox paints
// the field with a translucent blue band, Chrome/Safari with
// yellow). The cell's own background already reads as
// "active", so the autofill colour is purely noise.
//
// WebKit (Chrome/Safari) doesn't honour normal
// `background-color` overrides on autofilled inputs — the
// canonical workaround is a giant inset `box-shadow` that
// visually replaces the background, plus `text-fill-color`
// for the foreground colour the engine forces. Firefox uses
// the standard `:autofill` pseudo and respects ordinary
// `background-color` / `filter` overrides since v86.
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
{
WebkitBoxShadow: `0 0 0 1000px ${M.surface} inset !important`,
WebkitTextFillColor: `${M.textPrimary} !important`,
caretColor: `${M.textPrimary} !important`,
// The 5000s transition delay keeps the engine from
// re-running the autofill style transition on every
// focus / blur, which would re-paint the highlight.
transition: "background-color 5000s ease-in-out 0s",
},
"& input:autofill": {
backgroundColor: "transparent !important",
filter: "none !important",
color: `${M.textPrimary} !important`,
},
},
},
},
MuiChip: {
styleOverrides: {
root: {
backgroundColor: M.chipBg,
color: M.textPrimary,
fontWeight: 500,
borderRadius: 6,
},
// "Backlit" badge with accent glow — used for selected items in
// multi-select boxes so they stand out without being garish.
colorPrimary: {
backgroundColor:
"color-mix(in srgb, var(--sb-accent) 14%, transparent)",
color: "var(--sb-accent)",
border:
"1px solid color-mix(in srgb, var(--sb-accent) 35%, transparent)",
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)",
},
colorSuccess: { backgroundColor: alpha("#22c55e", 0.18), color: mode === "light" ? "#15803d" : "#86efac" },
colorWarning: { backgroundColor: alpha("#f59e0b", 0.18), color: mode === "light" ? "#a16207" : "#fcd34d" },
colorError: { backgroundColor: alpha("#ef4444", 0.18), color: mode === "light" ? "#b91c1c" : "#fca5a5" },
},
},
MuiListItemButton: {
styleOverrides: {
root: {
borderRadius: 8,
marginInline: 8,
paddingBlock: 6,
transition: "background-color 0.32s cubic-bezier(0.4,0,0.2,1)",
"&.Mui-selected": {
// Mode-aware accent tint: 16% on dark, 12% on light so the
// selected pill reads cleanly on both surfaces.
backgroundColor: `color-mix(in srgb, var(--sb-accent) ${
mode === "light" ? 12 : 16
}%, transparent)`,
"&:hover": {
backgroundColor:
"color-mix(in srgb, var(--sb-accent) 22%, transparent)",
},
"& .MuiListItemIcon-root": {
color: "var(--sb-accent)",
transition: "color 0.32s cubic-bezier(0.4,0,0.2,1)",
},
"& .MuiListItemText-primary": {
color: mode === "light" ? "#0f172a" : "#fff",
// Visually heavier on the active item without bumping
// `fontWeight`. Different font weights have different glyph
// advance widths, so toggling between 400 and 600 caused a
// ~1px horizontal jump every time the selection changed (or
// re-rendered on hover); painting an extra "stroke" via
// text-shadow gives the same heaviness with zero layout
// impact.
textShadow: "0 0 0.6px currentColor",
},
},
"&:hover": { backgroundColor: M.listItemHover },
},
},
},
MuiListItemIcon: {
styleOverrides: {
root: { minWidth: 36, color: M.textSecondary },
},
},
MuiTooltip: {
defaultProps: {
// See the MuiPopover comment above — Grow's asymmetric scale
// makes tooltip text appear to "snap upward" at the end of the
// open animation. A pure opacity fade has no transform so the
// contents simply appear in their final position.
slots: { transition: Fade },
slotProps: { transition: { timeout: 140 } },
},
styleOverrides: {
tooltip: {
backgroundColor: M.tooltipBg,
color: M.tooltipText,
fontSize: 12,
border: `1px solid ${M.border}`,
},
},
},
MuiAlert: {
styleOverrides: {
root: { borderRadius: 10 },
},
},
},
});
}
export function buildPalette(
modeInput: ThemeMode = DEFAULT_MODE,
): ThemePalette {
const mode: ThemeMode = modeInput === "light" ? "light" : "dark";
const M = colors(mode);
return {
mode,
surface: M.surface,
elevated: M.elevated,
border: M.border,
};
}

View File

@@ -0,0 +1,252 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import { flushSync } from "react-dom";
import { CssBaseline, ThemeProvider } from "@mui/material";
import {
DEFAULT_ACCENT,
DEFAULT_MODE,
buildPalette,
buildTheme,
isHexColor,
pickContrastingTextColor,
type ThemeMode,
type ThemePalette,
} from "../theme";
const ACCENT_KEY = "sing-box-admin:accent";
const MODE_KEY = "sing-box-admin:mode";
type ThemeOrigin = { clientX: number; clientY: number } | ReactMouseEvent | MouseEvent;
interface AccentContextValue {
accent: string;
setAccent: (color: string) => void;
resetAccent: () => void;
mode: ThemeMode;
setMode: (mode: ThemeMode, origin?: ThemeOrigin) => void;
toggleMode: (origin?: ThemeOrigin) => void;
palette: ThemePalette;
}
const AccentContext = createContext<AccentContextValue | undefined>(undefined);
function loadStoredAccent(): string {
try {
const raw = localStorage.getItem(ACCENT_KEY);
if (raw && isHexColor(raw)) return raw;
} catch {
/* ignore */
}
return DEFAULT_ACCENT;
}
function loadStoredMode(): ThemeMode {
try {
const raw = localStorage.getItem(MODE_KEY);
if (raw === "light" || raw === "dark") return raw;
} catch {
/* ignore */
}
return DEFAULT_MODE;
}
// AppThemeProvider owns the accent color + theme mode, persists both
// across reloads, and rebuilds the MUI theme on every change so the
// whole UI updates instantly.
export function AppThemeProvider({ children }: { children: ReactNode }) {
const [accent, setAccentState] = useState<string>(() => loadStoredAccent());
const [mode, setModeState] = useState<ThemeMode>(() => loadStoredMode());
// Wrap a state update in a circular-reveal animation that radiates from
// the click point (when supported) using the View Transitions API.
// Fallbacks: prefers-reduced-motion → no animation; older browsers without
// `document.startViewTransition` also fall back to instant state change.
const runWithTransition = useCallback((apply: () => void, origin?: ThemeOrigin) => {
const root = document.documentElement;
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
type ViewTransitionDoc = Document & {
startViewTransition?: (cb: () => void) => unknown;
};
const docVT = document as ViewTransitionDoc;
if (reduced || typeof docVT.startViewTransition !== "function") {
apply();
return;
}
// Default to the screen centre when no event is supplied (e.g. keyboard
// toggling). Compute the maximum radius so the circle always reaches the
// furthest corner regardless of click position.
const w = window.innerWidth;
const h = window.innerHeight;
const cx = origin?.clientX ?? w / 2;
const cy = origin?.clientY ?? h / 2;
const r = Math.hypot(Math.max(cx, w - cx), Math.max(cy, h - cy));
root.style.setProperty("--theme-cx", `${cx}px`);
root.style.setProperty("--theme-cy", `${cy}px`);
root.style.setProperty("--theme-r", `${r}px`);
docVT.startViewTransition(() => {
// flushSync forces React to commit the new theme synchronously inside
// the snapshot callback; otherwise the API would capture the old DOM.
flushSync(apply);
});
}, []);
const persistMode = useCallback((next: ThemeMode) => {
try {
localStorage.setItem(MODE_KEY, next);
} catch {
/* ignore */
}
}, []);
const setMode = useCallback(
(next: ThemeMode, origin?: ThemeOrigin) => {
if (next !== "light" && next !== "dark") return;
runWithTransition(() => {
setModeState(next);
persistMode(next);
}, origin);
},
[runWithTransition, persistMode],
);
const toggleMode = useCallback(
(origin?: ThemeOrigin) => {
runWithTransition(() => {
setModeState((prev) => {
const next: ThemeMode = prev === "light" ? "dark" : "light";
persistMode(next);
return next;
});
}, origin);
},
[runWithTransition, persistMode],
);
const setAccent = useCallback((color: string) => {
if (!isHexColor(color)) return;
setAccentState(color);
try {
localStorage.setItem(ACCENT_KEY, color);
} catch {
/* ignore */
}
}, []);
const resetAccent = useCallback(() => {
setAccentState(DEFAULT_ACCENT);
try {
localStorage.removeItem(ACCENT_KEY);
} catch {
/* ignore */
}
}, []);
// Accent colour is exposed as a CSS variable on <html>. Specific MUI
// components in the theme reference `var(--sb-accent)` instead of the
// raw hex, which keeps their emotion class hash stable when the accent
// changes — so a CSS `transition` declared on those rules animates
// smoothly from the old colour to the new one.
//
// We use `useLayoutEffect` instead of `useEffect` so the variable is
// applied before the browser paints; otherwise the very first frame
// would render with no `--sb-accent` set and accent elements would
// briefly flash unstyled before the effect ran.
useLayoutEffect(() => {
const root = document.documentElement;
root.style.setProperty("--sb-accent", accent);
// Companion contrast colour (black or white, picked by relative
// luminance) for foreground content layered on top of the accent —
// the "+" create IconButton on the CrudPage toolbar reads this var
// to keep its glyph readable on accents of any luminance. Without
// setting it here the IconButton's `var(--sb-accent-contrast,
// #ffffff)` always fell back to white, so a light/yellow accent
// produced an almost-invisible glyph that "didn't change with the
// theme".
root.style.setProperty("--sb-accent-contrast", pickContrastingTextColor(accent));
}, [accent]);
// The inline FOUC script in `index.html` stamps an initial
// `background-color` and `color-scheme` onto the <html> element so
// the page doesn't paint white before the bundle loads. Inline
// styles win over MUI's CssBaseline, so we have to keep those
// properties in sync here whenever the mode flips — otherwise
// `<html>` keeps the page-load colour and the document looks
// half-themed (background stuck on the old tone, text/buttons
// updated, etc.).
//
// The same FOUC script also sets inline `background-color` + `color`
// on <body> (so the very first paint after reload is fully themed),
// and inline styles win over the emotion class CssBaseline injects.
// If we don't refresh those here too, `body.color` stays at the
// page-load value forever, which makes every `color="inherit"`
// component (Layout's "Sign out", CrudPage's "Filters" / "Clear",
// the colour-picker button) read with stale text colour after a
// theme toggle — the labels then "snap back" to the correct colour
// only on full reload, which is exactly what was reported.
useLayoutEffect(() => {
const root = document.documentElement;
const bg = mode === "light" ? "#ffffff" : "#141414";
const fg = mode === "light" ? "#0f172a" : "#f5f5f5";
root.style.backgroundColor = bg;
root.style.colorScheme = mode;
if (document.body) {
document.body.style.backgroundColor = bg;
document.body.style.color = fg;
}
// <meta name="theme-color"> drives the mobile browser chrome /
// installed-PWA window colour. We update it from the same effect
// that sets the document background so the two values can never
// disagree (a previous arrangement had a separate `useEffect` on
// `palette` re-writing the meta a frame later, which produced a
// brief mismatched flash on mode toggle).
const meta = document.querySelector<HTMLMetaElement>(
'meta[name="theme-color"]',
);
if (meta) meta.content = bg;
}, [mode]);
// Sync between tabs.
useEffect(() => {
const handler = (e: StorageEvent) => {
if (e.key === ACCENT_KEY) setAccentState(loadStoredAccent());
if (e.key === MODE_KEY) setModeState(loadStoredMode());
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, []);
const theme = useMemo(() => buildTheme(accent, mode), [accent, mode]);
// The exposed palette only carries mode-derived tokens (surface /
// elevated / border), so it doesn't need to invalidate on accent
// changes — accent flows through `var(--sb-accent)` instead.
const palette = useMemo(() => buildPalette(mode), [mode]);
const value = useMemo<AccentContextValue>(
() => ({ accent, setAccent, resetAccent, mode, setMode, toggleMode, palette }),
[accent, setAccent, resetAccent, mode, setMode, toggleMode, palette],
);
return (
<AccentContext.Provider value={value}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</AccentContext.Provider>
);
}
export function useAccent(): AccentContextValue {
const ctx = useContext(AccentContext);
if (!ctx) throw new Error("useAccent must be used within AppThemeProvider");
return ctx;
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
},
"include": ["src"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/theme.ts","./src/vite-env.d.ts","./src/api/client.ts","./src/api/types.ts","./src/auth/AuthContext.tsx","./src/components/ColorPickerButton.tsx","./src/components/CopyableId.tsx","./src/components/CrudPage.tsx","./src/components/Layout.tsx","./src/components/LoginBackdropMesh.tsx","./src/components/LoginThemeControls.tsx","./src/components/PageHeader.tsx","./src/notifications/NotificationsProvider.tsx","./src/pages/BandwidthLimitersPage.tsx","./src/pages/ConnectionLimitersPage.tsx","./src/pages/DashboardPage.tsx","./src/pages/LoginPage.tsx","./src/pages/NodesPage.tsx","./src/pages/RateLimitersPage.tsx","./src/pages/SquadsPage.tsx","./src/pages/TrafficLimitersPage.tsx","./src/pages/UsersPage.tsx","./src/pages/squadField.tsx","./src/theme/AppThemeProvider.tsx"],"version":"5.9.3"}

View File

@@ -0,0 +1,64 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
// preloadInterFonts injects `<link rel="preload">` tags into the
// production index.html for the latin subset of every Inter weight
// the SPA imports (400/500/600/700). The font files come from
// `@fontsource/inter` and are hashed by Vite into `dist/assets/`,
// so the filenames aren't known until after the bundle is emitted —
// hence a `transformIndexHtml` hook scoped to build mode that reads
// `ctx.bundle` and matches the emitted woff2 names.
//
// Why latin only: the admin panel UI is English-only ASCII, so the
// latin subset is what the very first paint actually needs. Preload-
// ing every fontsource subset (latin, latin-ext, cyrillic, cyrillic-
// ext, greek, greek-ext × 4 weights = 24 files) would just waste
// bandwidth and starve more critical resources of priority. If the
// UI ever needs to render non-ASCII glyphs at first paint, this list
// can be extended.
function preloadInterFonts(): Plugin {
const WEIGHTS = ["400", "500", "600", "700"] as const;
return {
name: "preload-inter-fonts",
apply: "build",
transformIndexHtml: {
order: "post",
handler(html, ctx) {
if (!ctx.bundle) return html;
const tags: string[] = [];
for (const weight of WEIGHTS) {
const re = new RegExp(`inter-latin-${weight}-normal-[^/]+\\.woff2$`);
const fileName = Object.keys(ctx.bundle).find((n) => re.test(n));
if (!fileName) continue;
// `base: "/"` is used in this config (so deep BrowserRouter
// URLs like `/users` resolve `assets/...` from the site root
// rather than relative to the current path). Emit matching
// `/` prefixes here so the preload hits the exact same URL
// the browser would otherwise request from the fontsource
// @font-face rule, deduplicating the fetch instead of
// doubling it.
tags.push(
`<link rel="preload" href="/${fileName}" as="font" type="font/woff2" crossorigin>`,
);
}
if (tags.length === 0) return html;
return html.replace("</head>", ` ${tags.join("\n ")}\n </head>`);
},
},
};
}
export default defineConfig({
plugins: [react(), preloadInterFonts()],
// Absolute base so the SPA's asset URLs resolve from the site root.
// The Go backend serves index.html for every unknown path (BrowserRouter
// deep-link fallback); with a relative base, navigating directly to e.g.
// `/users` would make the embedded `./assets/index-XYZ.js` URL resolve
// to `/assets/index-XYZ.js` only when sitting at the root — at any
// multi-segment path it would resolve relative to that path and miss.
base: "/",
build: {
target: "es2020",
chunkSizeWarningLimit: 1500,
},
});