471 lines
13 KiB
Markdown
471 lines
13 KiB
Markdown
# VPN Proxy Client Route Console Redesign Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Replace the current macOS client overview with a route-first console that makes the active traffic path, local proxy address, selected mode, and next action obvious at a glance.
|
|
|
|
**Architecture:** Keep `resolveClientRoute()` as the single source of truth and keep `ClientOverviewPage` as the orchestrator. Split the screen into small presentational components inside `src/web/components/ClientOverviewPage.jsx`, then replace only the client-mode CSS block in `src/web/styles.css` so gateway and Windows work stay untouched.
|
|
|
|
**Tech Stack:** React 19, Vite, Node.js `node:test`, existing CSS variables, Open Design static HTML artifact.
|
|
|
|
**Design Artifact:** `docs/design/open-design/vpn-proxy-route-console-redesign.html`
|
|
|
|
---
|
|
|
|
## Current Findings
|
|
|
|
- `src/web/components/ClientOverviewPage.jsx` already has the right model: one overview screen, mutually exclusive `Gateway`, `VPN`, and `Direct` modes, and route state from `resolveClientRoute()`.
|
|
- `src/web/styles.css` makes the client screen visually separate, but it uses a dark blue-green palette that reads as a monitoring dashboard rather than a macOS setup tool.
|
|
- The current status panel, route line, mode grid, and proxy panel have similar visual weight. The user must scan several boxes to answer the primary question: where does my traffic go right now?
|
|
- Copyable proxy addresses sit in the side panel. They are useful, but they are visually separated from the route story.
|
|
- The three mode buttons look like cards. They work, but they do not communicate that mode selection changes the middle segment of the route.
|
|
|
|
## Target Design
|
|
|
|
Use a light, restrained operational UI for a normal macOS desktop context: a user has Docker running, a browser open, and is checking why an app uses a certain proxy path. The interface should feel closer to a compact network control console than a server dashboard.
|
|
|
|
The first viewport should show:
|
|
|
|
- top status: service running, restart, apply route;
|
|
- left mode rail: Gateway, Local VPN, Direct;
|
|
- main route strip: `Mac apps > local proxy > selected route > Internet`;
|
|
- right utility panel: copy proxy addresses, proxy port, recent activity;
|
|
- settings below route: only the form for the selected mode.
|
|
|
|
## File Structure
|
|
|
|
- Modify `src/web/components/ClientOverviewPage.jsx`: reorganize render structure into route console subcomponents while preserving props and handlers.
|
|
- Modify `src/web/styles.css`: replace `.client-*` layout styles from `.client-mode .app-main` through the final client media query.
|
|
- Test `test/web/client-route.test.js`: extend route state coverage so UI changes do not hide incorrect mode/status combinations.
|
|
- Keep `docs/design/open-design/vpn-proxy-route-console-redesign.html`: reference artifact for visual decisions.
|
|
|
|
---
|
|
|
|
### Task 1: Lock Route Contract Before UI Changes
|
|
|
|
**Files:**
|
|
- Modify: `test/web/client-route.test.js`
|
|
|
|
- [ ] **Step 1: Add tests for all user-visible route statuses**
|
|
|
|
Add these cases to `test/web/client-route.test.js`:
|
|
|
|
```js
|
|
test('resolves running local VPN route', () => {
|
|
const route = resolveClientRoute({
|
|
state: {
|
|
singboxRunning: true,
|
|
configExists: true,
|
|
proxyPort: 8082,
|
|
selectedTag: 'finland-02',
|
|
clientSettings: { homeBypassEnabled: false, sharedProxyEnabled: false },
|
|
},
|
|
activeServer: { tag: 'finland-02' },
|
|
});
|
|
|
|
assert.equal(route.mode, 'vpn');
|
|
assert.equal(route.status, 'connected');
|
|
assert.equal(route.localProxy, '127.0.0.1:8082');
|
|
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8082', 'VPN finland-02', 'Internet']);
|
|
});
|
|
|
|
test('resolves gateway route when shared proxy is enabled', () => {
|
|
const route = resolveClientRoute({
|
|
state: {
|
|
singboxRunning: true,
|
|
configExists: true,
|
|
proxyPort: 8082,
|
|
clientSettings: {
|
|
sharedProxyEnabled: true,
|
|
sharedProxy: { host: '192.168.50.111', port: 8080 },
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.equal(route.mode, 'gateway');
|
|
assert.equal(route.status, 'connected');
|
|
assert.equal(route.target, '192.168.50.111:8080');
|
|
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8082', 'Gateway 192.168.50.111:8080', 'Internet']);
|
|
});
|
|
|
|
test('resolves direct route when home bypass is enabled', () => {
|
|
const route = resolveClientRoute({
|
|
state: {
|
|
singboxRunning: true,
|
|
configExists: true,
|
|
clientSettings: { homeBypassEnabled: true, sharedProxyEnabled: false, proxyPort: 8084 },
|
|
},
|
|
});
|
|
|
|
assert.equal(route.mode, 'direct');
|
|
assert.equal(route.status, 'connected');
|
|
assert.equal(route.localProxy, '127.0.0.1:8084');
|
|
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8084', 'Direct', 'Internet']);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the route tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- test/web/client-route.test.js
|
|
```
|
|
|
|
Expected: all existing and new route tests pass.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add test/web/client-route.test.js
|
|
git commit -m "test: lock client route display contract"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Restructure Client Overview Markup
|
|
|
|
**Files:**
|
|
- Modify: `src/web/components/ClientOverviewPage.jsx`
|
|
|
|
- [ ] **Step 1: Replace the route line with route nodes**
|
|
|
|
Replace `RouteLine` with:
|
|
|
|
```jsx
|
|
function RouteStrip({ route }) {
|
|
const nodes = [
|
|
{ label: 'Источник', value: route.path[0], detail: 'приложения Mac' },
|
|
{ label: 'Локальный proxy', value: route.localProxy, detail: 'HTTP и SOCKS5' },
|
|
{ label: 'Режим', value: route.target, detail: route.targetDetail, active: route.status === 'connected' },
|
|
{ label: 'Выход', value: 'Internet', detail: route.status === 'connected' ? 'маршрут активен' : 'ожидает запуска' },
|
|
];
|
|
|
|
return (
|
|
<div className="client-route-strip">
|
|
{nodes.map((node) => (
|
|
<div className={`client-route-node ${node.active ? 'active' : ''}`} key={node.label}>
|
|
<small>{node.label}</small>
|
|
<strong>{node.value}</strong>
|
|
<span>{node.detail}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RoutePath({ route }) {
|
|
return (
|
|
<div className="client-route-path">
|
|
{route.path.map((item, index) => (
|
|
<React.Fragment key={`${item}-${index}`}>
|
|
<strong>{item}</strong>
|
|
{index < route.path.length - 1 && <span>{'>'}</span>}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add a mode rail component**
|
|
|
|
Add:
|
|
|
|
```jsx
|
|
function ModeRail({ route, setupMode, clientSettings, state, busy, onGateway, onVpn, onDirect }) {
|
|
const modes = [
|
|
{
|
|
id: 'gateway',
|
|
title: 'Общий gateway',
|
|
subtitle: clientSettings?.sharedProxy
|
|
? `${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}`
|
|
: 'серверная proxy',
|
|
onClick: onGateway,
|
|
},
|
|
{
|
|
id: 'vpn',
|
|
title: 'Локальный VPN',
|
|
subtitle: state?.selectedTag || 'выбрать сервер',
|
|
onClick: onVpn,
|
|
},
|
|
{
|
|
id: 'direct',
|
|
title: 'Напрямую',
|
|
subtitle: 'без VPN',
|
|
onClick: onDirect,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<aside className="client-mode-rail">
|
|
<div className="client-section-label">Режим подключения</div>
|
|
<div className="client-mode-list">
|
|
{modes.map((mode) => (
|
|
<button
|
|
key={mode.id}
|
|
type="button"
|
|
className={`client-rail-mode ${setupMode === mode.id ? 'selected' : ''} ${route.mode === mode.id ? 'active' : ''}`}
|
|
disabled={busy}
|
|
onClick={mode.onClick}
|
|
>
|
|
<span className="client-mode-dot" />
|
|
<span>
|
|
<strong>{mode.title}</strong>
|
|
<small>{mode.subtitle}</small>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Replace the top-level JSX**
|
|
|
|
Use this layout in `ClientOverviewPage`:
|
|
|
|
```jsx
|
|
return (
|
|
<div className="client-console">
|
|
<ModeRail
|
|
route={route}
|
|
setupMode={setupMode}
|
|
clientSettings={clientSettings}
|
|
state={state}
|
|
busy={busy}
|
|
onGateway={selectGateway}
|
|
onVpn={selectVpn}
|
|
onDirect={() => {
|
|
setSetupMode('direct');
|
|
enableDirect();
|
|
}}
|
|
/>
|
|
|
|
<section className="client-route-workspace">
|
|
<StatusPanel route={route} state={state} />
|
|
<RouteStrip route={route} />
|
|
<RoutePath route={route} />
|
|
|
|
<section className="client-mode-panel">
|
|
{setupMode === 'gateway' && (
|
|
<GatewaySettings settings={clientSettings} busy={busy} onCheck={onCheckSharedProxy} />
|
|
)}
|
|
{setupMode === 'vpn' && (
|
|
<VpnSettings
|
|
state={state}
|
|
servers={servers}
|
|
subscriptionUrl={subscriptionUrl}
|
|
setSubscriptionUrl={setSubscriptionUrl}
|
|
pendingTag={pendingTag}
|
|
setPendingTag={setPendingTag}
|
|
busy={busy}
|
|
onFetchSubscription={onFetchSubscription}
|
|
onApply={onApply}
|
|
/>
|
|
)}
|
|
{setupMode === 'direct' && <DirectSettings busy={busy} onEnable={enableDirect} />}
|
|
</section>
|
|
</section>
|
|
|
|
<ProxySettings state={state} settings={clientSettings} busy={busy} onSave={onSaveClientSettings} />
|
|
</div>
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 4: Run build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run build
|
|
```
|
|
|
|
Expected: Vite build succeeds.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/web/components/ClientOverviewPage.jsx
|
|
git commit -m "refactor: reshape client overview around route console"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Replace Client Visual System
|
|
|
|
**Files:**
|
|
- Modify: `src/web/styles.css`
|
|
|
|
- [ ] **Step 1: Replace only the client CSS block**
|
|
|
|
Replace the CSS from `.client-mode .app-main` through the client media query with the style direction from `docs/design/open-design/vpn-proxy-route-console-redesign.html`. Keep selectors scoped to `.client-*` so gateway screens keep the existing palette.
|
|
|
|
Use these token values for the client block:
|
|
|
|
```css
|
|
.app-body.client-mode {
|
|
grid-template-columns: 1fr;
|
|
background: oklch(0.965 0.008 232);
|
|
}
|
|
|
|
.client-mode .topbar {
|
|
background: oklch(0.978 0.007 232);
|
|
border-bottom-color: oklch(0.835 0.018 232);
|
|
}
|
|
|
|
.client-mode .app-main {
|
|
max-width: 1320px;
|
|
width: 100%;
|
|
margin: 0 auto;
|
|
padding: 18px;
|
|
color: oklch(0.238 0.028 238);
|
|
}
|
|
|
|
.client-console {
|
|
min-height: calc(100vh - var(--topbar-h) - 36px);
|
|
display: grid;
|
|
grid-template-columns: 264px minmax(0, 1fr) 312px;
|
|
overflow: hidden;
|
|
background: oklch(0.986 0.006 232);
|
|
border: 1px solid oklch(0.835 0.018 232);
|
|
border-radius: 8px;
|
|
box-shadow: 0 18px 42px oklch(0.36 0.035 238 / 0.13);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add responsive behavior**
|
|
|
|
Add:
|
|
|
|
```css
|
|
@media (max-width: 1080px) {
|
|
.client-console {
|
|
grid-template-columns: 220px minmax(0, 1fr);
|
|
}
|
|
|
|
.client-side-panel {
|
|
grid-column: 1 / -1;
|
|
border-left: 0;
|
|
border-top: 1px solid oklch(0.835 0.018 232);
|
|
}
|
|
|
|
.client-route-strip {
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
.client-console,
|
|
.client-route-strip,
|
|
.client-inline-form,
|
|
.client-port-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.client-mode-rail {
|
|
border-right: 0;
|
|
border-bottom: 1px solid oklch(0.835 0.018 232);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify no banned patterns were introduced**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n "background-clip:\\s*text|border-left:\\s*[2-9]|border-right:\\s*[2-9]|backdrop-filter|letter-spacing:\\s*-" src/web/styles.css
|
|
```
|
|
|
|
Expected: no matches.
|
|
|
|
- [ ] **Step 4: Run build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run build
|
|
```
|
|
|
|
Expected: Vite build succeeds.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/web/styles.css
|
|
git commit -m "style: apply light route console client theme"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Browser Verification
|
|
|
|
**Files:**
|
|
- No file changes expected.
|
|
|
|
- [ ] **Step 1: Start the dev server**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run dev -- --host 127.0.0.1 --port 4567
|
|
```
|
|
|
|
Expected: Vite listens on `http://127.0.0.1:4567`.
|
|
|
|
- [ ] **Step 2: Open client mode with representative state**
|
|
|
|
Use the browser to open:
|
|
|
|
```text
|
|
http://127.0.0.1:4567
|
|
```
|
|
|
|
Expected: the first viewport shows the mode rail, route strip, route path, selected-mode form, and copyable proxy addresses without overlap at desktop width.
|
|
|
|
- [ ] **Step 3: Check mobile width**
|
|
|
|
Resize to 390px wide.
|
|
|
|
Expected: rail, route workspace, and proxy panel stack vertically; long proxy URLs truncate inside their containers; action buttons remain readable.
|
|
|
|
- [ ] **Step 4: Run final verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test
|
|
npm run build
|
|
git diff --check
|
|
```
|
|
|
|
Expected: all commands pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/web/components/ClientOverviewPage.jsx src/web/styles.css test/web/client-route.test.js
|
|
git commit -m "feat: redesign client overview as route console"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
Spec coverage:
|
|
|
|
- Current UX assessment is captured in `Current Findings`.
|
|
- New design direction is captured in `Target Design`.
|
|
- Open Design artifact is referenced explicitly.
|
|
- Implementation tasks cover route contract, markup, scoped CSS, and browser verification.
|
|
|
|
Placeholder scan:
|
|
|
|
- No `TBD`, `TODO`, or unspecified validation steps remain.
|
|
|
|
Type consistency:
|
|
|
|
- Route fields match `resolveClientRoute()`: `mode`, `status`, `localProxy`, `target`, `targetDetail`, `path`.
|