feat: scaffold operator-first 3proxy panel ui

This commit is contained in:
2026-04-01 22:52:38 +03:00
commit 0d035f3278
26 changed files with 3674 additions and 0 deletions

30
src/App.test.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import App from './App';
describe('App login gate', () => {
it('rejects wrong hardcoded credentials and keeps the panel locked', async () => {
const user = userEvent.setup();
render(<App />);
await user.type(screen.getByLabelText(/login/i), 'admin');
await user.type(screen.getByLabelText(/password/i), 'wrong-pass');
await user.click(screen.getByRole('button', { name: /open panel/i }));
expect(screen.getByText(/wrong panel credentials/i)).toBeInTheDocument();
expect(screen.queryByRole('navigation', { name: /primary/i })).not.toBeInTheDocument();
});
it('unlocks the panel with the configured credentials', async () => {
const user = userEvent.setup();
render(<App />);
await user.type(screen.getByLabelText(/login/i), 'admin');
await user.type(screen.getByLabelText(/password/i), 'proxy-ui-demo');
await user.click(screen.getByRole('button', { name: /open panel/i }));
expect(screen.getByRole('navigation', { name: /primary/i })).toBeInTheDocument();
expect(screen.getByText(/operator panel/i)).toBeInTheDocument();
});
});

381
src/App.tsx Normal file
View File

@@ -0,0 +1,381 @@
import { FormEvent, useMemo, useState } from 'react';
import './app.css';
import { dashboardSnapshot, panelAuth } from './data/mockDashboard';
import {
buildProxyLink,
formatBytes,
formatQuotaState,
formatTrafficShare,
getServiceTone,
isQuotaExceeded,
} from './lib/3proxy';
type TabId = 'dashboard' | 'users' | 'system';
const tabs: Array<{ id: TabId; label: string; description: string }> = [
{ id: 'dashboard', label: 'Dashboard', description: 'Health, traffic, quick actions' },
{ id: 'users', label: 'Users', description: 'Accounts, quotas, quick-copy links' },
{ id: 'system', label: 'System', description: 'Config profile, ports, runtime controls' },
];
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">
<p className="eyebrow">3proxy control plane</p>
<h1>Minimal panel, operator-first signal.</h1>
<p className="lede">
The first slice focuses on clarity: health, users, quotas, and system controls without
dashboard noise.
</p>
<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>
<div className="login-note">
<span>Prototype auth is intentionally hardcoded.</span>
<span>Runtime-backed auth will replace this in the next phase.</span>
</div>
</section>
</main>
);
}
function DashboardTab() {
const serviceTone = getServiceTone(dashboardSnapshot.service.status);
return (
<section className="tab-grid">
<article className="panel-card hero-card">
<div>
<p className="eyebrow">3proxy runtime</p>
<h2>Service health stays front and center.</h2>
<p className="muted">
The live card will be wired to process health and graceful reload endpoints in the next
implementation step.
</p>
</div>
<div className="hero-status">
<span className={`status-pill ${serviceTone}`}>{dashboardSnapshot.service.status}</span>
<span>{dashboardSnapshot.service.pidLabel}</span>
<span>{dashboardSnapshot.service.versionLabel}</span>
</div>
<div className="action-row">
<button type="button">Start</button>
<button type="button" className="secondary">
Restart
</button>
</div>
</article>
<article className="panel-card">
<p className="section-label">Traffic</p>
<strong className="metric">{formatBytes(dashboardSnapshot.traffic.totalBytes)}</strong>
<p className="muted">
{dashboardSnapshot.traffic.liveConnections} live connections across{' '}
{dashboardSnapshot.traffic.activeUsers} active users.
</p>
<div className="sparkline-list">
{dashboardSnapshot.traffic.daily.map((bucket) => (
<div key={bucket.day} className="sparkline-row">
<span>{bucket.day}</span>
<div className="sparkline-track">
<div style={{ width: `${bucket.share * 100}%` }} />
</div>
<span>{formatBytes(bucket.bytes)}</span>
</div>
))}
</div>
</article>
<article className="panel-card">
<p className="section-label">User pressure</p>
<strong className="metric">{dashboardSnapshot.users.total}</strong>
<p className="muted">Configured users in the current proxy profile.</p>
<div className="stat-stack">
<div>
<span>Live now</span>
<strong>{dashboardSnapshot.users.live}</strong>
</div>
<div>
<span>Near quota</span>
<strong>{dashboardSnapshot.users.nearQuota}</strong>
</div>
<div>
<span>Exceeded</span>
<strong>{dashboardSnapshot.users.exceeded}</strong>
</div>
</div>
</article>
<article className="panel-card">
<p className="section-label">Runtime attention</p>
<div className="attention-list">
{dashboardSnapshot.attention.map((item) => (
<div key={item.title} className="attention-item">
<span className={`dot ${item.level}`} />
<div>
<strong>{item.title}</strong>
<p>{item.message}</p>
</div>
</div>
))}
</div>
</article>
</section>
);
}
function UsersTab() {
const [copiedId, setCopiedId] = useState<string | null>(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="tab-grid users-grid">
<article className="panel-card user-form-card">
<p className="section-label">Add user</p>
<h2>Shape new access without leaving the panel.</h2>
<form className="user-form">
<label>
Username
<input placeholder="night-shift-01" />
</label>
<label>
Password
<input placeholder="generated secret" />
</label>
<label>
Service port
<input placeholder="1080" />
</label>
<label>
Quota (MB)
<input placeholder="Optional" />
</label>
<button type="button">Queue user creation</button>
</form>
<p className="muted">
Final flow will write `users`, `allow`, and quota counters into generated 3proxy config
and data files.
</p>
</article>
<article className="panel-card table-card">
<div className="table-header">
<div>
<p className="section-label">Users</p>
<h2>Traffic, quota, and copyable proxy links.</h2>
</div>
<span className="muted">{dashboardSnapshot.userRecords.length} rows</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>User</th>
<th>Status</th>
<th>Traffic</th>
<th>Quota</th>
<th>Share</th>
<th>Proxy</th>
</tr>
</thead>
<tbody>
{dashboardSnapshot.userRecords.map((user) => {
const proxyLink = buildProxyLink(user.username, user.password, user.host, user.port);
const exhausted = isQuotaExceeded(user.usedBytes, user.quotaBytes);
return (
<tr key={user.id}>
<td>
<div className="user-cell">
<strong>{user.username}</strong>
<span>{user.host}</span>
</div>
</td>
<td>
<span className={`status-pill ${getServiceTone(user.status)}`}>{user.status}</span>
</td>
<td>{formatBytes(user.usedBytes)}</td>
<td>{formatQuotaState(user.usedBytes, user.quotaBytes)}</td>
<td>{formatTrafficShare(user.usedBytes, dashboardSnapshot.traffic.totalBytes)}</td>
<td>
<button
type="button"
className={exhausted ? 'danger-link' : 'copy-link'}
onClick={() => handleCopy(user.id, proxyLink)}
>
{copiedId === user.id ? 'Copied' : 'Copy'}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</article>
</section>
);
}
function SystemTab() {
const serviceToggles = useMemo(
() =>
dashboardSnapshot.system.services.map((service) => (
<div key={service.name} className="service-row">
<div>
<strong>{service.name}</strong>
<span>{service.description}</span>
</div>
<div className="service-meta">
<span>:{service.port}</span>
<span className={`status-pill ${service.enabled ? 'live' : 'idle'}`}>
{service.enabled ? 'enabled' : 'disabled'}
</span>
</div>
</div>
)),
[],
);
return (
<section className="tab-grid system-grid">
<article className="panel-card">
<p className="section-label">Runtime model</p>
<h2>One place for ports, services, and reload strategy.</h2>
<div className="system-stats">
<div>
<span>Config mode</span>
<strong>{dashboardSnapshot.system.configMode}</strong>
</div>
<div>
<span>Reload</span>
<strong>{dashboardSnapshot.system.reloadMode}</strong>
</div>
<div>
<span>Storage</span>
<strong>{dashboardSnapshot.system.storageMode}</strong>
</div>
</div>
</article>
<article className="panel-card">
<p className="section-label">Services</p>
<div className="service-list">{serviceToggles}</div>
</article>
<article className="panel-card config-card">
<div className="table-header">
<div>
<p className="section-label">Generated config preview</p>
<h2>Readable before it becomes writable.</h2>
</div>
<span className="muted">View-only for now</span>
</div>
<pre>{dashboardSnapshot.system.previewConfig}</pre>
</article>
</section>
);
}
export default function App() {
const [isAuthed, setIsAuthed] = useState(false);
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
if (!isAuthed) {
return <LoginGate onUnlock={() => setIsAuthed(true)} />;
}
return (
<main className="app-shell">
<aside className="sidebar">
<div className="brand-block">
<p className="eyebrow">3proxy UI</p>
<h1>Operator panel</h1>
<p className="muted">
Dashboard, users, and system settings aligned with official 3proxy primitives.
</p>
</div>
<nav className="nav-list" aria-label="Primary">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
className={activeTab === tab.id ? 'nav-item active' : 'nav-item'}
onClick={() => setActiveTab(tab.id)}
>
<span>{tab.label}</span>
<small>{tab.description}</small>
</button>
))}
</nav>
<div className="sidebar-foot">
<span className={`status-pill ${getServiceTone(dashboardSnapshot.service.status)}`}>
{dashboardSnapshot.service.status}
</span>
<p>{dashboardSnapshot.service.lastEvent}</p>
</div>
</aside>
<section className="content-shell">
<header className="topbar">
<div>
<p className="section-label">Current host</p>
<h2>{dashboardSnapshot.system.publicHost}</h2>
</div>
<div className="topbar-meta">
<span>{dashboardSnapshot.service.versionLabel}</span>
<span>{dashboardSnapshot.service.uptimeLabel}</span>
</div>
</header>
{activeTab === 'dashboard' ? <DashboardTab /> : null}
{activeTab === 'users' ? <UsersTab /> : null}
{activeTab === 'system' ? <SystemTab /> : null}
</section>
</main>
);
}

565
src/app.css Normal file
View File

@@ -0,0 +1,565 @@
:root {
font-family: Aptos, "Segoe UI Variable", "Segoe UI", sans-serif;
line-height: 1.5;
font-weight: 400;
color: #d9dddf;
background:
radial-gradient(circle at top left, rgba(203, 140, 62, 0.12), transparent 35%),
radial-gradient(circle at bottom right, rgba(79, 143, 122, 0.14), transparent 28%),
linear-gradient(180deg, #11161a 0%, #0a0e11 100%);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--bg: #0d1115;
--bg-elevated: rgba(18, 24, 28, 0.86);
--bg-soft: rgba(237, 223, 201, 0.06);
--line: rgba(255, 255, 255, 0.09);
--line-strong: rgba(255, 255, 255, 0.16);
--text: #f5f5ef;
--text-muted: #99a4a8;
--accent: #d8b064;
--accent-strong: #efc16a;
--accent-soft: rgba(216, 176, 100, 0.16);
--success: #6ed2a9;
--warn: #ffcb72;
--danger: #ff8578;
--idle: #899399;
--shadow: 0 24px 48px rgba(0, 0, 0, 0.28);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
border: none;
}
#app {
min-height: 100vh;
}
.login-shell,
.app-shell {
min-height: 100vh;
}
.login-shell {
display: grid;
place-items: center;
padding: 32px;
}
.login-card,
.panel-card,
.sidebar,
.topbar {
border: 1px solid var(--line);
background: var(--bg-elevated);
backdrop-filter: blur(18px);
box-shadow: var(--shadow);
}
.login-card {
width: min(100%, 540px);
padding: 36px;
border-radius: 28px;
display: grid;
gap: 20px;
animation: rise-in 0.6s ease-out;
}
.eyebrow,
.section-label {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.78rem;
color: var(--accent-strong);
}
.login-card h1,
.brand-block h1,
.panel-card h2,
.topbar h2 {
margin: 0;
font-family: "Aptos Display", Aptos, "Segoe UI Variable", sans-serif;
line-height: 1.02;
color: var(--text);
}
.login-card h1 {
font-size: clamp(2.4rem, 6vw, 4.25rem);
}
.lede,
.muted,
.attention-item p,
.sidebar-foot p,
.service-row span,
.user-cell span {
margin: 0;
color: var(--text-muted);
}
.login-form,
.user-form {
display: grid;
gap: 14px;
}
.login-form label,
.user-form label {
display: grid;
gap: 8px;
color: var(--text);
}
.login-form input,
.user-form input {
width: 100%;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.login-form input:focus,
.user-form input:focus {
outline: 2px solid rgba(216, 176, 100, 0.55);
outline-offset: 1px;
}
.login-form button,
.action-row button,
.user-form button,
.copy-link,
.danger-link,
.nav-item {
transition:
transform 0.16s ease,
background-color 0.16s ease,
border-color 0.16s ease,
color 0.16s ease;
}
.login-form button,
.action-row button,
.user-form button {
padding: 14px 18px;
border-radius: 14px;
background: linear-gradient(135deg, #efc16a 0%, #c98d3e 100%);
color: #121315;
font-weight: 700;
}
.secondary {
background: transparent !important;
color: var(--text) !important;
border: 1px solid var(--line-strong);
}
.login-form button:hover,
.action-row button:hover,
.user-form button:hover,
.copy-link:hover,
.danger-link:hover,
.nav-item:hover {
transform: translateY(-1px);
}
.form-error {
margin: 0;
color: var(--danger);
}
.login-note {
display: grid;
gap: 4px;
padding-top: 4px;
color: var(--text-muted);
border-top: 1px solid var(--line);
}
.app-shell {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 18px;
padding: 18px;
}
.sidebar {
border-radius: 28px;
padding: 28px 22px;
display: grid;
gap: 28px;
position: sticky;
top: 18px;
max-height: calc(100vh - 36px);
}
.brand-block {
display: grid;
gap: 12px;
}
.brand-block h1 {
font-size: 2rem;
}
.nav-list {
display: grid;
gap: 10px;
}
.nav-item {
padding: 16px;
border-radius: 18px;
text-align: left;
background: rgba(255, 255, 255, 0.03);
border: 1px solid transparent;
color: var(--text);
display: grid;
gap: 4px;
}
.nav-item small {
color: var(--text-muted);
}
.nav-item.active {
background: var(--accent-soft);
border-color: rgba(239, 193, 106, 0.3);
}
.sidebar-foot {
margin-top: auto;
display: grid;
gap: 10px;
padding-top: 20px;
border-top: 1px solid var(--line);
}
.content-shell {
display: grid;
gap: 18px;
}
.topbar {
padding: 22px 26px;
border-radius: 24px;
display: flex;
justify-content: space-between;
gap: 18px;
align-items: center;
animation: rise-in 0.4s ease-out;
}
.topbar-meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
color: var(--text-muted);
}
.tab-grid {
display: grid;
gap: 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.users-grid,
.system-grid {
grid-template-columns: 1.1fr 1.9fr;
}
.panel-card {
border-radius: 24px;
padding: 24px;
display: grid;
gap: 18px;
min-width: 0;
animation: rise-in 0.55s ease-out both;
}
.hero-card {
background:
linear-gradient(160deg, rgba(216, 176, 100, 0.14), transparent 55%),
var(--bg-elevated);
}
.hero-status,
.action-row,
.stat-stack,
.attention-item,
.service-row,
.system-stats {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.metric {
font-size: clamp(2rem, 4vw, 3.3rem);
line-height: 1;
color: var(--text);
}
.sparkline-list,
.attention-list,
.service-list {
display: grid;
gap: 12px;
}
.sparkline-row {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) 90px;
gap: 12px;
align-items: center;
color: var(--text-muted);
}
.sparkline-track {
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.sparkline-track div {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #8cd4a8 0%, #efc16a 100%);
}
.stat-stack {
justify-content: space-between;
}
.stat-stack div,
.system-stats div {
flex: 1 1 140px;
display: grid;
gap: 6px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 18px;
background: var(--bg-soft);
}
.attention-item,
.service-row {
padding: 14px 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
}
.dot {
width: 10px;
height: 10px;
border-radius: 999px;
flex: none;
}
.dot.warn {
background: var(--warn);
}
.dot.live {
background: var(--success);
}
.dot.fail {
background: var(--danger);
}
.table-card,
.config-card {
min-width: 0;
}
.table-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: start;
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 20px;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
}
th,
td {
padding: 16px 18px;
text-align: left;
border-bottom: 1px solid var(--line);
}
th {
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.76rem;
}
tbody tr:last-child td {
border-bottom: none;
}
.user-cell {
display: grid;
gap: 2px;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 7px 11px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.11em;
font-size: 0.72rem;
border: 1px solid currentColor;
}
.status-pill.live {
color: var(--success);
background: rgba(110, 210, 169, 0.09);
}
.status-pill.warn {
color: var(--warn);
background: rgba(255, 203, 114, 0.09);
}
.status-pill.fail {
color: var(--danger);
background: rgba(255, 133, 120, 0.09);
}
.status-pill.idle {
color: var(--idle);
background: rgba(137, 147, 153, 0.09);
}
.copy-link,
.danger-link {
padding: 10px 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.danger-link {
color: var(--danger);
}
.service-row,
.service-meta {
justify-content: space-between;
}
.config-card {
grid-column: 1 / -1;
}
pre {
margin: 0;
padding: 18px;
border-radius: 20px;
overflow: auto;
background: #090d10;
border: 1px solid var(--line);
color: #d4dad5;
font-family: "Cascadia Code", "IBM Plex Mono", Consolas, monospace;
line-height: 1.6;
}
@media (max-width: 1120px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
max-height: none;
}
.tab-grid,
.users-grid,
.system-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.login-shell,
.app-shell {
padding: 12px;
}
.login-card,
.sidebar,
.panel-card,
.topbar {
border-radius: 22px;
padding: 20px;
}
.topbar {
flex-direction: column;
align-items: start;
}
.sparkline-row {
grid-template-columns: 64px minmax(0, 1fr);
}
.sparkline-row span:last-child {
grid-column: 2;
}
.table-wrap {
border-radius: 16px;
}
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="32" height="32" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"/><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

135
src/data/mockDashboard.ts Normal file
View File

@@ -0,0 +1,135 @@
export const panelAuth = {
login: 'admin',
password: 'proxy-ui-demo',
};
export const dashboardSnapshot = {
service: {
status: 'live' as const,
pidLabel: 'pid 17',
versionLabel: '3proxy 0.9.x',
uptimeLabel: 'uptime 6h 14m',
lastEvent: 'Last graceful reload 2m ago',
},
traffic: {
totalBytes: 1_557_402_624,
liveConnections: 42,
activeUsers: 9,
daily: [
{ day: 'Mon', bytes: 206_569_472, share: 0.38 },
{ day: 'Tue', bytes: 331_350_016, share: 0.62 },
{ day: 'Wed', bytes: 414_187_520, share: 0.77 },
{ day: 'Thu', bytes: 178_257_920, share: 0.33 },
{ 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',
username: 'night-shift',
password: 'kettle!23',
host: 'edge.example.net',
port: 1080,
status: 'live' as const,
usedBytes: 621_805_568,
quotaBytes: 1_073_741_824,
},
{
id: 'u-2',
username: 'ops-east',
password: 'east/line',
host: 'edge.example.net',
port: 1080,
status: 'warn' as const,
usedBytes: 949_010_432,
quotaBytes: 1_073_741_824,
},
{
id: 'u-3',
username: 'lab-unlimited',
password: 'open lane',
host: 'edge.example.net',
port: 2080,
status: 'idle' as const,
usedBytes: 42_844_160,
quotaBytes: null,
},
{
id: 'u-4',
username: 'burst-user',
password: 'spent-all',
host: 'edge.example.net',
port: 2080,
status: 'fail' as const,
usedBytes: 1_228_800_000,
quotaBytes: 1_073_741_824,
},
],
system: {
publicHost: 'edge.example.net',
configMode: 'generated + monitored',
reloadMode: 'graceful reload first',
storageMode: 'flat files for config and counters',
services: [
{
name: 'socks',
description: 'Primary SOCKS5 entrypoint with user auth.',
port: 1080,
enabled: true,
},
{
name: 'admin',
description: 'Restricted admin visibility endpoint.',
port: 8081,
enabled: true,
},
{
name: 'proxy',
description: 'Optional HTTP/HTTPS proxy profile.',
port: 3128,
enabled: false,
},
],
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`,
},
};

58
src/lib/3proxy.test.ts Normal file
View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
buildProxyLink,
formatBytes,
formatQuotaState,
formatTrafficShare,
isQuotaExceeded,
} from './3proxy';
describe('formatBytes', () => {
it('returns 0 B for invalid or negative values', () => {
expect(formatBytes(Number.NaN)).toBe('0 B');
expect(formatBytes(-50)).toBe('0 B');
});
it('formats megabytes and gigabytes with stable precision', () => {
expect(formatBytes(10 * 1024 * 1024)).toBe('10.0 MB');
expect(formatBytes(2.5 * 1024 * 1024 * 1024)).toBe('2.50 GB');
});
});
describe('buildProxyLink', () => {
it('encodes usernames and passwords with reserved characters', () => {
expect(buildProxyLink('ops east', 'a/b:c', 'proxy.local', 1080)).toBe(
'socks5://ops%20east:a%2Fb%3Ac@proxy.local:1080',
);
});
it('throws on invalid host or port', () => {
expect(() => buildProxyLink('user', 'pass', '', 1080)).toThrow(/Host is required/);
expect(() => buildProxyLink('user', 'pass', 'proxy.local', 70000)).toThrow(/Port must be/);
expect(() => buildProxyLink('user', 'pass', 'proxy.local', 0)).toThrow(/Port must be/);
});
});
describe('quota helpers', () => {
it('treats null quota as unlimited', () => {
expect(isQuotaExceeded(100, null)).toBe(false);
expect(formatQuotaState(100, null)).toBe('Unlimited');
});
it('marks exhausted quota when used bytes match the limit exactly', () => {
const quota = 512 * 1024 * 1024;
expect(isQuotaExceeded(quota, quota)).toBe(true);
expect(formatQuotaState(quota, quota)).toBe('Exceeded');
});
});
describe('formatTrafficShare', () => {
it('does not emit Infinity or NaN for empty totals', () => {
expect(formatTrafficShare(50, 0)).toBe('0.0%');
expect(formatTrafficShare(50, -10)).toBe('0.0%');
});
it('formats stable one-decimal percentages', () => {
expect(formatTrafficShare(256, 1024)).toBe('25.0%');
});
});

72
src/lib/3proxy.ts Normal file
View File

@@ -0,0 +1,72 @@
const MB = 1024 * 1024;
const GB = MB * 1024;
export type ServiceState = 'live' | 'warn' | 'fail' | 'idle';
export function formatBytes(value: number): string {
if (!Number.isFinite(value) || value < 0) {
return '0 B';
}
if (value >= GB) {
return `${(value / GB).toFixed(2)} GB`;
}
if (value >= MB) {
return `${(value / MB).toFixed(1)} MB`;
}
return `${Math.round(value)} B`;
}
export function buildProxyLink(
username: string,
password: string,
host: string,
port: number,
protocol = 'socks5',
): string {
if (!host.trim()) {
throw new Error('Host is required to build a proxy link.');
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('Port must be an integer between 1 and 65535.');
}
return `${protocol}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
}
export function isQuotaExceeded(usedBytes: number, quotaBytes: number | null): boolean {
if (quotaBytes === null) {
return false;
}
return usedBytes >= quotaBytes;
}
export function formatQuotaState(usedBytes: number, quotaBytes: number | null): string {
if (quotaBytes === null) {
return 'Unlimited';
}
const remaining = quotaBytes - usedBytes;
if (remaining <= 0) {
return 'Exceeded';
}
return `${formatBytes(remaining)} left`;
}
export function formatTrafficShare(usedBytes: number, totalBytes: number): string {
if (totalBytes <= 0) {
return '0.0%';
}
return `${((usedBytes / totalBytes) * 100).toFixed(1)}%`;
}
export function getServiceTone(state: ServiceState): ServiceState {
return state;
}

9
src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('app')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

7
src/test/setup.ts Normal file
View File

@@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});