Polish user actions and runtime controls
This commit is contained in:
@@ -42,3 +42,5 @@ Updated: 2026-04-02
|
||||
25. Replaced frontend polling with websocket live sync over `/ws`, sending only changed top-level snapshot sections while keeping the current `http/ws` path compatible with future `https/wss` deployment.
|
||||
26. Stopped incoming runtime sync from overwriting dirty Settings drafts and added hash-based tab navigation so refresh/back/forward stay on the current panel tab.
|
||||
27. Verified websocket delivery in Docker over plain `ws://127.0.0.1:3000/ws` by authenticating, receiving `snapshot.init`, mutating panel state, and observing a follow-up `snapshot.patch`.
|
||||
28. Reworked Users actions into fixed-width icon buttons, added edit-in-modal flow, generated credentials only for new users, and blocked action buttons while commands are in flight.
|
||||
29. Added backend user-update support plus runtime stop control, then verified both in Docker by updating `u-1` and stopping the real bundled 3proxy process through the API.
|
||||
|
||||
@@ -23,15 +23,15 @@ Updated: 2026-04-02
|
||||
## Frontend
|
||||
|
||||
- `src/main.tsx`: application bootstrap
|
||||
- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, localized labels, early theme application, and protected panel mutations
|
||||
- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, icon-based user actions, create/edit user modal flows, runtime stop control, localized labels, early theme application, and protected panel mutations
|
||||
- `src/SystemTab.tsx`: Settings tab with separate panel-settings and services cards, editable proxy endpoint, dirty-draft protection against incoming live sync, unified service type editing, remove confirmation, and generated config preview
|
||||
- `src/App.test.tsx`: login-gate, preferences persistence, hash-tab restoration, websocket-sync safety, modal interaction, pause/resume, delete-confirm, and settings-save UI tests
|
||||
- `src/app.css`: full panel styling
|
||||
- `src/App.test.tsx`: login-gate, preferences persistence, hash-tab restoration, websocket-sync safety, generated-credential/create-edit modal flows, runtime stop, pause/resume, delete-confirm, and settings-save UI tests
|
||||
- `src/app.css`: full panel styling including fixed-width icon action buttons and busy-state treatment
|
||||
- `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot
|
||||
- `src/lib/3proxy.ts`: formatting and status helpers
|
||||
- `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 with `system` as the default theme
|
||||
- `src/lib/panelText.ts`: English/Russian UI text catalog for the panel shell and settings flows
|
||||
- `src/lib/panelText.ts`: English/Russian UI text catalog for the panel shell, user-edit actions, and runtime controls
|
||||
- `src/shared/contracts.ts`: shared panel, service, user, and API data contracts
|
||||
- `src/shared/validation.ts`: shared validation for user creation, system edits, service type mapping, and quota conversion
|
||||
- `src/test/setup.ts`: Testing Library matchers plus browser WebSocket test double
|
||||
@@ -39,8 +39,8 @@ Updated: 2026-04-02
|
||||
## Server
|
||||
|
||||
- `server/index.ts`: backend entrypoint, runtime bootstrap, and HTTP server wiring for websocket upgrades
|
||||
- `server/app.ts`: Express app with login, protected panel state/runtime routes, live-sync change notifications, 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, cascade delete, and config edge cases
|
||||
- `server/app.ts`: Express app with login, protected panel state/runtime routes, live-sync change notifications, writable system configuration API with linked-user cleanup on removed services, and editable user records
|
||||
- `server/app.test.ts`: API tests for user management plus user editing, runtime stop, 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/config.ts`: 3proxy config renderer, validation, and dashboard derivation for SOCKS/HTTP managed services
|
||||
- `server/lib/config.test.ts`: config-generation regression tests
|
||||
@@ -49,7 +49,7 @@ Updated: 2026-04-02
|
||||
- `server/lib/snapshot.ts`: runtime-backed dashboard snapshot assembly that combines stored panel state with parsed 3proxy traffic observations
|
||||
- `server/lib/traffic.ts`: 3proxy access-log reader that derives current user usage, recent activity, daily totals, and lightweight live-connection estimates
|
||||
- `server/lib/traffic.test.ts`: parser and empty-runtime regression tests for log-derived traffic metrics
|
||||
- `server/lib/runtime.ts`: managed 3proxy process controller
|
||||
- `server/lib/runtime.ts`: managed 3proxy process controller with start/stop/restart/reload operations
|
||||
- `server/lib/store.ts`: JSON-backed persistent state store with legacy admin-service migration
|
||||
|
||||
## Static
|
||||
|
||||
@@ -36,6 +36,16 @@ class FakeRuntime implements RuntimeController {
|
||||
return this.start();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.status = {
|
||||
status: 'idle',
|
||||
pid: null,
|
||||
startedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
return this.getSnapshot();
|
||||
}
|
||||
|
||||
async reload() {
|
||||
return this.getSnapshot();
|
||||
}
|
||||
@@ -101,6 +111,41 @@ describe('panel api', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('updates a user through the api', async () => {
|
||||
const app = await createTestApp();
|
||||
const token = await authorize(app);
|
||||
const initial = await request(app).get('/api/state').set('Authorization', `Bearer ${token}`);
|
||||
const userId = initial.body.userRecords[0].id;
|
||||
|
||||
const updated = await request(app).put(`/api/users/${userId}`).set('Authorization', `Bearer ${token}`).send({
|
||||
username: 'night-shift-updated',
|
||||
password: 'fresh-secret',
|
||||
serviceId: 'socks-lab',
|
||||
quotaMb: 512,
|
||||
});
|
||||
|
||||
expect(updated.status).toBe(200);
|
||||
expect(updated.body.userRecords.find((entry: { id: string }) => entry.id === userId)).toMatchObject({
|
||||
username: 'night-shift-updated',
|
||||
password: 'fresh-secret',
|
||||
serviceId: 'socks-lab',
|
||||
quotaBytes: 512 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it('stops the runtime through the api', async () => {
|
||||
const app = await createTestApp();
|
||||
const token = await authorize(app);
|
||||
|
||||
await request(app).post('/api/runtime/start').set('Authorization', `Bearer ${token}`);
|
||||
const stopped = await request(app).post('/api/runtime/stop').set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(stopped.status).toBe(200);
|
||||
expect(stopped.body.service.status).toBe('idle');
|
||||
expect(stopped.body.service.pidLabel).toBe('pid -');
|
||||
expect(stopped.body.service.lastEvent).toMatch(/stop requested/i);
|
||||
});
|
||||
|
||||
it('rejects system updates when two services reuse the same port', async () => {
|
||||
const app = await createTestApp();
|
||||
const token = await authorize(app);
|
||||
|
||||
@@ -94,18 +94,26 @@ export function createApp({ store, runtime, runtimeRootDir, auth, liveSync }: Ap
|
||||
try {
|
||||
const state = await store.read();
|
||||
const action = request.params.action;
|
||||
const controller = action === 'restart' ? runtime.restart.bind(runtime) : runtime.start.bind(runtime);
|
||||
const controller =
|
||||
action === 'restart'
|
||||
? runtime.restart.bind(runtime)
|
||||
: action === 'stop'
|
||||
? runtime.stop.bind(runtime)
|
||||
: runtime.start.bind(runtime);
|
||||
|
||||
if (!['start', 'restart'].includes(action)) {
|
||||
if (!['start', 'restart', 'stop'].includes(action)) {
|
||||
response.status(404).json({ error: 'Unknown runtime action.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeSnapshot = await controller();
|
||||
state.service.lastEvent = action === 'restart' ? 'Runtime restarted from panel' : 'Runtime start requested from panel';
|
||||
if (runtimeSnapshot.startedAt) {
|
||||
state.service.startedAt = runtimeSnapshot.startedAt;
|
||||
}
|
||||
state.service.lastEvent =
|
||||
action === 'restart'
|
||||
? 'Runtime restarted from panel'
|
||||
: action === 'stop'
|
||||
? 'Runtime stop requested from panel'
|
||||
: 'Runtime start requested from panel';
|
||||
state.service.startedAt = runtimeSnapshot.startedAt ?? null;
|
||||
|
||||
await writeConfigAndState(store, state, runtimePaths);
|
||||
liveSync?.notifyPotentialChange();
|
||||
@@ -130,6 +138,40 @@ export function createApp({ store, runtime, runtimeRootDir, auth, liveSync }: Ap
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/users/:id', async (request, response, next) => {
|
||||
try {
|
||||
const state = await store.read();
|
||||
const user = state.userRecords.find((entry) => entry.id === request.params.id);
|
||||
|
||||
if (!user) {
|
||||
response.status(404).json({ error: 'User not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const input = validateCreateUserInput(request.body as Partial<CreateUserInput>, state.system.services);
|
||||
const duplicateUser = state.userRecords.find(
|
||||
(entry) => entry.id !== user.id && entry.username.toLowerCase() === input.username.toLowerCase(),
|
||||
);
|
||||
|
||||
if (duplicateUser) {
|
||||
response.status(400).json({ error: 'Username already exists.' });
|
||||
return;
|
||||
}
|
||||
|
||||
user.username = input.username;
|
||||
user.password = input.password;
|
||||
user.serviceId = input.serviceId;
|
||||
user.quotaBytes = input.quotaMb === null ? null : input.quotaMb * 1024 * 1024;
|
||||
state.service.lastEvent = `User ${user.username} updated from panel`;
|
||||
|
||||
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
||||
liveSync?.notifyPotentialChange();
|
||||
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/system', async (request, response, next) => {
|
||||
try {
|
||||
const state = await store.read();
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { RuntimePaths, RuntimeSnapshot } from './config';
|
||||
export interface RuntimeController {
|
||||
getSnapshot(): RuntimeSnapshot;
|
||||
start(): Promise<RuntimeSnapshot>;
|
||||
stop(): Promise<RuntimeSnapshot>;
|
||||
restart(): Promise<RuntimeSnapshot>;
|
||||
reload(): Promise<RuntimeSnapshot>;
|
||||
}
|
||||
@@ -83,19 +84,17 @@ export class ThreeProxyManager implements RuntimeController {
|
||||
return this.start();
|
||||
}
|
||||
|
||||
async reload(): Promise<RuntimeSnapshot> {
|
||||
if (!this.child || this.child.exitCode !== null || !this.child.pid) {
|
||||
return this.start();
|
||||
}
|
||||
|
||||
process.kill(this.child.pid, 'SIGUSR1');
|
||||
return this.getSnapshot();
|
||||
}
|
||||
|
||||
private async stop(): Promise<void> {
|
||||
async stop(): Promise<RuntimeSnapshot> {
|
||||
if (!this.child || this.child.exitCode !== null || !this.child.pid) {
|
||||
this.child = null;
|
||||
return;
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
status: 'idle',
|
||||
pid: null,
|
||||
startedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
return this.getSnapshot();
|
||||
}
|
||||
|
||||
const current = this.child;
|
||||
@@ -105,6 +104,16 @@ export class ThreeProxyManager implements RuntimeController {
|
||||
|
||||
current.kill('SIGTERM');
|
||||
await waitForExit;
|
||||
return this.getSnapshot();
|
||||
}
|
||||
|
||||
async reload(): Promise<RuntimeSnapshot> {
|
||||
if (!this.child || this.child.exitCode !== null || !this.child.pid) {
|
||||
return this.start();
|
||||
}
|
||||
|
||||
process.kill(this.child.pid, 'SIGUSR1');
|
||||
return this.getSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,16 @@ describe('App login gate', () => {
|
||||
expect(screen.getByRole('button', { name: /new user/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can stop the runtime from the dashboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
await loginIntoPanel(user);
|
||||
await user.click(screen.getByRole('button', { name: /stop/i }));
|
||||
|
||||
expect(screen.getAllByText(/^idle$/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('opens add-user flow in a modal and closes it on escape', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
@@ -116,7 +126,8 @@ describe('App login gate', () => {
|
||||
const dialog = screen.getByRole('dialog', { name: /add user/i });
|
||||
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/night-shift-01/i)).toBeInTheDocument();
|
||||
expect(String((screen.getByLabelText(/username/i) as HTMLInputElement).value)).toMatch(/^user-/i);
|
||||
expect(String((screen.getByLabelText(/password/i) as HTMLInputElement).value)).toMatch(/^pw-/i);
|
||||
expect(screen.getByLabelText(/service/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/port/i)).not.toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/edge\.example\.net:1080/i)).toBeInTheDocument();
|
||||
@@ -133,11 +144,11 @@ describe('App login gate', () => {
|
||||
await loginIntoPanel(user);
|
||||
await user.click(screen.getByRole('button', { name: /users/i }));
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /pause/i })[0]);
|
||||
await user.click(screen.getAllByRole('button', { name: /pause user/i })[0]);
|
||||
expect(screen.getByText(/^paused$/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: /resume/i })).toHaveLength(1);
|
||||
expect(screen.getAllByRole('button', { name: /resume user/i })).toHaveLength(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /resume/i }));
|
||||
await user.click(screen.getByRole('button', { name: /resume user/i }));
|
||||
expect(screen.queryByText(/^paused$/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -150,7 +161,7 @@ describe('App login gate', () => {
|
||||
|
||||
expect(screen.getByText(/night-shift/i)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /delete/i })[0]);
|
||||
await user.click(screen.getAllByRole('button', { name: /^delete user$/i })[0]);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: /delete user/i });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
@@ -190,6 +201,27 @@ describe('App login gate', () => {
|
||||
expect(screen.getAllByText(/gw\.example\.net:1180/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('opens edit-user flow with existing credentials instead of generating new ones', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
await loginIntoPanel(user);
|
||||
await user.click(screen.getByRole('button', { name: /users/i }));
|
||||
await user.click(screen.getAllByRole('button', { name: /edit user/i })[0]);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: /edit user/i });
|
||||
|
||||
expect(within(dialog).getByLabelText(/username/i)).toHaveValue('night-shift');
|
||||
expect(within(dialog).getByLabelText(/password/i)).toHaveValue('kettle!23');
|
||||
|
||||
await user.clear(within(dialog).getByLabelText(/username/i));
|
||||
await user.type(within(dialog).getByLabelText(/username/i), 'night-shift-updated');
|
||||
await user.click(within(dialog).getByRole('button', { name: /save user/i }));
|
||||
|
||||
expect(screen.getByText(/night-shift-updated/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog', { name: /edit user/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not overwrite dirty system settings when a websocket patch arrives', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
403
src/App.tsx
403
src/App.tsx
@@ -1,4 +1,4 @@
|
||||
import { FormEvent, KeyboardEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { type ReactNode, FormEvent, KeyboardEvent, useEffect, useMemo, useState } from 'react';
|
||||
import './app.css';
|
||||
import { fallbackDashboardSnapshot, panelAuth } from './data/mockDashboard';
|
||||
import {
|
||||
@@ -112,25 +112,32 @@ function LoginGate({
|
||||
);
|
||||
}
|
||||
|
||||
function AddUserModal({
|
||||
function UserEditorModal({
|
||||
mode,
|
||||
host,
|
||||
services,
|
||||
initialValues,
|
||||
onClose,
|
||||
onCreate,
|
||||
onSubmit,
|
||||
preferences,
|
||||
}: {
|
||||
mode: 'create' | 'edit';
|
||||
host: string;
|
||||
services: ProxyServiceRecord[];
|
||||
initialValues?: CreateUserInput;
|
||||
onClose: () => void;
|
||||
onCreate: (input: CreateUserInput) => Promise<void>;
|
||||
onSubmit: (input: CreateUserInput) => Promise<void>;
|
||||
preferences: PanelPreferences;
|
||||
}) {
|
||||
const text = getPanelText(preferences.language);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [serviceId, setServiceId] = useState(services[0]?.id ?? '');
|
||||
const [quotaMb, setQuotaMb] = useState('');
|
||||
const [username, setUsername] = useState(() => initialValues?.username ?? generateUsername());
|
||||
const [password, setPassword] = useState(() => initialValues?.password ?? generatePassword());
|
||||
const [serviceId, setServiceId] = useState(() => initialValues?.serviceId ?? services[0]?.id ?? '');
|
||||
const [quotaMb, setQuotaMb] = useState(() =>
|
||||
initialValues?.quotaMb === null || initialValues?.quotaMb === undefined ? '' : String(initialValues.quotaMb),
|
||||
);
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
@@ -152,9 +159,10 @@ function AddUserModal({
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await onCreate({
|
||||
await onSubmit({
|
||||
username,
|
||||
password,
|
||||
serviceId,
|
||||
@@ -162,22 +170,24 @@ function AddUserModal({
|
||||
});
|
||||
onClose();
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : 'Unable to create user.');
|
||||
setError(submitError instanceof Error ? submitError.message : 'Unable to save user.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<section
|
||||
aria-labelledby="add-user-title"
|
||||
aria-labelledby="user-editor-title"
|
||||
aria-modal="true"
|
||||
className="modal-card"
|
||||
role="dialog"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h2 id="add-user-title">{text.users.addUser}</h2>
|
||||
<button type="button" className="button-secondary" onClick={onClose}>
|
||||
<h2 id="user-editor-title">{mode === 'create' ? text.users.addUser : text.users.editUser}</h2>
|
||||
<button type="button" className="button-secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
{text.common.close}
|
||||
</button>
|
||||
</div>
|
||||
@@ -187,6 +197,7 @@ function AddUserModal({
|
||||
<input
|
||||
autoFocus
|
||||
placeholder="night-shift-01"
|
||||
disabled={isSubmitting}
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
@@ -195,13 +206,14 @@ function AddUserModal({
|
||||
{text.users.password}
|
||||
<input
|
||||
placeholder="generated-secret"
|
||||
disabled={isSubmitting}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{text.users.service}
|
||||
<select value={serviceId} onChange={(event) => setServiceId(event.target.value)}>
|
||||
<select disabled={isSubmitting} value={serviceId} onChange={(event) => setServiceId(event.target.value)}>
|
||||
{services.map((service) => (
|
||||
<option key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
@@ -213,6 +225,7 @@ function AddUserModal({
|
||||
{text.users.quotaMb}
|
||||
<input
|
||||
placeholder={text.common.optional}
|
||||
disabled={isSubmitting}
|
||||
value={quotaMb}
|
||||
onChange={(event) => setQuotaMb(event.target.value)}
|
||||
/>
|
||||
@@ -227,10 +240,12 @@ function AddUserModal({
|
||||
</div>
|
||||
{error ? <p className="form-error modal-error">{error}</p> : null}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="button-secondary" onClick={onClose}>
|
||||
<button type="button" className="button-secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
{text.common.cancel}
|
||||
</button>
|
||||
<button type="submit">{text.common.createUser}</button>
|
||||
<button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
|
||||
{mode === 'create' ? text.common.createUser : text.common.saveUser}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -240,11 +255,13 @@ function AddUserModal({
|
||||
|
||||
function ConfirmDeleteModal({
|
||||
username,
|
||||
isSubmitting,
|
||||
onClose,
|
||||
onConfirm,
|
||||
preferences,
|
||||
}: {
|
||||
username: string;
|
||||
isSubmitting: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
preferences: PanelPreferences;
|
||||
@@ -284,10 +301,10 @@ function ConfirmDeleteModal({
|
||||
{text.users.deletePromptSuffix}
|
||||
</p>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="button-secondary" onClick={onClose}>
|
||||
<button type="button" className="button-secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
{text.common.cancel}
|
||||
</button>
|
||||
<button type="button" className="button-danger" onClick={onConfirm}>
|
||||
<button type="button" className="button-danger" onClick={onConfirm} disabled={isSubmitting} aria-busy={isSubmitting}>
|
||||
{text.users.deleteAction}
|
||||
</button>
|
||||
</div>
|
||||
@@ -302,11 +319,22 @@ function DashboardTab({
|
||||
preferences,
|
||||
}: {
|
||||
snapshot: DashboardSnapshot;
|
||||
onRuntimeAction: (action: 'start' | 'restart') => Promise<void>;
|
||||
onRuntimeAction: (action: 'start' | 'stop' | 'restart') => Promise<void>;
|
||||
preferences: PanelPreferences;
|
||||
}) {
|
||||
const text = getPanelText(preferences.language);
|
||||
const serviceTone = getServiceTone(snapshot.service.status);
|
||||
const [pendingAction, setPendingAction] = useState<'start' | 'stop' | 'restart' | null>(null);
|
||||
|
||||
const handleRuntimeAction = async (action: 'start' | 'stop' | 'restart') => {
|
||||
setPendingAction(action);
|
||||
|
||||
try {
|
||||
await onRuntimeAction(action);
|
||||
} finally {
|
||||
setPendingAction((current) => (current === action ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="page-grid">
|
||||
@@ -334,10 +362,31 @@ function DashboardTab({
|
||||
</div>
|
||||
</dl>
|
||||
<div className="actions-row">
|
||||
<button type="button" onClick={() => onRuntimeAction('start')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRuntimeAction('start')}
|
||||
disabled={pendingAction !== null || snapshot.service.status === 'live'}
|
||||
aria-busy={pendingAction === 'start'}
|
||||
className={pendingAction === 'start' ? 'is-busy' : undefined}
|
||||
>
|
||||
{text.dashboard.start}
|
||||
</button>
|
||||
<button type="button" className="button-secondary" onClick={() => onRuntimeAction('restart')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`button-secondary${pendingAction === 'stop' ? ' is-busy' : ''}`}
|
||||
onClick={() => handleRuntimeAction('stop')}
|
||||
disabled={pendingAction !== null || snapshot.service.status === 'idle'}
|
||||
aria-busy={pendingAction === 'stop'}
|
||||
>
|
||||
{text.dashboard.stop}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`button-secondary${pendingAction === 'restart' ? ' is-busy' : ''}`}
|
||||
onClick={() => handleRuntimeAction('restart')}
|
||||
disabled={pendingAction !== null}
|
||||
aria-busy={pendingAction === 'restart'}
|
||||
>
|
||||
{text.dashboard.restart}
|
||||
</button>
|
||||
</div>
|
||||
@@ -403,20 +452,24 @@ function DashboardTab({
|
||||
function UsersTab({
|
||||
snapshot,
|
||||
onCreateUser,
|
||||
onUpdateUser,
|
||||
onTogglePause,
|
||||
onDeleteUser,
|
||||
preferences,
|
||||
}: {
|
||||
snapshot: DashboardSnapshot;
|
||||
onCreateUser: (input: CreateUserInput) => Promise<void>;
|
||||
onUpdateUser: (userId: string, input: CreateUserInput) => Promise<void>;
|
||||
onTogglePause: (userId: string) => Promise<void>;
|
||||
onDeleteUser: (userId: string) => Promise<void>;
|
||||
preferences: PanelPreferences;
|
||||
}) {
|
||||
const text = getPanelText(preferences.language);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editTargetId, setEditTargetId] = useState<string | null>(null);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [pendingUserAction, setPendingUserAction] = useState<{ userId: string; action: 'edit' | 'pause' | 'delete' } | null>(null);
|
||||
|
||||
const servicesById = useMemo(
|
||||
() => new Map(snapshot.system.services.map((service) => [service.id, service] as const)),
|
||||
@@ -425,6 +478,7 @@ function UsersTab({
|
||||
|
||||
const assignableServices = snapshot.system.services.filter((service) => service.enabled && service.assignable);
|
||||
const deleteTarget = snapshot.userRecords.find((user) => user.id === deleteTargetId) ?? null;
|
||||
const editTarget = snapshot.userRecords.find((user) => user.id === editTargetId) ?? null;
|
||||
|
||||
const handleCopy = async (userId: string, proxyLink: string) => {
|
||||
await navigator.clipboard.writeText(proxyLink);
|
||||
@@ -432,6 +486,33 @@ function UsersTab({
|
||||
window.setTimeout(() => setCopiedId((value) => (value === userId ? null : value)), 1200);
|
||||
};
|
||||
|
||||
const handlePauseToggle = async (userId: string) => {
|
||||
setPendingUserAction({ userId, action: 'pause' });
|
||||
try {
|
||||
await onTogglePause(userId);
|
||||
} finally {
|
||||
setPendingUserAction((current) =>
|
||||
current?.userId === userId && current.action === 'pause' ? null : current,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingUserAction({ userId: deleteTarget.id, action: 'delete' });
|
||||
try {
|
||||
await onDeleteUser(deleteTarget.id);
|
||||
setDeleteTargetId(null);
|
||||
} finally {
|
||||
setPendingUserAction((current) =>
|
||||
current?.userId === deleteTarget.id && current.action === 'delete' ? null : current,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-grid single-column">
|
||||
@@ -449,7 +530,7 @@ function UsersTab({
|
||||
<span>{snapshot.users.nearQuota} {text.users.nearQuota}</span>
|
||||
<span>{snapshot.users.exceeded} {text.users.exceeded}</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => setIsModalOpen(true)} disabled={assignableServices.length === 0}>
|
||||
<button type="button" onClick={() => setIsCreateModalOpen(true)} disabled={assignableServices.length === 0}>
|
||||
{text.users.newUser}
|
||||
</button>
|
||||
</div>
|
||||
@@ -488,6 +569,9 @@ function UsersTab({
|
||||
: '';
|
||||
const exhausted = isQuotaExceeded(user.usedBytes, user.quotaBytes);
|
||||
const displayStatus = user.paused ? 'paused' : user.status;
|
||||
const isPendingRow = pendingUserAction?.userId === user.id;
|
||||
const isPausePending = isPendingRow && pendingUserAction?.action === 'pause';
|
||||
const isDeletePending = isPendingRow && pendingUserAction?.action === 'delete';
|
||||
|
||||
return (
|
||||
<tr key={user.id}>
|
||||
@@ -511,31 +595,47 @@ function UsersTab({
|
||||
<td>{formatQuotaStateLabel(user.usedBytes, user.quotaBytes, preferences.language)}</td>
|
||||
<td>{formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className={exhausted ? 'copy-button danger' : 'copy-button'}
|
||||
<ActionIconButton
|
||||
ariaLabel={copiedId === user.id ? text.common.copied : text.users.copyProxyLink}
|
||||
title={copiedId === user.id ? text.common.copied : text.users.copyProxyLink}
|
||||
tone={exhausted ? 'danger' : 'secondary'}
|
||||
onClick={() => handleCopy(user.id, proxyLink)}
|
||||
disabled={!service}
|
||||
>
|
||||
{copiedId === user.id ? text.common.copied : text.common.copy}
|
||||
</button>
|
||||
{copiedId === user.id ? <CheckIcon /> : <CopyIcon />}
|
||||
</ActionIconButton>
|
||||
</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button-secondary button-small"
|
||||
onClick={() => onTogglePause(user.id)}
|
||||
<ActionIconButton
|
||||
ariaLabel={text.users.editAction}
|
||||
title={text.users.editAction}
|
||||
tone="secondary"
|
||||
onClick={() => setEditTargetId(user.id)}
|
||||
disabled={isPendingRow}
|
||||
>
|
||||
{user.paused ? text.users.resume : text.users.pause}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-danger button-small"
|
||||
<EditIcon />
|
||||
</ActionIconButton>
|
||||
<ActionIconButton
|
||||
ariaLabel={user.paused ? text.users.resumeAction : text.users.pauseAction}
|
||||
title={user.paused ? text.users.resumeAction : text.users.pauseAction}
|
||||
tone="secondary"
|
||||
onClick={() => handlePauseToggle(user.id)}
|
||||
disabled={isPendingRow}
|
||||
busy={isPausePending}
|
||||
>
|
||||
{user.paused ? <ResumeIcon /> : <PauseIcon />}
|
||||
</ActionIconButton>
|
||||
<ActionIconButton
|
||||
ariaLabel={text.users.deleteActionLabel}
|
||||
title={text.users.deleteActionLabel}
|
||||
tone="danger"
|
||||
onClick={() => setDeleteTargetId(user.id)}
|
||||
disabled={isPendingRow}
|
||||
busy={isDeletePending}
|
||||
>
|
||||
{text.common.delete}
|
||||
</button>
|
||||
<TrashIcon />
|
||||
</ActionIconButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -547,12 +647,35 @@ function UsersTab({
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{isModalOpen ? (
|
||||
<AddUserModal
|
||||
{isCreateModalOpen ? (
|
||||
<UserEditorModal
|
||||
mode="create"
|
||||
host={snapshot.system.publicHost}
|
||||
services={assignableServices}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onCreate={onCreateUser}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSubmit={onCreateUser}
|
||||
preferences={preferences}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{editTarget ? (
|
||||
<UserEditorModal
|
||||
mode="edit"
|
||||
host={snapshot.system.publicHost}
|
||||
services={assignableServices}
|
||||
initialValues={toUserEditorValues(editTarget)}
|
||||
onClose={() => setEditTargetId(null)}
|
||||
onSubmit={async (input) => {
|
||||
setPendingUserAction({ userId: editTarget.id, action: 'edit' });
|
||||
try {
|
||||
await onUpdateUser(editTarget.id, input);
|
||||
setEditTargetId(null);
|
||||
} finally {
|
||||
setPendingUserAction((current) =>
|
||||
current?.userId === editTarget.id && current.action === 'edit' ? null : current,
|
||||
);
|
||||
}
|
||||
}}
|
||||
preferences={preferences}
|
||||
/>
|
||||
) : null}
|
||||
@@ -561,10 +684,8 @@ function UsersTab({
|
||||
<ConfirmDeleteModal
|
||||
username={deleteTarget.username}
|
||||
onClose={() => setDeleteTargetId(null)}
|
||||
onConfirm={async () => {
|
||||
await onDeleteUser(deleteTarget.id);
|
||||
setDeleteTargetId(null);
|
||||
}}
|
||||
isSubmitting={pendingUserAction?.userId === deleteTarget.id && pendingUserAction.action === 'delete'}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
preferences={preferences}
|
||||
/>
|
||||
) : null}
|
||||
@@ -572,6 +693,104 @@ function UsersTab({
|
||||
);
|
||||
}
|
||||
|
||||
function ActionIconButton({
|
||||
ariaLabel,
|
||||
title,
|
||||
tone,
|
||||
disabled = false,
|
||||
busy = false,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
title?: string;
|
||||
tone: 'secondary' | 'danger';
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`icon-button ${tone}${busy ? ' is-busy' : ''}`}
|
||||
aria-label={ariaLabel}
|
||||
title={title ?? ariaLabel}
|
||||
disabled={disabled}
|
||||
aria-busy={busy}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path
|
||||
d="M7 3.5A2.5 2.5 0 0 1 9.5 1h6A2.5 2.5 0 0 1 18 3.5v8A2.5 2.5 0 0 1 15.5 14H15v1.5A2.5 2.5 0 0 1 12.5 18h-6A2.5 2.5 0 0 1 4 15.5v-8A2.5 2.5 0 0 1 6.5 5H7V3.5Zm1.5 1.5h4V3.5a1 1 0 0 0-1-1h-3a1 1 0 0 0-1 1V5Zm-2 1.5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-6Zm8.5 6a2.47 2.47 0 0 0 .5-1.5v-5a1 1 0 0 0-1-1H14v7.5h1Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path
|
||||
d="M16.7 5.3a1 1 0 0 1 0 1.4l-8 8a1 1 0 0 1-1.4 0l-4-4a1 1 0 1 1 1.4-1.4L8 12.59l7.3-7.3a1 1 0 0 1 1.4 0Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function EditIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path
|
||||
d="M13.59 2.59a2 2 0 0 1 2.82 0l1 1a2 2 0 0 1 0 2.82l-8.93 8.93a1 1 0 0 1-.46.26l-3.5.88a1 1 0 0 1-1.21-1.21l.88-3.5a1 1 0 0 1 .26-.46l8.93-8.93Zm1.76 2.88-1-1a.5.5 0 0 0-.7 0L5 13.11l-.49 1.95 1.95-.49 8.64-8.64a.5.5 0 0 0 0-.7Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PauseIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path
|
||||
d="M6 3a1 1 0 0 1 1 1v12a1 1 0 1 1-2 0V4a1 1 0 0 1 1-1Zm8 0a1 1 0 0 1 1 1v12a1 1 0 1 1-2 0V4a1 1 0 0 1 1-1Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumeIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path
|
||||
d="M6 4.8A1.8 1.8 0 0 1 8.77 3.3l6.4 4.2a1.8 1.8 0 0 1 0 3l-6.4 4.2A1.8 1.8 0 0 1 6 13.2V4.8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path
|
||||
d="M8 2a1 1 0 0 0-1 1v1H4.5a.75.75 0 0 0 0 1.5H5l.68 9.18A2.5 2.5 0 0 0 8.17 17h3.66a2.5 2.5 0 0 0 2.49-2.32L15 5.5h.5a.75.75 0 0 0 0-1.5H13V3a1 1 0 0 0-1-1H8Zm3.5 2h-3V3.5h3V4Zm-3 3.25a.75.75 0 0 1 .75.75v5a.75.75 0 0 1-1.5 0V8a.75.75 0 0 1 .75-.75Zm3 0a.75.75 0 0 1 .75.75v5a.75.75 0 0 1-1.5 0V8a.75.75 0 0 1 .75-.75Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [preferences, setPreferences] = useState<PanelPreferences>(() => {
|
||||
const loaded = loadPanelPreferences();
|
||||
@@ -766,7 +985,7 @@ export default function App() {
|
||||
setSnapshot((current) => fallback(current));
|
||||
};
|
||||
|
||||
const handleRuntimeAction = async (action: 'start' | 'restart') => {
|
||||
const handleRuntimeAction = async (action: 'start' | 'stop' | 'restart') => {
|
||||
await mutateSnapshot(
|
||||
() =>
|
||||
fetch(`/api/runtime/${action}`, {
|
||||
@@ -778,8 +997,15 @@ export default function App() {
|
||||
...current,
|
||||
service: {
|
||||
...current.service,
|
||||
status: 'live',
|
||||
lastEvent: action === 'restart' ? 'Runtime restarted from panel' : 'Runtime start requested from panel',
|
||||
status: action === 'stop' ? 'idle' : 'live',
|
||||
pidLabel: action === 'stop' ? 'pid -' : current.service.pidLabel,
|
||||
uptimeLabel: action === 'stop' ? 'uptime -' : current.service.uptimeLabel,
|
||||
lastEvent:
|
||||
action === 'restart'
|
||||
? 'Runtime restarted from panel'
|
||||
: action === 'stop'
|
||||
? 'Runtime stop requested from panel'
|
||||
: 'Runtime start requested from panel',
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -858,6 +1084,65 @@ export default function App() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateUser = async (userId: string, input: CreateUserInput) => {
|
||||
const validated = validateCreateUserInput(input, snapshot.system.services);
|
||||
|
||||
if (
|
||||
snapshot.userRecords.some(
|
||||
(user) => user.id !== userId && user.username.toLowerCase() === validated.username.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
throw new Error('Username already exists.');
|
||||
}
|
||||
|
||||
let payload: DashboardSnapshot | null;
|
||||
try {
|
||||
payload = await requestSnapshot(() =>
|
||||
fetch(`/api/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...buildAuthHeaders(session.token),
|
||||
},
|
||||
body: JSON.stringify(validated),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof SessionExpiredError) {
|
||||
resetSession();
|
||||
throw new Error('Panel session expired. Sign in again.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
setSnapshot((current) =>
|
||||
withDerivedSnapshot({
|
||||
...current,
|
||||
service: {
|
||||
...current.service,
|
||||
lastEvent: `User ${validated.username} updated from panel`,
|
||||
},
|
||||
userRecords: current.userRecords.map((user) =>
|
||||
user.id === userId
|
||||
? {
|
||||
...user,
|
||||
username: validated.username,
|
||||
password: validated.password,
|
||||
serviceId: validated.serviceId,
|
||||
quotaBytes: quotaMbToBytes(validated.quotaMb),
|
||||
}
|
||||
: user,
|
||||
),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSnapshot(payload);
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
await mutateSnapshot(
|
||||
() =>
|
||||
@@ -963,6 +1248,7 @@ export default function App() {
|
||||
<UsersTab
|
||||
snapshot={snapshot}
|
||||
onCreateUser={handleCreateUser}
|
||||
onUpdateUser={handleUpdateUser}
|
||||
onTogglePause={handleTogglePause}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
preferences={preferences}
|
||||
@@ -1171,3 +1457,20 @@ function getHashForTab(tab: TabId): string {
|
||||
return '#dashboard';
|
||||
}
|
||||
}
|
||||
|
||||
function toUserEditorValues(user: ProxyUserRecord): CreateUserInput {
|
||||
return {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
serviceId: user.serviceId,
|
||||
quotaMb: user.quotaBytes === null ? null : Math.round(user.quotaBytes / (1024 * 1024)),
|
||||
};
|
||||
}
|
||||
|
||||
function generateUsername(): string {
|
||||
return `user-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function generatePassword(): string {
|
||||
return `pw-${Math.random().toString(36).slice(2, 8)}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
32
src/app.css
32
src/app.css
@@ -75,6 +75,11 @@ button:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
button[aria-busy='true'],
|
||||
.is-busy {
|
||||
box-shadow: inset 0 0 0 999px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
@@ -164,7 +169,6 @@ button:disabled {
|
||||
|
||||
button,
|
||||
.button-secondary,
|
||||
.copy-button,
|
||||
.button-danger {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
@@ -175,8 +179,7 @@ button,
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.button-secondary,
|
||||
.copy-button {
|
||||
.button-secondary {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
@@ -195,17 +198,34 @@ button,
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
.icon-button {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 76px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.copy-button.danger {
|
||||
.icon-button.secondary {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon-button.danger {
|
||||
border-color: color-mix(in srgb, var(--danger) 45%, var(--border-strong));
|
||||
background: color-mix(in srgb, var(--danger) 12%, var(--surface));
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
|
||||
@@ -27,7 +27,9 @@ const text = {
|
||||
delete: 'Delete',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
edit: 'Edit',
|
||||
createUser: 'Create user',
|
||||
saveUser: 'Save user',
|
||||
endpoint: 'Endpoint',
|
||||
protocol: 'Protocol',
|
||||
status: 'Status',
|
||||
@@ -62,6 +64,7 @@ const text = {
|
||||
dailyUsage: 'Daily usage',
|
||||
attention: 'Attention',
|
||||
start: 'Start',
|
||||
stop: 'Stop',
|
||||
restart: 'Restart',
|
||||
},
|
||||
users: {
|
||||
@@ -80,12 +83,18 @@ const text = {
|
||||
proxy: 'Proxy',
|
||||
actions: 'Actions',
|
||||
addUser: 'Add user',
|
||||
editUser: 'Edit user',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
service: 'Service',
|
||||
quotaMb: 'Quota (MB)',
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
copyProxyLink: 'Copy proxy link',
|
||||
editAction: 'Edit user',
|
||||
pauseAction: 'Pause user',
|
||||
resumeAction: 'Resume user',
|
||||
deleteActionLabel: 'Delete user',
|
||||
deleteTitle: 'Delete user',
|
||||
deletePrompt: 'Remove profile',
|
||||
deletePromptSuffix: '? This action will delete the user entry from the current panel state.',
|
||||
@@ -144,7 +153,9 @@ const text = {
|
||||
delete: 'Удалить',
|
||||
copy: 'Копировать',
|
||||
copied: 'Скопировано',
|
||||
edit: 'Редактировать',
|
||||
createUser: 'Создать пользователя',
|
||||
saveUser: 'Сохранить пользователя',
|
||||
endpoint: 'Точка входа',
|
||||
protocol: 'Протокол',
|
||||
status: 'Статус',
|
||||
@@ -179,6 +190,7 @@ const text = {
|
||||
dailyUsage: 'Дневное использование',
|
||||
attention: 'Внимание',
|
||||
start: 'Запустить',
|
||||
stop: 'Остановить',
|
||||
restart: 'Перезапустить',
|
||||
},
|
||||
users: {
|
||||
@@ -197,12 +209,18 @@ const text = {
|
||||
proxy: 'Прокси',
|
||||
actions: 'Действия',
|
||||
addUser: 'Добавить пользователя',
|
||||
editUser: 'Редактирование пользователя',
|
||||
username: 'Имя пользователя',
|
||||
password: 'Пароль',
|
||||
service: 'Сервис',
|
||||
quotaMb: 'Лимит (МБ)',
|
||||
pause: 'Пауза',
|
||||
resume: 'Возобновить',
|
||||
copyProxyLink: 'Копировать proxy URL',
|
||||
editAction: 'Редактировать пользователя',
|
||||
pauseAction: 'Поставить на паузу',
|
||||
resumeAction: 'Возобновить пользователя',
|
||||
deleteActionLabel: 'Удалить пользователя',
|
||||
deleteTitle: 'Удаление пользователя',
|
||||
deletePrompt: 'Удалить профиль',
|
||||
deletePromptSuffix: '? Это действие удалит пользователя из текущего состояния панели.',
|
||||
|
||||
Reference in New Issue
Block a user