Refine settings and panel preferences
This commit is contained in:
@@ -31,3 +31,4 @@ 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. Reworked Settings to store panel language/theme in `localStorage`, added RU/EN UI switching, and made service deletion warn before cascading linked-user removal.
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ Updated: 2026-04-02
|
|||||||
- `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/panelPreferences.ts`: client-side `localStorage` preferences and theme application helpers
|
||||||
|
- `src/lib/panelText.ts`: EN/RU panel copy and labels for translated UI chrome
|
||||||
- `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/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, protocol mapping, and quota conversion
|
||||||
|
|||||||
@@ -153,6 +153,24 @@ describe('panel api', () => {
|
|||||||
expect(response.body.system.previewConfig).toContain('socks -p1180 -u2');
|
expect(response.body.system.previewConfig).toContain('socks -p1180 -u2');
|
||||||
expect(response.body.service.lastEvent).toBe('System configuration updated from panel');
|
expect(response.body.service.lastEvent).toBe('System configuration updated from panel');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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((user: { username: string }) => user.username === 'night-shift')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(response.body.userRecords.some((user: { username: string }) => user.username === 'ops-east')).toBe(false);
|
||||||
|
expect(response.body.service.lastEvent).toMatch(/removed 2 linked users/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createTestApp() {
|
async function createTestApp() {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ async function loginIntoPanel(user: ReturnType<typeof userEvent.setup>) {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.sessionStorage.clear();
|
window.sessionStorage.clear();
|
||||||
|
window.localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('App login gate', () => {
|
describe('App login gate', () => {
|
||||||
@@ -50,6 +51,23 @@ 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('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 +125,43 @@ 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('saves service settings from the settings tab and applies them 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));
|
|
||||||
await user.type(screen.getByLabelText(/public host/i), 'ops-gateway.example.net');
|
|
||||||
|
|
||||||
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
219
src/App.tsx
219
src/App.tsx
@@ -4,11 +4,12 @@ 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 +23,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 +37,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 +68,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 +83,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 +94,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 +108,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 +167,22 @@ 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 +192,23 @@ 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 +220,14 @@ 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 +254,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 +276,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 +342,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 +359,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 +382,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 +415,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 +453,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 +475,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 +494,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 +504,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 +530,7 @@ function UsersTab({
|
|||||||
services={assignableServices}
|
services={assignableServices}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsModalOpen(false)}
|
||||||
onCreate={onCreateUser}
|
onCreate={onCreateUser}
|
||||||
|
preferences={preferences}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -519,6 +542,7 @@ function UsersTab({
|
|||||||
await onDeleteUser(deleteTarget.id);
|
await onDeleteUser(deleteTarget.id);
|
||||||
setDeleteTargetId(null);
|
setDeleteTargetId(null);
|
||||||
}}
|
}}
|
||||||
|
preferences={preferences}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
@@ -526,9 +550,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 +644,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 +799,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 +807,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,15 +829,15 @@ 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
|
||||||
@@ -804,7 +845,7 @@ export default function App() {
|
|||||||
className="button-secondary"
|
className="button-secondary"
|
||||||
onClick={resetSession}
|
onClick={resetSession}
|
||||||
>
|
>
|
||||||
Sign out
|
{text.common.signOut}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -817,21 +858,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 +915,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();
|
||||||
|
|||||||
@@ -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,241 @@ 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.common.panelPreferences}</h2>
|
||||||
<div className="system-fields">
|
</div>
|
||||||
<label className="field-group">
|
<div className="system-fields">
|
||||||
Public host
|
<label className="field-group">
|
||||||
<input
|
{text.common.language}
|
||||||
value={draft.publicHost}
|
<select
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, publicHost: event.target.value }))}
|
value={preferences.language}
|
||||||
/>
|
onChange={(event) =>
|
||||||
</label>
|
onPreferencesChange({
|
||||||
<label className="field-group">
|
...preferences,
|
||||||
Config mode
|
language: event.target.value as PanelLanguage,
|
||||||
<input
|
})
|
||||||
value={draft.configMode}
|
}
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, configMode: event.target.value }))}
|
>
|
||||||
/>
|
<option value="en">{text.common.english}</option>
|
||||||
</label>
|
<option value="ru">{text.common.russian}</option>
|
||||||
<label className="field-group">
|
</select>
|
||||||
Reload mode
|
</label>
|
||||||
<input
|
<label className="field-group">
|
||||||
value={draft.reloadMode}
|
{text.common.theme}
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, reloadMode: event.target.value }))}
|
<select
|
||||||
/>
|
value={preferences.theme}
|
||||||
</label>
|
onChange={(event) =>
|
||||||
<label className="field-group">
|
onPreferencesChange({
|
||||||
Storage mode
|
...preferences,
|
||||||
<input
|
theme: event.target.value as PanelTheme,
|
||||||
value={draft.storageMode}
|
})
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, storageMode: event.target.value }))}
|
}
|
||||||
/>
|
>
|
||||||
</label>
|
<option value="light">{getThemeLabel(preferences.language, 'light')}</option>
|
||||||
</div>
|
<option value="dark">{getThemeLabel(preferences.language, 'dark')}</option>
|
||||||
<p className="system-hint">
|
<option value="system">{getThemeLabel(preferences.language, 'system')}</option>
|
||||||
These values describe how the panel generates and reloads the 3proxy config. Saving keeps
|
</select>
|
||||||
existing users attached only to enabled assignable services.
|
</label>
|
||||||
</p>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="panel-card">
|
<article className="panel-card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h2>Services</h2>
|
<h2>{text.settings.title}</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button-secondary"
|
className="button-secondary"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
services: [...current.services, createServiceDraft(current.services)],
|
services: [...current.services, createServiceDraft(current.services)],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Add service
|
{text.common.addService}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="service-editor-list">
|
<div className="service-editor-list">
|
||||||
{draft.services.map((service, index) => (
|
{draft.services.map((service, index) => (
|
||||||
<section key={service.id} className="service-editor-row">
|
<section key={service.id} className="service-editor-row">
|
||||||
<div className="service-editor-header">
|
<div className="service-editor-header">
|
||||||
<div>
|
<div>
|
||||||
<strong>Service {index + 1}</strong>
|
<strong>
|
||||||
<p>{service.id}</p>
|
{text.settings.serviceLabel} {index + 1}
|
||||||
</div>
|
</strong>
|
||||||
<button
|
<p>{service.id}</p>
|
||||||
type="button"
|
</div>
|
||||||
className="button-secondary button-small"
|
<button
|
||||||
onClick={() =>
|
type="button"
|
||||||
setDraft((current) => ({
|
className="button-secondary button-small"
|
||||||
...current,
|
onClick={() => setRemoveServiceId(service.id)}
|
||||||
services: current.services.filter((entry) => entry.id !== service.id),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="service-editor-grid">
|
|
||||||
<label className="field-group">
|
|
||||||
Name
|
|
||||||
<input
|
|
||||||
value={service.name}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateService(service.id, (current) => ({ ...current, name: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field-group">
|
|
||||||
Port
|
|
||||||
<input
|
|
||||||
inputMode="numeric"
|
|
||||||
value={String(service.port)}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateService(service.id, (current) => ({
|
|
||||||
...current,
|
|
||||||
port: Number(event.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field-group">
|
|
||||||
Command
|
|
||||||
<select
|
|
||||||
value={service.command}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateService(service.id, (current) => {
|
|
||||||
const command = event.target.value as ProxyServiceRecord['command'];
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
command,
|
|
||||||
protocol: getProtocolForCommand(command),
|
|
||||||
assignable: command === 'admin' ? false : current.assignable,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<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">
|
||||||
|
{text.settings.name}
|
||||||
|
<input
|
||||||
|
value={service.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateService(service.id, (current) => ({ ...current, name: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field-group">
|
||||||
|
{text.settings.port}
|
||||||
|
<input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={String(service.port)}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateService(service.id, (current) => ({
|
||||||
|
...current,
|
||||||
|
port: Number(event.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label className="field-group">
|
<label className="field-group">
|
||||||
Protocol
|
{text.settings.serviceType}
|
||||||
<input value={service.protocol} readOnly />
|
<select
|
||||||
</label>
|
value={service.command}
|
||||||
<label className="field-group field-span-2">
|
onChange={(event) =>
|
||||||
Description
|
updateService(service.id, (current) => {
|
||||||
<input
|
const command = event.target.value as ProxyServiceRecord['command'];
|
||||||
value={service.description}
|
return {
|
||||||
onChange={(event) =>
|
...current,
|
||||||
updateService(service.id, (current) => ({
|
command,
|
||||||
...current,
|
protocol: getProtocolForCommand(command),
|
||||||
description: event.target.value,
|
assignable: command === 'admin' ? false : current.assignable,
|
||||||
}))
|
};
|
||||||
}
|
})
|
||||||
/>
|
}
|
||||||
</label>
|
>
|
||||||
</div>
|
<option value="socks">{text.settings.typeSocks}</option>
|
||||||
<div className="toggle-row">
|
<option value="proxy">{text.settings.typeProxy}</option>
|
||||||
<label className="toggle-check">
|
<option value="admin">{text.settings.typeAdmin}</option>
|
||||||
<input
|
</select>
|
||||||
type="checkbox"
|
</label>
|
||||||
checked={service.enabled}
|
<label className="field-group field-span-2">
|
||||||
onChange={(event) =>
|
{text.settings.description}
|
||||||
updateService(service.id, (current) => ({
|
<input
|
||||||
...current,
|
value={service.description}
|
||||||
enabled: event.target.checked,
|
onChange={(event) =>
|
||||||
}))
|
updateService(service.id, (current) => ({
|
||||||
}
|
...current,
|
||||||
/>
|
description: event.target.value,
|
||||||
Enabled
|
}))
|
||||||
</label>
|
}
|
||||||
<label className="toggle-check">
|
/>
|
||||||
<input
|
</label>
|
||||||
type="checkbox"
|
</div>
|
||||||
checked={service.assignable}
|
<div className="toggle-row">
|
||||||
disabled={service.command === 'admin'}
|
<label className="toggle-check">
|
||||||
onChange={(event) =>
|
<input
|
||||||
updateService(service.id, (current) => ({
|
type="checkbox"
|
||||||
...current,
|
checked={service.enabled}
|
||||||
assignable: current.command === 'admin' ? false : event.target.checked,
|
onChange={(event) =>
|
||||||
}))
|
updateService(service.id, (current) => ({
|
||||||
}
|
...current,
|
||||||
/>
|
enabled: event.target.checked,
|
||||||
Assignable to users
|
}))
|
||||||
</label>
|
}
|
||||||
</div>
|
/>
|
||||||
</section>
|
{text.common.enabled}
|
||||||
))}
|
</label>
|
||||||
</div>
|
<label className="toggle-check">
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
<input
|
||||||
<div className="system-actions">
|
type="checkbox"
|
||||||
<button
|
checked={service.assignable}
|
||||||
type="button"
|
disabled={service.command === 'admin'}
|
||||||
className="button-secondary"
|
onChange={(event) =>
|
||||||
onClick={() => {
|
updateService(service.id, (current) => ({
|
||||||
setDraft(cloneSystemSettings(snapshot.system));
|
...current,
|
||||||
setError('');
|
assignable: current.command === 'admin' ? false : event.target.checked,
|
||||||
}}
|
}))
|
||||||
>
|
}
|
||||||
Reset
|
/>
|
||||||
</button>
|
{text.common.assignable}
|
||||||
<button type="submit" disabled={isSaving}>
|
</label>
|
||||||
{isSaving ? 'Saving...' : 'Save system'}
|
</div>
|
||||||
</button>
|
</section>
|
||||||
</div>
|
))}
|
||||||
</article>
|
</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}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
src/app.css
25
src/app.css
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -372,13 +389,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,
|
||||||
@@ -608,8 +625,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
74
src/lib/panelPreferences.ts
Normal file
74
src/lib/panelPreferences.ts
Normal 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';
|
||||||
|
|
||||||
|
export const defaultPanelPreferences: PanelPreferences = {
|
||||||
|
language: 'en',
|
||||||
|
theme: 'system',
|
||||||
|
};
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
269
src/lib/panelText.ts
Normal file
269
src/lib/panelText.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import type { ServiceCommand } from '../shared/contracts';
|
||||||
|
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',
|
||||||
|
deleteUser: 'Delete user',
|
||||||
|
copy: 'Copy',
|
||||||
|
copied: 'Copied',
|
||||||
|
createUser: 'Create user',
|
||||||
|
create: 'Create',
|
||||||
|
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',
|
||||||
|
settingsSaved: 'Settings updated from panel',
|
||||||
|
left: 'left',
|
||||||
|
unlimited: 'Unlimited',
|
||||||
|
exceeded: 'Exceeded',
|
||||||
|
panelPreferences: 'Panel preferences',
|
||||||
|
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.',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
deleteAction: 'Delete user',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
title: 'Services',
|
||||||
|
generatedConfig: 'Generated config',
|
||||||
|
serviceLabel: 'Service',
|
||||||
|
serviceType: 'Type',
|
||||||
|
typeSocks: 'SOCKS5 proxy',
|
||||||
|
typeProxy: 'HTTP proxy',
|
||||||
|
typeAdmin: 'Admin interface',
|
||||||
|
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: 'Удалить',
|
||||||
|
deleteUser: 'Удалить пользователя',
|
||||||
|
copy: 'Копировать',
|
||||||
|
copied: 'Скопировано',
|
||||||
|
createUser: 'Создать пользователя',
|
||||||
|
create: 'Создать',
|
||||||
|
endpoint: 'Точка входа',
|
||||||
|
protocol: 'Протокол',
|
||||||
|
status: 'Статус',
|
||||||
|
version: 'Версия',
|
||||||
|
users: 'Пользователи',
|
||||||
|
enabled: 'Включен',
|
||||||
|
assignable: 'Можно назначать пользователям',
|
||||||
|
unavailable: 'Недоступно',
|
||||||
|
unknownService: 'Неизвестный сервис',
|
||||||
|
serviceMissing: 'сервис отсутствует',
|
||||||
|
optional: 'Необязательно',
|
||||||
|
activeUsers: 'Активные пользователи',
|
||||||
|
total: 'Всего',
|
||||||
|
connections: 'Соединения',
|
||||||
|
process: 'Процесс',
|
||||||
|
uptime: 'Время работы',
|
||||||
|
lastEvent: 'Последнее событие',
|
||||||
|
settingsSaved: 'Настройки обновлены из панели',
|
||||||
|
left: 'осталось',
|
||||||
|
unlimited: 'Без лимита',
|
||||||
|
exceeded: 'Превышено',
|
||||||
|
panelPreferences: 'Параметры панели',
|
||||||
|
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: '? Это действие удалит пользователя из текущего состояния панели.',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
deleteAction: 'Удалить пользователя',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
title: 'Сервисы',
|
||||||
|
generatedConfig: 'Сгенерированный конфиг',
|
||||||
|
serviceLabel: 'Сервис',
|
||||||
|
serviceType: 'Тип',
|
||||||
|
typeSocks: 'SOCKS5 прокси',
|
||||||
|
typeProxy: 'HTTP прокси',
|
||||||
|
typeAdmin: 'Админ-интерфейс',
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServiceTypeLabel(language: PanelLanguage, command: ServiceCommand): string {
|
||||||
|
const t = getPanelText(language);
|
||||||
|
if (command === 'socks') {
|
||||||
|
return t.settings.typeSocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'proxy') {
|
||||||
|
return t.settings.typeProxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.settings.typeAdmin;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user