Restore settings preferences and simplify services editor

This commit is contained in:
2026-04-02 01:25:38 +03:00
parent 91856beec9
commit f5ae311a82
16 changed files with 934 additions and 333 deletions

View File

@@ -31,3 +31,5 @@ Updated: 2026-04-02
15. Added editable System settings with shared validation, a system update API, service/port conflict protection, and UI coverage for local save flows. 15. Added editable System settings with shared validation, a system update API, service/port conflict protection, and UI coverage for local save flows.
16. Simplified the System tab layout by removing the redundant Runtime card and collapsing those fields into a compact settings section. 16. Simplified the System tab layout by removing the redundant Runtime card and collapsing those fields into a compact settings section.
17. Moved panel auth to server-issued expiring tokens with `sessionStorage` persistence and Compose-configurable credentials/TTL. 17. Moved panel auth to server-issued expiring tokens with `sessionStorage` persistence and Compose-configurable credentials/TTL.
18. Restored panel language/theme preferences in Settings with `localStorage`, merged service `Command/Protocol` into a single `Type`, and removed the legacy `admin` service path from managed panel state.
19. Added service-removal confirmation with linked-user warnings, backend cascade deletion for removed services, and migration that strips persisted legacy `admin` services from stored state.

View File

@@ -23,27 +23,29 @@ Updated: 2026-04-02
## Frontend ## Frontend
- `src/main.tsx`: application bootstrap - `src/main.tsx`: application bootstrap
- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, and protected panel mutations - `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, localized labels, theme application, and protected panel mutations
- `src/SystemTab.tsx`: editable system settings and managed services form with compact panel-level controls - `src/SystemTab.tsx`: Settings tab with panel language/style preferences, unified service type editing, remove confirmation, and generated config preview
- `src/App.test.tsx`: login-gate, modal interaction, pause/resume, delete-confirm, and system-save UI tests - `src/App.test.tsx`: login-gate, preferences persistence, modal interaction, pause/resume, delete-confirm, and settings-save UI tests
- `src/app.css`: full panel styling - `src/app.css`: full panel styling
- `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot - `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot
- `src/lib/3proxy.ts`: formatting and status helpers - `src/lib/3proxy.ts`: formatting and status helpers
- `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules - `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules
- `src/lib/panelPreferences.ts`: `localStorage`-backed panel language/theme preferences plus theme application helpers
- `src/lib/panelText.ts`: English/Russian UI text catalog for the panel shell and settings flows
- `src/shared/contracts.ts`: shared panel, service, user, and API data contracts - `src/shared/contracts.ts`: shared panel, service, user, and API data contracts
- `src/shared/validation.ts`: shared validation for user creation, system edits, protocol mapping, and quota conversion - `src/shared/validation.ts`: shared validation for user creation, system edits, service type mapping, and quota conversion
- `src/test/setup.ts`: Testing Library matchers - `src/test/setup.ts`: Testing Library matchers
## Server ## Server
- `server/index.ts`: backend entrypoint and runtime bootstrap - `server/index.ts`: backend entrypoint and runtime bootstrap
- `server/app.ts`: Express app with login, protected panel state/runtime routes, and writable system configuration API - `server/app.ts`: Express app with login, protected panel state/runtime routes, and writable system configuration API with linked-user cleanup on removed services
- `server/app.test.ts`: API tests for user management plus system-update safety edge cases - `server/app.test.ts`: API tests for user management plus system-update safety, cascade delete, and config edge cases
- `server/lib/auth.ts`: expiring token issuance and bearer-token verification for the panel - `server/lib/auth.ts`: expiring token issuance and bearer-token verification for the panel
- `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation - `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation for SOCKS/HTTP managed services
- `server/lib/config.test.ts`: config-generation regression tests - `server/lib/config.test.ts`: config-generation regression tests
- `server/lib/runtime.ts`: managed 3proxy process controller - `server/lib/runtime.ts`: managed 3proxy process controller
- `server/lib/store.ts`: JSON-backed persistent state store - `server/lib/store.ts`: JSON-backed persistent state store with legacy admin-service migration
## Static ## Static

View File

@@ -69,13 +69,13 @@ describe('panel api', () => {
expect(typeof response.body.expiresAt).toBe('string'); expect(typeof response.body.expiresAt).toBe('string');
}); });
it('rejects user creation against a non-assignable service', async () => { it('rejects user creation against a disabled service', async () => {
const app = await createTestApp(); const app = await createTestApp();
const token = await authorize(app); const token = await authorize(app);
const response = await request(app).post('/api/users').set('Authorization', `Bearer ${token}`).send({ const response = await request(app).post('/api/users').set('Authorization', `Bearer ${token}`).send({
username: 'bad-admin-user', username: 'bad-proxy-user',
password: 'secret123', password: 'secret123',
serviceId: 'admin', serviceId: 'proxy',
quotaMb: 100, quotaMb: 100,
}); });
@@ -132,6 +132,26 @@ describe('panel api', () => {
expect(response.body.error).toMatch(/night-shift/i); expect(response.body.error).toMatch(/night-shift/i);
}); });
it('removes linked users when a service is deleted from settings', async () => {
const app = await createTestApp();
const token = await authorize(app);
const initial = await request(app).get('/api/state').set('Authorization', `Bearer ${token}`);
const system = createSystemPayload(initial.body);
system.services = system.services.filter((service) => service.id !== 'socks-main');
const response = await request(app).put('/api/system').set('Authorization', `Bearer ${token}`).send(system);
expect(response.status).toBe(200);
expect(response.body.userRecords.some((entry: { username: string }) => entry.username === 'night-shift')).toBe(
false,
);
expect(response.body.userRecords.some((entry: { username: string }) => entry.username === 'ops-east')).toBe(
false,
);
expect(response.body.service.lastEvent).toMatch(/removed 2 linked users/i);
});
it('updates system settings and regenerates the rendered config', async () => { it('updates system settings and regenerates the rendered config', async () => {
const app = await createTestApp(); const app = await createTestApp();
const token = await authorize(app); const token = await authorize(app);

View File

@@ -129,8 +129,23 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
app.put('/api/system', async (request, response, next) => { app.put('/api/system', async (request, response, next) => {
try { try {
const state = await store.read(); const state = await store.read();
state.system = validateSystemInput(request.body as Partial<UpdateSystemInput>, state.userRecords); const requestedSystem = request.body as Partial<UpdateSystemInput>;
state.service.lastEvent = 'System configuration updated from panel'; const nextServiceIds = new Set(
(Array.isArray(requestedSystem.services) ? requestedSystem.services : []).map((service) => service.id),
);
const removedServiceIds = new Set(
state.system.services
.map((service) => service.id)
.filter((serviceId) => !nextServiceIds.has(serviceId)),
);
const removedUsers = state.userRecords.filter((user) => removedServiceIds.has(user.serviceId));
state.userRecords = state.userRecords.filter((user) => !removedServiceIds.has(user.serviceId));
state.system = validateSystemInput(requestedSystem, state.userRecords);
state.service.lastEvent =
removedUsers.length > 0
? `System configuration updated from panel and removed ${removedUsers.length} linked users`
: 'System configuration updated from panel';
await persistRuntimeMutation(store, runtime, state, runtimePaths); await persistRuntimeMutation(store, runtime, state, runtimePaths);
response.json(await getSnapshot(store, runtime, runtimePaths)); response.json(await getSnapshot(store, runtime, runtimePaths));
} catch (error) { } catch (error) {

View File

@@ -15,7 +15,7 @@ describe('render3proxyConfig', () => {
expect(config).toContain('socks -p1080 -u2'); expect(config).toContain('socks -p1080 -u2');
expect(config).toContain('socks -p2080 -u2'); expect(config).toContain('socks -p2080 -u2');
expect(config).toContain('admin -p8081 -s'); expect(config).not.toContain('admin -p');
expect(config).toContain('allow night-shift,ops-east'); expect(config).toContain('allow night-shift,ops-east');
expect(config).toContain('allow lab-unlimited,burst-user'); expect(config).toContain('allow lab-unlimited,burst-user');
}); });

View File

@@ -198,10 +198,6 @@ function renderUserCredential(user: ProxyUserRecord): string {
} }
function renderServiceCommand(service: ProxyServiceRecord): string { function renderServiceCommand(service: ProxyServiceRecord): string {
if (service.command === 'admin') {
return `admin -p${service.port} -s`;
}
if (service.command === 'socks') { if (service.command === 'socks') {
return `socks -p${service.port} -u2`; return `socks -p${service.port} -u2`;
} }

View File

@@ -11,7 +11,14 @@ export class StateStore {
try { try {
const raw = await fs.readFile(this.statePath, 'utf8'); const raw = await fs.readFile(this.statePath, 'utf8');
return JSON.parse(raw) as ControlPlaneState; const state = JSON.parse(raw) as ControlPlaneState;
const migrated = migrateLegacyAdminServices(state);
if (migrated !== state) {
await this.write(migrated);
}
return migrated;
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error; throw error;
@@ -28,3 +35,28 @@ export class StateStore {
await fs.writeFile(this.statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); await fs.writeFile(this.statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
} }
} }
function migrateLegacyAdminServices(state: ControlPlaneState): ControlPlaneState {
const legacyServiceIds = new Set(
state.system.services
.filter((service) => (service as { command?: unknown }).command === 'admin')
.map((service) => service.id),
);
if (legacyServiceIds.size === 0) {
return state;
}
return {
...state,
service: {
...state.service,
lastEvent: 'Legacy admin service removed from stored panel state',
},
userRecords: state.userRecords.filter((user) => !legacyServiceIds.has(user.serviceId)),
system: {
...state.system,
services: state.system.services.filter((service) => !legacyServiceIds.has(service.id)),
},
};
}

View File

@@ -10,7 +10,9 @@ async function loginIntoPanel(user: ReturnType<typeof userEvent.setup>) {
} }
beforeEach(() => { beforeEach(() => {
document.documentElement.dataset.theme = '';
window.sessionStorage.clear(); window.sessionStorage.clear();
window.localStorage.clear();
}); });
describe('App login gate', () => { describe('App login gate', () => {
@@ -50,6 +52,39 @@ describe('App login gate', () => {
expect(screen.queryByRole('button', { name: /open panel/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /open panel/i })).not.toBeInTheDocument();
}); });
it('stores panel language in localStorage and restores it after a remount', async () => {
const user = userEvent.setup();
const firstRender = render(<App />);
await loginIntoPanel(user);
await user.click(screen.getByRole('button', { name: /settings/i }));
await user.selectOptions(screen.getByLabelText(/panel language/i), 'ru');
expect(screen.getByRole('button', { name: /панель/i })).toBeInTheDocument();
firstRender.unmount();
render(<App />);
expect(screen.getByRole('button', { name: /панель/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /настройки/i })).toBeInTheDocument();
});
it('stores panel theme in localStorage and restores it after a remount', async () => {
const user = userEvent.setup();
const firstRender = render(<App />);
await loginIntoPanel(user);
await user.click(screen.getByRole('button', { name: /settings/i }));
await user.selectOptions(screen.getByLabelText(/panel style/i), 'dark');
expect(document.documentElement.dataset.theme).toBe('dark');
firstRender.unmount();
render(<App />);
expect(document.documentElement.dataset.theme).toBe('dark');
});
it('opens add-user flow in a modal and closes it on escape', async () => { it('opens add-user flow in a modal and closes it on escape', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />);
@@ -107,26 +142,49 @@ describe('App login gate', () => {
expect(screen.queryByRole('dialog', { name: /delete user/i })).not.toBeInTheDocument(); expect(screen.queryByRole('dialog', { name: /delete user/i })).not.toBeInTheDocument();
}); });
it('saves system settings from the system tab and applies them to the local fallback state', async () => { it('uses a combined service type field in settings and applies saved ports to the local fallback state', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />);
await loginIntoPanel(user); await loginIntoPanel(user);
await user.click(screen.getByRole('button', { name: /system/i })); await user.click(screen.getByRole('button', { name: /settings/i }));
await user.clear(screen.getByLabelText(/public host/i)); expect(screen.queryByText(/panel settings/i)).not.toBeInTheDocument();
await user.type(screen.getByLabelText(/public host/i), 'ops-gateway.example.net'); expect(screen.queryByLabelText(/public host/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/command/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/protocol/i)).not.toBeInTheDocument();
expect(screen.getAllByLabelText(/type/i)).toHaveLength(3);
expect(screen.queryByRole('option', { name: /admin/i })).not.toBeInTheDocument();
const firstPortInput = screen.getAllByLabelText(/port/i)[0]; const firstPortInput = screen.getAllByLabelText(/port/i)[0];
await user.clear(firstPortInput); await user.clear(firstPortInput);
await user.type(firstPortInput, '1180'); await user.type(firstPortInput, '1180');
await user.click(screen.getByRole('button', { name: /save system/i })); await user.click(screen.getByRole('button', { name: /save settings/i }));
expect(screen.getByText(/ops-gateway\.example\.net/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /users/i })); await user.click(screen.getByRole('button', { name: /users/i }));
expect(screen.getAllByText(/ops-gateway\.example\.net:1180/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/edge\.example\.net:1180/i).length).toBeGreaterThan(0);
});
it('warns before deleting a service and removes linked users after confirmation', async () => {
const user = userEvent.setup();
render(<App />);
await loginIntoPanel(user);
await user.click(screen.getByRole('button', { name: /settings/i }));
await user.click(screen.getAllByRole('button', { name: /^remove$/i })[0]);
const dialog = screen.getByRole('dialog', { name: /delete service/i });
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByText(/linked users to be removed/i)).toBeInTheDocument();
expect(within(dialog).getByText(/night-shift, ops-east/i)).toBeInTheDocument();
await user.click(within(dialog).getByRole('button', { name: /^remove$/i }));
await user.click(screen.getByRole('button', { name: /save settings/i }));
await user.click(screen.getByRole('button', { name: /users/i }));
expect(screen.queryByText(/night-shift/i)).not.toBeInTheDocument();
expect(screen.queryByText(/ops-east/i)).not.toBeInTheDocument();
}); });
}); });

View File

@@ -4,11 +4,18 @@ import { fallbackDashboardSnapshot, panelAuth } from './data/mockDashboard';
import { import {
buildProxyLink, buildProxyLink,
formatBytes, formatBytes,
formatQuotaState,
formatTrafficShare, formatTrafficShare,
getServiceTone, getServiceTone,
isQuotaExceeded, isQuotaExceeded,
} from './lib/3proxy'; } from './lib/3proxy';
import {
applyPanelTheme,
loadPanelPreferences,
observeSystemTheme,
savePanelPreferences,
type PanelPreferences,
} from './lib/panelPreferences';
import { getPanelText } from './lib/panelText';
import type { import type {
CreateUserInput, CreateUserInput,
DashboardSnapshot, DashboardSnapshot,
@@ -22,10 +29,10 @@ import { quotaMbToBytes, validateCreateUserInput } from './shared/validation';
type TabId = 'dashboard' | 'users' | 'system'; type TabId = 'dashboard' | 'users' | 'system';
const tabs: Array<{ id: TabId; label: string }> = [ const tabs: Array<{ id: TabId; textKey: 'dashboard' | 'users' | 'settings' }> = [
{ id: 'dashboard', label: 'Dashboard' }, { id: 'dashboard', textKey: 'dashboard' },
{ id: 'users', label: 'Users' }, { id: 'users', textKey: 'users' },
{ id: 'system', label: 'System' }, { id: 'system', textKey: 'settings' },
]; ];
const SESSION_KEY = '3proxy-ui-panel-session'; const SESSION_KEY = '3proxy-ui-panel-session';
@@ -36,7 +43,14 @@ interface StoredSession {
expiresAt: string; expiresAt: string;
} }
function LoginGate({ onUnlock }: { onUnlock: (login: string, password: string) => Promise<void> }) { function LoginGate({
onUnlock,
preferences,
}: {
onUnlock: (login: string, password: string) => Promise<void>;
preferences: PanelPreferences;
}) {
const text = getPanelText(preferences.language);
const [login, setLogin] = useState(''); const [login, setLogin] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -60,12 +74,12 @@ function LoginGate({ onUnlock }: { onUnlock: (login: string, password: string) =
<main className="login-shell"> <main className="login-shell">
<section className="login-card"> <section className="login-card">
<div className="login-copy"> <div className="login-copy">
<h1>3proxy UI</h1> <h1>{text.auth.title}</h1>
<p>Sign in to the control panel.</p> <p>{text.auth.subtitle}</p>
</div> </div>
<form className="login-form" onSubmit={handleSubmit}> <form className="login-form" onSubmit={handleSubmit}>
<label> <label>
Login {text.auth.login}
<input <input
autoComplete="username" autoComplete="username"
name="login" name="login"
@@ -75,7 +89,7 @@ function LoginGate({ onUnlock }: { onUnlock: (login: string, password: string) =
/> />
</label> </label>
<label> <label>
Password {text.auth.password}
<input <input
autoComplete="current-password" autoComplete="current-password"
name="password" name="password"
@@ -86,7 +100,7 @@ function LoginGate({ onUnlock }: { onUnlock: (login: string, password: string) =
/> />
</label> </label>
<button type="submit" disabled={isSubmitting}> <button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Opening...' : 'Open panel'} {isSubmitting ? text.auth.opening : text.auth.open}
</button> </button>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
</form> </form>
@@ -100,12 +114,15 @@ function AddUserModal({
services, services,
onClose, onClose,
onCreate, onCreate,
preferences,
}: { }: {
host: string; host: string;
services: ProxyServiceRecord[]; services: ProxyServiceRecord[];
onClose: () => void; onClose: () => void;
onCreate: (input: CreateUserInput) => Promise<void>; onCreate: (input: CreateUserInput) => Promise<void>;
preferences: PanelPreferences;
}) { }) {
const text = getPanelText(preferences.language);
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [serviceId, setServiceId] = useState(services[0]?.id ?? ''); const [serviceId, setServiceId] = useState(services[0]?.id ?? '');
@@ -156,22 +173,31 @@ function AddUserModal({
onClick={stopPropagation} onClick={stopPropagation}
> >
<div className="modal-header"> <div className="modal-header">
<h2 id="add-user-title">Add user</h2> <h2 id="add-user-title">{text.users.addUser}</h2>
<button type="button" className="button-secondary" onClick={onClose}> <button type="button" className="button-secondary" onClick={onClose}>
Close {text.common.close}
</button> </button>
</div> </div>
<form className="modal-form" onSubmit={handleSubmit}> <form className="modal-form" onSubmit={handleSubmit}>
<label> <label>
Username {text.users.username}
<input autoFocus placeholder="night-shift-01" value={username} onChange={(event) => setUsername(event.target.value)} /> <input
autoFocus
placeholder="night-shift-01"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</label> </label>
<label> <label>
Password {text.users.password}
<input placeholder="generated-secret" value={password} onChange={(event) => setPassword(event.target.value)} /> <input
placeholder="generated-secret"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label> </label>
<label> <label>
Service {text.users.service}
<select value={serviceId} onChange={(event) => setServiceId(event.target.value)}> <select value={serviceId} onChange={(event) => setServiceId(event.target.value)}>
{services.map((service) => ( {services.map((service) => (
<option key={service.id} value={service.id}> <option key={service.id} value={service.id}>
@@ -181,23 +207,27 @@ function AddUserModal({
</select> </select>
</label> </label>
<label> <label>
Quota (MB) {text.users.quotaMb}
<input placeholder="Optional" value={quotaMb} onChange={(event) => setQuotaMb(event.target.value)} /> <input
placeholder={text.common.optional}
value={quotaMb}
onChange={(event) => setQuotaMb(event.target.value)}
/>
</label> </label>
<div className="modal-preview"> <div className="modal-preview">
<span>Endpoint</span> <span>{text.common.endpoint}</span>
<strong>{selectedService ? `${host}:${selectedService.port}` : 'Unavailable'}</strong> <strong>{selectedService ? `${host}:${selectedService.port}` : text.common.unavailable}</strong>
</div> </div>
<div className="modal-preview"> <div className="modal-preview">
<span>Protocol</span> <span>{text.common.protocol}</span>
<strong>{selectedService ? selectedService.protocol : 'Unavailable'}</strong> <strong>{selectedService ? selectedService.protocol : text.common.unavailable}</strong>
</div> </div>
{error ? <p className="form-error modal-error">{error}</p> : null} {error ? <p className="form-error modal-error">{error}</p> : null}
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="button-secondary" onClick={onClose}> <button type="button" className="button-secondary" onClick={onClose}>
Cancel {text.common.cancel}
</button> </button>
<button type="submit">Create user</button> <button type="submit">{text.common.createUser}</button>
</div> </div>
</form> </form>
</section> </section>
@@ -209,11 +239,15 @@ function ConfirmDeleteModal({
username, username,
onClose, onClose,
onConfirm, onConfirm,
preferences,
}: { }: {
username: string; username: string;
onClose: () => void; onClose: () => void;
onConfirm: () => Promise<void>; onConfirm: () => Promise<void>;
preferences: PanelPreferences;
}) { }) {
const text = getPanelText(preferences.language);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => { const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
@@ -240,18 +274,18 @@ function ConfirmDeleteModal({
onClick={stopPropagation} onClick={stopPropagation}
> >
<div className="modal-header"> <div className="modal-header">
<h2 id="delete-user-title">Delete user</h2> <h2 id="delete-user-title">{text.users.deleteTitle}</h2>
</div> </div>
<p className="confirm-copy"> <p className="confirm-copy">
Remove profile <strong>{username}</strong>? This action will delete the user entry from the {text.users.deletePrompt} <strong>{username}</strong>
current panel state. {text.users.deletePromptSuffix}
</p> </p>
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="button-secondary" onClick={onClose}> <button type="button" className="button-secondary" onClick={onClose}>
Cancel {text.common.cancel}
</button> </button>
<button type="button" className="button-danger" onClick={onConfirm}> <button type="button" className="button-danger" onClick={onConfirm}>
Delete user {text.users.deleteAction}
</button> </button>
</div> </div>
</section> </section>
@@ -262,62 +296,65 @@ function ConfirmDeleteModal({
function DashboardTab({ function DashboardTab({
snapshot, snapshot,
onRuntimeAction, onRuntimeAction,
preferences,
}: { }: {
snapshot: DashboardSnapshot; snapshot: DashboardSnapshot;
onRuntimeAction: (action: 'start' | 'restart') => Promise<void>; onRuntimeAction: (action: 'start' | 'restart') => Promise<void>;
preferences: PanelPreferences;
}) { }) {
const text = getPanelText(preferences.language);
const serviceTone = getServiceTone(snapshot.service.status); const serviceTone = getServiceTone(snapshot.service.status);
return ( return (
<section className="page-grid"> <section className="page-grid">
<article className="panel-card"> <article className="panel-card">
<div className="card-header"> <div className="card-header">
<h2>Service</h2> <h2>{text.dashboard.service}</h2>
<span className={`status-pill ${serviceTone}`}>{snapshot.service.status}</span> <span className={`status-pill ${serviceTone}`}>{text.status[snapshot.service.status]}</span>
</div> </div>
<dl className="kv-list"> <dl className="kv-list">
<div> <div>
<dt>Process</dt> <dt>{text.common.process}</dt>
<dd>{snapshot.service.pidLabel}</dd> <dd>{snapshot.service.pidLabel}</dd>
</div> </div>
<div> <div>
<dt>Version</dt> <dt>{text.common.version}</dt>
<dd>{snapshot.service.versionLabel}</dd> <dd>{snapshot.service.versionLabel}</dd>
</div> </div>
<div> <div>
<dt>Uptime</dt> <dt>{text.common.uptime}</dt>
<dd>{snapshot.service.uptimeLabel}</dd> <dd>{snapshot.service.uptimeLabel}</dd>
</div> </div>
<div> <div>
<dt>Last event</dt> <dt>{text.common.lastEvent}</dt>
<dd>{snapshot.service.lastEvent}</dd> <dd>{snapshot.service.lastEvent}</dd>
</div> </div>
</dl> </dl>
<div className="actions-row"> <div className="actions-row">
<button type="button" onClick={() => onRuntimeAction('start')}> <button type="button" onClick={() => onRuntimeAction('start')}>
Start {text.dashboard.start}
</button> </button>
<button type="button" className="button-secondary" onClick={() => onRuntimeAction('restart')}> <button type="button" className="button-secondary" onClick={() => onRuntimeAction('restart')}>
Restart {text.dashboard.restart}
</button> </button>
</div> </div>
</article> </article>
<article className="panel-card"> <article className="panel-card">
<div className="card-header"> <div className="card-header">
<h2>Traffic</h2> <h2>{text.dashboard.traffic}</h2>
</div> </div>
<div className="stats-strip"> <div className="stats-strip">
<div> <div>
<span>Total</span> <span>{text.common.total}</span>
<strong>{formatBytes(snapshot.traffic.totalBytes)}</strong> <strong>{formatBytes(snapshot.traffic.totalBytes)}</strong>
</div> </div>
<div> <div>
<span>Connections</span> <span>{text.common.connections}</span>
<strong>{snapshot.traffic.liveConnections}</strong> <strong>{snapshot.traffic.liveConnections}</strong>
</div> </div>
<div> <div>
<span>Active users</span> <span>{text.common.activeUsers}</span>
<strong>{snapshot.traffic.activeUsers}</strong> <strong>{snapshot.traffic.activeUsers}</strong>
</div> </div>
</div> </div>
@@ -325,7 +362,7 @@ function DashboardTab({
<article className="panel-card"> <article className="panel-card">
<div className="card-header"> <div className="card-header">
<h2>Daily usage</h2> <h2>{text.dashboard.dailyUsage}</h2>
</div> </div>
<div className="usage-list"> <div className="usage-list">
{snapshot.traffic.daily.map((bucket) => ( {snapshot.traffic.daily.map((bucket) => (
@@ -342,7 +379,7 @@ function DashboardTab({
<article className="panel-card"> <article className="panel-card">
<div className="card-header"> <div className="card-header">
<h2>Attention</h2> <h2>{text.dashboard.attention}</h2>
</div> </div>
<div className="event-list"> <div className="event-list">
{snapshot.attention.map((item) => ( {snapshot.attention.map((item) => (
@@ -365,12 +402,15 @@ function UsersTab({
onCreateUser, onCreateUser,
onTogglePause, onTogglePause,
onDeleteUser, onDeleteUser,
preferences,
}: { }: {
snapshot: DashboardSnapshot; snapshot: DashboardSnapshot;
onCreateUser: (input: CreateUserInput) => Promise<void>; onCreateUser: (input: CreateUserInput) => Promise<void>;
onTogglePause: (userId: string) => Promise<void>; onTogglePause: (userId: string) => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>; onDeleteUser: (userId: string) => Promise<void>;
preferences: PanelPreferences;
}) { }) {
const text = getPanelText(preferences.language);
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null); const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
@@ -395,35 +435,37 @@ function UsersTab({
<article className="panel-card"> <article className="panel-card">
<div className="table-toolbar"> <div className="table-toolbar">
<div className="toolbar-title"> <div className="toolbar-title">
<h2>Users</h2> <h2>{text.users.title}</h2>
<p>{snapshot.userRecords.length} accounts in current profile</p> <p>
{snapshot.userRecords.length} {text.users.accountsInProfile}
</p>
</div> </div>
<div className="toolbar-actions"> <div className="toolbar-actions">
<div className="summary-pills"> <div className="summary-pills">
<span>{snapshot.users.live} live</span> <span>{snapshot.users.live} {text.users.live}</span>
<span>{snapshot.users.nearQuota} near quota</span> <span>{snapshot.users.nearQuota} {text.users.nearQuota}</span>
<span>{snapshot.users.exceeded} exceeded</span> <span>{snapshot.users.exceeded} {text.users.exceeded}</span>
</div> </div>
<button type="button" onClick={() => setIsModalOpen(true)} disabled={assignableServices.length === 0}> <button type="button" onClick={() => setIsModalOpen(true)} disabled={assignableServices.length === 0}>
New user {text.users.newUser}
</button> </button>
</div> </div>
</div> </div>
{assignableServices.length === 0 ? ( {assignableServices.length === 0 ? (
<p className="table-note">Enable an assignable service in System before creating new users.</p> <p className="table-note">{text.users.enableServiceHint}</p>
) : null} ) : null}
<div className="table-wrap"> <div className="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>User</th> <th>{text.users.user}</th>
<th>Endpoint</th> <th>{text.users.endpoint}</th>
<th>Status</th> <th>{text.common.status}</th>
<th>Used</th> <th>{text.users.used}</th>
<th>Remaining</th> <th>{text.users.remaining}</th>
<th>Share</th> <th>{text.users.share}</th>
<th>Proxy</th> <th>{text.users.proxy}</th>
<th>Actions</th> <th>{text.users.actions}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -431,7 +473,7 @@ function UsersTab({
const service = servicesById.get(user.serviceId); const service = servicesById.get(user.serviceId);
const endpoint = service const endpoint = service
? `${snapshot.system.publicHost}:${service.port}` ? `${snapshot.system.publicHost}:${service.port}`
: 'service missing'; : text.common.serviceMissing;
const proxyLink = service const proxyLink = service
? buildProxyLink( ? buildProxyLink(
user.username, user.username,
@@ -453,17 +495,17 @@ function UsersTab({
</td> </td>
<td> <td>
<div className="endpoint-cell"> <div className="endpoint-cell">
<strong>{service?.name ?? 'Unknown service'}</strong> <strong>{service?.name ?? text.common.unknownService}</strong>
<span>{endpoint}</span> <span>{endpoint}</span>
</div> </div>
</td> </td>
<td> <td>
<span className={`status-pill ${getServiceTone(displayStatus)}`}> <span className={`status-pill ${getServiceTone(displayStatus)}`}>
{displayStatus} {text.status[displayStatus]}
</span> </span>
</td> </td>
<td>{formatBytes(user.usedBytes)}</td> <td>{formatBytes(user.usedBytes)}</td>
<td>{formatQuotaState(user.usedBytes, user.quotaBytes)}</td> <td>{formatQuotaStateLabel(user.usedBytes, user.quotaBytes, preferences.language)}</td>
<td>{formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)}</td> <td>{formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)}</td>
<td> <td>
<button <button
@@ -472,7 +514,7 @@ function UsersTab({
onClick={() => handleCopy(user.id, proxyLink)} onClick={() => handleCopy(user.id, proxyLink)}
disabled={!service} disabled={!service}
> >
{copiedId === user.id ? 'Copied' : 'Copy'} {copiedId === user.id ? text.common.copied : text.common.copy}
</button> </button>
</td> </td>
<td> <td>
@@ -482,14 +524,14 @@ function UsersTab({
className="button-secondary button-small" className="button-secondary button-small"
onClick={() => onTogglePause(user.id)} onClick={() => onTogglePause(user.id)}
> >
{user.paused ? 'Resume' : 'Pause'} {user.paused ? text.users.resume : text.users.pause}
</button> </button>
<button <button
type="button" type="button"
className="button-danger button-small" className="button-danger button-small"
onClick={() => setDeleteTargetId(user.id)} onClick={() => setDeleteTargetId(user.id)}
> >
Delete {text.common.delete}
</button> </button>
</div> </div>
</td> </td>
@@ -508,6 +550,7 @@ function UsersTab({
services={assignableServices} services={assignableServices}
onClose={() => setIsModalOpen(false)} onClose={() => setIsModalOpen(false)}
onCreate={onCreateUser} onCreate={onCreateUser}
preferences={preferences}
/> />
) : null} ) : null}
@@ -519,6 +562,7 @@ function UsersTab({
await onDeleteUser(deleteTarget.id); await onDeleteUser(deleteTarget.id);
setDeleteTargetId(null); setDeleteTargetId(null);
}} }}
preferences={preferences}
/> />
) : null} ) : null}
</> </>
@@ -526,9 +570,24 @@ function UsersTab({
} }
export default function App() { export default function App() {
const [preferences, setPreferences] = useState<PanelPreferences>(() => loadPanelPreferences());
const [session, setSession] = useState<StoredSession | null>(() => loadStoredSession()); const [session, setSession] = useState<StoredSession | null>(() => loadStoredSession());
const [activeTab, setActiveTab] = useState<TabId>('dashboard'); const [activeTab, setActiveTab] = useState<TabId>('dashboard');
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(fallbackDashboardSnapshot); const [snapshot, setSnapshot] = useState<DashboardSnapshot>(fallbackDashboardSnapshot);
const text = getPanelText(preferences.language);
useEffect(() => {
applyPanelTheme(preferences.theme);
savePanelPreferences(preferences);
}, [preferences]);
useEffect(() => {
if (preferences.theme !== 'system') {
return;
}
return observeSystemTheme(() => applyPanelTheme('system'));
}, [preferences.theme]);
const resetSession = () => { const resetSession = () => {
clearStoredSession(); clearStoredSession();
@@ -605,7 +664,7 @@ export default function App() {
}, [session]); }, [session]);
if (!session) { if (!session) {
return <LoginGate onUnlock={handleUnlock} />; return <LoginGate onUnlock={handleUnlock} preferences={preferences} />;
} }
const mutateSnapshot = async ( const mutateSnapshot = async (
@@ -760,6 +819,7 @@ export default function App() {
} }
if (!payload) { if (!payload) {
const nextServiceIds = new Set(input.services.map((service) => service.id));
setSnapshot((current) => setSnapshot((current) =>
withDerivedSnapshot({ withDerivedSnapshot({
...current, ...current,
@@ -767,6 +827,7 @@ export default function App() {
...current.service, ...current.service,
lastEvent: 'System configuration updated from panel', lastEvent: 'System configuration updated from panel',
}, },
userRecords: current.userRecords.filter((user) => nextServiceIds.has(user.serviceId)),
system: { system: {
...input, ...input,
previewConfig: current.system.previewConfig, previewConfig: current.system.previewConfig,
@@ -788,23 +849,19 @@ export default function App() {
</div> </div>
<div className="header-meta"> <div className="header-meta">
<div> <div>
<span>Status</span> <span>{text.common.status}</span>
<strong>{snapshot.service.status}</strong> <strong>{text.status[snapshot.service.status]}</strong>
</div> </div>
<div> <div>
<span>Version</span> <span>{text.common.version}</span>
<strong>{snapshot.service.versionLabel}</strong> <strong>{snapshot.service.versionLabel}</strong>
</div> </div>
<div> <div>
<span>Users</span> <span>{text.common.users}</span>
<strong>{snapshot.users.total}</strong> <strong>{snapshot.users.total}</strong>
</div> </div>
<button <button type="button" className="button-secondary" onClick={resetSession}>
type="button" {text.common.signOut}
className="button-secondary"
onClick={resetSession}
>
Sign out
</button> </button>
</div> </div>
</header> </header>
@@ -817,21 +874,31 @@ export default function App() {
className={activeTab === tab.id ? 'tab-button active' : 'tab-button'} className={activeTab === tab.id ? 'tab-button active' : 'tab-button'}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
> >
{tab.label} {text.tabs[tab.textKey]}
</button> </button>
))} ))}
</nav> </nav>
{activeTab === 'dashboard' ? <DashboardTab snapshot={snapshot} onRuntimeAction={handleRuntimeAction} /> : null} {activeTab === 'dashboard' ? (
<DashboardTab snapshot={snapshot} onRuntimeAction={handleRuntimeAction} preferences={preferences} />
) : null}
{activeTab === 'users' ? ( {activeTab === 'users' ? (
<UsersTab <UsersTab
snapshot={snapshot} snapshot={snapshot}
onCreateUser={handleCreateUser} onCreateUser={handleCreateUser}
onTogglePause={handleTogglePause} onTogglePause={handleTogglePause}
onDeleteUser={handleDeleteUser} onDeleteUser={handleDeleteUser}
preferences={preferences}
/>
) : null}
{activeTab === 'system' ? (
<SystemTab
snapshot={snapshot}
preferences={preferences}
onPreferencesChange={setPreferences}
onSaveSystem={handleSaveSystem}
/> />
) : null} ) : null}
{activeTab === 'system' ? <SystemTab snapshot={snapshot} onSaveSystem={handleSaveSystem} /> : null}
</main> </main>
); );
} }
@@ -864,6 +931,26 @@ function withDerivedSnapshot(snapshot: DashboardSnapshot): DashboardSnapshot {
}; };
} }
function formatQuotaStateLabel(
usedBytes: number,
quotaBytes: number | null,
language: PanelPreferences['language'],
): string {
const text = getPanelText(language);
if (quotaBytes === null) {
return text.common.unlimited;
}
const remaining = quotaBytes - usedBytes;
if (remaining <= 0) {
return text.common.exceeded;
}
return `${formatBytes(remaining)} ${text.common.left}`;
}
async function requestSnapshot(request: () => Promise<Response>): Promise<DashboardSnapshot | null> { async function requestSnapshot(request: () => Promise<Response>): Promise<DashboardSnapshot | null> {
try { try {
const response = await request(); const response = await request();

View File

@@ -1,22 +1,48 @@
import { FormEvent, useEffect, useState } from 'react'; import { FormEvent, useEffect, useMemo, useState } from 'react';
import type { DashboardSnapshot, ProxyServiceRecord, SystemSettings, UpdateSystemInput } from './shared/contracts'; import type { DashboardSnapshot, ProxyServiceRecord, SystemSettings, UpdateSystemInput } from './shared/contracts';
import type { PanelLanguage, PanelPreferences, PanelTheme } from './lib/panelPreferences';
import { getPanelText, getThemeLabel } from './lib/panelText';
import { getProtocolForCommand, validateSystemInput } from './shared/validation'; import { getProtocolForCommand, validateSystemInput } from './shared/validation';
interface SystemTabProps { interface SystemTabProps {
snapshot: DashboardSnapshot; snapshot: DashboardSnapshot;
preferences: PanelPreferences;
onPreferencesChange: (next: PanelPreferences) => void;
onSaveSystem: (input: UpdateSystemInput) => Promise<void>; onSaveSystem: (input: UpdateSystemInput) => Promise<void>;
} }
export default function SystemTab({ snapshot, onSaveSystem }: SystemTabProps) { export default function SystemTab({
snapshot,
preferences,
onPreferencesChange,
onSaveSystem,
}: SystemTabProps) {
const [draft, setDraft] = useState<UpdateSystemInput>(() => cloneSystemSettings(snapshot.system)); const [draft, setDraft] = useState<UpdateSystemInput>(() => cloneSystemSettings(snapshot.system));
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [removeServiceId, setRemoveServiceId] = useState<string | null>(null);
const text = getPanelText(preferences.language);
useEffect(() => { useEffect(() => {
setDraft(cloneSystemSettings(snapshot.system)); setDraft(cloneSystemSettings(snapshot.system));
setError(''); setError('');
}, [snapshot.system]); }, [snapshot.system]);
const linkedUsersByService = useMemo(() => {
const result = new Map<string, string[]>();
snapshot.userRecords.forEach((user) => {
const usernames = result.get(user.serviceId) ?? [];
usernames.push(user.username);
result.set(user.serviceId, usernames);
});
return result;
}, [snapshot.userRecords]);
const removeTarget = draft.services.find((service) => service.id === removeServiceId) ?? null;
const removeTargetUsers = removeTarget ? linkedUsersByService.get(removeTarget.id) ?? [] : [];
const updateService = (serviceId: string, updater: (service: ProxyServiceRecord) => ProxyServiceRecord) => { const updateService = (serviceId: string, updater: (service: ProxyServiceRecord) => ProxyServiceRecord) => {
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
@@ -30,7 +56,9 @@ export default function SystemTab({ snapshot, onSaveSystem }: SystemTabProps) {
setError(''); setError('');
try { try {
const validated = validateSystemInput(draft, snapshot.userRecords); const nextServiceIds = new Set(draft.services.map((service) => service.id));
const remainingUsers = snapshot.userRecords.filter((user) => nextServiceIds.has(user.serviceId));
const validated = validateSystemInput(draft, remainingUsers);
await onSaveSystem(validated); await onSaveSystem(validated);
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save system settings.'); setError(submitError instanceof Error ? submitError.message : 'Unable to save system settings.');
@@ -40,204 +68,232 @@ export default function SystemTab({ snapshot, onSaveSystem }: SystemTabProps) {
}; };
return ( return (
<form className="system-editor" onSubmit={handleSubmit}> <>
<section className="page-grid single-column system-grid"> <form className="system-editor" onSubmit={handleSubmit}>
<article className="panel-card system-settings-card"> <section className="page-grid single-column system-grid">
<div className="card-header"> <article className="panel-card">
<h2>Panel settings</h2> <div className="card-header">
</div> <h2>{text.settings.title}</h2>
<div className="system-fields"> <div className="settings-toolbar">
<label className="field-group"> <label className="field-group compact-field">
Public host <span>{text.common.language}</span>
<input <select
value={draft.publicHost} value={preferences.language}
onChange={(event) => setDraft((current) => ({ ...current, publicHost: event.target.value }))} onChange={(event) =>
/> onPreferencesChange({
</label> ...preferences,
<label className="field-group"> language: event.target.value as PanelLanguage,
Config mode })
<input
value={draft.configMode}
onChange={(event) => setDraft((current) => ({ ...current, configMode: event.target.value }))}
/>
</label>
<label className="field-group">
Reload mode
<input
value={draft.reloadMode}
onChange={(event) => setDraft((current) => ({ ...current, reloadMode: event.target.value }))}
/>
</label>
<label className="field-group">
Storage mode
<input
value={draft.storageMode}
onChange={(event) => setDraft((current) => ({ ...current, storageMode: event.target.value }))}
/>
</label>
</div>
<p className="system-hint">
These values describe how the panel generates and reloads the 3proxy config. Saving keeps
existing users attached only to enabled assignable services.
</p>
</article>
<article className="panel-card">
<div className="card-header">
<h2>Services</h2>
<button
type="button"
className="button-secondary"
onClick={() =>
setDraft((current) => ({
...current,
services: [...current.services, createServiceDraft(current.services)],
}))
}
>
Add service
</button>
</div>
<div className="service-editor-list">
{draft.services.map((service, index) => (
<section key={service.id} className="service-editor-row">
<div className="service-editor-header">
<div>
<strong>Service {index + 1}</strong>
<p>{service.id}</p>
</div>
<button
type="button"
className="button-secondary button-small"
onClick={() =>
setDraft((current) => ({
...current,
services: current.services.filter((entry) => entry.id !== service.id),
}))
} }
> >
Remove <option value="en">{text.common.english}</option>
</button> <option value="ru">{text.common.russian}</option>
</div> </select>
<div className="service-editor-grid"> </label>
<label className="field-group"> <label className="field-group compact-field">
Name <span>{text.common.theme}</span>
<input <select
value={service.name} value={preferences.theme}
onChange={(event) => onChange={(event) =>
updateService(service.id, (current) => ({ ...current, name: event.target.value })) onPreferencesChange({
} ...preferences,
/> theme: event.target.value as PanelTheme,
</label> })
<label className="field-group"> }
Port >
<input <option value="light">{getThemeLabel(preferences.language, 'light')}</option>
inputMode="numeric" <option value="dark">{getThemeLabel(preferences.language, 'dark')}</option>
value={String(service.port)} <option value="system">{getThemeLabel(preferences.language, 'system')}</option>
onChange={(event) => </select>
updateService(service.id, (current) => ({ </label>
...current, <button
port: Number(event.target.value), type="button"
})) className="button-secondary"
} onClick={() =>
/> setDraft((current) => ({
</label> ...current,
<label className="field-group"> services: [...current.services, createServiceDraft(current.services)],
Command }))
<select }
value={service.command} >
onChange={(event) => {text.common.addService}
updateService(service.id, (current) => { </button>
const command = event.target.value as ProxyServiceRecord['command']; </div>
return { </div>
...current, <div className="service-editor-list">
command, {draft.services.map((service, index) => (
protocol: getProtocolForCommand(command), <section key={service.id} className="service-editor-row">
assignable: command === 'admin' ? false : current.assignable, <div className="service-editor-header">
}; <div>
}) <strong>
} {text.settings.serviceLabel} {index + 1}
</strong>
<p>{service.id}</p>
</div>
<button
type="button"
className="button-secondary button-small"
onClick={() => setRemoveServiceId(service.id)}
> >
<option value="socks">socks</option> {text.common.remove}
<option value="proxy">proxy</option> </button>
<option value="admin">admin</option> </div>
</select> <div className="service-editor-grid">
</label> <label className="field-group">
<label className="field-group"> {text.settings.name}
Protocol <input
<input value={service.protocol} readOnly /> value={service.name}
</label> onChange={(event) =>
<label className="field-group field-span-2"> updateService(service.id, (current) => ({ ...current, name: event.target.value }))
Description }
<input />
value={service.description} </label>
onChange={(event) => <label className="field-group">
updateService(service.id, (current) => ({ {text.settings.port}
...current, <input
description: event.target.value, inputMode="numeric"
})) value={String(service.port)}
} onChange={(event) =>
/> updateService(service.id, (current) => ({
</label> ...current,
</div> port: Number(event.target.value),
<div className="toggle-row"> }))
<label className="toggle-check"> }
<input />
type="checkbox" </label>
checked={service.enabled} <label className="field-group">
onChange={(event) => {text.settings.serviceType}
updateService(service.id, (current) => ({ <select
...current, value={service.command}
enabled: event.target.checked, onChange={(event) =>
})) updateService(service.id, (current) => {
} const command = event.target.value as ProxyServiceRecord['command'];
/> return {
Enabled ...current,
</label> command,
<label className="toggle-check"> protocol: getProtocolForCommand(command),
<input };
type="checkbox" })
checked={service.assignable} }
disabled={service.command === 'admin'} >
onChange={(event) => <option value="socks">{text.settings.typeSocks}</option>
updateService(service.id, (current) => ({ <option value="proxy">{text.settings.typeProxy}</option>
...current, </select>
assignable: current.command === 'admin' ? false : event.target.checked, </label>
})) <label className="field-group field-span-2">
} {text.settings.description}
/> <input
Assignable to users value={service.description}
</label> onChange={(event) =>
</div> updateService(service.id, (current) => ({
</section> ...current,
))} description: event.target.value,
</div> }))
{error ? <p className="form-error">{error}</p> : null} }
<div className="system-actions"> />
<button </label>
type="button" </div>
className="button-secondary" <div className="toggle-row">
onClick={() => { <label className="toggle-check">
setDraft(cloneSystemSettings(snapshot.system)); <input
setError(''); type="checkbox"
}} checked={service.enabled}
> onChange={(event) =>
Reset updateService(service.id, (current) => ({
</button> ...current,
<button type="submit" disabled={isSaving}> enabled: event.target.checked,
{isSaving ? 'Saving...' : 'Save system'} }))
</button> }
</div> />
</article> {text.common.enabled}
</label>
<label className="toggle-check">
<input
type="checkbox"
checked={service.assignable}
onChange={(event) =>
updateService(service.id, (current) => ({
...current,
assignable: event.target.checked,
}))
}
/>
{text.common.assignable}
</label>
</div>
</section>
))}
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="system-actions">
<button
type="button"
className="button-secondary"
onClick={() => {
setDraft(cloneSystemSettings(snapshot.system));
setError('');
}}
>
{text.common.reset}
</button>
<button type="submit" disabled={isSaving}>
{isSaving ? `${text.common.save}...` : text.common.saveSettings}
</button>
</div>
</article>
<article className="panel-card wide-card"> <article className="panel-card wide-card">
<div className="card-header"> <div className="card-header">
<h2>Generated config</h2> <h2>{text.settings.generatedConfig}</h2>
</div> </div>
<pre>{snapshot.system.previewConfig}</pre> <pre>{snapshot.system.previewConfig}</pre>
</article> </article>
</section> </section>
</form> </form>
{removeTarget ? (
<div className="modal-backdrop" role="presentation" onClick={() => setRemoveServiceId(null)}>
<section
aria-labelledby="remove-service-title"
aria-modal="true"
className="modal-card confirm-card"
role="dialog"
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">
<h2 id="remove-service-title">{text.settings.serviceRemoveTitle}</h2>
</div>
<p className="confirm-copy">
<strong>{removeTarget.name}</strong>{' '}
{removeTargetUsers.length > 0 ? text.settings.removeWarningUsers : text.settings.removeWarningNone}
</p>
{removeTargetUsers.length > 0 ? (
<p className="confirm-copy">
{text.settings.removeWarningCount} {removeTargetUsers.join(', ')}
</p>
) : null}
<div className="modal-actions">
<button type="button" className="button-secondary" onClick={() => setRemoveServiceId(null)}>
{text.common.cancel}
</button>
<button
type="button"
className="button-danger"
onClick={() => {
setDraft((current) => ({
...current,
services: current.services.filter((service) => service.id !== removeTarget.id),
}));
setRemoveServiceId(null);
}}
>
{text.common.remove}
</button>
</div>
</section>
</div>
) : null}
</>
); );
} }

View File

@@ -22,6 +22,23 @@
--shadow: 0 1px 2px rgba(17, 24, 39, 0.04); --shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
} }
:root[data-theme='dark'] {
color-scheme: dark;
--page-bg: #111827;
--surface: #18212f;
--surface-muted: #1f2937;
--border: #334155;
--border-strong: #475569;
--text: #f8fafc;
--muted: #94a3b8;
--accent: #60a5fa;
--accent-muted: rgba(96, 165, 250, 0.15);
--success: #4ade80;
--warning: #fbbf24;
--danger: #f87171;
--shadow: 0 1px 2px rgba(2, 6, 23, 0.35);
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -374,13 +391,13 @@ button,
.usage-bar { .usage-bar {
height: 8px; height: 8px;
border-radius: 999px; border-radius: 999px;
background: #eceff3; background: color-mix(in srgb, var(--border) 70%, transparent);
overflow: hidden; overflow: hidden;
} }
.usage-bar div { .usage-bar div {
height: 100%; height: 100%;
background: #94a3b8; background: color-mix(in srgb, var(--accent) 55%, var(--muted));
} }
.event-row, .event-row,
@@ -429,12 +446,28 @@ button,
} }
.table-note, .table-note,
.system-hint,
.service-editor-header p { .service-editor-header p {
margin: 0; margin: 0;
color: var(--muted); color: var(--muted);
} }
.settings-toolbar {
display: flex;
align-items: end;
gap: 12px;
flex-wrap: wrap;
}
.compact-field {
min-width: 220px;
flex: 0 1 240px;
}
.compact-field span {
color: var(--muted);
font-size: 12px;
}
.toolbar-actions { .toolbar-actions {
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -548,10 +581,6 @@ tbody tr:last-child td {
display: block; display: block;
} }
.system-settings-card {
gap: 12px;
}
.system-fields, .system-fields,
.service-editor-grid { .service-editor-grid {
display: grid; display: grid;
@@ -610,8 +639,8 @@ pre {
overflow: auto; overflow: auto;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
background: #fbfbfc; background: color-mix(in srgb, var(--surface-muted) 80%, var(--surface));
color: #1f2937; color: var(--text);
font: 13px/1.55 Consolas, "Courier New", monospace; font: 13px/1.55 Consolas, "Courier New", monospace;
} }
@@ -679,7 +708,8 @@ pre {
} }
.shell-header, .shell-header,
.table-toolbar { .table-toolbar,
.settings-toolbar {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }

View File

@@ -88,16 +88,6 @@ export const dashboardSnapshot: ControlPlaneState = {
assignable: true, assignable: true,
}, },
{ {
id: 'admin',
name: 'Admin',
command: 'admin',
protocol: 'http',
description: 'Restricted admin visibility endpoint.',
port: 8081,
enabled: true,
assignable: false,
},
{
id: 'proxy', id: 'proxy',
name: 'HTTP proxy', name: 'HTTP proxy',
command: 'proxy', command: 'proxy',

View File

@@ -0,0 +1,74 @@
export type PanelLanguage = 'en' | 'ru';
export type PanelTheme = 'light' | 'dark' | 'system';
export interface PanelPreferences {
language: PanelLanguage;
theme: PanelTheme;
}
const PREFERENCES_KEY = '3proxy-ui-panel-preferences-v2';
export const defaultPanelPreferences: PanelPreferences = {
language: 'en',
theme: 'light',
};
export function loadPanelPreferences(): PanelPreferences {
try {
const raw = window.localStorage.getItem(PREFERENCES_KEY);
if (!raw) {
return defaultPanelPreferences;
}
const parsed = JSON.parse(raw) as Partial<PanelPreferences>;
return {
language: parsed.language === 'ru' ? 'ru' : 'en',
theme: isPanelTheme(parsed.theme) ? parsed.theme : defaultPanelPreferences.theme,
};
} catch {
return defaultPanelPreferences;
}
}
export function savePanelPreferences(preferences: PanelPreferences): void {
window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(preferences));
}
export function applyPanelTheme(theme: PanelTheme): void {
document.documentElement.dataset.theme = resolvePanelTheme(theme);
}
export function observeSystemTheme(onChange: () => void): () => void {
if (typeof window.matchMedia !== 'function') {
return () => undefined;
}
const media = window.matchMedia('(prefers-color-scheme: dark)');
const listener = () => onChange();
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}
media.addListener(listener);
return () => media.removeListener(listener);
}
function resolvePanelTheme(theme: PanelTheme): 'light' | 'dark' {
if (theme === 'light' || theme === 'dark') {
return theme;
}
if (typeof window.matchMedia === 'function') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'light';
}
function isPanelTheme(value: unknown): value is PanelTheme {
return value === 'light' || value === 'dark' || value === 'system';
}

243
src/lib/panelText.ts Normal file
View File

@@ -0,0 +1,243 @@
import type { PanelLanguage, PanelTheme } from './panelPreferences';
const text = {
en: {
tabs: {
dashboard: 'Dashboard',
users: 'Users',
settings: 'Settings',
},
auth: {
title: '3proxy UI',
subtitle: 'Sign in to the control panel.',
login: 'Login',
password: 'Password',
open: 'Open panel',
opening: 'Opening...',
},
common: {
close: 'Close',
cancel: 'Cancel',
reset: 'Reset',
save: 'Save',
saveSettings: 'Save settings',
signOut: 'Sign out',
addService: 'Add service',
remove: 'Remove',
delete: 'Delete',
copy: 'Copy',
copied: 'Copied',
createUser: 'Create user',
endpoint: 'Endpoint',
protocol: 'Protocol',
status: 'Status',
version: 'Version',
users: 'Users',
enabled: 'Enabled',
assignable: 'Assignable to users',
unavailable: 'Unavailable',
unknownService: 'Unknown service',
serviceMissing: 'service missing',
optional: 'Optional',
activeUsers: 'Active users',
total: 'Total',
connections: 'Connections',
process: 'Process',
uptime: 'Uptime',
lastEvent: 'Last event',
left: 'left',
unlimited: 'Unlimited',
exceeded: 'Exceeded',
language: 'Panel language',
theme: 'Panel style',
english: 'English',
russian: 'Russian',
light: 'Light',
dark: 'Dark',
system: 'System',
},
dashboard: {
service: 'Service',
traffic: 'Traffic',
dailyUsage: 'Daily usage',
attention: 'Attention',
start: 'Start',
restart: 'Restart',
},
users: {
title: 'Users',
accountsInProfile: 'accounts in current profile',
newUser: 'New user',
live: 'live',
nearQuota: 'near quota',
exceeded: 'exceeded',
enableServiceHint: 'Enable an assignable service in Settings before creating new users.',
user: 'User',
endpoint: 'Endpoint',
used: 'Used',
remaining: 'Remaining',
share: 'Share',
proxy: 'Proxy',
actions: 'Actions',
addUser: 'Add user',
username: 'Username',
password: 'Password',
service: 'Service',
quotaMb: 'Quota (MB)',
pause: 'Pause',
resume: 'Resume',
deleteTitle: 'Delete user',
deletePrompt: 'Remove profile',
deletePromptSuffix: '? This action will delete the user entry from the current panel state.',
deleteAction: 'Delete user',
},
settings: {
title: 'Services',
generatedConfig: 'Generated config',
serviceLabel: 'Service',
serviceType: 'Type',
typeSocks: 'SOCKS5 proxy',
typeProxy: 'HTTP proxy',
name: 'Name',
port: 'Port',
description: 'Description',
serviceRemoveTitle: 'Delete service',
removeWarningNone: 'Delete this service from the panel configuration?',
removeWarningUsers: 'Delete this service and remove all linked users?',
removeWarningCount: 'Linked users to be removed:',
},
status: {
live: 'live',
warn: 'warn',
fail: 'fail',
idle: 'idle',
paused: 'paused',
enabled: 'enabled',
disabled: 'disabled',
},
},
ru: {
tabs: {
dashboard: 'Панель',
users: 'Пользователи',
settings: 'Настройки',
},
auth: {
title: '3proxy UI',
subtitle: 'Войдите в панель управления.',
login: 'Логин',
password: 'Пароль',
open: 'Открыть панель',
opening: 'Открываем...',
},
common: {
close: 'Закрыть',
cancel: 'Отмена',
reset: 'Сбросить',
save: 'Сохранить',
saveSettings: 'Сохранить настройки',
signOut: 'Выйти',
addService: 'Добавить сервис',
remove: 'Удалить',
delete: 'Удалить',
copy: 'Копировать',
copied: 'Скопировано',
createUser: 'Создать пользователя',
endpoint: 'Точка входа',
protocol: 'Протокол',
status: 'Статус',
version: 'Версия',
users: 'Пользователи',
enabled: 'Включен',
assignable: 'Можно назначать пользователям',
unavailable: 'Недоступно',
unknownService: 'Неизвестный сервис',
serviceMissing: 'сервис отсутствует',
optional: 'Необязательно',
activeUsers: 'Активные пользователи',
total: 'Всего',
connections: 'Соединения',
process: 'Процесс',
uptime: 'Время работы',
lastEvent: 'Последнее событие',
left: 'осталось',
unlimited: 'Без лимита',
exceeded: 'Превышено',
language: 'Язык панели',
theme: 'Стиль панели',
english: 'Английский',
russian: 'Русский',
light: 'Светлый',
dark: 'Темный',
system: 'Системный',
},
dashboard: {
service: 'Сервис',
traffic: 'Трафик',
dailyUsage: 'Дневное использование',
attention: 'Внимание',
start: 'Запустить',
restart: 'Перезапустить',
},
users: {
title: 'Пользователи',
accountsInProfile: 'аккаунтов в текущем профиле',
newUser: 'Новый пользователь',
live: 'в работе',
nearQuota: 'близко к лимиту',
exceeded: 'превышено',
enableServiceHint: 'Сначала включите назначаемый сервис во вкладке Настройки.',
user: 'Пользователь',
endpoint: 'Точка входа',
used: 'Использовано',
remaining: 'Остаток',
share: 'Доля',
proxy: 'Прокси',
actions: 'Действия',
addUser: 'Добавить пользователя',
username: 'Имя пользователя',
password: 'Пароль',
service: 'Сервис',
quotaMb: 'Лимит (МБ)',
pause: 'Пауза',
resume: 'Возобновить',
deleteTitle: 'Удаление пользователя',
deletePrompt: 'Удалить профиль',
deletePromptSuffix: '? Это действие удалит пользователя из текущего состояния панели.',
deleteAction: 'Удалить пользователя',
},
settings: {
title: 'Сервисы',
generatedConfig: 'Сгенерированный конфиг',
serviceLabel: 'Сервис',
serviceType: 'Тип',
typeSocks: 'SOCKS5 прокси',
typeProxy: 'HTTP прокси',
name: 'Название',
port: 'Порт',
description: 'Описание',
serviceRemoveTitle: 'Удаление сервиса',
removeWarningNone: 'Удалить этот сервис из конфигурации панели?',
removeWarningUsers: 'Удалить этот сервис и всех связанных с ним пользователей?',
removeWarningCount: 'Будут удалены пользователи:',
},
status: {
live: 'в работе',
warn: 'предупреждение',
fail: 'ошибка',
idle: 'ожидание',
paused: 'пауза',
enabled: 'включен',
disabled: 'выключен',
},
},
} as const;
export function getPanelText(language: PanelLanguage) {
return text[language];
}
export function getThemeLabel(language: PanelLanguage, theme: PanelTheme): string {
const t = getPanelText(language);
return theme === 'light' ? t.common.light : theme === 'dark' ? t.common.dark : t.common.system;
}

View File

@@ -1,7 +1,7 @@
import type { ServiceState } from '../lib/3proxy'; import type { ServiceState } from '../lib/3proxy';
export type ServiceProtocol = 'socks5' | 'http'; export type ServiceProtocol = 'socks5' | 'http';
export type ServiceCommand = 'socks' | 'proxy' | 'admin'; export type ServiceCommand = 'socks' | 'proxy';
export interface DailyTrafficBucket { export interface DailyTrafficBucket {
day: string; day: string;

View File

@@ -11,7 +11,7 @@ const TOKEN_PATTERN = /^[A-Za-z0-9._~!@#$%^&*+=:/-]+$/;
const SERVICE_ID_PATTERN = /^[A-Za-z0-9._-]+$/; const SERVICE_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
const HOST_PATTERN = /^[A-Za-z0-9.-]+$/; const HOST_PATTERN = /^[A-Za-z0-9.-]+$/;
const COMMANDS: ServiceCommand[] = ['socks', 'proxy', 'admin']; const COMMANDS: ServiceCommand[] = ['socks', 'proxy'];
const MB = 1024 * 1024; const MB = 1024 * 1024;
@@ -113,10 +113,6 @@ export function validateSystemInput(
} }
seenPorts.set(port, label); seenPorts.set(port, label);
if (command === 'admin' && assignable) {
throw new Error('Admin services cannot be assignable.');
}
return { return {
id, id,
name, name,
@@ -125,7 +121,7 @@ export function validateSystemInput(
protocol: getProtocolForCommand(command), protocol: getProtocolForCommand(command),
port, port,
enabled, enabled,
assignable: command === 'admin' ? false : assignable, assignable,
}; };
}); });