feat: add dockerized 3proxy control plane backend
This commit is contained in:
365
src/App.tsx
365
src/App.tsx
@@ -1,6 +1,6 @@
|
||||
import { FormEvent, KeyboardEvent, useEffect, useState } from 'react';
|
||||
import { FormEvent, KeyboardEvent, useEffect, useMemo, useState } from 'react';
|
||||
import './app.css';
|
||||
import { dashboardSnapshot, panelAuth } from './data/mockDashboard';
|
||||
import { fallbackDashboardSnapshot, panelAuth } from './data/mockDashboard';
|
||||
import {
|
||||
buildProxyLink,
|
||||
formatBytes,
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
getServiceTone,
|
||||
isQuotaExceeded,
|
||||
} from './lib/3proxy';
|
||||
import type {
|
||||
CreateUserInput,
|
||||
DashboardSnapshot,
|
||||
ProxyServiceRecord,
|
||||
ProxyUserRecord,
|
||||
} from './shared/contracts';
|
||||
|
||||
type TabId = 'dashboard' | 'users' | 'system';
|
||||
|
||||
@@ -18,18 +24,6 @@ const tabs: Array<{ id: TabId; label: string }> = [
|
||||
{ id: 'system', label: 'System' },
|
||||
];
|
||||
|
||||
const assignableServices = dashboardSnapshot.system.services.filter(
|
||||
(service) => service.enabled && service.assignable,
|
||||
);
|
||||
|
||||
const servicesById = new Map(
|
||||
dashboardSnapshot.system.services.map((service) => [service.id, service] as const),
|
||||
);
|
||||
|
||||
type UserRow = (typeof dashboardSnapshot.userRecords)[number] & {
|
||||
paused?: boolean;
|
||||
};
|
||||
|
||||
function LoginGate({ onUnlock }: { onUnlock: () => void }) {
|
||||
const [login, setLogin] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -82,8 +76,22 @@ function LoginGate({ onUnlock }: { onUnlock: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AddUserModal({ onClose }: { onClose: () => void }) {
|
||||
const [serviceId, setServiceId] = useState(assignableServices[0]?.id ?? '');
|
||||
function AddUserModal({
|
||||
host,
|
||||
services,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: {
|
||||
host: string;
|
||||
services: ProxyServiceRecord[];
|
||||
onClose: () => void;
|
||||
onCreate: (input: CreateUserInput) => Promise<void>;
|
||||
}) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [serviceId, setServiceId] = useState(services[0]?.id ?? '');
|
||||
const [quotaMb, setQuotaMb] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
@@ -101,7 +109,23 @@ function AddUserModal({ onClose }: { onClose: () => void }) {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const selectedService = servicesById.get(serviceId);
|
||||
const selectedService = services.find((service) => service.id === serviceId);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
await onCreate({
|
||||
username,
|
||||
password,
|
||||
serviceId,
|
||||
quotaMb: quotaMb.trim() ? Number(quotaMb) : null,
|
||||
});
|
||||
onClose();
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : 'Unable to create user.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation" onClick={onClose}>
|
||||
@@ -118,19 +142,19 @@ function AddUserModal({ onClose }: { onClose: () => void }) {
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<form className="modal-form">
|
||||
<form className="modal-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Username
|
||||
<input autoFocus placeholder="night-shift-01" />
|
||||
<input autoFocus placeholder="night-shift-01" value={username} onChange={(event) => setUsername(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input placeholder="generated secret" />
|
||||
<input placeholder="generated-secret" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Service
|
||||
<select value={serviceId} onChange={(event) => setServiceId(event.target.value)}>
|
||||
{assignableServices.map((service) => (
|
||||
{services.map((service) => (
|
||||
<option key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</option>
|
||||
@@ -139,25 +163,22 @@ function AddUserModal({ onClose }: { onClose: () => void }) {
|
||||
</label>
|
||||
<label>
|
||||
Quota (MB)
|
||||
<input placeholder="Optional" />
|
||||
<input placeholder="Optional" value={quotaMb} onChange={(event) => setQuotaMb(event.target.value)} />
|
||||
</label>
|
||||
<div className="modal-preview">
|
||||
<span>Endpoint</span>
|
||||
<strong>
|
||||
{selectedService
|
||||
? `${dashboardSnapshot.system.publicHost}:${selectedService.port}`
|
||||
: 'Unavailable'}
|
||||
</strong>
|
||||
<strong>{selectedService ? `${host}:${selectedService.port}` : 'Unavailable'}</strong>
|
||||
</div>
|
||||
<div className="modal-preview">
|
||||
<span>Protocol</span>
|
||||
<strong>{selectedService ? selectedService.protocol : 'Unavailable'}</strong>
|
||||
</div>
|
||||
{error ? <p className="form-error modal-error">{error}</p> : null}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="button-secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button">Create user</button>
|
||||
<button type="submit">Create user</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -172,7 +193,7 @@ function ConfirmDeleteModal({
|
||||
}: {
|
||||
username: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
@@ -219,37 +240,45 @@ function ConfirmDeleteModal({
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardTab() {
|
||||
const serviceTone = getServiceTone(dashboardSnapshot.service.status);
|
||||
function DashboardTab({
|
||||
snapshot,
|
||||
onRuntimeAction,
|
||||
}: {
|
||||
snapshot: DashboardSnapshot;
|
||||
onRuntimeAction: (action: 'start' | 'restart') => Promise<void>;
|
||||
}) {
|
||||
const serviceTone = getServiceTone(snapshot.service.status);
|
||||
|
||||
return (
|
||||
<section className="page-grid">
|
||||
<article className="panel-card">
|
||||
<div className="card-header">
|
||||
<h2>Service</h2>
|
||||
<span className={`status-pill ${serviceTone}`}>{dashboardSnapshot.service.status}</span>
|
||||
<span className={`status-pill ${serviceTone}`}>{snapshot.service.status}</span>
|
||||
</div>
|
||||
<dl className="kv-list">
|
||||
<div>
|
||||
<dt>Process</dt>
|
||||
<dd>{dashboardSnapshot.service.pidLabel}</dd>
|
||||
<dd>{snapshot.service.pidLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Version</dt>
|
||||
<dd>{dashboardSnapshot.service.versionLabel}</dd>
|
||||
<dd>{snapshot.service.versionLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Uptime</dt>
|
||||
<dd>{dashboardSnapshot.service.uptimeLabel}</dd>
|
||||
<dd>{snapshot.service.uptimeLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last event</dt>
|
||||
<dd>{dashboardSnapshot.service.lastEvent}</dd>
|
||||
<dd>{snapshot.service.lastEvent}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="actions-row">
|
||||
<button type="button">Start</button>
|
||||
<button type="button" className="button-secondary">
|
||||
<button type="button" onClick={() => onRuntimeAction('start')}>
|
||||
Start
|
||||
</button>
|
||||
<button type="button" className="button-secondary" onClick={() => onRuntimeAction('restart')}>
|
||||
Restart
|
||||
</button>
|
||||
</div>
|
||||
@@ -262,15 +291,15 @@ function DashboardTab() {
|
||||
<div className="stats-strip">
|
||||
<div>
|
||||
<span>Total</span>
|
||||
<strong>{formatBytes(dashboardSnapshot.traffic.totalBytes)}</strong>
|
||||
<strong>{formatBytes(snapshot.traffic.totalBytes)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Connections</span>
|
||||
<strong>{dashboardSnapshot.traffic.liveConnections}</strong>
|
||||
<strong>{snapshot.traffic.liveConnections}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Active users</span>
|
||||
<strong>{dashboardSnapshot.traffic.activeUsers}</strong>
|
||||
<strong>{snapshot.traffic.activeUsers}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -280,7 +309,7 @@ function DashboardTab() {
|
||||
<h2>Daily usage</h2>
|
||||
</div>
|
||||
<div className="usage-list">
|
||||
{dashboardSnapshot.traffic.daily.map((bucket) => (
|
||||
{snapshot.traffic.daily.map((bucket) => (
|
||||
<div key={bucket.day} className="usage-row">
|
||||
<span>{bucket.day}</span>
|
||||
<div className="usage-bar">
|
||||
@@ -297,7 +326,7 @@ function DashboardTab() {
|
||||
<h2>Attention</h2>
|
||||
</div>
|
||||
<div className="event-list">
|
||||
{dashboardSnapshot.attention.map((item) => (
|
||||
{snapshot.attention.map((item) => (
|
||||
<div key={item.title} className="event-row">
|
||||
<span className={`event-marker ${item.level}`} />
|
||||
<div>
|
||||
@@ -312,44 +341,35 @@ function DashboardTab() {
|
||||
);
|
||||
}
|
||||
|
||||
function UsersTab() {
|
||||
function UsersTab({
|
||||
snapshot,
|
||||
onCreateUser,
|
||||
onTogglePause,
|
||||
onDeleteUser,
|
||||
}: {
|
||||
snapshot: DashboardSnapshot;
|
||||
onCreateUser: (input: CreateUserInput) => Promise<void>;
|
||||
onTogglePause: (userId: string) => Promise<void>;
|
||||
onDeleteUser: (userId: string) => Promise<void>;
|
||||
}) {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [users, setUsers] = useState<UserRow[]>(() => dashboardSnapshot.userRecords);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
|
||||
const servicesById = useMemo(
|
||||
() => new Map(snapshot.system.services.map((service) => [service.id, service] as const)),
|
||||
[snapshot.system.services],
|
||||
);
|
||||
|
||||
const assignableServices = snapshot.system.services.filter((service) => service.enabled && service.assignable);
|
||||
const deleteTarget = snapshot.userRecords.find((user) => user.id === deleteTargetId) ?? null;
|
||||
|
||||
const handleCopy = async (userId: string, proxyLink: string) => {
|
||||
await navigator.clipboard.writeText(proxyLink);
|
||||
setCopiedId(userId);
|
||||
window.setTimeout(() => setCopiedId((value) => (value === userId ? null : value)), 1200);
|
||||
};
|
||||
|
||||
const handleTogglePause = (userId: string) => {
|
||||
setUsers((current) =>
|
||||
current.map((user) => (user.id === userId ? { ...user, paused: !user.paused } : user)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteTargetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUsers((current) => current.filter((user) => user.id !== deleteTargetId));
|
||||
setDeleteTargetId(null);
|
||||
};
|
||||
|
||||
const deleteTarget = users.find((user) => user.id === deleteTargetId) ?? null;
|
||||
const liveUsers = users.filter((user) => !user.paused && user.status === 'live').length;
|
||||
const nearQuotaUsers = users.filter((user) => {
|
||||
if (user.paused || user.quotaBytes === null || isQuotaExceeded(user.usedBytes, user.quotaBytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return user.usedBytes / user.quotaBytes >= 0.8;
|
||||
}).length;
|
||||
const exceededUsers = users.filter((user) => isQuotaExceeded(user.usedBytes, user.quotaBytes)).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-grid single-column">
|
||||
@@ -357,13 +377,13 @@ function UsersTab() {
|
||||
<div className="table-toolbar">
|
||||
<div className="toolbar-title">
|
||||
<h2>Users</h2>
|
||||
<p>{users.length} accounts in current profile</p>
|
||||
<p>{snapshot.userRecords.length} accounts in current profile</p>
|
||||
</div>
|
||||
<div className="toolbar-actions">
|
||||
<div className="summary-pills">
|
||||
<span>{liveUsers} live</span>
|
||||
<span>{nearQuotaUsers} near quota</span>
|
||||
<span>{exceededUsers} exceeded</span>
|
||||
<span>{snapshot.users.live} live</span>
|
||||
<span>{snapshot.users.nearQuota} near quota</span>
|
||||
<span>{snapshot.users.exceeded} exceeded</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => setIsModalOpen(true)}>
|
||||
New user
|
||||
@@ -385,16 +405,16 @@ function UsersTab() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => {
|
||||
{snapshot.userRecords.map((user) => {
|
||||
const service = servicesById.get(user.serviceId);
|
||||
const endpoint = service
|
||||
? `${dashboardSnapshot.system.publicHost}:${service.port}`
|
||||
? `${snapshot.system.publicHost}:${service.port}`
|
||||
: 'service missing';
|
||||
const proxyLink = service
|
||||
? buildProxyLink(
|
||||
user.username,
|
||||
user.password,
|
||||
dashboardSnapshot.system.publicHost,
|
||||
snapshot.system.publicHost,
|
||||
service.port,
|
||||
service.protocol,
|
||||
)
|
||||
@@ -422,7 +442,7 @@ function UsersTab() {
|
||||
</td>
|
||||
<td>{formatBytes(user.usedBytes)}</td>
|
||||
<td>{formatQuotaState(user.usedBytes, user.quotaBytes)}</td>
|
||||
<td>{formatTrafficShare(user.usedBytes, dashboardSnapshot.traffic.totalBytes)}</td>
|
||||
<td>{formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
@@ -438,7 +458,7 @@ function UsersTab() {
|
||||
<button
|
||||
type="button"
|
||||
className="button-secondary button-small"
|
||||
onClick={() => handleTogglePause(user.id)}
|
||||
onClick={() => onTogglePause(user.id)}
|
||||
>
|
||||
{user.paused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
@@ -460,19 +480,30 @@ function UsersTab() {
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{isModalOpen ? <AddUserModal onClose={() => setIsModalOpen(false)} /> : null}
|
||||
{isModalOpen ? (
|
||||
<AddUserModal
|
||||
host={snapshot.system.publicHost}
|
||||
services={assignableServices}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onCreate={onCreateUser}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{deleteTarget ? (
|
||||
<ConfirmDeleteModal
|
||||
username={deleteTarget.username}
|
||||
onClose={() => setDeleteTargetId(null)}
|
||||
onConfirm={handleDelete}
|
||||
onConfirm={async () => {
|
||||
await onDeleteUser(deleteTarget.id);
|
||||
setDeleteTargetId(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemTab() {
|
||||
function SystemTab({ snapshot }: { snapshot: DashboardSnapshot }) {
|
||||
return (
|
||||
<section className="page-grid system-grid">
|
||||
<article className="panel-card">
|
||||
@@ -482,15 +513,15 @@ function SystemTab() {
|
||||
<dl className="kv-list">
|
||||
<div>
|
||||
<dt>Config mode</dt>
|
||||
<dd>{dashboardSnapshot.system.configMode}</dd>
|
||||
<dd>{snapshot.system.configMode}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Reload</dt>
|
||||
<dd>{dashboardSnapshot.system.reloadMode}</dd>
|
||||
<dd>{snapshot.system.reloadMode}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Storage</dt>
|
||||
<dd>{dashboardSnapshot.system.storageMode}</dd>
|
||||
<dd>{snapshot.system.storageMode}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
@@ -500,7 +531,7 @@ function SystemTab() {
|
||||
<h2>Services</h2>
|
||||
</div>
|
||||
<div className="service-list">
|
||||
{dashboardSnapshot.system.services.map((service) => (
|
||||
{snapshot.system.services.map((service) => (
|
||||
<div key={service.name} className="service-row">
|
||||
<div>
|
||||
<strong>{service.name}</strong>
|
||||
@@ -521,7 +552,7 @@ function SystemTab() {
|
||||
<div className="card-header">
|
||||
<h2>Generated config</h2>
|
||||
</div>
|
||||
<pre>{dashboardSnapshot.system.previewConfig}</pre>
|
||||
<pre>{snapshot.system.previewConfig}</pre>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
@@ -530,30 +561,139 @@ function SystemTab() {
|
||||
export default function App() {
|
||||
const [isAuthed, setIsAuthed] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
|
||||
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(fallbackDashboardSnapshot);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void fetch('/api/state')
|
||||
.then((response) => (response.ok ? response.json() : Promise.reject(new Error('API unavailable'))))
|
||||
.then((payload: DashboardSnapshot) => {
|
||||
if (!cancelled) {
|
||||
setSnapshot(payload);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep fallback snapshot for local UI and tests when backend is not running.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isAuthed) {
|
||||
return <LoginGate onUnlock={() => setIsAuthed(true)} />;
|
||||
}
|
||||
|
||||
const mutateSnapshot = async (
|
||||
request: () => Promise<Response>,
|
||||
fallback: (current: DashboardSnapshot) => DashboardSnapshot,
|
||||
) => {
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.ok) {
|
||||
const payload = (await response.json()) as DashboardSnapshot;
|
||||
setSnapshot(payload);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to local optimistic state when the API is unavailable.
|
||||
}
|
||||
|
||||
setSnapshot((current) => fallback(current));
|
||||
};
|
||||
|
||||
const handleRuntimeAction = async (action: 'start' | 'restart') => {
|
||||
await mutateSnapshot(
|
||||
() => fetch(`/api/runtime/${action}`, { method: 'POST' }),
|
||||
(current) =>
|
||||
withDerivedSnapshot({
|
||||
...current,
|
||||
service: {
|
||||
...current.service,
|
||||
status: 'live',
|
||||
lastEvent: action === 'restart' ? 'Runtime restarted from panel' : 'Runtime start requested from panel',
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateUser = async (input: CreateUserInput) => {
|
||||
await mutateSnapshot(
|
||||
() =>
|
||||
fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
(current) => {
|
||||
const nextUser: ProxyUserRecord = {
|
||||
id: `u-${Math.random().toString(36).slice(2, 10)}`,
|
||||
username: input.username.trim(),
|
||||
password: input.password.trim(),
|
||||
serviceId: input.serviceId,
|
||||
status: 'idle',
|
||||
paused: false,
|
||||
usedBytes: 0,
|
||||
quotaBytes: input.quotaMb === null ? null : input.quotaMb * 1024 * 1024,
|
||||
};
|
||||
|
||||
return withDerivedSnapshot({
|
||||
...current,
|
||||
service: {
|
||||
...current.service,
|
||||
lastEvent: `User ${nextUser.username} created from panel`,
|
||||
},
|
||||
userRecords: [...current.userRecords, nextUser],
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleTogglePause = async (userId: string) => {
|
||||
await mutateSnapshot(
|
||||
() => fetch(`/api/users/${userId}/pause`, { method: 'POST' }),
|
||||
(current) =>
|
||||
withDerivedSnapshot({
|
||||
...current,
|
||||
userRecords: current.userRecords.map((user) =>
|
||||
user.id === userId ? { ...user, paused: !user.paused } : user,
|
||||
),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
await mutateSnapshot(
|
||||
() => fetch(`/api/users/${userId}`, { method: 'DELETE' }),
|
||||
(current) =>
|
||||
withDerivedSnapshot({
|
||||
...current,
|
||||
userRecords: current.userRecords.filter((user) => user.id !== userId),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<header className="shell-header">
|
||||
<div className="shell-title">
|
||||
<h1>3proxy UI</h1>
|
||||
<p>{dashboardSnapshot.system.publicHost}</p>
|
||||
<p>{snapshot.system.publicHost}</p>
|
||||
</div>
|
||||
<div className="header-meta">
|
||||
<div>
|
||||
<span>Status</span>
|
||||
<strong>{dashboardSnapshot.service.status}</strong>
|
||||
<strong>{snapshot.service.status}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Version</span>
|
||||
<strong>{dashboardSnapshot.service.versionLabel}</strong>
|
||||
<strong>{snapshot.service.versionLabel}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Users</span>
|
||||
<strong>{dashboardSnapshot.users.total}</strong>
|
||||
<strong>{snapshot.users.total}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -571,9 +711,44 @@ export default function App() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{activeTab === 'dashboard' ? <DashboardTab /> : null}
|
||||
{activeTab === 'users' ? <UsersTab /> : null}
|
||||
{activeTab === 'system' ? <SystemTab /> : null}
|
||||
{activeTab === 'dashboard' ? <DashboardTab snapshot={snapshot} onRuntimeAction={handleRuntimeAction} /> : null}
|
||||
{activeTab === 'users' ? (
|
||||
<UsersTab
|
||||
snapshot={snapshot}
|
||||
onCreateUser={handleCreateUser}
|
||||
onTogglePause={handleTogglePause}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'system' ? <SystemTab snapshot={snapshot} /> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function withDerivedSnapshot(snapshot: DashboardSnapshot): DashboardSnapshot {
|
||||
const live = snapshot.userRecords.filter((user) => !user.paused && user.status === 'live').length;
|
||||
const exceeded = snapshot.userRecords.filter(
|
||||
(user) => user.quotaBytes !== null && user.usedBytes >= user.quotaBytes,
|
||||
).length;
|
||||
const nearQuota = snapshot.userRecords.filter((user) => {
|
||||
if (user.paused || user.quotaBytes === null || user.usedBytes >= user.quotaBytes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return user.usedBytes / user.quotaBytes >= 0.8;
|
||||
}).length;
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
traffic: {
|
||||
...snapshot.traffic,
|
||||
activeUsers: snapshot.userRecords.filter((user) => !user.paused).length,
|
||||
},
|
||||
users: {
|
||||
total: snapshot.userRecords.length,
|
||||
live,
|
||||
nearQuota,
|
||||
exceeded,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { ControlPlaneState, DashboardSnapshot } from '../shared/contracts';
|
||||
|
||||
export const panelAuth = {
|
||||
login: 'admin',
|
||||
password: 'proxy-ui-demo',
|
||||
};
|
||||
|
||||
export const dashboardSnapshot = {
|
||||
export const dashboardSnapshot: ControlPlaneState = {
|
||||
service: {
|
||||
status: 'live' as const,
|
||||
pidLabel: 'pid 17',
|
||||
versionLabel: '3proxy 0.9.x',
|
||||
uptimeLabel: 'uptime 6h 14m',
|
||||
lastEvent: 'Last graceful reload 2m ago',
|
||||
startedAt: '2026-04-01T15:45:00.000Z',
|
||||
},
|
||||
traffic: {
|
||||
totalBytes: 1_557_402_624,
|
||||
@@ -23,29 +23,6 @@ export const dashboardSnapshot = {
|
||||
{ day: 'Fri', bytes: 547_037_696, share: 1 },
|
||||
],
|
||||
},
|
||||
users: {
|
||||
total: 18,
|
||||
live: 9,
|
||||
nearQuota: 3,
|
||||
exceeded: 1,
|
||||
},
|
||||
attention: [
|
||||
{
|
||||
level: 'warn' as const,
|
||||
title: 'Quota pressure detected',
|
||||
message: '3 users crossed 80% of their assigned transfer cap.',
|
||||
},
|
||||
{
|
||||
level: 'live' as const,
|
||||
title: 'Config watcher online',
|
||||
message: 'The next runtime slice will prefer graceful reload over full restart.',
|
||||
},
|
||||
{
|
||||
level: 'fail' as const,
|
||||
title: 'Admin API not wired yet',
|
||||
message: 'Buttons are UI-first placeholders until the backend control plane lands.',
|
||||
},
|
||||
],
|
||||
userRecords: [
|
||||
{
|
||||
id: 'u-1',
|
||||
@@ -68,7 +45,7 @@ export const dashboardSnapshot = {
|
||||
{
|
||||
id: 'u-3',
|
||||
username: 'lab-unlimited',
|
||||
password: 'open lane',
|
||||
password: 'open-lane',
|
||||
serviceId: 'socks-lab',
|
||||
status: 'idle' as const,
|
||||
usedBytes: 42_844_160,
|
||||
@@ -91,59 +68,80 @@ export const dashboardSnapshot = {
|
||||
storageMode: 'flat files for config and counters',
|
||||
services: [
|
||||
{
|
||||
id: 'socks-main',
|
||||
name: 'SOCKS5 main',
|
||||
protocol: 'socks5',
|
||||
description: 'Primary SOCKS5 entrypoint with user auth.',
|
||||
port: 1080,
|
||||
id: 'socks-main',
|
||||
name: 'SOCKS5 main',
|
||||
command: 'socks',
|
||||
protocol: 'socks5',
|
||||
description: 'Primary SOCKS5 entrypoint with user auth.',
|
||||
port: 1080,
|
||||
enabled: true,
|
||||
assignable: true,
|
||||
},
|
||||
{
|
||||
id: 'socks-lab',
|
||||
name: 'SOCKS5 lab',
|
||||
protocol: 'socks5',
|
||||
description: 'Secondary SOCKS5 service for lab and overflow users.',
|
||||
port: 2080,
|
||||
id: 'socks-lab',
|
||||
name: 'SOCKS5 lab',
|
||||
command: 'socks',
|
||||
protocol: 'socks5',
|
||||
description: 'Secondary SOCKS5 service for lab and overflow users.',
|
||||
port: 2080,
|
||||
enabled: true,
|
||||
assignable: true,
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
name: 'Admin',
|
||||
protocol: 'http',
|
||||
description: 'Restricted admin visibility endpoint.',
|
||||
port: 8081,
|
||||
id: 'admin',
|
||||
name: 'Admin',
|
||||
command: 'admin',
|
||||
protocol: 'http',
|
||||
description: 'Restricted admin visibility endpoint.',
|
||||
port: 8081,
|
||||
enabled: true,
|
||||
assignable: false,
|
||||
},
|
||||
{
|
||||
id: 'proxy',
|
||||
name: 'HTTP proxy',
|
||||
protocol: 'http',
|
||||
description: 'Optional HTTP/HTTPS proxy profile.',
|
||||
port: 3128,
|
||||
id: 'proxy',
|
||||
name: 'HTTP proxy',
|
||||
command: 'proxy',
|
||||
protocol: 'http',
|
||||
description: 'Optional HTTP/HTTPS proxy profile.',
|
||||
port: 3128,
|
||||
enabled: false,
|
||||
assignable: true,
|
||||
},
|
||||
],
|
||||
previewConfig: `daemon
|
||||
pidfile /var/run/3proxy/3proxy.pid
|
||||
monitor /etc/3proxy/generated/3proxy.cfg
|
||||
|
||||
auth strong
|
||||
users night-shift:CL:kettle!23 ops-east:CL:east/line
|
||||
|
||||
counter /var/lib/3proxy/counters.3cf D /var/lib/3proxy/reports/%Y-%m-%d.txt
|
||||
countall 1 D 1024 night-shift * * * * * *
|
||||
countall 2 D 1024 ops-east * * * * * *
|
||||
|
||||
flush
|
||||
allow night-shift,ops-east
|
||||
socks -p1080 -u2
|
||||
|
||||
flush
|
||||
allow *
|
||||
admin -p8081 -s`,
|
||||
},
|
||||
};
|
||||
|
||||
export const fallbackDashboardSnapshot: DashboardSnapshot = {
|
||||
service: {
|
||||
status: 'live',
|
||||
pidLabel: 'pid 17',
|
||||
versionLabel: dashboardSnapshot.service.versionLabel,
|
||||
uptimeLabel: 'uptime 6h 14m',
|
||||
lastEvent: dashboardSnapshot.service.lastEvent,
|
||||
},
|
||||
traffic: dashboardSnapshot.traffic,
|
||||
users: {
|
||||
total: dashboardSnapshot.userRecords.length,
|
||||
live: dashboardSnapshot.userRecords.filter((user) => user.status === 'live').length,
|
||||
nearQuota: 1,
|
||||
exceeded: 1,
|
||||
},
|
||||
attention: [
|
||||
{
|
||||
level: 'warn',
|
||||
title: 'Quota pressure detected',
|
||||
message: 'Fallback snapshot is active until the backend API responds.',
|
||||
},
|
||||
],
|
||||
userRecords: dashboardSnapshot.userRecords,
|
||||
system: {
|
||||
...dashboardSnapshot.system,
|
||||
previewConfig: `pidfile /runtime/3proxy.pid
|
||||
monitor /runtime/generated/3proxy.cfg
|
||||
auth strong
|
||||
users night-shift:CL:kettle!23 ops-east:CL:east/line
|
||||
flush
|
||||
allow night-shift,ops-east
|
||||
socks -p1080 -u2`,
|
||||
},
|
||||
};
|
||||
|
||||
87
src/shared/contracts.ts
Normal file
87
src/shared/contracts.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { ServiceState } from '../lib/3proxy';
|
||||
|
||||
export type ServiceProtocol = 'socks5' | 'http';
|
||||
export type ServiceCommand = 'socks' | 'proxy' | 'admin';
|
||||
|
||||
export interface DailyTrafficBucket {
|
||||
day: string;
|
||||
bytes: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface ProxyServiceRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
command: ServiceCommand;
|
||||
protocol: ServiceProtocol;
|
||||
description: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
assignable: boolean;
|
||||
}
|
||||
|
||||
export interface ProxyUserRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
password: string;
|
||||
serviceId: string;
|
||||
status: Exclude<ServiceState, 'paused'>;
|
||||
usedBytes: number;
|
||||
quotaBytes: number | null;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export interface ControlPlaneState {
|
||||
service: {
|
||||
versionLabel: string;
|
||||
lastEvent: string;
|
||||
startedAt: string | null;
|
||||
};
|
||||
traffic: {
|
||||
totalBytes: number;
|
||||
liveConnections: number;
|
||||
activeUsers: number;
|
||||
daily: DailyTrafficBucket[];
|
||||
};
|
||||
userRecords: ProxyUserRecord[];
|
||||
system: {
|
||||
publicHost: string;
|
||||
configMode: string;
|
||||
reloadMode: string;
|
||||
storageMode: string;
|
||||
services: ProxyServiceRecord[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DashboardSnapshot {
|
||||
service: {
|
||||
status: ServiceState;
|
||||
pidLabel: string;
|
||||
versionLabel: string;
|
||||
uptimeLabel: string;
|
||||
lastEvent: string;
|
||||
};
|
||||
traffic: ControlPlaneState['traffic'];
|
||||
users: {
|
||||
total: number;
|
||||
live: number;
|
||||
nearQuota: number;
|
||||
exceeded: number;
|
||||
};
|
||||
attention: Array<{
|
||||
level: Exclude<ServiceState, 'idle' | 'paused'>;
|
||||
title: string;
|
||||
message: string;
|
||||
}>;
|
||||
userRecords: ProxyUserRecord[];
|
||||
system: ControlPlaneState['system'] & {
|
||||
previewConfig: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
username: string;
|
||||
password: string;
|
||||
serviceId: string;
|
||||
quotaMb: number | null;
|
||||
}
|
||||
Reference in New Issue
Block a user