diff --git a/docs/PLAN.md b/docs/PLAN.md
index b0a9063..0bcbfbf 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -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.
diff --git a/docs/PROJECT_INDEX.md b/docs/PROJECT_INDEX.md
index b961f29..ac22085 100644
--- a/docs/PROJECT_INDEX.md
+++ b/docs/PROJECT_INDEX.md
@@ -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
diff --git a/server/app.test.ts b/server/app.test.ts
index 7d790de..13d30dd 100644
--- a/server/app.test.ts
+++ b/server/app.test.ts
@@ -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);
diff --git a/server/app.ts b/server/app.ts
index 9432c5c..ebd3e9c 100644
--- a/server/app.ts
+++ b/server/app.ts
@@ -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, 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();
diff --git a/server/lib/runtime.ts b/server/lib/runtime.ts
index 3461811..c203fcb 100644
--- a/server/lib/runtime.ts
+++ b/server/lib/runtime.ts
@@ -6,6 +6,7 @@ import type { RuntimePaths, RuntimeSnapshot } from './config';
export interface RuntimeController {
getSnapshot(): RuntimeSnapshot;
start(): Promise;
+ stop(): Promise;
restart(): Promise;
reload(): Promise;
}
@@ -83,19 +84,17 @@ export class ThreeProxyManager implements RuntimeController {
return this.start();
}
- async reload(): Promise {
- 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 {
+ async stop(): Promise {
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 {
+ if (!this.child || this.child.exitCode !== null || !this.child.pid) {
+ return this.start();
+ }
+
+ process.kill(this.child.pid, 'SIGUSR1');
+ return this.getSnapshot();
}
}
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 0afd7fb..dc624af 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -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();
+
+ 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();
@@ -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();
+
+ 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();
diff --git a/src/App.tsx b/src/App.tsx
index 176d982..3be7854 100644
--- a/src/App.tsx
+++ b/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;
+ onSubmit: (input: CreateUserInput) => Promise;
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) => {
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 (
{error ? {error}
: null}
-
@@ -240,11 +255,13 @@ function AddUserModal({
function ConfirmDeleteModal({
username,
+ isSubmitting,
onClose,
onConfirm,
preferences,
}: {
username: string;
+ isSubmitting: boolean;
onClose: () => void;
onConfirm: () => Promise;
preferences: PanelPreferences;
@@ -284,10 +301,10 @@ function ConfirmDeleteModal({
{text.users.deletePromptSuffix}
-
+
{text.common.cancel}
-
+
{text.users.deleteAction}
@@ -302,11 +319,22 @@ function DashboardTab({
preferences,
}: {
snapshot: DashboardSnapshot;
- onRuntimeAction: (action: 'start' | 'restart') => Promise;
+ onRuntimeAction: (action: 'start' | 'stop' | 'restart') => Promise;
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 (
@@ -334,10 +362,31 @@ function DashboardTab({
- onRuntimeAction('start')}>
+ handleRuntimeAction('start')}
+ disabled={pendingAction !== null || snapshot.service.status === 'live'}
+ aria-busy={pendingAction === 'start'}
+ className={pendingAction === 'start' ? 'is-busy' : undefined}
+ >
{text.dashboard.start}
- onRuntimeAction('restart')}>
+ handleRuntimeAction('stop')}
+ disabled={pendingAction !== null || snapshot.service.status === 'idle'}
+ aria-busy={pendingAction === 'stop'}
+ >
+ {text.dashboard.stop}
+
+ handleRuntimeAction('restart')}
+ disabled={pendingAction !== null}
+ aria-busy={pendingAction === 'restart'}
+ >
{text.dashboard.restart}
@@ -403,20 +452,24 @@ function DashboardTab({
function UsersTab({
snapshot,
onCreateUser,
+ onUpdateUser,
onTogglePause,
onDeleteUser,
preferences,
}: {
snapshot: DashboardSnapshot;
onCreateUser: (input: CreateUserInput) => Promise;
+ onUpdateUser: (userId: string, input: CreateUserInput) => Promise;
onTogglePause: (userId: string) => Promise;
onDeleteUser: (userId: string) => Promise;
preferences: PanelPreferences;
}) {
const text = getPanelText(preferences.language);
const [copiedId, setCopiedId] = useState(null);
- const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [editTargetId, setEditTargetId] = useState(null);
const [deleteTargetId, setDeleteTargetId] = useState(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 (
<>
@@ -449,7 +530,7 @@ function UsersTab({
{snapshot.users.nearQuota} {text.users.nearQuota}
{snapshot.users.exceeded} {text.users.exceeded}
- setIsModalOpen(true)} disabled={assignableServices.length === 0}>
+ setIsCreateModalOpen(true)} disabled={assignableServices.length === 0}>
{text.users.newUser}
@@ -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 (
@@ -511,31 +595,47 @@ function UsersTab({
| {formatQuotaStateLabel(user.usedBytes, user.quotaBytes, preferences.language)} |
{formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)} |
- handleCopy(user.id, proxyLink)}
disabled={!service}
>
- {copiedId === user.id ? text.common.copied : text.common.copy}
-
+ {copiedId === user.id ? : }
+
|
- onTogglePause(user.id)}
+ setEditTargetId(user.id)}
+ disabled={isPendingRow}
>
- {user.paused ? text.users.resume : text.users.pause}
-
-
+
+ handlePauseToggle(user.id)}
+ disabled={isPendingRow}
+ busy={isPausePending}
+ >
+ {user.paused ? : }
+
+ setDeleteTargetId(user.id)}
+ disabled={isPendingRow}
+ busy={isDeletePending}
>
- {text.common.delete}
-
+
+
|
@@ -547,12 +647,35 @@ function UsersTab({
- {isModalOpen ? (
- setIsModalOpen(false)}
- onCreate={onCreateUser}
+ onClose={() => setIsCreateModalOpen(false)}
+ onSubmit={onCreateUser}
+ preferences={preferences}
+ />
+ ) : null}
+
+ {editTarget ? (
+ 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({
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 (
+
+ {children}
+
+ );
+}
+
+function CopyIcon() {
+ return (
+
+ );
+}
+
+function CheckIcon() {
+ return (
+
+ );
+}
+
+function EditIcon() {
+ return (
+
+ );
+}
+
+function PauseIcon() {
+ return (
+
+ );
+}
+
+function ResumeIcon() {
+ return (
+
+ );
+}
+
+function TrashIcon() {
+ return (
+
+ );
+}
+
export default function App() {
const [preferences, setPreferences] = useState(() => {
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() {