feat: scaffold operator-first 3proxy panel ui
This commit is contained in:
30
src/App.test.tsx
Normal file
30
src/App.test.tsx
Normal 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
381
src/App.tsx
Normal 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
565
src/app.css
Normal 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
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
src/assets/typescript.svg
Normal file
1
src/assets/typescript.svg
Normal 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
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
135
src/data/mockDashboard.ts
Normal 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
58
src/lib/3proxy.test.ts
Normal 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
72
src/lib/3proxy.ts
Normal 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
9
src/main.tsx
Normal 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
7
src/test/setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
Reference in New Issue
Block a user