feat: add windows client UI

This commit is contained in:
2026-05-21 20:28:12 +03:00
parent 71e628fbde
commit 6e0d97b65b
6 changed files with 631 additions and 24 deletions

View File

@@ -7,6 +7,7 @@ import { Sidebar } from './components/Sidebar.jsx';
import { StatusPane } from './components/StatusPane.jsx';
import { OverviewPage } from './components/OverviewPage.jsx';
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
import { WindowsOverviewPage } from './components/WindowsOverviewPage.jsx';
import { ServersPage } from './components/ServersPage.jsx';
import { RoutingPage } from './components/RoutingPage.jsx';
import { LogsPage } from './components/LogsPage.jsx';
@@ -97,6 +98,9 @@ function App() {
if (state?.mode === 'client' && page !== 'overview') {
navigate('overview');
}
if (state?.mode === 'windows' && (page === 'servers' || page === 'routing')) {
navigate('overview');
}
}, [state?.mode, page]);
useEffect(() => () => {
@@ -381,6 +385,7 @@ function App() {
[servers, state?.selectedTag],
);
const isClientMode = state?.mode === 'client';
const isWindowsMode = state?.mode === 'windows';
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
const dirtyDevices = Boolean(
@@ -409,11 +414,14 @@ function App() {
onTryApply={rollback}
/>
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
<div className={`app-body${isClientMode ? ' client-mode' : ''}${isWindowsMode ? ' windows-mode' : ''}`}>
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
<main className="app-main">
{(page === 'overview' || isClientMode) && (
{page === 'overview' && isWindowsMode && (
<WindowsOverviewPage pushToast={pushToast} />
)}
{(page === 'overview' || isClientMode) && !isWindowsMode && (
isClientMode ? (
<ClientOverviewPage
state={state}
@@ -447,7 +455,7 @@ function App() {
/>
)
)}
{page === 'servers' && !isClientMode && (
{page === 'servers' && !isClientMode && !isWindowsMode && (
<ServersPage
state={state}
servers={servers}
@@ -463,7 +471,7 @@ function App() {
pushToast={pushToast}
/>
)}
{page === 'routing' && !isClientMode && (
{page === 'routing' && !isClientMode && !isWindowsMode && (
<RoutingPage
rules={customRules}
saveStatus={rulesSaveStatus}
@@ -497,7 +505,7 @@ function App() {
)}
{/* Sticky bar — для routing/servers */}
{(page === 'routing' && dirtyRouting) && (
{(page === 'routing' && dirtyRouting && !isWindowsMode) && (
<div className="sticky-bar">
<div className="flex">
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
@@ -522,7 +530,7 @@ function App() {
</div>
)}
{(page === 'servers' && dirtyServer) && (
{(page === 'servers' && dirtyServer && !isWindowsMode) && (
<div className="sticky-bar">
<div className="flex">
<span className="dot warning" />
@@ -539,7 +547,7 @@ function App() {
)}
</main>
{!isClientMode && (
{!isClientMode && !isWindowsMode && (
<StatusPane
state={state}
busy={busy}

View File

@@ -123,5 +123,40 @@ export const api = {
}),
},
windows: {
status: () => request("/api/windows/status"),
profiles: {
get: () => request("/api/windows/profiles"),
save: (profiles) =>
request("/api/windows/profiles", {
method: "PUT",
body: JSON.stringify({ profiles }),
}),
scan: (profiles) =>
request("/api/windows/profiles/scan", {
method: "POST",
body: JSON.stringify({ profiles }),
}),
apply: () =>
request("/api/windows/profiles/apply", {
method: "POST",
}),
},
targets: {
get: () => request("/api/windows/targets"),
save: (targets) =>
request("/api/windows/targets", {
method: "PUT",
body: JSON.stringify({ targets }),
}),
},
service: (service, action) =>
request("/api/windows/service", {
method: "POST",
body: JSON.stringify({ service, action }),
}),
logs: () => request("/api/windows/logs"),
},
configValidate: () => request("/api/config/validate", { method: "POST" }),
};

View File

@@ -8,10 +8,18 @@ const NAV = [
{ id: 'settings', label: 'Настройки', ico: '⚙' },
];
const WINDOWS_NAV = [
{ id: 'overview', label: 'Overview', ico: 'O' },
{ id: 'logs', label: 'Logs', ico: 'L' },
{ id: 'settings', label: 'Settings', ico: 'S' },
];
export function Sidebar({ active, onChange, badges = {}, mode = 'gateway' }) {
const items = mode === 'client'
? NAV.filter((item) => item.id !== 'routing')
: NAV;
const items = mode === 'windows'
? WINDOWS_NAV
: mode === 'client'
? NAV.filter((item) => item.id !== 'routing')
: NAV;
return (
<nav className="sidebar">

View File

@@ -26,17 +26,22 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
: null;
const isClient = state?.mode === 'client';
const isWindows = state?.mode === 'windows';
const brand = isWindows ? 'VPN Proxy Windows' : isClient ? 'VPN Client' : 'VPN Gateway';
return (
<header className="topbar">
<div className="topbar-brand">
<span className="logo-dot" />
{state?.mode === 'client' ? 'VPN Client' : 'VPN Gateway'}
{brand}
</div>
<div className="topbar-status">
<StatusBadge status={status} />
{activeServer && (
{isWindows && (
<small className="muted">App profiles and ProxiFyre routing</small>
)}
{!isWindows && activeServer && (
<div className="status-text">
<strong>
{flagFor(activeServer)} {activeServer.tag}
@@ -47,29 +52,31 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
</small>
</div>
)}
{!activeServer && (
{!isWindows && !activeServer && (
<small className="muted">Сервер не выбран</small>
)}
{traffic && <span className="badge neutral">{traffic}</span>}
{!isWindows && traffic && <span className="badge neutral">{traffic}</span>}
</div>
<div className="topbar-actions">
{!isClient && dirty && (
{!isClient && !isWindows && dirty && (
<span className="badge warning"> Несохранённые изменения</span>
)}
{!isClient && state?.previousTag && (
{!isClient && !isWindows && state?.previousTag && (
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
Откат
</button>
)}
<button
className="btn btn-secondary sm"
onClick={onRestart}
disabled={!state?.configExists}
title="Перезапустить sing-box"
>
Перезапуск
</button>
{!isWindows && (
<button
className="btn btn-secondary sm"
onClick={onRestart}
disabled={!state?.configExists}
title="Перезапустить sing-box"
>
Перезапуск
</button>
)}
</div>
</header>
);

View File

@@ -0,0 +1,261 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
function targetLabel(target) {
if (!target) return 'No proxy target';
return `${target.name} - ${target.host}:${target.port}`;
}
function routeTitle(status) {
const helper = status?.helperStatus;
const proxifyre = helper?.result?.proxifyre || helper?.proxifyre;
const singbox = helper?.result?.singbox || helper?.singbox;
if (proxifyre === 'Running' && singbox === 'Running') return 'Apps are routed through local sing-box';
if (proxifyre === 'Running') return 'Apps are routed through an existing proxy';
return 'App routing is stopped';
}
function routeState(status) {
const helper = status?.helperStatus;
const proxifyre = helper?.result?.proxifyre || helper?.proxifyre;
if (proxifyre === 'Running') return 'running';
if (helper?.success === false) return 'error';
return 'stopped';
}
function emptyProfile() {
return {
id: `profile-${Date.now()}`,
name: 'New profile',
enabled: true,
proxyTargetId: 'local-singbox',
protocols: ['TCP', 'UDP'],
items: [],
};
}
function ProfileList({ profiles, selectedId, onSelect }) {
return (
<div className="win-profile-list">
{profiles.map((profile) => (
<button
key={profile.id}
className={`win-profile-row ${profile.id === selectedId ? 'active' : ''}`}
onClick={() => onSelect(profile.id)}
type="button"
>
<span className={`win-profile-check ${profile.enabled ? 'on' : ''}`}>on</span>
<span>
<strong>{profile.name}</strong>
<small>{profile.items.length} items - target: {profile.proxyTargetId}</small>
</span>
<em>{profile.resolvedCount ?? profile.items.length}</em>
</button>
))}
</div>
);
}
function ProfileDetails({ profile, targets, onChange }) {
const [newItem, setNewItem] = useState('');
const [newType, setNewType] = useState('process');
if (!profile) {
return <div className="win-profile-empty">Select or add a profile.</div>;
}
function patch(patchValue) {
onChange({ ...profile, ...patchValue });
}
function addItem() {
const value = newItem.trim();
if (!value) return;
patch({
items: [
...profile.items,
{ type: newType, value, recursive: newType === 'folder' },
],
});
setNewItem('');
}
return (
<div className="win-detail">
<label className="checkbox win-enabled">
<input
checked={profile.enabled}
type="checkbox"
onChange={(event) => patch({ enabled: event.target.checked })}
/>
Enabled profile
</label>
<label>
<span>Name</span>
<input
className="input"
value={profile.name}
onChange={(event) => patch({ name: event.target.value })}
/>
</label>
<label>
<span>Proxy target</span>
<select
className="select"
value={profile.proxyTargetId}
onChange={(event) => patch({ proxyTargetId: event.target.value })}
>
{targets.map((target) => (
<option key={target.id} value={target.id}>{targetLabel(target)}</option>
))}
</select>
</label>
<div className="win-add-item">
<select className="select" value={newType} onChange={(event) => setNewType(event.target.value)}>
<option value="process">Process</option>
<option value="folder">Folder</option>
<option value="exe">EXE file</option>
</select>
<input
className="input"
value={newItem}
placeholder="Discord, %LOCALAPPDATA%\\vesktop, or C:\\Games\\game.exe"
onChange={(event) => setNewItem(event.target.value)}
onKeyDown={(event) => event.key === 'Enter' && addItem()}
/>
<button className="btn btn-secondary" onClick={addItem} type="button">Add</button>
</div>
<div className="win-items">
{profile.items.map((item, index) => (
<div key={`${item.type}-${item.value}-${index}`} className="win-item">
<span>{item.value}</span>
<small>{item.type}</small>
<button
className="btn btn-link sm"
onClick={() => patch({ items: profile.items.filter((_, i) => i !== index) })}
type="button"
>
Remove
</button>
</div>
))}
</div>
</div>
);
}
export function WindowsOverviewPage({ pushToast }) {
const [status, setStatus] = useState(null);
const [profiles, setProfiles] = useState([]);
const [targets, setTargets] = useState([]);
const [selectedId, setSelectedId] = useState('');
const [busy, setBusy] = useState(false);
async function load() {
const data = await api.windows.status();
setStatus(data);
const nextProfiles = data.profiles || [];
setProfiles(nextProfiles);
setTargets(data.targets || []);
setSelectedId((current) => current || nextProfiles[0]?.id || '');
}
useEffect(() => {
load().catch((error) => pushToast?.({ kind: 'danger', title: 'Windows status failed', message: error.message }));
const timer = setInterval(() => load().catch(() => {}), 5000);
return () => clearInterval(timer);
}, []);
const selected = useMemo(
() => profiles.find((profile) => profile.id === selectedId) || null,
[profiles, selectedId],
);
function replaceProfile(nextProfile) {
setProfiles((prev) => prev.map((profile) => profile.id === nextProfile.id ? nextProfile : profile));
}
async function saveProfiles(nextProfiles = profiles) {
setBusy(true);
try {
const data = await api.windows.profiles.save(nextProfiles);
setProfiles(data.summaries || data.profiles || []);
pushToast?.({ kind: 'success', title: 'Profiles saved' });
} catch (error) {
pushToast?.({ kind: 'danger', title: 'Save failed', message: error.message });
} finally {
setBusy(false);
}
}
async function applyProfiles() {
setBusy(true);
try {
await api.windows.profiles.save(profiles);
await api.windows.profiles.apply();
await load();
pushToast?.({ kind: 'success', title: 'ProxiFyre updated' });
} catch (error) {
pushToast?.({ kind: 'danger', title: 'Apply failed', message: error.message });
} finally {
setBusy(false);
}
}
function addProfile() {
const profile = emptyProfile();
setProfiles((prev) => [...prev, profile]);
setSelectedId(profile.id);
}
return (
<div className="windows-page">
<section className="windows-status-panel">
<div className="windows-status-main">
<span className={`windows-status-dot ${routeState(status)}`} />
<div>
<h1>{routeTitle(status)}</h1>
<p>Profiles send selected apps through ProxiFyre to local sing-box or an existing proxy target.</p>
</div>
</div>
<div className="windows-route-line">
<span>Selected apps</span><b>-&gt;</b><span>ProxiFyre</span><b>-&gt;</b><span>Proxy target</span>
</div>
</section>
<section className="windows-workspace">
<div className="panel">
<div className="panel-head">
<div>
<h2>Profiles</h2>
<small>{profiles.filter((profile) => profile.enabled).length} enabled</small>
</div>
<button className="btn btn-secondary" onClick={addProfile} type="button">Add profile</button>
</div>
<ProfileList profiles={profiles} selectedId={selectedId} onSelect={setSelectedId} />
</div>
<div className="panel">
<div className="panel-head">
<div>
<h2>{selected?.name || 'Profile'}</h2>
<small>{selected ? targetLabel(targets.find((target) => target.id === selected.proxyTargetId)) : 'No selection'}</small>
</div>
<button className="btn btn-primary" disabled={busy} onClick={applyProfiles} type="button">Apply changes</button>
</div>
<ProfileDetails profile={selected} targets={targets} onChange={replaceProfile} />
</div>
</section>
<section className="panel windows-activity">
<div className="panel-head">
<h2>Recent activity</h2>
<button className="btn btn-secondary" disabled={busy} onClick={() => saveProfiles()} type="button">Save only</button>
</div>
{(status?.activity || []).slice(0, 5).map((entry) => (
<div key={entry.id} className="windows-activity-row">
<strong>{entry.type}</strong>
<span>{entry.message}</span>
<small>{entry.ts}</small>
</div>
))}
</section>
</div>
);
}

View File

@@ -1096,6 +1096,294 @@ code, .mono {
}
}
/* ============ Windows overview ============ */
.app-body.windows-mode {
grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
}
.windows-mode .app-main {
max-width: 1180px;
width: 100%;
margin: 0 auto;
padding-top: 18px;
}
.windows-page {
display: grid;
gap: 12px;
}
.windows-page .panel {
background: #101820;
border: 1px solid #263442;
border-radius: 8px;
padding: 14px;
}
.windows-page .panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.windows-page .panel-head h2 {
font-size: 16px;
}
.windows-status-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
padding: 16px;
background: #101820;
border: 1px solid #263442;
border-radius: 8px;
}
.windows-status-main {
display: flex;
gap: 12px;
align-items: flex-start;
min-width: 0;
}
.windows-status-dot {
width: 12px;
height: 12px;
margin-top: 8px;
border-radius: 50%;
background: var(--subtle);
box-shadow: 0 0 0 6px rgba(111, 140, 124, 0.12);
flex: 0 0 12px;
}
.windows-status-dot.running {
background: var(--success);
box-shadow: 0 0 0 6px rgba(109, 255, 157, 0.12);
}
.windows-status-dot.stopped {
background: var(--warning);
box-shadow: 0 0 0 6px rgba(255, 209, 102, 0.12);
}
.windows-status-dot.error {
background: var(--danger);
box-shadow: 0 0 0 6px rgba(255, 92, 92, 0.12);
}
.windows-status-panel h1 {
margin: 0 0 4px;
font-size: 28px;
line-height: 1.1;
letter-spacing: 0;
}
.windows-status-panel p {
color: var(--muted);
}
.windows-route-line {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #0b1219;
border: 1px solid #253341;
border-radius: 8px;
color: var(--muted);
overflow-x: auto;
white-space: nowrap;
}
.windows-route-line span {
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
}
.windows-route-line b {
color: var(--subtle);
font-weight: 600;
}
.windows-workspace {
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(420px, 1.2fr);
gap: 12px;
align-items: start;
}
.win-profile-list {
display: grid;
gap: 8px;
}
.win-profile-row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
width: 100%;
min-height: 58px;
padding: 10px;
text-align: left;
background: #0b1219;
border: 1px solid #253341;
border-radius: 8px;
color: var(--text);
cursor: pointer;
}
.win-profile-row:hover {
border-color: #4c6d88;
}
.win-profile-row.active {
border-color: var(--info);
background: rgba(142, 212, 255, 0.08);
}
.win-profile-row strong,
.win-profile-row small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.win-profile-row small,
.win-profile-row em {
color: var(--muted);
font-size: 12px;
font-style: normal;
}
.win-profile-check {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 50%;
background: #172536;
color: var(--subtle);
font-size: 11px;
font-weight: 700;
}
.win-profile-check.on {
background: rgba(109, 255, 157, 0.14);
color: var(--success);
}
.win-detail {
display: grid;
gap: 12px;
}
.win-detail label:not(.checkbox) {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 12px;
font-weight: 500;
}
.win-enabled {
justify-self: start;
}
.win-add-item {
display: grid;
grid-template-columns: 120px minmax(0, 1fr) auto;
gap: 8px;
}
.win-items {
display: grid;
gap: 8px;
}
.win-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 8px;
align-items: center;
min-height: 42px;
padding: 8px 10px;
background: #0b1219;
border: 1px solid #253341;
border-radius: 8px;
}
.win-item span {
overflow-wrap: anywhere;
font-family: var(--font-mono);
font-size: 12px;
}
.win-item small {
color: var(--muted);
}
.win-profile-empty {
padding: 24px 0;
text-align: center;
color: var(--muted);
}
.windows-activity {
display: grid;
gap: 0;
}
.windows-activity-row {
display: grid;
grid-template-columns: 140px minmax(0, 1fr) auto;
gap: 10px;
padding: 10px 0;
border-top: 1px solid #253341;
color: var(--muted);
font-size: 13px;
}
.windows-activity-row strong {
color: var(--info);
font-size: 12px;
}
.windows-activity-row span {
overflow-wrap: anywhere;
}
@media (max-width: 1100px) {
.app-body.windows-mode {
grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
}
}
@media (max-width: 980px) {
.windows-status-panel,
.windows-workspace,
.windows-activity-row {
grid-template-columns: 1fr;
}
.win-add-item {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.app-body.windows-mode {
grid-template-columns: 1fr;
}
}
/* For drawer rule editor */
.field-row {
display: grid;