755 lines
23 KiB
TypeScript
755 lines
23 KiB
TypeScript
import { FormEvent, KeyboardEvent, useEffect, useMemo, useState } from 'react';
|
|
import './app.css';
|
|
import { fallbackDashboardSnapshot, panelAuth } from './data/mockDashboard';
|
|
import {
|
|
buildProxyLink,
|
|
formatBytes,
|
|
formatQuotaState,
|
|
formatTrafficShare,
|
|
getServiceTone,
|
|
isQuotaExceeded,
|
|
} from './lib/3proxy';
|
|
import type {
|
|
CreateUserInput,
|
|
DashboardSnapshot,
|
|
ProxyServiceRecord,
|
|
ProxyUserRecord,
|
|
} from './shared/contracts';
|
|
|
|
type TabId = 'dashboard' | 'users' | 'system';
|
|
|
|
const tabs: Array<{ id: TabId; label: string }> = [
|
|
{ id: 'dashboard', label: 'Dashboard' },
|
|
{ id: 'users', label: 'Users' },
|
|
{ id: 'system', label: 'System' },
|
|
];
|
|
|
|
function LoginGate({ onUnlock }: { onUnlock: () => void }) {
|
|
const [login, setLogin] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [error, setError] = useState('');
|
|
|
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
|
|
if (login === panelAuth.login && password === panelAuth.password) {
|
|
setError('');
|
|
onUnlock();
|
|
return;
|
|
}
|
|
|
|
setError('Wrong panel credentials. Check the hardcoded startup values.');
|
|
};
|
|
|
|
return (
|
|
<main className="login-shell">
|
|
<section className="login-card">
|
|
<div className="login-copy">
|
|
<h1>3proxy UI</h1>
|
|
<p>Sign in to the control panel.</p>
|
|
</div>
|
|
<form className="login-form" onSubmit={handleSubmit}>
|
|
<label>
|
|
Login
|
|
<input
|
|
autoComplete="username"
|
|
name="login"
|
|
value={login}
|
|
onChange={(event) => setLogin(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Password
|
|
<input
|
|
autoComplete="current-password"
|
|
name="password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
/>
|
|
</label>
|
|
<button type="submit">Open panel</button>
|
|
{error ? <p className="form-error">{error}</p> : null}
|
|
</form>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
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) => {
|
|
if (event.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [onClose]);
|
|
|
|
const stopPropagation = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
event.stopPropagation();
|
|
};
|
|
|
|
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}>
|
|
<section
|
|
aria-labelledby="add-user-title"
|
|
aria-modal="true"
|
|
className="modal-card"
|
|
role="dialog"
|
|
onClick={stopPropagation}
|
|
>
|
|
<div className="modal-header">
|
|
<h2 id="add-user-title">Add user</h2>
|
|
<button type="button" className="button-secondary" onClick={onClose}>
|
|
Close
|
|
</button>
|
|
</div>
|
|
<form className="modal-form" onSubmit={handleSubmit}>
|
|
<label>
|
|
Username
|
|
<input autoFocus placeholder="night-shift-01" value={username} onChange={(event) => setUsername(event.target.value)} />
|
|
</label>
|
|
<label>
|
|
Password
|
|
<input placeholder="generated-secret" value={password} onChange={(event) => setPassword(event.target.value)} />
|
|
</label>
|
|
<label>
|
|
Service
|
|
<select value={serviceId} onChange={(event) => setServiceId(event.target.value)}>
|
|
{services.map((service) => (
|
|
<option key={service.id} value={service.id}>
|
|
{service.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Quota (MB)
|
|
<input placeholder="Optional" value={quotaMb} onChange={(event) => setQuotaMb(event.target.value)} />
|
|
</label>
|
|
<div className="modal-preview">
|
|
<span>Endpoint</span>
|
|
<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="submit">Create user</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ConfirmDeleteModal({
|
|
username,
|
|
onClose,
|
|
onConfirm,
|
|
}: {
|
|
username: string;
|
|
onClose: () => void;
|
|
onConfirm: () => Promise<void>;
|
|
}) {
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [onClose]);
|
|
|
|
const stopPropagation = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
event.stopPropagation();
|
|
};
|
|
|
|
return (
|
|
<div className="modal-backdrop" role="presentation" onClick={onClose}>
|
|
<section
|
|
aria-labelledby="delete-user-title"
|
|
aria-modal="true"
|
|
className="modal-card confirm-card"
|
|
role="dialog"
|
|
onClick={stopPropagation}
|
|
>
|
|
<div className="modal-header">
|
|
<h2 id="delete-user-title">Delete user</h2>
|
|
</div>
|
|
<p className="confirm-copy">
|
|
Remove profile <strong>{username}</strong>? This action will delete the user entry from the
|
|
current panel state.
|
|
</p>
|
|
<div className="modal-actions">
|
|
<button type="button" className="button-secondary" onClick={onClose}>
|
|
Cancel
|
|
</button>
|
|
<button type="button" className="button-danger" onClick={onConfirm}>
|
|
Delete user
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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}`}>{snapshot.service.status}</span>
|
|
</div>
|
|
<dl className="kv-list">
|
|
<div>
|
|
<dt>Process</dt>
|
|
<dd>{snapshot.service.pidLabel}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Version</dt>
|
|
<dd>{snapshot.service.versionLabel}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Uptime</dt>
|
|
<dd>{snapshot.service.uptimeLabel}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Last event</dt>
|
|
<dd>{snapshot.service.lastEvent}</dd>
|
|
</div>
|
|
</dl>
|
|
<div className="actions-row">
|
|
<button type="button" onClick={() => onRuntimeAction('start')}>
|
|
Start
|
|
</button>
|
|
<button type="button" className="button-secondary" onClick={() => onRuntimeAction('restart')}>
|
|
Restart
|
|
</button>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="panel-card">
|
|
<div className="card-header">
|
|
<h2>Traffic</h2>
|
|
</div>
|
|
<div className="stats-strip">
|
|
<div>
|
|
<span>Total</span>
|
|
<strong>{formatBytes(snapshot.traffic.totalBytes)}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Connections</span>
|
|
<strong>{snapshot.traffic.liveConnections}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Active users</span>
|
|
<strong>{snapshot.traffic.activeUsers}</strong>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="panel-card">
|
|
<div className="card-header">
|
|
<h2>Daily usage</h2>
|
|
</div>
|
|
<div className="usage-list">
|
|
{snapshot.traffic.daily.map((bucket) => (
|
|
<div key={bucket.day} className="usage-row">
|
|
<span>{bucket.day}</span>
|
|
<div className="usage-bar">
|
|
<div style={{ width: `${bucket.share * 100}%` }} />
|
|
</div>
|
|
<strong>{formatBytes(bucket.bytes)}</strong>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</article>
|
|
|
|
<article className="panel-card">
|
|
<div className="card-header">
|
|
<h2>Attention</h2>
|
|
</div>
|
|
<div className="event-list">
|
|
{snapshot.attention.map((item) => (
|
|
<div key={item.title} className="event-row">
|
|
<span className={`event-marker ${item.level}`} />
|
|
<div>
|
|
<strong>{item.title}</strong>
|
|
<p>{item.message}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</article>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
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 [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);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<section className="page-grid single-column">
|
|
<article className="panel-card">
|
|
<div className="table-toolbar">
|
|
<div className="toolbar-title">
|
|
<h2>Users</h2>
|
|
<p>{snapshot.userRecords.length} accounts in current profile</p>
|
|
</div>
|
|
<div className="toolbar-actions">
|
|
<div className="summary-pills">
|
|
<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
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>User</th>
|
|
<th>Endpoint</th>
|
|
<th>Status</th>
|
|
<th>Used</th>
|
|
<th>Remaining</th>
|
|
<th>Share</th>
|
|
<th>Proxy</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{snapshot.userRecords.map((user) => {
|
|
const service = servicesById.get(user.serviceId);
|
|
const endpoint = service
|
|
? `${snapshot.system.publicHost}:${service.port}`
|
|
: 'service missing';
|
|
const proxyLink = service
|
|
? buildProxyLink(
|
|
user.username,
|
|
user.password,
|
|
snapshot.system.publicHost,
|
|
service.port,
|
|
service.protocol,
|
|
)
|
|
: '';
|
|
const exhausted = isQuotaExceeded(user.usedBytes, user.quotaBytes);
|
|
const displayStatus = user.paused ? 'paused' : user.status;
|
|
|
|
return (
|
|
<tr key={user.id}>
|
|
<td>
|
|
<div className="user-cell">
|
|
<strong>{user.username}</strong>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div className="endpoint-cell">
|
|
<strong>{service?.name ?? 'Unknown service'}</strong>
|
|
<span>{endpoint}</span>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span className={`status-pill ${getServiceTone(displayStatus)}`}>
|
|
{displayStatus}
|
|
</span>
|
|
</td>
|
|
<td>{formatBytes(user.usedBytes)}</td>
|
|
<td>{formatQuotaState(user.usedBytes, user.quotaBytes)}</td>
|
|
<td>{formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)}</td>
|
|
<td>
|
|
<button
|
|
type="button"
|
|
className={exhausted ? 'copy-button danger' : 'copy-button'}
|
|
onClick={() => handleCopy(user.id, proxyLink)}
|
|
disabled={!service}
|
|
>
|
|
{copiedId === user.id ? 'Copied' : 'Copy'}
|
|
</button>
|
|
</td>
|
|
<td>
|
|
<div className="row-actions">
|
|
<button
|
|
type="button"
|
|
className="button-secondary button-small"
|
|
onClick={() => onTogglePause(user.id)}
|
|
>
|
|
{user.paused ? 'Resume' : 'Pause'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="button-danger button-small"
|
|
onClick={() => setDeleteTargetId(user.id)}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
|
|
{isModalOpen ? (
|
|
<AddUserModal
|
|
host={snapshot.system.publicHost}
|
|
services={assignableServices}
|
|
onClose={() => setIsModalOpen(false)}
|
|
onCreate={onCreateUser}
|
|
/>
|
|
) : null}
|
|
|
|
{deleteTarget ? (
|
|
<ConfirmDeleteModal
|
|
username={deleteTarget.username}
|
|
onClose={() => setDeleteTargetId(null)}
|
|
onConfirm={async () => {
|
|
await onDeleteUser(deleteTarget.id);
|
|
setDeleteTargetId(null);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SystemTab({ snapshot }: { snapshot: DashboardSnapshot }) {
|
|
return (
|
|
<section className="page-grid system-grid">
|
|
<article className="panel-card">
|
|
<div className="card-header">
|
|
<h2>Runtime</h2>
|
|
</div>
|
|
<dl className="kv-list">
|
|
<div>
|
|
<dt>Config mode</dt>
|
|
<dd>{snapshot.system.configMode}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Reload</dt>
|
|
<dd>{snapshot.system.reloadMode}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Storage</dt>
|
|
<dd>{snapshot.system.storageMode}</dd>
|
|
</div>
|
|
</dl>
|
|
</article>
|
|
|
|
<article className="panel-card">
|
|
<div className="card-header">
|
|
<h2>Services</h2>
|
|
</div>
|
|
<div className="service-list">
|
|
{snapshot.system.services.map((service) => (
|
|
<div key={service.name} className="service-row">
|
|
<div>
|
|
<strong>{service.name}</strong>
|
|
<p>{service.description}</p>
|
|
</div>
|
|
<div className="service-meta">
|
|
<span>{service.port}</span>
|
|
<span className={`status-pill ${service.enabled ? 'live' : 'idle'}`}>
|
|
{service.enabled ? 'enabled' : 'disabled'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</article>
|
|
|
|
<article className="panel-card wide-card">
|
|
<div className="card-header">
|
|
<h2>Generated config</h2>
|
|
</div>
|
|
<pre>{snapshot.system.previewConfig}</pre>
|
|
</article>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
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>{snapshot.system.publicHost}</p>
|
|
</div>
|
|
<div className="header-meta">
|
|
<div>
|
|
<span>Status</span>
|
|
<strong>{snapshot.service.status}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Version</span>
|
|
<strong>{snapshot.service.versionLabel}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Users</span>
|
|
<strong>{snapshot.users.total}</strong>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<nav className="tabbar" aria-label="Primary">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
className={activeTab === tab.id ? 'tab-button active' : 'tab-button'}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
{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,
|
|
},
|
|
};
|
|
}
|