feat: add dockerized 3proxy control plane backend
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.codex
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
server-dist
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
coverage
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,6 +9,7 @@ lerna-debug.log*
|
|||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
server-dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
|||||||
48
Dockerfile
Normal file
48
Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
FROM node:22-bookworm-slim AS app-build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY tsconfig.json tsconfig.app.json tsconfig.node.json tsconfig.server.json vite.config.ts index.html ./
|
||||||
|
COPY public ./public
|
||||||
|
COPY src ./src
|
||||||
|
COPY server ./server
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim AS proxy-build
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential ca-certificates git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /tmp
|
||||||
|
RUN git clone --depth=1 https://github.com/3proxy/3proxy.git
|
||||||
|
|
||||||
|
WORKDIR /tmp/3proxy
|
||||||
|
RUN ln -s Makefile.Linux Makefile && make
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV AUTO_START_3PROXY=true
|
||||||
|
ENV THREEPROXY_BINARY=/usr/local/bin/3proxy
|
||||||
|
ENV RUNTIME_DIR=/app/runtime
|
||||||
|
|
||||||
|
COPY --from=app-build /app/dist ./dist
|
||||||
|
COPY --from=app-build /app/server-dist ./server-dist
|
||||||
|
COPY --from=proxy-build /tmp/3proxy/bin/3proxy /usr/local/bin/3proxy
|
||||||
|
|
||||||
|
RUN mkdir -p /app/runtime/generated /app/runtime/state/reports /app/runtime/logs
|
||||||
|
|
||||||
|
EXPOSE 3000 1080 2080 3128 8081
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "server-dist/index.cjs"]
|
||||||
32
README.md
32
README.md
@@ -4,29 +4,49 @@ Control panel and runtime bundle for 3proxy in Docker.
|
|||||||
|
|
||||||
## Current focus
|
## Current focus
|
||||||
|
|
||||||
The first delivered slice is the UI foundation:
|
The project now includes both the UI and the first backend/runtime slice:
|
||||||
|
|
||||||
- login screen with hardcoded panel credentials
|
- Express-based control plane API
|
||||||
- dashboard, users, and system views
|
- generated `3proxy.cfg` from persisted panel state
|
||||||
- domain utilities and tests for quota, status, and proxy-link behavior
|
- runtime manager for start/restart/reload
|
||||||
- project workflow docs for autonomous delivery
|
- Docker image that builds the panel and compiles 3proxy in-container
|
||||||
|
- panel views for dashboard, users, and system
|
||||||
|
- edge-case-focused frontend and backend tests
|
||||||
|
|
||||||
## Local run
|
## Local run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
|
npm run dev:server
|
||||||
```
|
```
|
||||||
|
|
||||||
Default panel credentials for the current UI prototype:
|
Default panel credentials:
|
||||||
|
|
||||||
- login: `admin`
|
- login: `admin`
|
||||||
- password: `proxy-ui-demo`
|
- password: `proxy-ui-demo`
|
||||||
|
|
||||||
|
## Docker run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Published ports:
|
||||||
|
|
||||||
|
- panel: `3000`
|
||||||
|
- socks main: `1080`
|
||||||
|
- socks lab: `2080`
|
||||||
|
- http proxy: `3128`
|
||||||
|
- 3proxy admin: `8081`
|
||||||
|
|
||||||
|
Runtime state is persisted in the Docker volume `3proxyui_3proxy-runtime`.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
|
npm run dev:server
|
||||||
npm run build
|
npm run build
|
||||||
npm run test
|
npm run test
|
||||||
npm run test:run
|
npm run test:run
|
||||||
|
|||||||
23
compose.yaml
Normal file
23
compose.yaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
3proxy-ui:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: 3proxy-ui
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
- "1080:1080"
|
||||||
|
- "2080:2080"
|
||||||
|
- "3128:3128"
|
||||||
|
- "8081:8081"
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
AUTO_START_3PROXY: "true"
|
||||||
|
THREEPROXY_BINARY: "/usr/local/bin/3proxy"
|
||||||
|
RUNTIME_DIR: "/app/runtime"
|
||||||
|
volumes:
|
||||||
|
- 3proxy-runtime:/app/runtime
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
3proxy-runtime:
|
||||||
11
docs/PLAN.md
11
docs/PLAN.md
@@ -4,13 +4,13 @@ Updated: 2026-04-01
|
|||||||
|
|
||||||
## Active
|
## Active
|
||||||
|
|
||||||
1. Present the UI-first slice for approval, then replace mocks with runtime-backed 3proxy control flows.
|
1. Harden the new backend/runtime layer, expand system configuration flows, and keep wiring the UI to real panel state instead of fallbacks.
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
1. Replace mocks with a backend control plane for config generation, process management, counters, and health checks.
|
1. Extend the backend to support system-tab editing for services, ports, and runtime configuration.
|
||||||
2. Add Docker runtime with 3proxy, panel server, health checks, and reload/start/restart operations.
|
2. Add stronger validation and tests for unsafe credentials, conflicting ports, and invalid service assignment.
|
||||||
3. Extend tests to cover config rendering, unsafe input handling, and runtime failure scenarios.
|
3. Refine Docker/runtime behavior and document real host-access expectations and deployment constraints.
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
@@ -25,3 +25,6 @@ Updated: 2026-04-01
|
|||||||
9. Stabilized the Users table copy action so the column no longer shifts when the button label changes to `Copied`.
|
9. Stabilized the Users table copy action so the column no longer shifts when the button label changes to `Copied`.
|
||||||
10. Added operator actions in the Users table for pause/resume and delete with confirmation modal coverage.
|
10. Added operator actions in the Users table for pause/resume and delete with confirmation modal coverage.
|
||||||
11. Added a root quick-start prompt file so a new agent can resume implementation or fixes with minimal onboarding.
|
11. Added a root quick-start prompt file so a new agent can resume implementation or fixes with minimal onboarding.
|
||||||
|
12. Added a backend control plane with persisted state, 3proxy config generation, runtime actions, and API-backed frontend wiring.
|
||||||
|
13. Added Docker build/compose runtime that compiles 3proxy in-container and starts the panel with a managed 3proxy process.
|
||||||
|
14. Added backend tests for config rendering and user-management API edge cases.
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ Updated: 2026-04-01
|
|||||||
## Root
|
## Root
|
||||||
|
|
||||||
- `000_START_HERE.md`: copy-ready continuation prompt for the next agent session
|
- `000_START_HERE.md`: copy-ready continuation prompt for the next agent session
|
||||||
|
- `.dockerignore`: trims Docker build context to runtime-relevant files only
|
||||||
- `AGENTS.md`: repository workflow rules for autonomous contributors
|
- `AGENTS.md`: repository workflow rules for autonomous contributors
|
||||||
|
- `compose.yaml`: Docker Compose entrypoint for the bundled panel + 3proxy runtime
|
||||||
|
- `Dockerfile`: multi-stage image that builds the panel and compiles 3proxy
|
||||||
- `README.md`: quick start and current project scope
|
- `README.md`: quick start and current project scope
|
||||||
- `package.json`: frontend scripts and dependencies
|
- `package.json`: frontend scripts and dependencies
|
||||||
|
- `tsconfig.server.json`: server type-check configuration
|
||||||
- `vite.config.ts`: Vite + Vitest configuration
|
- `vite.config.ts`: Vite + Vitest configuration
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
@@ -19,14 +23,25 @@ Updated: 2026-04-01
|
|||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
- `src/main.tsx`: application bootstrap
|
- `src/main.tsx`: application bootstrap
|
||||||
- `src/App.tsx`: authenticated panel shell rebuilt around a topbar/tab layout, plus modal user creation, pause/resume, and delete-confirm flows
|
- `src/App.tsx`: authenticated panel shell wired to backend APIs with local fallback behavior
|
||||||
- `src/App.test.tsx`: login-gate, modal interaction, pause/resume, and delete-confirm tests
|
- `src/App.test.tsx`: login-gate, modal interaction, pause/resume, and delete-confirm tests
|
||||||
- `src/app.css`: full panel styling
|
- `src/app.css`: full panel styling
|
||||||
- `src/data/mockDashboard.ts`: realistic mock state shaped for future API responses, including service-bound users
|
- `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot
|
||||||
- `src/lib/3proxy.ts`: formatting and status helpers
|
- `src/lib/3proxy.ts`: formatting and status helpers
|
||||||
- `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules
|
- `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules
|
||||||
|
- `src/shared/contracts.ts`: shared panel, service, user, and API data contracts
|
||||||
- `src/test/setup.ts`: Testing Library matchers
|
- `src/test/setup.ts`: Testing Library matchers
|
||||||
|
|
||||||
|
## Server
|
||||||
|
|
||||||
|
- `server/index.ts`: backend entrypoint and runtime bootstrap
|
||||||
|
- `server/app.ts`: Express app with panel state and runtime routes
|
||||||
|
- `server/app.test.ts`: API tests for user management edge cases
|
||||||
|
- `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation
|
||||||
|
- `server/lib/config.test.ts`: config-generation regression tests
|
||||||
|
- `server/lib/runtime.ts`: managed 3proxy process controller
|
||||||
|
- `server/lib/store.ts`: JSON-backed persistent state store
|
||||||
|
|
||||||
## Static
|
## Static
|
||||||
|
|
||||||
- `public/favicon.svg`: Vite default icon placeholder, to replace later
|
- `public/favicon.svg`: Vite default icon placeholder, to replace later
|
||||||
|
|||||||
1731
package-lock.json
generated
1731
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -5,7 +5,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"dev:server": "tsx server/index.ts",
|
||||||
|
"build:client": "vite build",
|
||||||
|
"build:server": "tsc -p tsconfig.server.json --noEmit && esbuild server/index.ts --bundle --platform=node --format=cjs --outfile=server-dist/index.cjs",
|
||||||
|
"build": "npm run build:client && npm run build:server",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run"
|
"test:run": "vitest run"
|
||||||
@@ -14,15 +17,21 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"esbuild": "^0.27.4",
|
||||||
"jsdom": "^29.0.1",
|
"jsdom": "^29.0.1",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^8.0.1",
|
"vite": "^8.0.1",
|
||||||
"vitest": "^4.1.2"
|
"vitest": "^4.1.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"express": "^5.2.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4"
|
||||||
}
|
}
|
||||||
|
|||||||
92
server/app.test.ts
Normal file
92
server/app.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { createApp } from './app';
|
||||||
|
import type { RuntimeSnapshot } from './lib/config';
|
||||||
|
import type { RuntimeController } from './lib/runtime';
|
||||||
|
import { StateStore } from './lib/store';
|
||||||
|
|
||||||
|
class FakeRuntime implements RuntimeController {
|
||||||
|
private status: RuntimeSnapshot = {
|
||||||
|
status: 'idle' as const,
|
||||||
|
pid: null,
|
||||||
|
startedAt: null,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
getSnapshot() {
|
||||||
|
return { ...this.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
this.status = {
|
||||||
|
status: 'live',
|
||||||
|
pid: 999,
|
||||||
|
startedAt: new Date('2026-04-01T00:00:00.000Z').toISOString(),
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
return this.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async restart() {
|
||||||
|
return this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async reload() {
|
||||||
|
return this.getSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(cleanupDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('panel api', () => {
|
||||||
|
it('rejects user creation against a non-assignable service', async () => {
|
||||||
|
const app = await createTestApp();
|
||||||
|
const response = await request(app).post('/api/users').send({
|
||||||
|
username: 'bad-admin-user',
|
||||||
|
password: 'secret123',
|
||||||
|
serviceId: 'admin',
|
||||||
|
quotaMb: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toMatch(/enabled assignable/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses and deletes a user through the api', async () => {
|
||||||
|
const app = await createTestApp();
|
||||||
|
const initial = await request(app).get('/api/state');
|
||||||
|
const userId = initial.body.userRecords[0].id;
|
||||||
|
const username = initial.body.userRecords[0].username;
|
||||||
|
|
||||||
|
const paused = await request(app).post(`/api/users/${userId}/pause`);
|
||||||
|
expect(paused.status).toBe(200);
|
||||||
|
expect(paused.body.userRecords.find((entry: { id: string }) => entry.id === userId).paused).toBe(true);
|
||||||
|
|
||||||
|
const removed = await request(app).delete(`/api/users/${userId}`);
|
||||||
|
expect(removed.status).toBe(200);
|
||||||
|
expect(removed.body.userRecords.some((entry: { username: string }) => entry.username === username)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createTestApp() {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), '3proxy-ui-'));
|
||||||
|
cleanupDirs.push(dir);
|
||||||
|
|
||||||
|
const runtime = new FakeRuntime();
|
||||||
|
const store = new StateStore(path.join(dir, 'state', 'panel-state.json'));
|
||||||
|
|
||||||
|
return createApp({
|
||||||
|
store,
|
||||||
|
runtime,
|
||||||
|
runtimeRootDir: dir,
|
||||||
|
});
|
||||||
|
}
|
||||||
179
server/app.ts
Normal file
179
server/app.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import express, { type Request, type Response } from 'express';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { ControlPlaneState, CreateUserInput } from '../src/shared/contracts';
|
||||||
|
import {
|
||||||
|
buildRuntimePaths,
|
||||||
|
createUserRecord,
|
||||||
|
deriveDashboardSnapshot,
|
||||||
|
render3proxyConfig,
|
||||||
|
validateCreateUserInput,
|
||||||
|
type RuntimePaths,
|
||||||
|
} from './lib/config';
|
||||||
|
import type { RuntimeController } from './lib/runtime';
|
||||||
|
import { StateStore } from './lib/store';
|
||||||
|
|
||||||
|
export interface AppServices {
|
||||||
|
store: StateStore;
|
||||||
|
runtime: RuntimeController;
|
||||||
|
runtimeRootDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApp({ store, runtime, runtimeRootDir }: AppServices) {
|
||||||
|
const app = express();
|
||||||
|
const runtimePaths = buildRuntimePaths(runtimeRootDir);
|
||||||
|
const distDir = path.resolve('dist');
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(distDir));
|
||||||
|
|
||||||
|
app.get('/api/health', async (_request, response) => {
|
||||||
|
const state = await store.read();
|
||||||
|
const previewConfig = render3proxyConfig(state, runtimePaths);
|
||||||
|
response.json({
|
||||||
|
ok: true,
|
||||||
|
runtime: runtime.getSnapshot(),
|
||||||
|
users: state.userRecords.length,
|
||||||
|
configBytes: Buffer.byteLength(previewConfig),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/state', async (_request, response, next) => {
|
||||||
|
try {
|
||||||
|
const payload = await getSnapshot(store, runtime, runtimePaths);
|
||||||
|
response.json(payload);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/runtime/:action', async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const state = await store.read();
|
||||||
|
const action = request.params.action;
|
||||||
|
const controller = action === 'restart' ? runtime.restart.bind(runtime) : runtime.start.bind(runtime);
|
||||||
|
|
||||||
|
if (!['start', 'restart'].includes(action)) {
|
||||||
|
response.status(404).json({ error: 'Unknown runtime action.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeSnapshot = await controller();
|
||||||
|
state.service.lastEvent = action === 'restart' ? 'Runtime restarted from panel' : 'Runtime start requested from panel';
|
||||||
|
if (runtimeSnapshot.startedAt) {
|
||||||
|
state.service.startedAt = runtimeSnapshot.startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeConfigAndState(store, state, runtimePaths);
|
||||||
|
response.json(await getSnapshot(store, runtime, runtimePaths));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/users', async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const state = await store.read();
|
||||||
|
const input = validateCreateUserInput(request.body as Partial<CreateUserInput>, state.system.services);
|
||||||
|
const record = createUserRecord(state, input);
|
||||||
|
state.userRecords.push(record);
|
||||||
|
state.service.lastEvent = `User ${record.username} created from panel`;
|
||||||
|
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
||||||
|
response.status(201).json(await getSnapshot(store, runtime, runtimePaths));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/users/:id/pause', async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const state = await store.read();
|
||||||
|
const user = state.userRecords.find((entry) => entry.id === request.params.id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
response.status(404).json({ error: 'User not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.paused = !user.paused;
|
||||||
|
state.service.lastEvent = user.paused
|
||||||
|
? `User ${user.username} paused from panel`
|
||||||
|
: `User ${user.username} resumed from panel`;
|
||||||
|
|
||||||
|
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
||||||
|
response.json(await getSnapshot(store, runtime, runtimePaths));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/users/:id', async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const state = await store.read();
|
||||||
|
const index = state.userRecords.findIndex((entry) => entry.id === request.params.id);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
response.status(404).json({ error: 'User not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [removed] = state.userRecords.splice(index, 1);
|
||||||
|
state.service.lastEvent = `User ${removed.username} deleted from panel`;
|
||||||
|
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
||||||
|
response.json(await getSnapshot(store, runtime, runtimePaths));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(async (_request, response) => {
|
||||||
|
const distPath = path.join(distDir, 'index.html');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const html = await fs.readFile(distPath, 'utf8');
|
||||||
|
response.type('html').send(html);
|
||||||
|
} catch {
|
||||||
|
response.status(404).send('Frontend build not found.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((error: unknown, _request: Request, response: Response, _next: express.NextFunction) => {
|
||||||
|
response.status(400).json({
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown server error.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSnapshot(
|
||||||
|
store: StateStore,
|
||||||
|
runtime: RuntimeController,
|
||||||
|
runtimePaths: RuntimePaths,
|
||||||
|
) {
|
||||||
|
const state = await store.read();
|
||||||
|
const previewConfig = render3proxyConfig(state, runtimePaths);
|
||||||
|
return deriveDashboardSnapshot(state, runtime.getSnapshot(), previewConfig);
|
||||||
|
}
|
||||||
|
async function persistRuntimeMutation(
|
||||||
|
store: StateStore,
|
||||||
|
runtime: RuntimeController,
|
||||||
|
state: ControlPlaneState,
|
||||||
|
runtimePaths: RuntimePaths,
|
||||||
|
) {
|
||||||
|
await writeConfigAndState(store, state, runtimePaths);
|
||||||
|
await runtime.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeConfigAndState(
|
||||||
|
store: StateStore,
|
||||||
|
state: ControlPlaneState,
|
||||||
|
runtimePaths: RuntimePaths,
|
||||||
|
) {
|
||||||
|
const config = render3proxyConfig(state, runtimePaths);
|
||||||
|
await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true });
|
||||||
|
await fs.mkdir(path.dirname(runtimePaths.logPath), { recursive: true });
|
||||||
|
await fs.mkdir(runtimePaths.reportDir, { recursive: true });
|
||||||
|
await fs.writeFile(runtimePaths.configPath, config, 'utf8');
|
||||||
|
await store.write(state);
|
||||||
|
}
|
||||||
32
server/index.ts
Normal file
32
server/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { createApp } from './app';
|
||||||
|
import { buildRuntimePaths, render3proxyConfig } from './lib/config';
|
||||||
|
import { ThreeProxyManager } from './lib/runtime';
|
||||||
|
import { StateStore } from './lib/store';
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT ?? '3000');
|
||||||
|
const runtimeRootDir = path.resolve(process.env.RUNTIME_DIR ?? 'runtime');
|
||||||
|
const statePath = path.join(runtimeRootDir, 'state', 'panel-state.json');
|
||||||
|
const binaryPath = path.resolve(process.env.THREEPROXY_BINARY ?? '/usr/local/bin/3proxy');
|
||||||
|
const autoStart = process.env.AUTO_START_3PROXY === 'true';
|
||||||
|
|
||||||
|
const store = new StateStore(statePath);
|
||||||
|
const runtimePaths = buildRuntimePaths(runtimeRootDir);
|
||||||
|
const runtime = new ThreeProxyManager(binaryPath, runtimePaths.configPath, runtimePaths, autoStart);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const initialState = await store.read();
|
||||||
|
await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true });
|
||||||
|
await fs.writeFile(runtimePaths.configPath, render3proxyConfig(initialState, runtimePaths), 'utf8');
|
||||||
|
await runtime.initialize();
|
||||||
|
const app = createApp({ store, runtime, runtimeRootDir });
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Panel server listening on http://0.0.0.0:${port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
39
server/lib/config.test.ts
Normal file
39
server/lib/config.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { createDefaultState, render3proxyConfig } from './config';
|
||||||
|
|
||||||
|
describe('render3proxyConfig', () => {
|
||||||
|
it('renders enabled services with their own ports and per-service ACLs', () => {
|
||||||
|
const state = createDefaultState();
|
||||||
|
const config = render3proxyConfig(state, {
|
||||||
|
rootDir: '/runtime',
|
||||||
|
configPath: '/runtime/generated/3proxy.cfg',
|
||||||
|
counterPath: '/runtime/state/counters.3cf',
|
||||||
|
reportDir: '/runtime/state/reports',
|
||||||
|
logPath: '/runtime/logs/3proxy.log',
|
||||||
|
pidPath: '/runtime/3proxy.pid',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config).toContain('socks -p1080 -u2');
|
||||||
|
expect(config).toContain('socks -p2080 -u2');
|
||||||
|
expect(config).toContain('admin -p8081 -s');
|
||||||
|
expect(config).toContain('allow night-shift,ops-east');
|
||||||
|
expect(config).toContain('allow lab-unlimited,burst-user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes paused users from credentials and ACLs', () => {
|
||||||
|
const state = createDefaultState();
|
||||||
|
state.userRecords[0].paused = true;
|
||||||
|
const config = render3proxyConfig(state, {
|
||||||
|
rootDir: '/runtime',
|
||||||
|
configPath: '/runtime/generated/3proxy.cfg',
|
||||||
|
counterPath: '/runtime/state/counters.3cf',
|
||||||
|
reportDir: '/runtime/state/reports',
|
||||||
|
logPath: '/runtime/logs/3proxy.log',
|
||||||
|
pidPath: '/runtime/3proxy.pid',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config).not.toContain('night-shift:CL:kettle!23');
|
||||||
|
expect(config).not.toContain('allow night-shift,ops-east');
|
||||||
|
expect(config).toContain('allow ops-east');
|
||||||
|
});
|
||||||
|
});
|
||||||
274
server/lib/config.ts
Normal file
274
server/lib/config.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { dashboardSnapshot } from '../../src/data/mockDashboard';
|
||||||
|
import type {
|
||||||
|
ControlPlaneState,
|
||||||
|
CreateUserInput,
|
||||||
|
DashboardSnapshot,
|
||||||
|
ProxyServiceRecord,
|
||||||
|
ProxyUserRecord,
|
||||||
|
} from '../../src/shared/contracts';
|
||||||
|
import type { ServiceState } from '../../src/lib/3proxy';
|
||||||
|
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
|
export interface RuntimeSnapshot {
|
||||||
|
status: Exclude<ServiceState, 'warn' | 'paused'>;
|
||||||
|
pid: number | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimePaths {
|
||||||
|
rootDir: string;
|
||||||
|
configPath: string;
|
||||||
|
counterPath: string;
|
||||||
|
reportDir: string;
|
||||||
|
logPath: string;
|
||||||
|
pidPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultState(): ControlPlaneState {
|
||||||
|
return structuredClone(dashboardSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRuntimePaths(rootDir: string): RuntimePaths {
|
||||||
|
return {
|
||||||
|
rootDir,
|
||||||
|
configPath: path.join(rootDir, 'generated', '3proxy.cfg'),
|
||||||
|
counterPath: path.join(rootDir, 'state', 'counters.3cf'),
|
||||||
|
reportDir: path.join(rootDir, 'state', 'reports'),
|
||||||
|
logPath: path.join(rootDir, 'logs', '3proxy.log'),
|
||||||
|
pidPath: path.join(rootDir, '3proxy.pid'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCreateUserInput(
|
||||||
|
input: Partial<CreateUserInput>,
|
||||||
|
services: ProxyServiceRecord[],
|
||||||
|
): CreateUserInput {
|
||||||
|
const username = input.username?.trim() ?? '';
|
||||||
|
const password = input.password?.trim() ?? '';
|
||||||
|
const serviceId = input.serviceId?.trim() ?? '';
|
||||||
|
const quotaMb = input.quotaMb ?? null;
|
||||||
|
|
||||||
|
assertSafeToken(username, 'Username');
|
||||||
|
assertSafeToken(password, 'Password');
|
||||||
|
|
||||||
|
if (!serviceId) {
|
||||||
|
throw new Error('Service is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = services.find((entry) => entry.id === serviceId);
|
||||||
|
if (!service || !service.enabled || !service.assignable) {
|
||||||
|
throw new Error('Service must reference an enabled assignable entry.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quotaMb !== null && (!Number.isFinite(quotaMb) || quotaMb <= 0 || !Number.isInteger(quotaMb))) {
|
||||||
|
throw new Error('Quota must be a positive integer number of megabytes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
serviceId,
|
||||||
|
quotaMb,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUserRecord(state: ControlPlaneState, input: CreateUserInput): ProxyUserRecord {
|
||||||
|
if (state.userRecords.some((user) => user.username === input.username)) {
|
||||||
|
throw new Error('Username already exists.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `u-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
username: input.username,
|
||||||
|
password: input.password,
|
||||||
|
serviceId: input.serviceId,
|
||||||
|
status: 'idle',
|
||||||
|
paused: false,
|
||||||
|
usedBytes: 0,
|
||||||
|
quotaBytes: input.quotaMb === null ? null : input.quotaMb * MB,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render3proxyConfig(state: ControlPlaneState, paths: RuntimePaths): string {
|
||||||
|
const lines = [
|
||||||
|
`pidfile ${normalizePath(paths.pidPath)}`,
|
||||||
|
`monitor ${normalizePath(paths.configPath)}`,
|
||||||
|
`log ${normalizePath(paths.logPath)} D`,
|
||||||
|
'auth strong',
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeUsers = state.userRecords.filter((user) => !user.paused);
|
||||||
|
if (activeUsers.length > 0) {
|
||||||
|
lines.push(`users ${activeUsers.map(renderUserCredential).join(' ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotaUsers = activeUsers.filter((user) => user.quotaBytes !== null);
|
||||||
|
if (quotaUsers.length > 0) {
|
||||||
|
lines.push(
|
||||||
|
`counter ${normalizePath(paths.counterPath)} D ${normalizePath(
|
||||||
|
path.join(paths.reportDir, '%Y-%m-%d.txt'),
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
quotaUsers.forEach((user, index) => {
|
||||||
|
lines.push(
|
||||||
|
`countall ${index + 1} D ${Math.ceil((user.quotaBytes ?? 0) / MB)} ${user.username} * * * * * *`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
state.system.services
|
||||||
|
.filter((service) => service.enabled)
|
||||||
|
.forEach((service) => {
|
||||||
|
lines.push('', 'flush');
|
||||||
|
|
||||||
|
if (service.assignable) {
|
||||||
|
const usernames = activeUsers
|
||||||
|
.filter((user) => user.serviceId === service.id)
|
||||||
|
.map((user) => user.username);
|
||||||
|
|
||||||
|
lines.push(usernames.length > 0 ? `allow ${usernames.join(',')}` : 'deny *');
|
||||||
|
} else {
|
||||||
|
lines.push('allow *');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(renderServiceCommand(service));
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveDashboardSnapshot(
|
||||||
|
state: ControlPlaneState,
|
||||||
|
runtime: RuntimeSnapshot,
|
||||||
|
previewConfig: string,
|
||||||
|
): DashboardSnapshot {
|
||||||
|
const liveUsers = state.userRecords.filter((user) => !user.paused && user.status === 'live').length;
|
||||||
|
const exceededUsers = state.userRecords.filter(
|
||||||
|
(user) => user.quotaBytes !== null && user.usedBytes >= user.quotaBytes,
|
||||||
|
).length;
|
||||||
|
const nearQuotaUsers = state.userRecords.filter((user) => {
|
||||||
|
if (user.paused || user.quotaBytes === null || user.usedBytes >= user.quotaBytes) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.usedBytes / user.quotaBytes >= 0.8;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const attention = [];
|
||||||
|
|
||||||
|
if (exceededUsers > 0) {
|
||||||
|
attention.push({
|
||||||
|
level: 'fail' as const,
|
||||||
|
title: 'Quota exceeded',
|
||||||
|
message: `${exceededUsers} user profiles crossed their configured quota.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nearQuotaUsers > 0) {
|
||||||
|
attention.push({
|
||||||
|
level: 'warn' as const,
|
||||||
|
title: 'Quota pressure detected',
|
||||||
|
message: `${nearQuotaUsers} user profiles are above 80% of their quota.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtime.status === 'live') {
|
||||||
|
attention.push({
|
||||||
|
level: 'live' as const,
|
||||||
|
title: '3proxy runtime online',
|
||||||
|
message: 'Backend control plane is currently attached to a live 3proxy process.',
|
||||||
|
});
|
||||||
|
} else if (runtime.lastError) {
|
||||||
|
attention.push({
|
||||||
|
level: 'fail' as const,
|
||||||
|
title: 'Runtime issue detected',
|
||||||
|
message: runtime.lastError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attention.length === 0) {
|
||||||
|
attention.push({
|
||||||
|
level: 'live' as const,
|
||||||
|
title: 'State loaded',
|
||||||
|
message: 'No critical runtime or quota issues are currently detected.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: {
|
||||||
|
status: runtime.status,
|
||||||
|
pidLabel: runtime.pid ? `pid ${runtime.pid}` : 'pid -',
|
||||||
|
versionLabel: state.service.versionLabel,
|
||||||
|
uptimeLabel: formatUptime(runtime.startedAt),
|
||||||
|
lastEvent: state.service.lastEvent,
|
||||||
|
},
|
||||||
|
traffic: {
|
||||||
|
...state.traffic,
|
||||||
|
activeUsers: state.userRecords.filter((user) => !user.paused).length,
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
total: state.userRecords.length,
|
||||||
|
live: liveUsers,
|
||||||
|
nearQuota: nearQuotaUsers,
|
||||||
|
exceeded: exceededUsers,
|
||||||
|
},
|
||||||
|
attention,
|
||||||
|
userRecords: state.userRecords,
|
||||||
|
system: {
|
||||||
|
...state.system,
|
||||||
|
previewConfig,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUserCredential(user: ProxyUserRecord): string {
|
||||||
|
return `${user.username}:CL:${user.password}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServiceCommand(service: ProxyServiceRecord): string {
|
||||||
|
if (service.command === 'admin') {
|
||||||
|
return `admin -p${service.port} -s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service.command === 'socks') {
|
||||||
|
return `socks -p${service.port} -u2`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `proxy -p${service.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafeToken(value: string, label: string): void {
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${label} is required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[A-Za-z0-9._~!@#$%^&*+=:/-]+$/.test(value)) {
|
||||||
|
throw new Error(`${label} contains unsupported characters.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(value: string): string {
|
||||||
|
return value.replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(startedAt: string | null): string {
|
||||||
|
if (!startedAt) {
|
||||||
|
return 'uptime -';
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = new Date(startedAt).getTime();
|
||||||
|
const diffMs = Date.now() - started;
|
||||||
|
|
||||||
|
if (!Number.isFinite(diffMs) || diffMs <= 0) {
|
||||||
|
return 'uptime 0m';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
|
return `uptime ${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
117
server/lib/runtime.ts
Normal file
117
server/lib/runtime.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
import type { RuntimePaths, RuntimeSnapshot } from './config';
|
||||||
|
|
||||||
|
export interface RuntimeController {
|
||||||
|
getSnapshot(): RuntimeSnapshot;
|
||||||
|
start(): Promise<RuntimeSnapshot>;
|
||||||
|
restart(): Promise<RuntimeSnapshot>;
|
||||||
|
reload(): Promise<RuntimeSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ThreeProxyManager implements RuntimeController {
|
||||||
|
private child: ChildProcess | null = null;
|
||||||
|
private snapshot: RuntimeSnapshot = {
|
||||||
|
status: 'idle',
|
||||||
|
pid: null,
|
||||||
|
startedAt: null,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly binaryPath: string,
|
||||||
|
private readonly configPath: string,
|
||||||
|
private readonly paths: RuntimePaths,
|
||||||
|
private readonly autoStart: boolean,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
await fs.mkdir(path.dirname(this.paths.configPath), { recursive: true });
|
||||||
|
await fs.mkdir(path.dirname(this.paths.counterPath), { recursive: true });
|
||||||
|
await fs.mkdir(this.paths.reportDir, { recursive: true });
|
||||||
|
await fs.mkdir(path.dirname(this.paths.logPath), { recursive: true });
|
||||||
|
|
||||||
|
if (this.autoStart) {
|
||||||
|
await this.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(): RuntimeSnapshot {
|
||||||
|
return { ...this.snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<RuntimeSnapshot> {
|
||||||
|
if (this.child && this.child.exitCode === null) {
|
||||||
|
return this.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureBinaryExists(this.binaryPath);
|
||||||
|
|
||||||
|
const child = spawn(this.binaryPath, [this.configPath], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.child = child;
|
||||||
|
this.snapshot = {
|
||||||
|
status: 'live',
|
||||||
|
pid: child.pid ?? null,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout.on('data', (chunk) => process.stdout.write(`[3proxy] ${chunk}`));
|
||||||
|
child.stderr.on('data', (chunk) => process.stderr.write(`[3proxy] ${chunk}`));
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
this.child = null;
|
||||||
|
this.snapshot = {
|
||||||
|
status: code === 0 || signal === 'SIGTERM' ? 'idle' : 'fail',
|
||||||
|
pid: null,
|
||||||
|
startedAt: null,
|
||||||
|
lastError:
|
||||||
|
code === 0 || signal === 'SIGTERM'
|
||||||
|
? null
|
||||||
|
: `3proxy exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async restart(): Promise<RuntimeSnapshot> {
|
||||||
|
await this.stop();
|
||||||
|
return this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async reload(): Promise<RuntimeSnapshot> {
|
||||||
|
if (!this.child || this.child.exitCode !== null || !this.child.pid) {
|
||||||
|
return this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.kill(this.child.pid, 'SIGUSR1');
|
||||||
|
return this.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stop(): Promise<void> {
|
||||||
|
if (!this.child || this.child.exitCode !== null || !this.child.pid) {
|
||||||
|
this.child = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.child;
|
||||||
|
const waitForExit = new Promise<void>((resolve) => {
|
||||||
|
current.once('exit', () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
current.kill('SIGTERM');
|
||||||
|
await waitForExit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureBinaryExists(binaryPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.access(binaryPath);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`3proxy binary not found at ${binaryPath}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
server/lib/store.ts
Normal file
30
server/lib/store.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { ControlPlaneState } from '../../src/shared/contracts';
|
||||||
|
import { createDefaultState } from './config';
|
||||||
|
|
||||||
|
export class StateStore {
|
||||||
|
constructor(private readonly statePath: string) {}
|
||||||
|
|
||||||
|
async read(): Promise<ControlPlaneState> {
|
||||||
|
await fs.mkdir(path.dirname(this.statePath), { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(this.statePath, 'utf8');
|
||||||
|
return JSON.parse(raw) as ControlPlaneState;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = createDefaultState();
|
||||||
|
await this.write(fallback);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(state: ControlPlaneState): Promise<void> {
|
||||||
|
await fs.mkdir(path.dirname(this.statePath), { recursive: true });
|
||||||
|
await fs.writeFile(this.statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
365
src/App.tsx
365
src/App.tsx
@@ -1,6 +1,6 @@
|
|||||||
import { FormEvent, KeyboardEvent, useEffect, useState } from 'react';
|
import { FormEvent, KeyboardEvent, useEffect, useMemo, useState } from 'react';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
import { dashboardSnapshot, panelAuth } from './data/mockDashboard';
|
import { fallbackDashboardSnapshot, panelAuth } from './data/mockDashboard';
|
||||||
import {
|
import {
|
||||||
buildProxyLink,
|
buildProxyLink,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
@@ -9,6 +9,12 @@ import {
|
|||||||
getServiceTone,
|
getServiceTone,
|
||||||
isQuotaExceeded,
|
isQuotaExceeded,
|
||||||
} from './lib/3proxy';
|
} from './lib/3proxy';
|
||||||
|
import type {
|
||||||
|
CreateUserInput,
|
||||||
|
DashboardSnapshot,
|
||||||
|
ProxyServiceRecord,
|
||||||
|
ProxyUserRecord,
|
||||||
|
} from './shared/contracts';
|
||||||
|
|
||||||
type TabId = 'dashboard' | 'users' | 'system';
|
type TabId = 'dashboard' | 'users' | 'system';
|
||||||
|
|
||||||
@@ -18,18 +24,6 @@ const tabs: Array<{ id: TabId; label: string }> = [
|
|||||||
{ id: 'system', label: 'System' },
|
{ id: 'system', label: 'System' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const assignableServices = dashboardSnapshot.system.services.filter(
|
|
||||||
(service) => service.enabled && service.assignable,
|
|
||||||
);
|
|
||||||
|
|
||||||
const servicesById = new Map(
|
|
||||||
dashboardSnapshot.system.services.map((service) => [service.id, service] as const),
|
|
||||||
);
|
|
||||||
|
|
||||||
type UserRow = (typeof dashboardSnapshot.userRecords)[number] & {
|
|
||||||
paused?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function LoginGate({ onUnlock }: { onUnlock: () => void }) {
|
function LoginGate({ onUnlock }: { onUnlock: () => void }) {
|
||||||
const [login, setLogin] = useState('');
|
const [login, setLogin] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -82,8 +76,22 @@ function LoginGate({ onUnlock }: { onUnlock: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddUserModal({ onClose }: { onClose: () => void }) {
|
function AddUserModal({
|
||||||
const [serviceId, setServiceId] = useState(assignableServices[0]?.id ?? '');
|
host,
|
||||||
|
services,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
host: string;
|
||||||
|
services: ProxyServiceRecord[];
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (input: CreateUserInput) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [serviceId, setServiceId] = useState(services[0]?.id ?? '');
|
||||||
|
const [quotaMb, setQuotaMb] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
@@ -101,7 +109,23 @@ function AddUserModal({ onClose }: { onClose: () => void }) {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedService = servicesById.get(serviceId);
|
const selectedService = services.find((service) => service.id === serviceId);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onCreate({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
serviceId,
|
||||||
|
quotaMb: quotaMb.trim() ? Number(quotaMb) : null,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (submitError) {
|
||||||
|
setError(submitError instanceof Error ? submitError.message : 'Unable to create user.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" role="presentation" onClick={onClose}>
|
<div className="modal-backdrop" role="presentation" onClick={onClose}>
|
||||||
@@ -118,19 +142,19 @@ function AddUserModal({ onClose }: { onClose: () => void }) {
|
|||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form className="modal-form">
|
<form className="modal-form" onSubmit={handleSubmit}>
|
||||||
<label>
|
<label>
|
||||||
Username
|
Username
|
||||||
<input autoFocus placeholder="night-shift-01" />
|
<input autoFocus placeholder="night-shift-01" value={username} onChange={(event) => setUsername(event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Password
|
Password
|
||||||
<input placeholder="generated secret" />
|
<input placeholder="generated-secret" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Service
|
Service
|
||||||
<select value={serviceId} onChange={(event) => setServiceId(event.target.value)}>
|
<select value={serviceId} onChange={(event) => setServiceId(event.target.value)}>
|
||||||
{assignableServices.map((service) => (
|
{services.map((service) => (
|
||||||
<option key={service.id} value={service.id}>
|
<option key={service.id} value={service.id}>
|
||||||
{service.name}
|
{service.name}
|
||||||
</option>
|
</option>
|
||||||
@@ -139,25 +163,22 @@ function AddUserModal({ onClose }: { onClose: () => void }) {
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Quota (MB)
|
Quota (MB)
|
||||||
<input placeholder="Optional" />
|
<input placeholder="Optional" value={quotaMb} onChange={(event) => setQuotaMb(event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<div className="modal-preview">
|
<div className="modal-preview">
|
||||||
<span>Endpoint</span>
|
<span>Endpoint</span>
|
||||||
<strong>
|
<strong>{selectedService ? `${host}:${selectedService.port}` : 'Unavailable'}</strong>
|
||||||
{selectedService
|
|
||||||
? `${dashboardSnapshot.system.publicHost}:${selectedService.port}`
|
|
||||||
: 'Unavailable'}
|
|
||||||
</strong>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-preview">
|
<div className="modal-preview">
|
||||||
<span>Protocol</span>
|
<span>Protocol</span>
|
||||||
<strong>{selectedService ? selectedService.protocol : 'Unavailable'}</strong>
|
<strong>{selectedService ? selectedService.protocol : 'Unavailable'}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
{error ? <p className="form-error modal-error">{error}</p> : null}
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button type="button" className="button-secondary" onClick={onClose}>
|
<button type="button" className="button-secondary" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="button">Create user</button>
|
<button type="submit">Create user</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -172,7 +193,7 @@ function ConfirmDeleteModal({
|
|||||||
}: {
|
}: {
|
||||||
username: string;
|
username: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
@@ -219,37 +240,45 @@ function ConfirmDeleteModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardTab() {
|
function DashboardTab({
|
||||||
const serviceTone = getServiceTone(dashboardSnapshot.service.status);
|
snapshot,
|
||||||
|
onRuntimeAction,
|
||||||
|
}: {
|
||||||
|
snapshot: DashboardSnapshot;
|
||||||
|
onRuntimeAction: (action: 'start' | 'restart') => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const serviceTone = getServiceTone(snapshot.service.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-grid">
|
<section className="page-grid">
|
||||||
<article className="panel-card">
|
<article className="panel-card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h2>Service</h2>
|
<h2>Service</h2>
|
||||||
<span className={`status-pill ${serviceTone}`}>{dashboardSnapshot.service.status}</span>
|
<span className={`status-pill ${serviceTone}`}>{snapshot.service.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<dl className="kv-list">
|
<dl className="kv-list">
|
||||||
<div>
|
<div>
|
||||||
<dt>Process</dt>
|
<dt>Process</dt>
|
||||||
<dd>{dashboardSnapshot.service.pidLabel}</dd>
|
<dd>{snapshot.service.pidLabel}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Version</dt>
|
<dt>Version</dt>
|
||||||
<dd>{dashboardSnapshot.service.versionLabel}</dd>
|
<dd>{snapshot.service.versionLabel}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Uptime</dt>
|
<dt>Uptime</dt>
|
||||||
<dd>{dashboardSnapshot.service.uptimeLabel}</dd>
|
<dd>{snapshot.service.uptimeLabel}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Last event</dt>
|
<dt>Last event</dt>
|
||||||
<dd>{dashboardSnapshot.service.lastEvent}</dd>
|
<dd>{snapshot.service.lastEvent}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="actions-row">
|
<div className="actions-row">
|
||||||
<button type="button">Start</button>
|
<button type="button" onClick={() => onRuntimeAction('start')}>
|
||||||
<button type="button" className="button-secondary">
|
Start
|
||||||
|
</button>
|
||||||
|
<button type="button" className="button-secondary" onClick={() => onRuntimeAction('restart')}>
|
||||||
Restart
|
Restart
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,15 +291,15 @@ function DashboardTab() {
|
|||||||
<div className="stats-strip">
|
<div className="stats-strip">
|
||||||
<div>
|
<div>
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<strong>{formatBytes(dashboardSnapshot.traffic.totalBytes)}</strong>
|
<strong>{formatBytes(snapshot.traffic.totalBytes)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Connections</span>
|
<span>Connections</span>
|
||||||
<strong>{dashboardSnapshot.traffic.liveConnections}</strong>
|
<strong>{snapshot.traffic.liveConnections}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Active users</span>
|
<span>Active users</span>
|
||||||
<strong>{dashboardSnapshot.traffic.activeUsers}</strong>
|
<strong>{snapshot.traffic.activeUsers}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -280,7 +309,7 @@ function DashboardTab() {
|
|||||||
<h2>Daily usage</h2>
|
<h2>Daily usage</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="usage-list">
|
<div className="usage-list">
|
||||||
{dashboardSnapshot.traffic.daily.map((bucket) => (
|
{snapshot.traffic.daily.map((bucket) => (
|
||||||
<div key={bucket.day} className="usage-row">
|
<div key={bucket.day} className="usage-row">
|
||||||
<span>{bucket.day}</span>
|
<span>{bucket.day}</span>
|
||||||
<div className="usage-bar">
|
<div className="usage-bar">
|
||||||
@@ -297,7 +326,7 @@ function DashboardTab() {
|
|||||||
<h2>Attention</h2>
|
<h2>Attention</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="event-list">
|
<div className="event-list">
|
||||||
{dashboardSnapshot.attention.map((item) => (
|
{snapshot.attention.map((item) => (
|
||||||
<div key={item.title} className="event-row">
|
<div key={item.title} className="event-row">
|
||||||
<span className={`event-marker ${item.level}`} />
|
<span className={`event-marker ${item.level}`} />
|
||||||
<div>
|
<div>
|
||||||
@@ -312,44 +341,35 @@ function DashboardTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UsersTab() {
|
function UsersTab({
|
||||||
|
snapshot,
|
||||||
|
onCreateUser,
|
||||||
|
onTogglePause,
|
||||||
|
onDeleteUser,
|
||||||
|
}: {
|
||||||
|
snapshot: DashboardSnapshot;
|
||||||
|
onCreateUser: (input: CreateUserInput) => Promise<void>;
|
||||||
|
onTogglePause: (userId: string) => Promise<void>;
|
||||||
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [users, setUsers] = useState<UserRow[]>(() => dashboardSnapshot.userRecords);
|
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const servicesById = useMemo(
|
||||||
|
() => new Map(snapshot.system.services.map((service) => [service.id, service] as const)),
|
||||||
|
[snapshot.system.services],
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignableServices = snapshot.system.services.filter((service) => service.enabled && service.assignable);
|
||||||
|
const deleteTarget = snapshot.userRecords.find((user) => user.id === deleteTargetId) ?? null;
|
||||||
|
|
||||||
const handleCopy = async (userId: string, proxyLink: string) => {
|
const handleCopy = async (userId: string, proxyLink: string) => {
|
||||||
await navigator.clipboard.writeText(proxyLink);
|
await navigator.clipboard.writeText(proxyLink);
|
||||||
setCopiedId(userId);
|
setCopiedId(userId);
|
||||||
window.setTimeout(() => setCopiedId((value) => (value === userId ? null : value)), 1200);
|
window.setTimeout(() => setCopiedId((value) => (value === userId ? null : value)), 1200);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTogglePause = (userId: string) => {
|
|
||||||
setUsers((current) =>
|
|
||||||
current.map((user) => (user.id === userId ? { ...user, paused: !user.paused } : user)),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
if (!deleteTargetId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUsers((current) => current.filter((user) => user.id !== deleteTargetId));
|
|
||||||
setDeleteTargetId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteTarget = users.find((user) => user.id === deleteTargetId) ?? null;
|
|
||||||
const liveUsers = users.filter((user) => !user.paused && user.status === 'live').length;
|
|
||||||
const nearQuotaUsers = users.filter((user) => {
|
|
||||||
if (user.paused || user.quotaBytes === null || isQuotaExceeded(user.usedBytes, user.quotaBytes)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return user.usedBytes / user.quotaBytes >= 0.8;
|
|
||||||
}).length;
|
|
||||||
const exceededUsers = users.filter((user) => isQuotaExceeded(user.usedBytes, user.quotaBytes)).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="page-grid single-column">
|
<section className="page-grid single-column">
|
||||||
@@ -357,13 +377,13 @@ function UsersTab() {
|
|||||||
<div className="table-toolbar">
|
<div className="table-toolbar">
|
||||||
<div className="toolbar-title">
|
<div className="toolbar-title">
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
<p>{users.length} accounts in current profile</p>
|
<p>{snapshot.userRecords.length} accounts in current profile</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar-actions">
|
<div className="toolbar-actions">
|
||||||
<div className="summary-pills">
|
<div className="summary-pills">
|
||||||
<span>{liveUsers} live</span>
|
<span>{snapshot.users.live} live</span>
|
||||||
<span>{nearQuotaUsers} near quota</span>
|
<span>{snapshot.users.nearQuota} near quota</span>
|
||||||
<span>{exceededUsers} exceeded</span>
|
<span>{snapshot.users.exceeded} exceeded</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => setIsModalOpen(true)}>
|
<button type="button" onClick={() => setIsModalOpen(true)}>
|
||||||
New user
|
New user
|
||||||
@@ -385,16 +405,16 @@ function UsersTab() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((user) => {
|
{snapshot.userRecords.map((user) => {
|
||||||
const service = servicesById.get(user.serviceId);
|
const service = servicesById.get(user.serviceId);
|
||||||
const endpoint = service
|
const endpoint = service
|
||||||
? `${dashboardSnapshot.system.publicHost}:${service.port}`
|
? `${snapshot.system.publicHost}:${service.port}`
|
||||||
: 'service missing';
|
: 'service missing';
|
||||||
const proxyLink = service
|
const proxyLink = service
|
||||||
? buildProxyLink(
|
? buildProxyLink(
|
||||||
user.username,
|
user.username,
|
||||||
user.password,
|
user.password,
|
||||||
dashboardSnapshot.system.publicHost,
|
snapshot.system.publicHost,
|
||||||
service.port,
|
service.port,
|
||||||
service.protocol,
|
service.protocol,
|
||||||
)
|
)
|
||||||
@@ -422,7 +442,7 @@ function UsersTab() {
|
|||||||
</td>
|
</td>
|
||||||
<td>{formatBytes(user.usedBytes)}</td>
|
<td>{formatBytes(user.usedBytes)}</td>
|
||||||
<td>{formatQuotaState(user.usedBytes, user.quotaBytes)}</td>
|
<td>{formatQuotaState(user.usedBytes, user.quotaBytes)}</td>
|
||||||
<td>{formatTrafficShare(user.usedBytes, dashboardSnapshot.traffic.totalBytes)}</td>
|
<td>{formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -438,7 +458,7 @@ function UsersTab() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button-secondary button-small"
|
className="button-secondary button-small"
|
||||||
onClick={() => handleTogglePause(user.id)}
|
onClick={() => onTogglePause(user.id)}
|
||||||
>
|
>
|
||||||
{user.paused ? 'Resume' : 'Pause'}
|
{user.paused ? 'Resume' : 'Pause'}
|
||||||
</button>
|
</button>
|
||||||
@@ -460,19 +480,30 @@ function UsersTab() {
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isModalOpen ? <AddUserModal onClose={() => setIsModalOpen(false)} /> : null}
|
{isModalOpen ? (
|
||||||
|
<AddUserModal
|
||||||
|
host={snapshot.system.publicHost}
|
||||||
|
services={assignableServices}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onCreate={onCreateUser}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{deleteTarget ? (
|
{deleteTarget ? (
|
||||||
<ConfirmDeleteModal
|
<ConfirmDeleteModal
|
||||||
username={deleteTarget.username}
|
username={deleteTarget.username}
|
||||||
onClose={() => setDeleteTargetId(null)}
|
onClose={() => setDeleteTargetId(null)}
|
||||||
onConfirm={handleDelete}
|
onConfirm={async () => {
|
||||||
|
await onDeleteUser(deleteTarget.id);
|
||||||
|
setDeleteTargetId(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SystemTab() {
|
function SystemTab({ snapshot }: { snapshot: DashboardSnapshot }) {
|
||||||
return (
|
return (
|
||||||
<section className="page-grid system-grid">
|
<section className="page-grid system-grid">
|
||||||
<article className="panel-card">
|
<article className="panel-card">
|
||||||
@@ -482,15 +513,15 @@ function SystemTab() {
|
|||||||
<dl className="kv-list">
|
<dl className="kv-list">
|
||||||
<div>
|
<div>
|
||||||
<dt>Config mode</dt>
|
<dt>Config mode</dt>
|
||||||
<dd>{dashboardSnapshot.system.configMode}</dd>
|
<dd>{snapshot.system.configMode}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Reload</dt>
|
<dt>Reload</dt>
|
||||||
<dd>{dashboardSnapshot.system.reloadMode}</dd>
|
<dd>{snapshot.system.reloadMode}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Storage</dt>
|
<dt>Storage</dt>
|
||||||
<dd>{dashboardSnapshot.system.storageMode}</dd>
|
<dd>{snapshot.system.storageMode}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
@@ -500,7 +531,7 @@ function SystemTab() {
|
|||||||
<h2>Services</h2>
|
<h2>Services</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="service-list">
|
<div className="service-list">
|
||||||
{dashboardSnapshot.system.services.map((service) => (
|
{snapshot.system.services.map((service) => (
|
||||||
<div key={service.name} className="service-row">
|
<div key={service.name} className="service-row">
|
||||||
<div>
|
<div>
|
||||||
<strong>{service.name}</strong>
|
<strong>{service.name}</strong>
|
||||||
@@ -521,7 +552,7 @@ function SystemTab() {
|
|||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h2>Generated config</h2>
|
<h2>Generated config</h2>
|
||||||
</div>
|
</div>
|
||||||
<pre>{dashboardSnapshot.system.previewConfig}</pre>
|
<pre>{snapshot.system.previewConfig}</pre>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -530,30 +561,139 @@ function SystemTab() {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const [isAuthed, setIsAuthed] = useState(false);
|
const [isAuthed, setIsAuthed] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
|
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
|
||||||
|
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(fallbackDashboardSnapshot);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void fetch('/api/state')
|
||||||
|
.then((response) => (response.ok ? response.json() : Promise.reject(new Error('API unavailable'))))
|
||||||
|
.then((payload: DashboardSnapshot) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSnapshot(payload);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Keep fallback snapshot for local UI and tests when backend is not running.
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!isAuthed) {
|
if (!isAuthed) {
|
||||||
return <LoginGate onUnlock={() => setIsAuthed(true)} />;
|
return <LoginGate onUnlock={() => setIsAuthed(true)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mutateSnapshot = async (
|
||||||
|
request: () => Promise<Response>,
|
||||||
|
fallback: (current: DashboardSnapshot) => DashboardSnapshot,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await request();
|
||||||
|
if (response.ok) {
|
||||||
|
const payload = (await response.json()) as DashboardSnapshot;
|
||||||
|
setSnapshot(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to local optimistic state when the API is unavailable.
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnapshot((current) => fallback(current));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRuntimeAction = async (action: 'start' | 'restart') => {
|
||||||
|
await mutateSnapshot(
|
||||||
|
() => fetch(`/api/runtime/${action}`, { method: 'POST' }),
|
||||||
|
(current) =>
|
||||||
|
withDerivedSnapshot({
|
||||||
|
...current,
|
||||||
|
service: {
|
||||||
|
...current.service,
|
||||||
|
status: 'live',
|
||||||
|
lastEvent: action === 'restart' ? 'Runtime restarted from panel' : 'Runtime start requested from panel',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateUser = async (input: CreateUserInput) => {
|
||||||
|
await mutateSnapshot(
|
||||||
|
() =>
|
||||||
|
fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
(current) => {
|
||||||
|
const nextUser: ProxyUserRecord = {
|
||||||
|
id: `u-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
username: input.username.trim(),
|
||||||
|
password: input.password.trim(),
|
||||||
|
serviceId: input.serviceId,
|
||||||
|
status: 'idle',
|
||||||
|
paused: false,
|
||||||
|
usedBytes: 0,
|
||||||
|
quotaBytes: input.quotaMb === null ? null : input.quotaMb * 1024 * 1024,
|
||||||
|
};
|
||||||
|
|
||||||
|
return withDerivedSnapshot({
|
||||||
|
...current,
|
||||||
|
service: {
|
||||||
|
...current.service,
|
||||||
|
lastEvent: `User ${nextUser.username} created from panel`,
|
||||||
|
},
|
||||||
|
userRecords: [...current.userRecords, nextUser],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePause = async (userId: string) => {
|
||||||
|
await mutateSnapshot(
|
||||||
|
() => fetch(`/api/users/${userId}/pause`, { method: 'POST' }),
|
||||||
|
(current) =>
|
||||||
|
withDerivedSnapshot({
|
||||||
|
...current,
|
||||||
|
userRecords: current.userRecords.map((user) =>
|
||||||
|
user.id === userId ? { ...user, paused: !user.paused } : user,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (userId: string) => {
|
||||||
|
await mutateSnapshot(
|
||||||
|
() => fetch(`/api/users/${userId}`, { method: 'DELETE' }),
|
||||||
|
(current) =>
|
||||||
|
withDerivedSnapshot({
|
||||||
|
...current,
|
||||||
|
userRecords: current.userRecords.filter((user) => user.id !== userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
<header className="shell-header">
|
<header className="shell-header">
|
||||||
<div className="shell-title">
|
<div className="shell-title">
|
||||||
<h1>3proxy UI</h1>
|
<h1>3proxy UI</h1>
|
||||||
<p>{dashboardSnapshot.system.publicHost}</p>
|
<p>{snapshot.system.publicHost}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-meta">
|
<div className="header-meta">
|
||||||
<div>
|
<div>
|
||||||
<span>Status</span>
|
<span>Status</span>
|
||||||
<strong>{dashboardSnapshot.service.status}</strong>
|
<strong>{snapshot.service.status}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Version</span>
|
<span>Version</span>
|
||||||
<strong>{dashboardSnapshot.service.versionLabel}</strong>
|
<strong>{snapshot.service.versionLabel}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Users</span>
|
<span>Users</span>
|
||||||
<strong>{dashboardSnapshot.users.total}</strong>
|
<strong>{snapshot.users.total}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -571,9 +711,44 @@ export default function App() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{activeTab === 'dashboard' ? <DashboardTab /> : null}
|
{activeTab === 'dashboard' ? <DashboardTab snapshot={snapshot} onRuntimeAction={handleRuntimeAction} /> : null}
|
||||||
{activeTab === 'users' ? <UsersTab /> : null}
|
{activeTab === 'users' ? (
|
||||||
{activeTab === 'system' ? <SystemTab /> : null}
|
<UsersTab
|
||||||
|
snapshot={snapshot}
|
||||||
|
onCreateUser={handleCreateUser}
|
||||||
|
onTogglePause={handleTogglePause}
|
||||||
|
onDeleteUser={handleDeleteUser}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{activeTab === 'system' ? <SystemTab snapshot={snapshot} /> : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withDerivedSnapshot(snapshot: DashboardSnapshot): DashboardSnapshot {
|
||||||
|
const live = snapshot.userRecords.filter((user) => !user.paused && user.status === 'live').length;
|
||||||
|
const exceeded = snapshot.userRecords.filter(
|
||||||
|
(user) => user.quotaBytes !== null && user.usedBytes >= user.quotaBytes,
|
||||||
|
).length;
|
||||||
|
const nearQuota = snapshot.userRecords.filter((user) => {
|
||||||
|
if (user.paused || user.quotaBytes === null || user.usedBytes >= user.quotaBytes) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.usedBytes / user.quotaBytes >= 0.8;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
traffic: {
|
||||||
|
...snapshot.traffic,
|
||||||
|
activeUsers: snapshot.userRecords.filter((user) => !user.paused).length,
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
total: snapshot.userRecords.length,
|
||||||
|
live,
|
||||||
|
nearQuota,
|
||||||
|
exceeded,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
|
import type { ControlPlaneState, DashboardSnapshot } from '../shared/contracts';
|
||||||
|
|
||||||
export const panelAuth = {
|
export const panelAuth = {
|
||||||
login: 'admin',
|
login: 'admin',
|
||||||
password: 'proxy-ui-demo',
|
password: 'proxy-ui-demo',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dashboardSnapshot = {
|
export const dashboardSnapshot: ControlPlaneState = {
|
||||||
service: {
|
service: {
|
||||||
status: 'live' as const,
|
|
||||||
pidLabel: 'pid 17',
|
|
||||||
versionLabel: '3proxy 0.9.x',
|
versionLabel: '3proxy 0.9.x',
|
||||||
uptimeLabel: 'uptime 6h 14m',
|
|
||||||
lastEvent: 'Last graceful reload 2m ago',
|
lastEvent: 'Last graceful reload 2m ago',
|
||||||
|
startedAt: '2026-04-01T15:45:00.000Z',
|
||||||
},
|
},
|
||||||
traffic: {
|
traffic: {
|
||||||
totalBytes: 1_557_402_624,
|
totalBytes: 1_557_402_624,
|
||||||
@@ -23,29 +23,6 @@ export const dashboardSnapshot = {
|
|||||||
{ day: 'Fri', bytes: 547_037_696, share: 1 },
|
{ 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: [
|
userRecords: [
|
||||||
{
|
{
|
||||||
id: 'u-1',
|
id: 'u-1',
|
||||||
@@ -68,7 +45,7 @@ export const dashboardSnapshot = {
|
|||||||
{
|
{
|
||||||
id: 'u-3',
|
id: 'u-3',
|
||||||
username: 'lab-unlimited',
|
username: 'lab-unlimited',
|
||||||
password: 'open lane',
|
password: 'open-lane',
|
||||||
serviceId: 'socks-lab',
|
serviceId: 'socks-lab',
|
||||||
status: 'idle' as const,
|
status: 'idle' as const,
|
||||||
usedBytes: 42_844_160,
|
usedBytes: 42_844_160,
|
||||||
@@ -93,6 +70,7 @@ export const dashboardSnapshot = {
|
|||||||
{
|
{
|
||||||
id: 'socks-main',
|
id: 'socks-main',
|
||||||
name: 'SOCKS5 main',
|
name: 'SOCKS5 main',
|
||||||
|
command: 'socks',
|
||||||
protocol: 'socks5',
|
protocol: 'socks5',
|
||||||
description: 'Primary SOCKS5 entrypoint with user auth.',
|
description: 'Primary SOCKS5 entrypoint with user auth.',
|
||||||
port: 1080,
|
port: 1080,
|
||||||
@@ -102,6 +80,7 @@ export const dashboardSnapshot = {
|
|||||||
{
|
{
|
||||||
id: 'socks-lab',
|
id: 'socks-lab',
|
||||||
name: 'SOCKS5 lab',
|
name: 'SOCKS5 lab',
|
||||||
|
command: 'socks',
|
||||||
protocol: 'socks5',
|
protocol: 'socks5',
|
||||||
description: 'Secondary SOCKS5 service for lab and overflow users.',
|
description: 'Secondary SOCKS5 service for lab and overflow users.',
|
||||||
port: 2080,
|
port: 2080,
|
||||||
@@ -111,6 +90,7 @@ export const dashboardSnapshot = {
|
|||||||
{
|
{
|
||||||
id: 'admin',
|
id: 'admin',
|
||||||
name: 'Admin',
|
name: 'Admin',
|
||||||
|
command: 'admin',
|
||||||
protocol: 'http',
|
protocol: 'http',
|
||||||
description: 'Restricted admin visibility endpoint.',
|
description: 'Restricted admin visibility endpoint.',
|
||||||
port: 8081,
|
port: 8081,
|
||||||
@@ -120,6 +100,7 @@ export const dashboardSnapshot = {
|
|||||||
{
|
{
|
||||||
id: 'proxy',
|
id: 'proxy',
|
||||||
name: 'HTTP proxy',
|
name: 'HTTP proxy',
|
||||||
|
command: 'proxy',
|
||||||
protocol: 'http',
|
protocol: 'http',
|
||||||
description: 'Optional HTTP/HTTPS proxy profile.',
|
description: 'Optional HTTP/HTTPS proxy profile.',
|
||||||
port: 3128,
|
port: 3128,
|
||||||
@@ -127,23 +108,40 @@ export const dashboardSnapshot = {
|
|||||||
assignable: true,
|
assignable: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
previewConfig: `daemon
|
},
|
||||||
pidfile /var/run/3proxy/3proxy.pid
|
};
|
||||||
monitor /etc/3proxy/generated/3proxy.cfg
|
|
||||||
|
export const fallbackDashboardSnapshot: DashboardSnapshot = {
|
||||||
auth strong
|
service: {
|
||||||
users night-shift:CL:kettle!23 ops-east:CL:east/line
|
status: 'live',
|
||||||
|
pidLabel: 'pid 17',
|
||||||
counter /var/lib/3proxy/counters.3cf D /var/lib/3proxy/reports/%Y-%m-%d.txt
|
versionLabel: dashboardSnapshot.service.versionLabel,
|
||||||
countall 1 D 1024 night-shift * * * * * *
|
uptimeLabel: 'uptime 6h 14m',
|
||||||
countall 2 D 1024 ops-east * * * * * *
|
lastEvent: dashboardSnapshot.service.lastEvent,
|
||||||
|
},
|
||||||
flush
|
traffic: dashboardSnapshot.traffic,
|
||||||
allow night-shift,ops-east
|
users: {
|
||||||
socks -p1080 -u2
|
total: dashboardSnapshot.userRecords.length,
|
||||||
|
live: dashboardSnapshot.userRecords.filter((user) => user.status === 'live').length,
|
||||||
flush
|
nearQuota: 1,
|
||||||
allow *
|
exceeded: 1,
|
||||||
admin -p8081 -s`,
|
},
|
||||||
|
attention: [
|
||||||
|
{
|
||||||
|
level: 'warn',
|
||||||
|
title: 'Quota pressure detected',
|
||||||
|
message: 'Fallback snapshot is active until the backend API responds.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
userRecords: dashboardSnapshot.userRecords,
|
||||||
|
system: {
|
||||||
|
...dashboardSnapshot.system,
|
||||||
|
previewConfig: `pidfile /runtime/3proxy.pid
|
||||||
|
monitor /runtime/generated/3proxy.cfg
|
||||||
|
auth strong
|
||||||
|
users night-shift:CL:kettle!23 ops-east:CL:east/line
|
||||||
|
flush
|
||||||
|
allow night-shift,ops-east
|
||||||
|
socks -p1080 -u2`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
87
src/shared/contracts.ts
Normal file
87
src/shared/contracts.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { ServiceState } from '../lib/3proxy';
|
||||||
|
|
||||||
|
export type ServiceProtocol = 'socks5' | 'http';
|
||||||
|
export type ServiceCommand = 'socks' | 'proxy' | 'admin';
|
||||||
|
|
||||||
|
export interface DailyTrafficBucket {
|
||||||
|
day: string;
|
||||||
|
bytes: number;
|
||||||
|
share: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyServiceRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
command: ServiceCommand;
|
||||||
|
protocol: ServiceProtocol;
|
||||||
|
description: string;
|
||||||
|
port: number;
|
||||||
|
enabled: boolean;
|
||||||
|
assignable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyUserRecord {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
serviceId: string;
|
||||||
|
status: Exclude<ServiceState, 'paused'>;
|
||||||
|
usedBytes: number;
|
||||||
|
quotaBytes: number | null;
|
||||||
|
paused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlPlaneState {
|
||||||
|
service: {
|
||||||
|
versionLabel: string;
|
||||||
|
lastEvent: string;
|
||||||
|
startedAt: string | null;
|
||||||
|
};
|
||||||
|
traffic: {
|
||||||
|
totalBytes: number;
|
||||||
|
liveConnections: number;
|
||||||
|
activeUsers: number;
|
||||||
|
daily: DailyTrafficBucket[];
|
||||||
|
};
|
||||||
|
userRecords: ProxyUserRecord[];
|
||||||
|
system: {
|
||||||
|
publicHost: string;
|
||||||
|
configMode: string;
|
||||||
|
reloadMode: string;
|
||||||
|
storageMode: string;
|
||||||
|
services: ProxyServiceRecord[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSnapshot {
|
||||||
|
service: {
|
||||||
|
status: ServiceState;
|
||||||
|
pidLabel: string;
|
||||||
|
versionLabel: string;
|
||||||
|
uptimeLabel: string;
|
||||||
|
lastEvent: string;
|
||||||
|
};
|
||||||
|
traffic: ControlPlaneState['traffic'];
|
||||||
|
users: {
|
||||||
|
total: number;
|
||||||
|
live: number;
|
||||||
|
nearQuota: number;
|
||||||
|
exceeded: number;
|
||||||
|
};
|
||||||
|
attention: Array<{
|
||||||
|
level: Exclude<ServiceState, 'idle' | 'paused'>;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}>;
|
||||||
|
userRecords: ProxyUserRecord[];
|
||||||
|
system: ControlPlaneState['system'] & {
|
||||||
|
previewConfig: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserInput {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
serviceId: string;
|
||||||
|
quotaMb: number | null;
|
||||||
|
}
|
||||||
16
tsconfig.server.json
Normal file
16
tsconfig.server.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["server/**/*.ts", "src/shared/**/*.ts", "src/data/mockDashboard.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user