Polish user actions and runtime controls

This commit is contained in:
2026-04-02 02:51:32 +03:00
parent c04847b21c
commit 3adda67eb9
9 changed files with 556 additions and 85 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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);

View File

@@ -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();

View File

@@ -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();
}
}

View File

@@ -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 />);

View File

@@ -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)}`;
}

View File

@@ -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);

View File

@@ -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: '? Это действие удалит пользователя из текущего состояния панели.',