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

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

24
AGENTS.md Normal file
View File

@@ -0,0 +1,24 @@
# Agent Rules
This repository is operated in an autonomous, documentation-first mode.
## Workflow
1. Start from official 3proxy documentation before changing runtime behavior.
2. Update `docs/PLAN.md` and `docs/PROJECT_INDEX.md` immediately after meaningful changes.
3. Prefer small, reviewable commits with descriptive messages after each completed batch of work.
4. Write tests for edge cases and failure modes, not only happy paths.
5. Never overwrite or revert user changes unless explicitly requested.
## Engineering Preferences
- Build UI and domain logic with clear separation so 3proxy runtime integration can replace mocks without redesign.
- Keep the panel informative, low-noise, and keyboard friendly.
- Default to explicit states for live, degraded, failed, quota warning, and unlimited quota.
- Treat credentials, config generation, and process control as security-sensitive code paths.
## Project Conventions
- Keep project documentation current in `docs/`.
- Add concise comments only where the intent is not obvious from code.
- Use ASCII unless the file already requires Unicode content.

33
README.md Normal file
View File

@@ -0,0 +1,33 @@
# 3proxy UI
Control panel and runtime bundle for 3proxy in Docker.
## Current focus
The first delivered slice is the UI foundation:
- login screen with hardcoded panel credentials
- dashboard, users, and system views
- domain utilities and tests for quota, status, and proxy-link behavior
- project workflow docs for autonomous delivery
## Local run
```bash
npm install
npm run dev
```
Default panel credentials for the current UI prototype:
- login: `admin`
- password: `proxy-ui-demo`
## Scripts
```bash
npm run dev
npm run build
npm run test
npm run test:run
```

20
docs/PLAN.md Normal file
View File

@@ -0,0 +1,20 @@
# Plan
Updated: 2026-04-01
## Active
1. Present the UI-first slice for approval, then replace mocks with runtime-backed 3proxy control flows.
## Next
1. Replace mocks with a backend control plane for config generation, process management, counters, and health checks.
2. Add Docker runtime with 3proxy, panel server, health checks, and reload/start/restart operations.
3. Extend tests to cover config rendering, unsafe input handling, and runtime failure scenarios.
## Done
1. Researched official 3proxy documentation for config scripting, services, counters, reports, auth, and reload hooks.
2. Initialized the repository and established autonomous agent workflow rules.
3. Implemented the first UI slice with hardcoded panel auth, operator-focused dashboard, users table, and system config preview.
4. Added paranoia-oriented tests for login gating, proxy link encoding, quota edge cases, and traffic share formatting.

31
docs/PROJECT_INDEX.md Normal file
View File

@@ -0,0 +1,31 @@
# Project Index
Updated: 2026-04-01
## Root
- `AGENTS.md`: repository workflow rules for autonomous contributors
- `README.md`: quick start and current project scope
- `package.json`: frontend scripts and dependencies
- `vite.config.ts`: Vite + Vitest configuration
## Documentation
- `docs/PLAN.md`: living implementation plan
- `docs/PROJECT_INDEX.md`: this index
- `docs/RESEARCH_3PROXY.md`: notes from official 3proxy documentation
## Frontend
- `src/main.tsx`: application bootstrap
- `src/App.tsx`: authenticated panel shell and tab composition
- `src/App.test.tsx`: login-gate component tests
- `src/app.css`: full panel styling
- `src/data/mockDashboard.ts`: realistic mock state shaped for future API responses
- `src/lib/3proxy.ts`: formatting and status helpers
- `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules
- `src/test/setup.ts`: Testing Library matchers
## Static
- `public/favicon.svg`: Vite default icon placeholder, to replace later

20
docs/RESEARCH_3PROXY.md Normal file
View File

@@ -0,0 +1,20 @@
# 3proxy Official Notes
Updated: 2026-04-01
Sources:
- [3proxy configuration reference](https://github.com/3proxy/3proxy/wiki/3proxy.cfg)
- [3proxy how-to documentation](https://3proxy.org/doc/howtoe.html)
- [3proxy man page](https://3proxy.org/doc/man8/3proxy.8.html)
## Relevant findings
1. `3proxy.cfg` is an executable-style script where command order matters, so the panel should generate config deterministically and preserve service ordering.
2. Services such as `socks`, `proxy`, `admin`, `dnspr`, `tcppm`, and `udppm` are started directly from config lines, making a structured config editor practical.
3. User auth is officially handled through `auth strong` and `users username:pwtype:password`; this maps cleanly to a user-management UI.
4. ACLs are reset with `flush`, which means a visual config builder should make service-level ACL boundaries explicit.
5. Traffic limits are handled through `counter`, `countin`, `countout`, and `countall`, with optional text reports generated from the counter file. This is the right base for quota support and usage reporting.
6. Built-in logging supports custom `logformat`, including `%U`, `%I`, `%O`, `%n`, `%E`, and timestamps. That provides a future path for richer analytics if counters are not enough.
7. `monitor <filename>` can trigger config reload on file changes, and the man page documents signal-based reload behavior, so the runtime layer should favor graceful reload over brute-force restarts where possible.
8. The `admin` service exists, but it should not be the primary control surface for this project. The panel should own config generation and use admin endpoints only when they provide safe, official visibility.

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3proxy UI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2145
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "3proxyui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^29.0.1",
"typescript": "~5.9.3",
"vite": "^8.0.1",
"vitest": "^4.1.2"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

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

23
tsconfig.app.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

11
vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
},
});