feat: scaffold operator-first 3proxy panel ui
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
24
AGENTS.md
Normal 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
33
README.md
Normal 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
20
docs/PLAN.md
Normal 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
31
docs/PROJECT_INDEX.md
Normal 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
20
docs/RESEARCH_3PROXY.md
Normal 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
13
index.html
Normal 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
2145
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal 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
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
24
public/icons.svg
Normal 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
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();
|
||||||
|
});
|
||||||
23
tsconfig.app.json
Normal file
23
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal 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
11
vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user