Refactor VPN proxy routing logic
142
.interface-design/system.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Windows Client Interface System
|
||||||
|
|
||||||
|
Дата: 2026-07-08
|
||||||
|
|
||||||
|
## Направление
|
||||||
|
|
||||||
|
Это максимально простая Windows-утилита управления службами и их состояниями. Она не должна ощущаться как VPN-dashboard, SaaS-панель или маркетинговый клиент. Главные глаголы интерфейса:
|
||||||
|
|
||||||
|
- проверить состояние;
|
||||||
|
- установить компонент;
|
||||||
|
- удалить компонент;
|
||||||
|
- запустить службу;
|
||||||
|
- остановить службу;
|
||||||
|
- обновить/перепроверить состояние;
|
||||||
|
- добавить приложение/путь;
|
||||||
|
- применить конфигурацию только когда prerequisites готовы.
|
||||||
|
|
||||||
|
Интерфейс должен быть плотным, спокойным, системным и предсказуемым. Пользователь открывает его не для анализа статистики, а чтобы быстро понять "что сейчас установлено, что запущено, что сломано и какую одну кнопку нажать дальше".
|
||||||
|
|
||||||
|
## Домен
|
||||||
|
|
||||||
|
Ключевые понятия: Windows service, installed/running/stopped/missing, install/uninstall, helper/elevation, ProxiFyre, Local sing-box, selected apps, endpoint, generated config, logs, diagnostics, readiness blockers.
|
||||||
|
|
||||||
|
Цветовой мир: темная Windows-оболочка, service console, terminal black, muted slate panels, driver/service green, warning amber, blocked red, focus/navigation blue.
|
||||||
|
|
||||||
|
Сигнатура продукта: `Service Control Row` - компактная строка компонента, где слева состояние службы, в центре человекочитаемый статус и путь/деталь, справа ровно одно главное действие плюс меню дополнительных действий. Эта строка должна быть повторяемым паттерном для ProxiFyre, Local sing-box и будущих компонентов.
|
||||||
|
|
||||||
|
## Визуальная модель
|
||||||
|
|
||||||
|
- Основной layout: fixed header, fixed tabs, scrollable work area, adaptive log/status area.
|
||||||
|
- Основная композиция: не больше одного главного действия на компонент или экран.
|
||||||
|
- Summary должен отвечать "готово / не готово / что сделать дальше", а не показывать длинный dashboard.
|
||||||
|
- Route chain полезен, но он вторичен к service-control модели. Он должен объяснять эффект состояний служб, а не превращать приложение в карту сети.
|
||||||
|
|
||||||
|
## Depth And Surfaces
|
||||||
|
|
||||||
|
Стратегия глубины: borders-only + subtle surface shifts. Без тяжелых shadows.
|
||||||
|
|
||||||
|
- `canvas`: `#101216` - фон приложения.
|
||||||
|
- `surface`: `#131720` - обычные панели.
|
||||||
|
- `surface-raised`: `#151923` - service rows и важные панели.
|
||||||
|
- `surface-control`: `#242a35` - neutral buttons.
|
||||||
|
- `surface-inset`: `#0d1016` - inputs и read-only inset fields.
|
||||||
|
- `border`: `#2b3342`.
|
||||||
|
- `border-strong`: `#343b49`.
|
||||||
|
- `focus`: `#3b82f6`.
|
||||||
|
|
||||||
|
Радиусы: 4px для buttons, inputs, rows, panels; 999px только для pills/dots. Карточки и панели не должны становиться мягкими/крупно-скругленными.
|
||||||
|
|
||||||
|
Spacing base: 4px. Основные значения: 6, 8, 10, 12, 14, 18. Для плотной утилиты 20+ использовать редко, только между большими группами.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
Шрифт: Inter/system stack как сейчас.
|
||||||
|
|
||||||
|
Плотная шкала:
|
||||||
|
|
||||||
|
- caption: 12px / 500-700 / muted;
|
||||||
|
- body: 14px / 400-500 / secondary;
|
||||||
|
- control: 14px / 700 / primary;
|
||||||
|
- section title: 16px / 700;
|
||||||
|
- screen title: 18px / 650-750;
|
||||||
|
- status title: 22px / 700 only for primary readiness state.
|
||||||
|
|
||||||
|
Иерархия должна строиться весом и цветом больше, чем размером. Letter spacing держать `0`.
|
||||||
|
|
||||||
|
## Компонентная база
|
||||||
|
|
||||||
|
### Button
|
||||||
|
|
||||||
|
Все кнопки должны идти через общий компонент и общие variants:
|
||||||
|
|
||||||
|
- `primary`: главное безопасное действие текущего блока. Зеленый использовать только когда действие реально применимо и готово к выполнению.
|
||||||
|
- `neutral`: refresh, open, cancel, secondary action.
|
||||||
|
- `add`: добавление процесса, EXE, папки, target. Иконка + tooltip; текстовая кнопка только когда без текста смысл неясен.
|
||||||
|
- `danger`: stop/uninstall/delete. Не смешивать с neutral.
|
||||||
|
- `icon`: квадрат 40-44px, только иконка, обязательны `aria-label`, `title` или tooltip.
|
||||||
|
|
||||||
|
Button states обязательны: default, hover, active, focus-visible, disabled, loading.
|
||||||
|
|
||||||
|
Loading state не должен выглядеть как зависание: показывать spinner/progress label, менять текст на действие в процессе ("Проверяю", "Устанавливаю", "Останавливаю"), блокировать повторный запуск.
|
||||||
|
|
||||||
|
### Service Control Row
|
||||||
|
|
||||||
|
Повторяемый компонент для служб:
|
||||||
|
|
||||||
|
- left: status dot/icon (`checking`, `missing`, `installed`, `running`, `stopped`, `error`);
|
||||||
|
- center: title + short status + optional path/details;
|
||||||
|
- right: primary action (`Установить`, `Запустить`, `Остановить`, `Обновить`) + overflow menu;
|
||||||
|
- expanded area: setup checklist, diagnostics, paths, generated config.
|
||||||
|
|
||||||
|
В строке не должно быть двух конкурирующих primary actions.
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
Tabs должны быть единым компонентом:
|
||||||
|
|
||||||
|
- role tablist/tab/tabpanel;
|
||||||
|
- arrow-key navigation;
|
||||||
|
- active indicator 3px blue;
|
||||||
|
- height 46px desktop, не меньше 40px narrow;
|
||||||
|
- transition 180-240ms только `opacity` + `transform`;
|
||||||
|
- no layout shift при переключении.
|
||||||
|
|
||||||
|
### Status And Logs
|
||||||
|
|
||||||
|
Status surface должен показывать человеческий текст первым, raw details вторым уровнем.
|
||||||
|
|
||||||
|
- Preview/native command errors: friendly summary in dock, raw error in details.
|
||||||
|
- `aria-live` объявляет только короткий статус.
|
||||||
|
- Narrow width: dock collapses into compact toast/details control.
|
||||||
|
|
||||||
|
### Forms And Inputs
|
||||||
|
|
||||||
|
Inputs are inset:
|
||||||
|
|
||||||
|
- background `surface-inset`;
|
||||||
|
- border `border-strong`;
|
||||||
|
- focus ring 1px `focus`;
|
||||||
|
- validation appears directly below or beside the field;
|
||||||
|
- invalid state disables dependent action and explains exact accepted format.
|
||||||
|
|
||||||
|
### Motion
|
||||||
|
|
||||||
|
Motion должна показывать, что интерфейс живой, но не тормозить повторяющиеся операции.
|
||||||
|
|
||||||
|
- Button press: 100-140ms, `transform: scale(0.98)`.
|
||||||
|
- Tab switch: 180-240ms, `opacity` + `translateX` 12-24px.
|
||||||
|
- Popover/menu: 150-180ms, `opacity` + `scale(0.97 -> 1)`.
|
||||||
|
- Service checking/installing: subtle spinner or border trace, but not decorative glow everywhere.
|
||||||
|
- Respect `prefers-reduced-motion`.
|
||||||
|
|
||||||
|
Не использовать `transition: all`.
|
||||||
|
|
||||||
|
## Implementation Priorities
|
||||||
|
|
||||||
|
1. Создать shared UI layer: `Button`, `IconButton`, `Tabs`, `ServiceControlRow`, `StatusPill`, `LogDock`, `Field`, `ActionMenu`.
|
||||||
|
2. Перевести текущие кнопки на variants, чтобы "обновить", "добавить", "установить", "удалить", "применить" выглядели и вели себя стабильно.
|
||||||
|
3. Ввести readiness blockers для apply actions.
|
||||||
|
4. Свести все анимации к общим duration/easing tokens.
|
||||||
|
5. Проверить keyboard flow и responsive snapshots после каждого крупного UI изменения.
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, MoreHorizontal, Trash2, Wand2 } from 'lucide-react';
|
import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, Trash2, Wand2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
applyProfiles,
|
applyProfiles,
|
||||||
fetchSingBoxSubscription,
|
fetchSingBoxSubscription,
|
||||||
@@ -34,6 +34,9 @@ import {
|
|||||||
type SingBoxSetupStatus,
|
type SingBoxSetupStatus,
|
||||||
} from '../api/tauriCommands';
|
} from '../api/tauriCommands';
|
||||||
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types';
|
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types';
|
||||||
|
import { Button, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
|
||||||
|
import { getApplyReadiness } from './readiness';
|
||||||
|
import { serviceControlState } from './viewModel';
|
||||||
|
|
||||||
type DraftItemType = Extract<ProfileItemType, 'process' | 'folder' | 'exe'>;
|
type DraftItemType = Extract<ProfileItemType, 'process' | 'folder' | 'exe'>;
|
||||||
type ProxiFyreAction = 'start' | 'stop' | 'install' | 'uninstall';
|
type ProxiFyreAction = 'start' | 'stop' | 'install' | 'uninstall';
|
||||||
@@ -152,12 +155,7 @@ export function App() {
|
|||||||
() => logEntries.find((entry) => entry.id === activeLogId) ?? null,
|
() => logEntries.find((entry) => entry.id === activeLogId) ?? null,
|
||||||
[activeLogId, logEntries],
|
[activeLogId, logEntries],
|
||||||
);
|
);
|
||||||
const finderStateClass = isDetectingComponents ? 'checking' : proxyfier?.installed ? 'found' : 'missing';
|
|
||||||
const finderVisualClass =
|
|
||||||
serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : '';
|
|
||||||
const isSingBoxInstalled = Boolean(singbox?.installed);
|
const isSingBoxInstalled = Boolean(singbox?.installed);
|
||||||
const singBoxStateClass = isDetectingComponents ? 'checking' : singbox?.installed ? 'found' : 'missing';
|
|
||||||
const singBoxVisualClass = singBoxAction ? 'working' : '';
|
|
||||||
const selectedServerTag = singBoxStatus?.config.selectedServerTag;
|
const selectedServerTag = singBoxStatus?.config.selectedServerTag;
|
||||||
const currentSnapshot = useMemo(
|
const currentSnapshot = useMemo(
|
||||||
() => configSnapshotFromUi(routeMode, proxyInput, items, selectedServerTag),
|
() => configSnapshotFromUi(routeMode, proxyInput, items, selectedServerTag),
|
||||||
@@ -882,24 +880,7 @@ export function App() {
|
|||||||
{ id: 'proxy', label: 'VPN / Прокси' },
|
{ id: 'proxy', label: 'VPN / Прокси' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return <Tabs items={tabs} activeId={activePanel} onChange={switchPanel} ariaLabel="Разделы Proxy" />;
|
||||||
<div className="panel-tabs" role="tablist" aria-label="Разделы Proxy">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
id={`tab-${tab.id}`}
|
|
||||||
aria-controls={`panel-${tab.id}`}
|
|
||||||
aria-selected={activePanel === tab.id}
|
|
||||||
className={activePanel === tab.id ? 'active' : ''}
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => switchPanel(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSummaryPanel() {
|
function renderSummaryPanel() {
|
||||||
@@ -985,79 +966,59 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="summary-actions">
|
<div className="summary-actions">
|
||||||
<button type="button" onClick={() => switchPanel('proxy')}>
|
<Button type="button" variant="neutral" onClick={() => switchPanel('proxy')}>
|
||||||
Настроить маршрут
|
Настроить маршрут
|
||||||
</button>
|
</Button>
|
||||||
<button type="button" onClick={openAppsPanel}>
|
<Button type="button" variant="neutral" onClick={openAppsPanel}>
|
||||||
Приложения ProxiFyre
|
Приложения ProxiFyre
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProxiFyreCard() {
|
function renderProxiFyreCard() {
|
||||||
|
const state = serviceControlState(proxyfier, isDetectingComponents);
|
||||||
|
const visualState = serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : null;
|
||||||
|
const primaryAction = proxyfier?.installed
|
||||||
|
? {
|
||||||
|
label: proxyfier.running ? 'Остановить' : 'Запустить',
|
||||||
|
onClick: () => void setProxiFyreServiceRunning(!proxyfier.running),
|
||||||
|
variant: proxyfier.running ? 'danger' as const : 'neutral' as const,
|
||||||
|
loading: serviceAction === 'start' || serviceAction === 'stop',
|
||||||
|
loadingLabel: serviceAction === 'start' ? 'Запускаю' : 'Останавливаю',
|
||||||
|
disabled: isDetectingComponents || Boolean(serviceAction),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: 'Установить',
|
||||||
|
onClick: () => void installProxiFyrePackage(),
|
||||||
|
variant: 'primary' as const,
|
||||||
|
loading: serviceAction === 'install',
|
||||||
|
loadingLabel: 'Устанавливаю',
|
||||||
|
disabled: isDetectingComponents || Boolean(serviceAction),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`finder-card proxifyre-card ${finderStateClass} ${finderVisualClass}`.trim()}>
|
<ServiceControlRow
|
||||||
<span className="finder-border-glow" aria-hidden="true">
|
state={state}
|
||||||
<span className="finder-border-glow-segment top" />
|
visualState={visualState}
|
||||||
<span className="finder-border-glow-segment right" />
|
className="proxifyre-card"
|
||||||
<span className="finder-border-glow-segment bottom" />
|
title={proxyfierTitle(proxyfier, isDetectingComponents)}
|
||||||
<span className="finder-border-glow-segment left" />
|
detail={proxyfierDetails(proxyfier, isDetectingComponents)}
|
||||||
</span>
|
primaryAction={primaryAction}
|
||||||
<span className="status-light" />
|
menu={proxyfier?.installed ? {
|
||||||
<div className="finder-text">
|
label: 'Дополнительные действия ProxiFyre',
|
||||||
<strong>{proxyfierTitle(proxyfier, isDetectingComponents)}</strong>
|
open: isServiceMenuOpen,
|
||||||
<span>{proxyfierDetails(proxyfier, isDetectingComponents)}</span>
|
onOpenChange: setIsServiceMenuOpen,
|
||||||
</div>
|
disabled: isDetectingComponents || Boolean(serviceAction),
|
||||||
<div className="service-actions" aria-label="Управление службой ProxiFyre">
|
items: [{
|
||||||
{proxyfier?.installed ? (
|
label: serviceAction === 'uninstall' ? 'Удаляю...' : 'Удалить ProxiFyre',
|
||||||
<>
|
danger: true,
|
||||||
<button
|
disabled: Boolean(serviceAction),
|
||||||
type="button"
|
onClick: () => void uninstallProxiFyrePackage(),
|
||||||
className={`service-button ${proxyfier.running ? 'stop' : ''}`.trim()}
|
}],
|
||||||
onClick={() => setProxiFyreServiceRunning(!proxyfier.running)}
|
} : undefined}
|
||||||
disabled={isDetectingComponents || Boolean(serviceAction)}
|
/>
|
||||||
>
|
|
||||||
{serviceAction === 'start' || serviceAction === 'stop'
|
|
||||||
? '...'
|
|
||||||
: proxyfier.running
|
|
||||||
? 'Остановить'
|
|
||||||
: 'Запустить'}
|
|
||||||
</button>
|
|
||||||
<div className="service-menu">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="service-menu-button"
|
|
||||||
onClick={() => setIsServiceMenuOpen((current) => !current)}
|
|
||||||
disabled={isDetectingComponents || Boolean(serviceAction)}
|
|
||||||
aria-label="Дополнительные действия ProxiFyre"
|
|
||||||
aria-expanded={isServiceMenuOpen}
|
|
||||||
title="Еще"
|
|
||||||
>
|
|
||||||
<MoreHorizontal size={20} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
{isServiceMenuOpen ? (
|
|
||||||
<div className="service-menu-popover">
|
|
||||||
<button type="button" onClick={() => void uninstallProxiFyrePackage()}>
|
|
||||||
{serviceAction === 'uninstall' ? 'Удаляю...' : 'Удалить ProxiFyre'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="service-button install"
|
|
||||||
onClick={() => void installProxiFyrePackage()}
|
|
||||||
disabled={isDetectingComponents || Boolean(serviceAction)}
|
|
||||||
>
|
|
||||||
{serviceAction === 'install' ? '...' : 'Установить'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1109,19 +1070,19 @@ export function App() {
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={addProcess}>
|
<Button type="button" variant="primary" onClick={addProcess}>
|
||||||
OK
|
OK
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="process-cancel-button"
|
variant="neutral"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setProcessInput('');
|
setProcessInput('');
|
||||||
setIsProcessInputOpen(false);
|
setIsProcessInputOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="app-add-skeleton">
|
<div className="app-add-skeleton">
|
||||||
@@ -1135,38 +1096,31 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="add-toolbar" aria-label="Добавить приложение">
|
<div className="add-toolbar" aria-label="Добавить приложение">
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
type="button"
|
||||||
className="add-tile"
|
variant="add"
|
||||||
onClick={() => setIsProcessInputOpen(true)}
|
onClick={() => setIsProcessInputOpen(true)}
|
||||||
aria-label="Добавить процесс"
|
label="Добавить процесс"
|
||||||
data-tooltip="Добавить процесс"
|
icon={<Cpu size={20} strokeWidth={1.8} />}
|
||||||
>
|
/>
|
||||||
<Cpu size={20} strokeWidth={1.8} />
|
<IconButton
|
||||||
<span className="sr-only">Добавить процесс</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
type="button"
|
||||||
className="add-tile"
|
variant="add"
|
||||||
onClick={() => void pickAndAddItem('exe')}
|
onClick={() => void pickAndAddItem('exe')}
|
||||||
disabled={Boolean(pickerAction)}
|
disabled={Boolean(pickerAction)}
|
||||||
aria-label="Добавить EXE-файл"
|
loading={pickerAction === 'exe'}
|
||||||
data-tooltip="Добавить EXE-файл"
|
label="Добавить EXE-файл"
|
||||||
>
|
icon={<FileCode2 size={20} strokeWidth={1.8} />}
|
||||||
{pickerAction === 'exe' ? <span className="tile-loading">...</span> : <FileCode2 size={20} strokeWidth={1.8} />}
|
/>
|
||||||
<span className="sr-only">Добавить EXE-файл</span>
|
<IconButton
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
type="button"
|
||||||
className="add-tile"
|
variant="add"
|
||||||
onClick={() => void pickAndAddItem('folder')}
|
onClick={() => void pickAndAddItem('folder')}
|
||||||
disabled={Boolean(pickerAction)}
|
disabled={Boolean(pickerAction)}
|
||||||
aria-label="Добавить папку"
|
loading={pickerAction === 'folder'}
|
||||||
data-tooltip="Добавить папку"
|
label="Добавить папку"
|
||||||
>
|
icon={<FolderOpen size={20} strokeWidth={1.8} />}
|
||||||
{pickerAction === 'folder' ? <span className="tile-loading">...</span> : <FolderOpen size={20} strokeWidth={1.8} />}
|
/>
|
||||||
<span className="sr-only">Добавить папку</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1189,9 +1143,9 @@ export function App() {
|
|||||||
<span>{itemTypeLabel(item.type)}</span>
|
<span>{itemTypeLabel(item.type)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => removeItem(item.id)} aria-label={`Удалить ${item.value}`}>
|
<Button type="button" variant="danger" onClick={() => removeItem(item.id)} aria-label={`Удалить ${item.value}`}>
|
||||||
Удалить
|
Удалить
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -1207,10 +1161,27 @@ export function App() {
|
|||||||
options: { showConfigPath?: boolean } = {},
|
options: { showConfigPath?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
const showConfigPath = options.showConfigPath ?? true;
|
const showConfigPath = options.showConfigPath ?? true;
|
||||||
|
const externalProxyError = routeMode === 'external' ? safeProxyError(proxyInput) : null;
|
||||||
|
const readiness = getApplyReadiness({
|
||||||
|
routeMode,
|
||||||
|
appCount: items.length,
|
||||||
|
proxiFyreInstalled: Boolean(proxyfier?.installed),
|
||||||
|
singBoxInstalled: isSingBoxInstalled,
|
||||||
|
selectedServerTag: singBoxStatus?.config.selectedServerTag,
|
||||||
|
externalProxyValue: proxyInput,
|
||||||
|
externalProxyError,
|
||||||
|
busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction),
|
||||||
|
});
|
||||||
|
const showBlocker = !readiness.ready && !isLoading && !isDetectingComponents;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{hasUnappliedChanges ? (
|
{showBlocker ? (
|
||||||
|
<div className="apply-state blocked" role="status">
|
||||||
|
<strong>{readiness.title}</strong>
|
||||||
|
<span>{readiness.text}</span>
|
||||||
|
</div>
|
||||||
|
) : hasUnappliedChanges ? (
|
||||||
<div className="apply-state pending" role="status">
|
<div className="apply-state pending" role="status">
|
||||||
<strong>Изменения еще не применены в ProxiFyre</strong>
|
<strong>Изменения еще не применены в ProxiFyre</strong>
|
||||||
<span>{applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))}</span>
|
<span>{applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))}</span>
|
||||||
@@ -1218,17 +1189,27 @@ export function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="command-row">
|
<div className="command-row">
|
||||||
<button type="button" className="apply-button" onClick={updateConfig} disabled={isApplying}>
|
<Button
|
||||||
{applyButtonLabel(context, isApplying, hasUnappliedChanges, singBoxAction)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
type="button"
|
||||||
className="open-config-button"
|
variant={readiness.ready ? 'primary' : 'neutral'}
|
||||||
onClick={openConfig}
|
size="lg"
|
||||||
disabled={isOpeningConfig}
|
onClick={updateConfig}
|
||||||
|
disabled={!readiness.ready}
|
||||||
|
loading={isApplying}
|
||||||
|
loadingLabel={applyButtonLabel(context, true, hasUnappliedChanges, singBoxAction)}
|
||||||
>
|
>
|
||||||
{isOpeningConfig ? '...' : 'Открыть'}
|
{applyButtonLabel(context, isApplying, hasUnappliedChanges, singBoxAction)}
|
||||||
</button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
|
size="lg"
|
||||||
|
onClick={openConfig}
|
||||||
|
loading={isOpeningConfig}
|
||||||
|
loadingLabel="Открываю"
|
||||||
|
>
|
||||||
|
Открыть
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showConfigPath && generatedConfigPath ? <p className="config-path">{generatedConfigPath}</p> : null}
|
{showConfigPath && generatedConfigPath ? <p className="config-path">{generatedConfigPath}</p> : null}
|
||||||
@@ -1270,35 +1251,68 @@ export function App() {
|
|||||||
<strong>{proxyPing ? pingResultTitle(proxyPing) : proxyValidation ? 'Проверь формат' : 'Проверка не запускалась'}</strong>
|
<strong>{proxyPing ? pingResultTitle(proxyPing) : proxyValidation ? 'Проверь формат' : 'Проверка не запускалась'}</strong>
|
||||||
<span>{proxyPing ? pingResultText(proxyPing) : proxyValidation ?? 'TCP check покажет, доступен ли host:port.'}</span>
|
<span>{proxyPing ? pingResultText(proxyPing) : proxyValidation ?? 'TCP check покажет, доступен ли host:port.'}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="open-config-button"
|
variant="neutral"
|
||||||
|
size="lg"
|
||||||
onClick={() => void pingExternalProxy()}
|
onClick={() => void pingExternalProxy()}
|
||||||
disabled={isProxyChecking || Boolean(proxyValidation)}
|
disabled={Boolean(proxyValidation)}
|
||||||
|
loading={isProxyChecking}
|
||||||
|
loadingLabel="Проверяю"
|
||||||
>
|
>
|
||||||
{isProxyChecking ? '...' : 'Проверить'}
|
Проверить
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSingBoxCard() {
|
function renderSingBoxCard() {
|
||||||
|
const state = serviceControlState(singbox, isDetectingComponents);
|
||||||
|
const primaryAction = singbox?.installed
|
||||||
|
? {
|
||||||
|
label: singbox.running ? 'Остановить' : 'Запустить',
|
||||||
|
onClick: () => void setSingBoxServiceRunning(!singbox.running),
|
||||||
|
variant: singbox.running ? 'danger' as const : 'neutral' as const,
|
||||||
|
loading: singBoxAction === 'start' || singBoxAction === 'stop',
|
||||||
|
loadingLabel: singBoxAction === 'start' ? 'Запускаю' : 'Останавливаю',
|
||||||
|
disabled: isDetectingComponents || Boolean(singBoxAction),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: 'Установить',
|
||||||
|
onClick: () => void installSingBoxPackage(),
|
||||||
|
variant: 'primary' as const,
|
||||||
|
loading: singBoxAction === 'install',
|
||||||
|
loadingLabel: 'Устанавливаю',
|
||||||
|
disabled: isDetectingComponents || Boolean(singBoxAction),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`finder-card singbox-card ${singBoxStateClass} ${singBoxVisualClass}`.trim()}>
|
<ServiceControlRow
|
||||||
<span className="finder-border-glow" aria-hidden="true">
|
state={state}
|
||||||
<span className="finder-border-glow-segment top" />
|
visualState={singBoxAction ? 'working' : null}
|
||||||
<span className="finder-border-glow-segment right" />
|
className="singbox-card"
|
||||||
<span className="finder-border-glow-segment bottom" />
|
title={singBoxTitle(singbox, isDetectingComponents)}
|
||||||
<span className="finder-border-glow-segment left" />
|
detail={singBoxDetails(singbox, singBoxStatus, isDetectingComponents)}
|
||||||
</span>
|
primaryAction={primaryAction}
|
||||||
<span className="status-light" />
|
menu={singbox?.installed ? {
|
||||||
<div className="finder-text">
|
label: 'Дополнительные действия Local sing-box',
|
||||||
<strong>{singBoxTitle(singbox, isDetectingComponents)}</strong>
|
open: isSingBoxMenuOpen,
|
||||||
<span>{singBoxDetails(singbox, singBoxStatus, isDetectingComponents)}</span>
|
onOpenChange: setIsSingBoxMenuOpen,
|
||||||
<div className="singbox-inline-actions">
|
disabled: isDetectingComponents || Boolean(singBoxAction),
|
||||||
<button
|
items: [{
|
||||||
|
label: singBoxAction === 'uninstall' ? 'Удаляю...' : 'Удалить Local sing-box',
|
||||||
|
danger: true,
|
||||||
|
disabled: Boolean(singBoxAction),
|
||||||
|
onClick: () => void uninstallSingBoxPackage(),
|
||||||
|
}],
|
||||||
|
} : undefined}
|
||||||
|
inlineActions={(
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
|
size="sm"
|
||||||
className="setup-toggle"
|
className="setup-toggle"
|
||||||
onClick={() => setIsSingBoxSetupOpen((current) => !current)}
|
onClick={() => setIsSingBoxSetupOpen((current) => !current)}
|
||||||
disabled={isDetectingComponents && !singBoxSetupStatus}
|
disabled={isDetectingComponents && !singBoxSetupStatus}
|
||||||
@@ -1308,67 +1322,20 @@ export function App() {
|
|||||||
{singBoxSetupStatus ? (
|
{singBoxSetupStatus ? (
|
||||||
<span>{singBoxSetupStatus.ready ? 'все есть' : `не хватает: ${singBoxSetupStatus.missingCount}`}</span>
|
<span>{singBoxSetupStatus.ready ? 'все есть' : `не хватает: ${singBoxSetupStatus.missingCount}`}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
className="info-toggle"
|
className="info-toggle"
|
||||||
onClick={() => setIsSingBoxInfoOpen((current) => !current)}
|
onClick={() => setIsSingBoxInfoOpen((current) => !current)}
|
||||||
aria-label="Подробности Local sing-box"
|
label="Подробности Local sing-box"
|
||||||
aria-expanded={isSingBoxInfoOpen}
|
aria-expanded={isSingBoxInfoOpen}
|
||||||
title="Подробности"
|
title="Подробности"
|
||||||
>
|
icon={<Info size={16} strokeWidth={2} />}
|
||||||
<Info size={16} strokeWidth={2} />
|
/>
|
||||||
</button>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
>
|
||||||
<div className="service-actions" aria-label="Управление службой Local sing-box">
|
|
||||||
{singbox?.installed ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`service-button ${singbox.running ? 'stop' : ''}`.trim()}
|
|
||||||
onClick={() => void setSingBoxServiceRunning(!singbox.running)}
|
|
||||||
disabled={isDetectingComponents || Boolean(singBoxAction)}
|
|
||||||
>
|
|
||||||
{singBoxAction === 'start' || singBoxAction === 'stop'
|
|
||||||
? '...'
|
|
||||||
: singbox.running
|
|
||||||
? 'Остановить'
|
|
||||||
: 'Запустить'}
|
|
||||||
</button>
|
|
||||||
<div className="service-menu">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="service-menu-button"
|
|
||||||
onClick={() => setIsSingBoxMenuOpen((current) => !current)}
|
|
||||||
disabled={isDetectingComponents || Boolean(singBoxAction)}
|
|
||||||
aria-label="Дополнительные действия Local sing-box"
|
|
||||||
aria-expanded={isSingBoxMenuOpen}
|
|
||||||
title="Еще"
|
|
||||||
>
|
|
||||||
<MoreHorizontal size={20} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
{isSingBoxMenuOpen ? (
|
|
||||||
<div className="service-menu-popover">
|
|
||||||
<button type="button" onClick={() => void uninstallSingBoxPackage()}>
|
|
||||||
{singBoxAction === 'uninstall' ? 'Удаляю...' : 'Удалить Local sing-box'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="service-button install"
|
|
||||||
onClick={() => void installSingBoxPackage()}
|
|
||||||
disabled={isDetectingComponents || Boolean(singBoxAction)}
|
|
||||||
>
|
|
||||||
{singBoxAction === 'install' ? '...' : 'Установить'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isSingBoxInfoOpen ? (
|
{isSingBoxInfoOpen ? (
|
||||||
<div className="singbox-info-popover">
|
<div className="singbox-info-popover">
|
||||||
<div className="singbox-info-grid">
|
<div className="singbox-info-grid">
|
||||||
@@ -1388,22 +1355,26 @@ export function App() {
|
|||||||
<strong>{singBoxStatus?.generatedConfigPath ?? 'не создан'}</strong>
|
<strong>{singBoxStatus?.generatedConfigPath ?? 'не создан'}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="singbox-info-actions">
|
<div className="singbox-info-actions">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void pingSingBoxServers()}
|
onClick={() => void pingSingBoxServers()}
|
||||||
disabled={Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length}
|
disabled={Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length}
|
||||||
|
variant="neutral"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
<Gauge size={15} strokeWidth={1.9} />
|
<Gauge size={15} strokeWidth={1.9} />
|
||||||
Ping все
|
Ping все
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void generateSingBoxNow()}
|
onClick={() => void generateSingBoxNow()}
|
||||||
disabled={Boolean(singBoxAction) || !selectedServerTag}
|
disabled={Boolean(singBoxAction) || !selectedServerTag}
|
||||||
|
variant="neutral"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
<Wand2 size={15} strokeWidth={1.9} />
|
<Wand2 size={15} strokeWidth={1.9} />
|
||||||
Конфиг
|
Конфиг
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1438,7 +1409,7 @@ export function App() {
|
|||||||
<span>Установи компонент, чтобы подключить подписку, выбрать сервер и включить локальный маршрут.</span>
|
<span>Установи компонент, чтобы подключить подписку, выбрать сервер и включить локальный маршрут.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ServiceControlRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1455,23 +1426,24 @@ export function App() {
|
|||||||
placeholder={singBoxStatus?.config.subscriptionDisplayUrl ?? 'https://example.com/sub'}
|
placeholder={singBoxStatus?.config.subscriptionDisplayUrl ?? 'https://example.com/sub'}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void syncSingBoxSubscription()}
|
onClick={() => void syncSingBoxSubscription()}
|
||||||
disabled={singBoxAction === 'fetch'}
|
loading={singBoxAction === 'fetch'}
|
||||||
|
loadingLabel="Загружаю"
|
||||||
|
variant="neutral"
|
||||||
>
|
>
|
||||||
{singBoxAction === 'fetch' ? '...' : subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? 'Загрузить' : 'Обновить'}
|
{subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? 'Загрузить' : 'Обновить'}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-command"
|
variant="danger"
|
||||||
onClick={() => void forgetSingBoxSubscriptionData()}
|
onClick={() => void forgetSingBoxSubscriptionData()}
|
||||||
disabled={Boolean(singBoxAction) || !singBoxStatus?.config.hasSubscription}
|
disabled={Boolean(singBoxAction) || !singBoxStatus?.config.hasSubscription}
|
||||||
aria-label="Очистить подписку Local sing-box"
|
label="Очистить подписку Local sing-box"
|
||||||
title="Очистить"
|
title="Очистить"
|
||||||
>
|
icon={<Trash2 size={18} strokeWidth={1.9} />}
|
||||||
<Trash2 size={18} strokeWidth={1.9} />
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{singBoxStatus?.cache?.servers.length ? (
|
{singBoxStatus?.cache?.servers.length ? (
|
||||||
@@ -1491,16 +1463,15 @@ export function App() {
|
|||||||
<strong>{displayServerTag(server.tag)}</strong>
|
<strong>{displayServerTag(server.tag)}</strong>
|
||||||
{ping ? <span className="server-ping-badge">{ping.ok ? `${ping.latency ?? 0} ms` : 'fail'}</span> : null}
|
{ping ? <span className="server-ping-badge">{ping.ok ? `${ping.latency ?? 0} ms` : 'fail'}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
type="button"
|
||||||
className="server-ping-button"
|
|
||||||
onClick={() => void pingSingleSingBoxServer(server)}
|
onClick={() => void pingSingleSingBoxServer(server)}
|
||||||
disabled={Boolean(serverPingTag)}
|
disabled={Boolean(serverPingTag)}
|
||||||
aria-label={`Проверить ${displayServerTag(server.tag)}`}
|
loading={serverPingTag === server.tag}
|
||||||
|
label={`Проверить ${displayServerTag(server.tag)}`}
|
||||||
title="Ping"
|
title="Ping"
|
||||||
>
|
icon={<Gauge size={14} strokeWidth={1.9} />}
|
||||||
{serverPingTag === server.tag ? '...' : <Gauge size={14} strokeWidth={1.9} />}
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1530,22 +1501,22 @@ export function App() {
|
|||||||
|
|
||||||
<section className="route-panel" aria-label="Маршрут приложений">
|
<section className="route-panel" aria-label="Маршрут приложений">
|
||||||
<div className="route-switch">
|
<div className="route-switch">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={routeMode === 'external' ? 'active' : ''}
|
variant={routeMode === 'external' ? 'primary' : 'neutral'}
|
||||||
onClick={() => changeRouteMode('external')}
|
onClick={() => changeRouteMode('external')}
|
||||||
aria-pressed={routeMode === 'external'}
|
aria-pressed={routeMode === 'external'}
|
||||||
>
|
>
|
||||||
Внешний прокси
|
Внешний прокси
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={routeMode === 'local-singbox' ? 'active' : ''}
|
variant={routeMode === 'local-singbox' ? 'primary' : 'neutral'}
|
||||||
onClick={() => changeRouteMode('local-singbox')}
|
onClick={() => changeRouteMode('local-singbox')}
|
||||||
aria-pressed={routeMode === 'local-singbox'}
|
aria-pressed={routeMode === 'local-singbox'}
|
||||||
>
|
>
|
||||||
Локальный прокси
|
Локальный прокси
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{routeMode === 'external' ? renderExternalProxyControls() : renderSingBoxCard()}
|
{routeMode === 'external' ? renderExternalProxyControls() : renderSingBoxCard()}
|
||||||
@@ -1574,14 +1545,16 @@ export function App() {
|
|||||||
<div>
|
<div>
|
||||||
<h1>Proxy для приложений</h1>
|
<h1>Proxy для приложений</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="ghost-button"
|
variant="neutral"
|
||||||
|
size="sm"
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
disabled={isLoading || isDetectingComponents}
|
loading={isLoading || isDetectingComponents}
|
||||||
|
loadingLabel={isLoading ? 'Загружаю' : 'Проверяю'}
|
||||||
>
|
>
|
||||||
{isLoading ? 'Загружаю...' : isDetectingComponents ? 'Проверяю...' : 'Обновить'}
|
Обновить
|
||||||
</button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{renderTabs()}
|
{renderTabs()}
|
||||||
@@ -1590,45 +1563,13 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer className={`log-dock ${activeLog?.kind ?? 'idle'}`} aria-live="polite">
|
<LogDock
|
||||||
<div className={`log-current ${activeLog ? 'visible' : 'hidden'}`}>
|
entries={logEntries}
|
||||||
{activeLog ? (
|
activeEntry={activeLog}
|
||||||
<>
|
open={isLogOpen}
|
||||||
<strong>{activeLog.title}</strong>
|
onToggle={() => setIsLogOpen((current) => !current)}
|
||||||
<span>{activeLog.text}</span>
|
formatTime={formatLogTime}
|
||||||
</>
|
/>
|
||||||
) : (
|
|
||||||
<span className="log-muted">Журнал событий</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button type="button" className="log-toggle" onClick={() => setIsLogOpen((current) => !current)}>
|
|
||||||
{isLogOpen ? 'Скрыть' : 'Посмотреть'}
|
|
||||||
<span>{logEntries.length}</span>
|
|
||||||
</button>
|
|
||||||
{isLogOpen ? (
|
|
||||||
<div className="log-history">
|
|
||||||
{logEntries.length ? (
|
|
||||||
logEntries.map((entry) => (
|
|
||||||
<div className={`log-history-row ${entry.kind}`} key={entry.id}>
|
|
||||||
<time>{formatLogTime(entry.at)}</time>
|
|
||||||
<div>
|
|
||||||
<strong>{entry.title}</strong>
|
|
||||||
<span>{entry.text}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="log-history-row">
|
|
||||||
<time>--:--:--</time>
|
|
||||||
<div>
|
|
||||||
<strong>Журнал пуст</strong>
|
|
||||||
<span>События появятся после проверок или применения конфигурации.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</footer>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
83
apps/windows-client/src/app/readiness.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
export type RouteMode = 'external' | 'local-singbox';
|
||||||
|
|
||||||
|
export interface ApplyReadinessInput {
|
||||||
|
routeMode: RouteMode;
|
||||||
|
appCount: number;
|
||||||
|
proxiFyreInstalled: boolean;
|
||||||
|
singBoxInstalled: boolean;
|
||||||
|
selectedServerTag?: string;
|
||||||
|
externalProxyValue: string;
|
||||||
|
externalProxyError?: string | null;
|
||||||
|
busy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyReadiness {
|
||||||
|
ready: boolean;
|
||||||
|
title?: string;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness {
|
||||||
|
if (input.busy) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
title: 'Операция уже выполняется',
|
||||||
|
text: 'Дождись завершения текущего действия перед повторным применением.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.proxiFyreInstalled) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
title: 'ProxiFyre не установлен',
|
||||||
|
text: 'Установи ProxiFyre, чтобы маршрутизировать выбранные приложения.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.appCount < 1) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
title: 'Нет приложений',
|
||||||
|
text: 'Добавь хотя бы один процесс, EXE-файл или папку.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.routeMode === 'external') {
|
||||||
|
if (!input.externalProxyValue.trim()) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
title: 'Прокси не указан',
|
||||||
|
text: 'Введи адрес SOCKS5 прокси в формате host:port или socks5://host:port.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.externalProxyError) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
title: 'Проверь формат прокси',
|
||||||
|
text: input.externalProxyError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.routeMode === 'local-singbox') {
|
||||||
|
if (!input.singBoxInstalled) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
title: 'Local sing-box не установлен',
|
||||||
|
text: 'Установи Local sing-box, чтобы применить локальный маршрут.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.selectedServerTag) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
title: 'Сервер не выбран',
|
||||||
|
text: 'Выбери сервер Local sing-box перед применением маршрута.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ready: true };
|
||||||
|
}
|
||||||
|
|
||||||
15
apps/windows-client/src/app/viewModel.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ComponentStatus } from '../domain/types';
|
||||||
|
import type { ServiceControlState } from '../ui';
|
||||||
|
|
||||||
|
export function serviceControlState(
|
||||||
|
component: ComponentStatus | undefined,
|
||||||
|
checking: boolean,
|
||||||
|
): ServiceControlState {
|
||||||
|
if (checking) return 'checking';
|
||||||
|
if (!component) return 'missing';
|
||||||
|
if (component.state === 'error') return 'error';
|
||||||
|
if (component.running) return 'running';
|
||||||
|
if (component.installed) return 'stopped';
|
||||||
|
return 'missing';
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,6 +3,19 @@
|
|||||||
--app-header-row-height: 50px;
|
--app-header-row-height: 50px;
|
||||||
--app-tab-height: 46px;
|
--app-tab-height: 46px;
|
||||||
--app-header-height: calc(var(--app-header-row-height) + var(--app-tab-height));
|
--app-header-height: calc(var(--app-header-row-height) + var(--app-tab-height));
|
||||||
|
--motion-fast: 120ms;
|
||||||
|
--motion-standard: 180ms;
|
||||||
|
--motion-panel: 220ms;
|
||||||
|
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
--ease-standard: cubic-bezier(0.22, 0.72, 0.18, 1);
|
||||||
|
--surface-canvas: #101216;
|
||||||
|
--surface-panel: #131720;
|
||||||
|
--surface-raised: #151923;
|
||||||
|
--surface-control: #242a35;
|
||||||
|
--surface-inset: #0d1016;
|
||||||
|
--border-soft: #2b3342;
|
||||||
|
--border-strong: #343b49;
|
||||||
|
--focus-ring: #3b82f6;
|
||||||
font-family:
|
font-family:
|
||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
sans-serif;
|
sans-serif;
|
||||||
@@ -74,6 +87,487 @@ button:disabled {
|
|||||||
opacity: 0.56;
|
opacity: 0.56;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-button,
|
||||||
|
.ui-icon-button {
|
||||||
|
appearance: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface-control);
|
||||||
|
color: #eef2ff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
background-color var(--motion-fast) var(--ease-out),
|
||||||
|
border-color var(--motion-fast) var(--ease-out),
|
||||||
|
color var(--motion-fast) var(--ease-out),
|
||||||
|
opacity var(--motion-fast) var(--ease-out),
|
||||||
|
transform var(--motion-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button:hover,
|
||||||
|
.ui-icon-button:hover {
|
||||||
|
background: #2d3543;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button:active,
|
||||||
|
.ui-icon-button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button:focus-visible,
|
||||||
|
.ui-icon-button:focus-visible,
|
||||||
|
.ui-tab:focus-visible,
|
||||||
|
.ui-action-menu-popover button:focus-visible {
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button:disabled,
|
||||||
|
.ui-icon-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.56;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button--sm {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button--md {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button--lg {
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button--primary {
|
||||||
|
border-color: #16a34a;
|
||||||
|
background: #22c55e;
|
||||||
|
color: #04130a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button--primary:hover {
|
||||||
|
background: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button--add,
|
||||||
|
.ui-icon-button--add {
|
||||||
|
border-color: #2b3342;
|
||||||
|
background: #202633;
|
||||||
|
color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button--add:hover,
|
||||||
|
.ui-icon-button--add:hover {
|
||||||
|
border-color: var(--focus-ring);
|
||||||
|
background: #182033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button--danger,
|
||||||
|
.ui-icon-button--danger {
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button--danger:hover,
|
||||||
|
.ui-icon-button--danger:hover {
|
||||||
|
border-color: rgba(239, 68, 68, 0.52);
|
||||||
|
background: rgba(127, 29, 29, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-icon-button {
|
||||||
|
width: 42px;
|
||||||
|
min-width: 42px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-icon,
|
||||||
|
.ui-button-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
animation: spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tabs {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--app-header-row-height);
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
min-height: var(--app-tab-height);
|
||||||
|
overflow-x: auto;
|
||||||
|
border-top: 1px solid #242a35;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
background: #181b22;
|
||||||
|
scrollbar-width: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tab {
|
||||||
|
appearance: none;
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: var(--app-tab-height);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #9aa8bd;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 750;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
transition:
|
||||||
|
background-color var(--motion-fast) var(--ease-out),
|
||||||
|
color var(--motion-fast) var(--ease-out);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tab:hover {
|
||||||
|
background: #1c212b;
|
||||||
|
color: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tab.is-active {
|
||||||
|
color: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tab.is-active::after {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 12px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px 999px 0 0;
|
||||||
|
background: var(--focus-ring);
|
||||||
|
box-shadow: 0 -6px 18px rgba(59, 130, 246, 0.34);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: fit-content;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #1a202b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
padding: 5px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-status-pill--ok {
|
||||||
|
border-color: rgba(34, 197, 94, 0.38);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-status-pill--warning {
|
||||||
|
border-color: rgba(245, 158, 11, 0.46);
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-status-pill--error {
|
||||||
|
border-color: rgba(239, 68, 68, 0.46);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-status-pill--checking {
|
||||||
|
border-color: rgba(59, 130, 246, 0.46);
|
||||||
|
color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field-label {
|
||||||
|
color: #8d99ae;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field input {
|
||||||
|
min-height: 42px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
color: #f8fafc;
|
||||||
|
outline: none;
|
||||||
|
padding: 9px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field input:focus {
|
||||||
|
border-color: var(--focus-ring);
|
||||||
|
box-shadow: 0 0 0 1px var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field-help {
|
||||||
|
color: #8d99ae;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field-help.is-error {
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-action-menu {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-action-menu-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 8;
|
||||||
|
min-width: 172px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #171c26;
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.38);
|
||||||
|
padding: 5px;
|
||||||
|
transform-origin: top right;
|
||||||
|
animation: ui-popover-in var(--motion-standard) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-action-menu-popover button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: #e5e7eb;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
padding: 7px 9px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-action-menu-popover button:hover {
|
||||||
|
background: #242a35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-action-menu-popover button.is-danger {
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-action-menu-popover button.is-danger:hover {
|
||||||
|
background: rgba(127, 29, 29, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-row {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(220px, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: stretch;
|
||||||
|
min-height: 56px;
|
||||||
|
overflow: visible;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-row > :not(.ui-service-border-glow) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-border-glow {
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
z-index: 0;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: inherit;
|
||||||
|
opacity: 0;
|
||||||
|
contain: layout paint;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity var(--motion-panel) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-row--checking .ui-service-border-glow,
|
||||||
|
.ui-service-row--working .ui-service-border-glow {
|
||||||
|
display: block;
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-row--settling .ui-service-border-glow {
|
||||||
|
display: block;
|
||||||
|
opacity: 0;
|
||||||
|
transition-duration: 0.7s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-border-glow-segment {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
background: #93c5fd;
|
||||||
|
box-shadow:
|
||||||
|
0 0 8px rgba(96, 165, 250, 0.95),
|
||||||
|
0 0 16px rgba(34, 197, 94, 0.36);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-border-glow-segment.top,
|
||||||
|
.ui-service-border-glow-segment.bottom {
|
||||||
|
width: 108px;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-border-glow-segment.right,
|
||||||
|
.ui-service-border-glow-segment.left {
|
||||||
|
width: 2px;
|
||||||
|
height: 64px;
|
||||||
|
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-border-glow-segment.top {
|
||||||
|
top: 0;
|
||||||
|
animation: finder-border-top 1.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-border-glow-segment.right {
|
||||||
|
right: 0;
|
||||||
|
animation: finder-border-right 1.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-border-glow-segment.bottom {
|
||||||
|
bottom: 0;
|
||||||
|
animation: finder-border-bottom 1.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-border-glow-segment.left {
|
||||||
|
left: 0;
|
||||||
|
animation: finder-border-left 1.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-dot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f59e0b;
|
||||||
|
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-row--running .ui-service-dot,
|
||||||
|
.ui-service-row--installed .ui-service-dot {
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-row--stopped .ui-service-dot,
|
||||||
|
.ui-service-row--missing .ui-service-dot {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-row--error .ui-service-dot {
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-row--checking .ui-service-dot {
|
||||||
|
border: 2px solid var(--focus-ring);
|
||||||
|
border-top-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
animation: spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-text > strong,
|
||||||
|
.ui-service-text > span {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-text > span {
|
||||||
|
color: #8d99ae;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-inline-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-row > .setup-details,
|
||||||
|
.ui-service-row > .singbox-info-popover,
|
||||||
|
.ui-service-row > .singbox-workspace,
|
||||||
|
.ui-service-row > .singbox-install-note {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-raw-detail {
|
||||||
|
color: #7d8aa0;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ui-popover-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.simple-shell {
|
.simple-shell {
|
||||||
display: block;
|
display: block;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -743,7 +1237,7 @@ button.summary-card:hover {
|
|||||||
color: #bfdbfe;
|
color: #bfdbfe;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-toggle span {
|
.setup-toggle .ui-button-label > span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border: 1px solid #343b49;
|
border: 1px solid #343b49;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -1132,6 +1626,12 @@ button.summary-card:hover {
|
|||||||
color: #eff6ff;
|
color: #eff6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-switch .ui-button--primary {
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
.subscription-line {
|
.subscription-line {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 38px minmax(0, 1fr) 112px 42px;
|
grid-template-columns: 38px minmax(0, 1fr) 112px 42px;
|
||||||
@@ -1598,6 +2098,17 @@ button.summary-card:hover {
|
|||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.command-row .ui-button,
|
||||||
|
.proxy-check-line .ui-button,
|
||||||
|
.subscription-line .ui-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-config-actions .ui-button {
|
||||||
|
height: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
.apply-button {
|
.apply-button {
|
||||||
min-height: 46px;
|
min-height: 46px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1721,7 +2232,7 @@ button.summary-card:hover {
|
|||||||
background: #2d3543;
|
background: #2d3543;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-toggle span {
|
.log-toggle .log-count {
|
||||||
min-width: 22px;
|
min-width: 22px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #1b202b;
|
background: #1b202b;
|
||||||
@@ -1958,6 +2469,11 @@ button.summary-card:hover {
|
|||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-tabs {
|
||||||
|
gap: 1px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-tabs button {
|
.panel-tabs button {
|
||||||
flex: 1 0 110px;
|
flex: 1 0 110px;
|
||||||
min-height: var(--app-tab-height);
|
min-height: var(--app-tab-height);
|
||||||
@@ -1965,6 +2481,13 @@ button.summary-card:hover {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-tab {
|
||||||
|
flex: 1 0 110px;
|
||||||
|
min-height: var(--app-tab-height);
|
||||||
|
padding: 8px 10px 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-section-head {
|
.panel-section-head {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2057,10 +2580,34 @@ button.summary-card:hover {
|
|||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-service-row {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.service-actions {
|
.service-actions {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-service-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 42px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-actions > .ui-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-service-actions > .ui-button:only-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-strip-items {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.setup-details {
|
.setup-details {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
@@ -2102,11 +2649,14 @@ button.summary-card:hover {
|
|||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-current strong,
|
.log-current strong {
|
||||||
.log-current span {
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-current span:not(.log-muted) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.log-history {
|
.log-history {
|
||||||
max-height: 220px;
|
max-height: 220px;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
|
|||||||
54
apps/windows-client/src/ui/ActionMenu.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { MoreHorizontal } from 'lucide-react';
|
||||||
|
import { IconButton } from './IconButton';
|
||||||
|
|
||||||
|
export interface ActionMenuItem {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionMenuProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
label: string;
|
||||||
|
items: ActionMenuItem[];
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionMenu({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
disabled,
|
||||||
|
}: ActionMenuProps) {
|
||||||
|
return (
|
||||||
|
<div className="ui-action-menu">
|
||||||
|
<IconButton
|
||||||
|
label={label}
|
||||||
|
icon={<MoreHorizontal size={20} strokeWidth={2} />}
|
||||||
|
onClick={() => onOpenChange(!open)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-expanded={open}
|
||||||
|
/>
|
||||||
|
{open ? (
|
||||||
|
<div className="ui-action-menu-popover" role="menu">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={item.danger ? 'is-danger' : ''}
|
||||||
|
onClick={item.onClick}
|
||||||
|
disabled={item.disabled}
|
||||||
|
key={item.label}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
49
apps/windows-client/src/ui/Button.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
|
||||||
|
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
loading?: boolean;
|
||||||
|
loadingLabel?: string;
|
||||||
|
leftIcon?: ReactNode;
|
||||||
|
rightIcon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = 'neutral',
|
||||||
|
size = 'md',
|
||||||
|
loading = false,
|
||||||
|
loadingLabel,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const classes = [
|
||||||
|
'ui-button',
|
||||||
|
`ui-button--${variant}`,
|
||||||
|
`ui-button--${size}`,
|
||||||
|
loading ? 'is-loading' : '',
|
||||||
|
className ?? '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
className={classes}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
>
|
||||||
|
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : leftIcon ? (
|
||||||
|
<span className="ui-button-icon" aria-hidden="true">{leftIcon}</span>
|
||||||
|
) : null}
|
||||||
|
<span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span>
|
||||||
|
{!loading && rightIcon ? <span className="ui-button-icon" aria-hidden="true">{rightIcon}</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
38
apps/windows-client/src/ui/Field.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label: string;
|
||||||
|
error?: string | null;
|
||||||
|
hint?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
hint,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
...props
|
||||||
|
}: FieldProps) {
|
||||||
|
const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||||
|
const helpId = `${inputId}-help`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className={`ui-field ${className ?? ''}`.trim()} htmlFor={inputId}>
|
||||||
|
<span className="ui-field-label">{label}</span>
|
||||||
|
<div className="ui-field-row">
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
id={inputId}
|
||||||
|
aria-invalid={Boolean(error)}
|
||||||
|
aria-describedby={error || hint ? helpId : undefined}
|
||||||
|
/>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
{error || hint ? <span id={helpId} className={`ui-field-help ${error ? 'is-error' : ''}`.trim()}>{error ?? hint}</span> : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
42
apps/windows-client/src/ui/IconButton.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type IconButtonVariant = 'neutral' | 'add' | 'danger';
|
||||||
|
|
||||||
|
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
variant?: IconButtonVariant;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconButton({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
variant = 'neutral',
|
||||||
|
loading = false,
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
title,
|
||||||
|
...props
|
||||||
|
}: IconButtonProps) {
|
||||||
|
const classes = [
|
||||||
|
'ui-icon-button',
|
||||||
|
`ui-icon-button--${variant}`,
|
||||||
|
loading ? 'is-loading' : '',
|
||||||
|
className ?? '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
type={props.type ?? 'button'}
|
||||||
|
className={classes}
|
||||||
|
aria-label={label}
|
||||||
|
title={title ?? label}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
>
|
||||||
|
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
82
apps/windows-client/src/ui/LogDock.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Button } from './Button';
|
||||||
|
|
||||||
|
export interface LogDockEntry {
|
||||||
|
id: string;
|
||||||
|
kind: 'success' | 'error' | 'info';
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogDockProps {
|
||||||
|
entries: LogDockEntry[];
|
||||||
|
activeEntry: LogDockEntry | null;
|
||||||
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
formatTime: (timestamp: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNativePreviewError(entry: LogDockEntry | null) {
|
||||||
|
if (!entry) return false;
|
||||||
|
return entry.text.includes("reading 'invoke'") || entry.text.includes('undefined (reading');
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayEntry(entry: LogDockEntry | null) {
|
||||||
|
if (!entry) return null;
|
||||||
|
if (!isNativePreviewError(entry)) return entry;
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
title: 'Desktop-команды недоступны',
|
||||||
|
text: 'Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogDock({
|
||||||
|
entries,
|
||||||
|
activeEntry,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
formatTime,
|
||||||
|
}: LogDockProps) {
|
||||||
|
const current = displayEntry(activeEntry);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className={`log-dock ${current?.kind ?? 'idle'}`} aria-live="polite">
|
||||||
|
<div className={`log-current ${current ? 'visible' : 'hidden'}`}>
|
||||||
|
{current ? (
|
||||||
|
<>
|
||||||
|
<strong>{current.title}</strong>
|
||||||
|
<span>{current.text}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="log-muted">Журнал событий</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="neutral" size="sm" className="log-toggle" onClick={onToggle}>
|
||||||
|
{open ? 'Скрыть' : 'Посмотреть'} <span className="log-count">{entries.length}</span>
|
||||||
|
</Button>
|
||||||
|
{open ? (
|
||||||
|
<div className="log-history">
|
||||||
|
{entries.length ? entries.map((entry) => {
|
||||||
|
const friendly = displayEntry(entry);
|
||||||
|
return (
|
||||||
|
<div className={`log-history-row ${entry.kind}`} key={entry.id}>
|
||||||
|
<time>{formatTime(entry.at)}</time>
|
||||||
|
<div>
|
||||||
|
<strong>{friendly?.title ?? entry.title}</strong>
|
||||||
|
<span>{friendly?.text ?? entry.text}</span>
|
||||||
|
{isNativePreviewError(entry) ? <span className="log-raw-detail">Детали: {entry.text}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}) : (
|
||||||
|
<div className="log-history-row">
|
||||||
|
<time>--:--:--</time>
|
||||||
|
<span>Событий пока нет.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/windows-client/src/ui/ServiceControlRow.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Button, type ButtonVariant } from './Button';
|
||||||
|
import { ActionMenu, type ActionMenuItem } from './ActionMenu';
|
||||||
|
|
||||||
|
export type ServiceControlState = 'checking' | 'missing' | 'installed' | 'running' | 'stopped' | 'error';
|
||||||
|
|
||||||
|
export interface ServicePrimaryAction {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
loading?: boolean;
|
||||||
|
loadingLabel?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceControlRowProps {
|
||||||
|
state: ServiceControlState;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
primaryAction?: ServicePrimaryAction;
|
||||||
|
menu?: {
|
||||||
|
label: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
items: ActionMenuItem[];
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
visualState?: 'working' | 'settling' | null;
|
||||||
|
className?: string;
|
||||||
|
inlineActions?: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceControlRow({
|
||||||
|
state,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
primaryAction,
|
||||||
|
menu,
|
||||||
|
visualState,
|
||||||
|
className,
|
||||||
|
inlineActions,
|
||||||
|
children,
|
||||||
|
}: ServiceControlRowProps) {
|
||||||
|
const classes = [
|
||||||
|
'ui-service-row',
|
||||||
|
`ui-service-row--${state}`,
|
||||||
|
visualState ? `ui-service-row--${visualState}` : '',
|
||||||
|
className ?? '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes}>
|
||||||
|
<span className="ui-service-border-glow" aria-hidden="true">
|
||||||
|
<span className="ui-service-border-glow-segment top" />
|
||||||
|
<span className="ui-service-border-glow-segment right" />
|
||||||
|
<span className="ui-service-border-glow-segment bottom" />
|
||||||
|
<span className="ui-service-border-glow-segment left" />
|
||||||
|
</span>
|
||||||
|
<span className="ui-service-dot" aria-hidden="true" />
|
||||||
|
<div className="ui-service-text">
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<span>{detail}</span>
|
||||||
|
{inlineActions ? <div className="ui-service-inline-actions">{inlineActions}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="ui-service-actions">
|
||||||
|
{primaryAction ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={primaryAction.variant ?? 'neutral'}
|
||||||
|
onClick={primaryAction.onClick}
|
||||||
|
disabled={primaryAction.disabled}
|
||||||
|
loading={primaryAction.loading}
|
||||||
|
loadingLabel={primaryAction.loadingLabel}
|
||||||
|
>
|
||||||
|
{primaryAction.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{menu ? (
|
||||||
|
<ActionMenu
|
||||||
|
label={menu.label}
|
||||||
|
open={menu.open}
|
||||||
|
onOpenChange={menu.onOpenChange}
|
||||||
|
items={menu.items}
|
||||||
|
disabled={menu.disabled}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
11
apps/windows-client/src/ui/StatusPill.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
|
||||||
|
|
||||||
|
export interface StatusPillProps {
|
||||||
|
tone?: StatusPillTone;
|
||||||
|
children: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusPill({ tone = 'muted', children }: StatusPillProps) {
|
||||||
|
return <span className={`ui-status-pill ui-status-pill--${tone}`}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
81
apps/windows-client/src/ui/Tabs.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useRef, type KeyboardEvent } from 'react';
|
||||||
|
|
||||||
|
export interface TabItem<T extends string> {
|
||||||
|
id: T;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabsProps<T extends string> {
|
||||||
|
items: Array<TabItem<T>>;
|
||||||
|
activeId: T;
|
||||||
|
onChange: (id: T) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tabs<T extends string>({
|
||||||
|
items,
|
||||||
|
activeId,
|
||||||
|
onChange,
|
||||||
|
ariaLabel,
|
||||||
|
}: TabsProps<T>) {
|
||||||
|
const refs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||||
|
|
||||||
|
function moveFocus(currentId: T, direction: 1 | -1) {
|
||||||
|
const currentIndex = items.findIndex((item) => item.id === currentId);
|
||||||
|
const nextIndex = (currentIndex + direction + items.length) % items.length;
|
||||||
|
const next = items[nextIndex];
|
||||||
|
if (!next) return;
|
||||||
|
onChange(next.id);
|
||||||
|
window.requestAnimationFrame(() => refs.current[nextIndex]?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, id: T) {
|
||||||
|
if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveFocus(id, 1);
|
||||||
|
} else if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveFocus(id, -1);
|
||||||
|
} else if (event.key === 'Home') {
|
||||||
|
event.preventDefault();
|
||||||
|
const first = items[0];
|
||||||
|
if (!first) return;
|
||||||
|
onChange(first.id);
|
||||||
|
window.requestAnimationFrame(() => refs.current[0]?.focus());
|
||||||
|
} else if (event.key === 'End') {
|
||||||
|
event.preventDefault();
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
if (!last) return;
|
||||||
|
onChange(last.id);
|
||||||
|
window.requestAnimationFrame(() => refs.current[items.length - 1]?.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ui-tabs" role="tablist" aria-label={ariaLabel}>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const active = item.id === activeId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`tab-${item.id}`}
|
||||||
|
aria-controls={`panel-${item.id}`}
|
||||||
|
aria-selected={active}
|
||||||
|
tabIndex={active ? 0 : -1}
|
||||||
|
className={`ui-tab ${active ? 'is-active' : ''}`.trim()}
|
||||||
|
key={item.id}
|
||||||
|
ref={(node) => {
|
||||||
|
refs.current[index] = node;
|
||||||
|
}}
|
||||||
|
onClick={() => onChange(item.id)}
|
||||||
|
onKeyDown={(event) => handleKeyDown(event, item.id)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
17
apps/windows-client/src/ui/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export { ActionMenu } from './ActionMenu';
|
||||||
|
export type { ActionMenuItem } from './ActionMenu';
|
||||||
|
export { Button } from './Button';
|
||||||
|
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
|
||||||
|
export { Field } from './Field';
|
||||||
|
export type { FieldProps } from './Field';
|
||||||
|
export { IconButton } from './IconButton';
|
||||||
|
export type { IconButtonProps, IconButtonVariant } from './IconButton';
|
||||||
|
export { LogDock } from './LogDock';
|
||||||
|
export type { LogDockEntry, LogDockProps } from './LogDock';
|
||||||
|
export { ServiceControlRow } from './ServiceControlRow';
|
||||||
|
export type { ServiceControlRowProps, ServiceControlState } from './ServiceControlRow';
|
||||||
|
export { StatusPill } from './StatusPill';
|
||||||
|
export type { StatusPillProps, StatusPillTone } from './StatusPill';
|
||||||
|
export { Tabs } from './Tabs';
|
||||||
|
export type { TabItem, TabsProps } from './Tabs';
|
||||||
|
|
||||||
BIN
docs/audits/windows-client-ux-2026-07-08/01-summary-desktop.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 59 KiB |
BIN
docs/audits/windows-client-ux-2026-07-08/03-proxy-desktop.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/audits/windows-client-ux-2026-07-08/05-summary-narrow.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/audits/windows-client-ux-2026-07-08/06-proxifyre-narrow.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
188
docs/audits/windows-client-ux-2026-07-08/AUDIT.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# UX/UI-аудит Windows-клиента
|
||||||
|
|
||||||
|
Дата: 2026-07-08
|
||||||
|
|
||||||
|
## Область аудита
|
||||||
|
|
||||||
|
Проверен текущий React UI `apps/windows-client` для Vite/Tauri как компактная Windows-утилита управления proxy-маршрутизацией приложений. Аудит выполнялся в browser-preview на `http://127.0.0.1:5174/`, поэтому нативные Tauri-команды были недоступны, а интерфейс показывал preview/runtime-ошибки. Все выводы ниже привязаны к скриншотам, снятым в этом прогоне.
|
||||||
|
|
||||||
|
## Цель пользователя
|
||||||
|
|
||||||
|
Главная задача пользователя - быстро управлять службами и понять:
|
||||||
|
|
||||||
|
- какие компоненты установлены;
|
||||||
|
- какие службы запущены, остановлены или отсутствуют;
|
||||||
|
- какое действие доступно прямо сейчас: установить, удалить, запустить, остановить, обновить, добавить или применить;
|
||||||
|
- что сейчас маршрутизируется;
|
||||||
|
- через какую цепочку компонентов идет трафик;
|
||||||
|
- готовы ли ProxiFyre и опциональный Local sing-box;
|
||||||
|
- можно ли безопасно применить изменения;
|
||||||
|
- что делать дальше, если что-то сломано.
|
||||||
|
|
||||||
|
Цель по доступности: интерфейс должен быть управляемым с клавиатуры, с понятными статусами, читаемым восстановлением после ошибок, предсказуемыми focus states и устойчивой адаптацией к узким размерам Windows-окна.
|
||||||
|
|
||||||
|
## Доказательства
|
||||||
|
|
||||||
|
1. `01-summary-desktop.png` - панель "Сводка", desktop-ширина. Состояние: смешанное. Главный статус виден, но примененное состояние еще "загружается", а рядом уже показан бейдж "Совпадает".
|
||||||
|
2. `02-proxifyre-desktop.png` - панель ProxiFyre, desktop-ширина. Состояние: в целом рабочее. Состав компонента и список приложений компактны, но главное действие apply доступно даже при отсутствующих prerequisites.
|
||||||
|
3. `03-proxy-desktop.png` - панель "VPN / Прокси", desktop-ширина. Состояние: рабочее. Настройка маршрута понятна, но route path выглядит как вторичный текст, а apply-action выглядит валидным до того, как маршрут может успешно примениться.
|
||||||
|
4. `04-proxy-validation-desktop.png` - невалидный proxy input. Состояние: хорошая база. Inline-валидация находится рядом с полем и отключает действие проверки.
|
||||||
|
5. `05-summary-narrow.png` - панель "Сводка", узкая ширина. Состояние: напряженное. Контент перестраивается, но нижний log dock и длинные сообщения обрезают важную информацию.
|
||||||
|
6. `06-proxifyre-narrow.png` - панель ProxiFyre, узкая ширина. Состояние: напряженное. Контролы складываются неплохо, но setup chips переполняются по горизонтали, а нижний dock конкурирует с основной задачей.
|
||||||
|
|
||||||
|
## Доменное направление
|
||||||
|
|
||||||
|
Ключевые понятия домена: цепочка маршрута, здоровье компонентов, локальный runtime, внешний endpoint, выбранные приложения, сгенерированный конфиг, граница apply/restart, диагностика.
|
||||||
|
|
||||||
|
Цветовой мир: темная Windows-оболочка, терминальный черный, зеленый для driver/service ready, amber для предупреждений, красный для блокеров/firewall, синий для links/actions, slate для config-файлов.
|
||||||
|
|
||||||
|
Сигнатурный элемент, который стоит усилить: `Service Control Row` - повторяемая строка компонента, где слева состояние службы, в центре человекочитаемый статус, справа одно главное действие и меню дополнительных действий. Route-chain readout тоже нужен, но как объяснение эффекта этих служб: `Apps -> ProxiFyre -> target -> VPN/server`.
|
||||||
|
|
||||||
|
Дефолты, которые стоит отбрасывать:
|
||||||
|
|
||||||
|
- generic dashboard cards -> service-control utility;
|
||||||
|
- большая зеленая apply-кнопка всегда видна -> apply gated by readiness + inline blockers;
|
||||||
|
- raw logs как главный error surface -> сначала человеческое recovery-сообщение, raw details вторым уровнем.
|
||||||
|
- разрозненные кнопки и анимации -> shared component base с едиными variants/states/motion tokens.
|
||||||
|
|
||||||
|
## Сильные стороны
|
||||||
|
|
||||||
|
- Приложение уже ощущается как компактная desktop-утилита, а не как сайт. Фиксированный header, tabs, плотные панели и темная системная палитра подходят задаче.
|
||||||
|
- Продуктовая модель сильная: ProxiFyre и Local sing-box разделены, sing-box не сделан обязательным.
|
||||||
|
- Inline-валидация SOCKS5-поля расположена рядом с полем и отключает "Проверить", когда формат неверный.
|
||||||
|
- Зона добавления приложения использует иконки с доступными labels и tooltip, поэтому плотный workflow остается сканируемым.
|
||||||
|
- Для основных анимаций есть `prefers-reduced-motion`.
|
||||||
|
- UI использует настоящие `button` и tab roles, а не click-only divs. Это хорошая база для доступности.
|
||||||
|
|
||||||
|
## UX-риски
|
||||||
|
|
||||||
|
1. Apply выглядит доступным, когда маршрут еще не actionable.
|
||||||
|
- Доказательства: `02-proxifyre-desktop.png`, `03-proxy-desktop.png`.
|
||||||
|
- ProxiFyre отсутствует и список приложений пуст, но "Обновить конфиг" / "Обновить маршрут" ярко-зеленые. Это провоцирует failed action вместо направленного setup.
|
||||||
|
|
||||||
|
2. Главная цепочка маршрута не является визуальным фокусом.
|
||||||
|
- Доказательства: `01-summary-desktop.png`, `03-proxy-desktop.png`.
|
||||||
|
- Самая важная mental model - путь трафика, но сейчас он показан plain text в таблице или вторичном блоке. Пользователь вынужден читать фрагменты статуса вместо того, чтобы сразу увидеть цепочку.
|
||||||
|
|
||||||
|
3. В "Сводке" есть противоречивое состояние.
|
||||||
|
- Доказательство: `01-summary-desktop.png`.
|
||||||
|
- "Состояние загружается" показано рядом с "Совпадает", а система одновременно говорит "Не настроено". Это может создать ощущение, что действий не требуется.
|
||||||
|
|
||||||
|
4. Preview/native command failures слишком сырые.
|
||||||
|
- Доказательства: все скриншоты.
|
||||||
|
- Dock показывает `Cannot read properties of undefined (reading 'invoke')`. Для разработчика это полезно, но как первичный пользовательский error surface это шум.
|
||||||
|
|
||||||
|
5. Bottom log dock конфликтует с узкой компоновкой.
|
||||||
|
- Доказательства: `05-summary-narrow.png`, `06-proxifyre-narrow.png`.
|
||||||
|
- Важные сообщения обрезаются до "Компонен..." и "Cannot read prop..."; dock постоянно занимает вертикальное место в и так коротком окне.
|
||||||
|
|
||||||
|
6. ProxiFyre setup chips плохо перестраиваются.
|
||||||
|
- Доказательство: `06-proxifyre-narrow.png`.
|
||||||
|
- Горизонтальная chip-лента скрывает третий dependency и ухудшает диагностическую ясность.
|
||||||
|
|
||||||
|
7. Терминология немного смешана.
|
||||||
|
- Доказательства: `01-summary-desktop.png`, `03-proxy-desktop.png`.
|
||||||
|
- `config`, `TCP check`, `VPN сервер`, `Local sing-box`, `ProxiFyre` - все эти термины допустимы, но нужен единый принцип: сначала пользовательский русский, технический идентификатор вторым уровнем.
|
||||||
|
|
||||||
|
8. Компонентная база пока не выражена как система.
|
||||||
|
- Доказательства: `02-proxifyre-desktop.png`, `03-proxy-desktop.png`, `06-proxifyre-narrow.png`.
|
||||||
|
- Кнопки "Обновить", "Установить", "Открыть", "Проверить", add-icon buttons и service actions выглядят близко, но пока не читаются как строгая система variants. Из-за этого сложно гарантировать одинаковые hover/loading/disabled/focus states и одинаковую motion-модель.
|
||||||
|
|
||||||
|
## Риски доступности
|
||||||
|
|
||||||
|
- Tablist использует `role="tab"`, но не видно поддержки ожидаемого arrow-key behavior и roving tab index. Пользователю с клавиатурой, вероятно, придется проходить все tabs через Tab вместо Left/Right.
|
||||||
|
- Focus visibility есть через browser defaults, но визуально она тяжелая и не согласована со стилем приложения. Нужен отдельный `:focus-visible` token для buttons, tabs, inputs и icon controls.
|
||||||
|
- Status dots сильно полагаются на цвет. Стоит дублировать состояние видимым текстом или `aria-label`/screen-reader text там, где текст рядом не объясняет статус.
|
||||||
|
- Footer использует `aria-live`, но повторяющиеся raw runtime errors могут быть шумными для assistive tech. Сначала стоит объявлять короткий статус, а подробный error text держать за "Посмотреть".
|
||||||
|
- На узкой ширине truncation может скрывать actionable-часть сообщений и button labels.
|
||||||
|
- Скриншоты не доказывают полный keyboard order, screen-reader output или contrast ratios. Для этого нужен интерактивный accessibility pass.
|
||||||
|
|
||||||
|
## План улучшений
|
||||||
|
|
||||||
|
### P0 - Сделать service-control flow заслуживающим доверия
|
||||||
|
|
||||||
|
1. Ввести shared component base:
|
||||||
|
- `Button` с variants: `primary`, `neutral`, `add`, `danger`, `icon`;
|
||||||
|
- `Tabs` с единым keyboard behavior и animation contract;
|
||||||
|
- `ServiceControlRow` для ProxiFyre, Local sing-box и будущих служб;
|
||||||
|
- `StatusPill`, `Field`, `ActionMenu`, `LogDock`;
|
||||||
|
- единые states: default, hover, active, focus-visible, disabled, loading.
|
||||||
|
|
||||||
|
2. Нормализовать motion tokens:
|
||||||
|
- button press: 100-140ms;
|
||||||
|
- tab switch: 180-240ms, только `opacity` + `transform`;
|
||||||
|
- popover/menu: 150-180ms;
|
||||||
|
- service operation: видимый loading state, чтобы интерфейс не ощущался зависшим;
|
||||||
|
- не использовать `transition: all`.
|
||||||
|
|
||||||
|
3. Заменить raw preview/native invocation failures на дружелюбное состояние:
|
||||||
|
- "Desktop-команды недоступны в browser preview. Запусти через Tauri для управления службами."
|
||||||
|
- Raw error оставить только в деталях лога.
|
||||||
|
|
||||||
|
4. Заблокировать primary apply actions до готовности:
|
||||||
|
- disabled + explanation, когда список приложений пуст;
|
||||||
|
- disabled + explanation, когда ProxiFyre отсутствует;
|
||||||
|
- disabled + explanation, когда выбран local route без установленного/запущенного sing-box или выбранного сервера;
|
||||||
|
- "Открыть конфиг" оставить вторичным действием, не равным apply по весу.
|
||||||
|
|
||||||
|
5. Исправить язык summary-state:
|
||||||
|
- использовать взаимоисключающие состояния: "Проверяю", "Не готово", "Готово к применению", "Применено", "Есть черновик";
|
||||||
|
- не показывать "Совпадает", пока saved/applied state реально не загружен.
|
||||||
|
|
||||||
|
### P1 - Пересобрать главную mental model
|
||||||
|
|
||||||
|
6. Вынести route-chain component как объясняющий элемент:
|
||||||
|
- узел `Apps` с количеством;
|
||||||
|
- узел `ProxiFyre` со статусом installed/running;
|
||||||
|
- узел `Target`: external SOCKS5 или Local sing-box;
|
||||||
|
- узел `Server`, когда активен local sing-box;
|
||||||
|
- каждый сломанный узел показывает одно следующее действие.
|
||||||
|
|
||||||
|
7. Переработать "Сводку" в command center:
|
||||||
|
- сверху: route-chain и readiness системы;
|
||||||
|
- посередине: applied vs draft diff, только если есть отличие;
|
||||||
|
- снизу: максимум два next actions;
|
||||||
|
- длинные config paths и подробный состав компонентов убрать из первого взгляда.
|
||||||
|
|
||||||
|
8. Разделить "настроить маршрут" и "применить маршрут":
|
||||||
|
- Proxy panel отвечает за endpoint choice и connection check;
|
||||||
|
- ProxiFyre panel отвечает за app selection и service readiness;
|
||||||
|
- Summary подтверждает объединенный маршрут и применяет только при готовности.
|
||||||
|
|
||||||
|
### P2 - Усилить responsive и component craft
|
||||||
|
|
||||||
|
7. Превратить setup chips в wrapping checklist на узких ширинах.
|
||||||
|
|
||||||
|
8. Сделать log dock адаптивным:
|
||||||
|
- desktop: persistent compact dock подходит;
|
||||||
|
- narrow: collapsed toast/details sheet вместо постоянного full-width footer.
|
||||||
|
|
||||||
|
9. Определить design tokens:
|
||||||
|
- surfaces: canvas, panel, inset, elevated;
|
||||||
|
- text: primary, secondary, muted, disabled;
|
||||||
|
- status: ready, warning, blocked, checking;
|
||||||
|
- focus ring и border intensities.
|
||||||
|
|
||||||
|
10. Стандартизировать copy:
|
||||||
|
- последовательно использовать "конфиг" или "конфигурация";
|
||||||
|
- заменить "TCP check" на "TCP-проверка";
|
||||||
|
- `Local sing-box` оставить техническим именем компонента, но в user-facing headings связывать с "локальный прокси".
|
||||||
|
|
||||||
|
### P3 - Verification pass
|
||||||
|
|
||||||
|
11. Добавить focused interaction QA:
|
||||||
|
- keyboard tab order;
|
||||||
|
- arrow-key tab navigation;
|
||||||
|
- focus return из menus/popovers;
|
||||||
|
- disabled-action explanations;
|
||||||
|
- empty, loading, error, missing-component, ready и unapplied-change states.
|
||||||
|
|
||||||
|
12. Добавить responsive visual snapshots:
|
||||||
|
- 1280x720 desktop;
|
||||||
|
- 800x600 compact desktop;
|
||||||
|
- 420x720 narrow window;
|
||||||
|
- длинные proxy/server names и длинные Windows paths.
|
||||||
|
|
||||||
|
## Рекомендуемый первый redesign slice
|
||||||
|
|
||||||
|
Сначала стоит сделать shared UI foundation: `Button`, `Tabs`, `ServiceControlRow`, `StatusPill`, `Field`, `ActionMenu`, `LogDock` и motion tokens. После этого собрать ProxiFyre и Local sing-box через один `ServiceControlRow`, а route-chain использовать как объясняющий компонент в "Сводке" и "VPN / Прокси". Такой порядок сначала стабилизирует поведение кнопок/табы/анимации, затем улучшит ориентацию и recovery без переписывания всего UI.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Windows Client Service-Control UI System Evidence
|
||||||
|
|
||||||
|
## Acceptance Evidence
|
||||||
|
|
||||||
|
- Desktop Summary screenshot: `docs/goals/windows-client-service-control-ui-system/evidence/01-summary-desktop.png`
|
||||||
|
- Shows shared tabs, shared refresh button, and friendly preview error: `Desktop-команды недоступны`.
|
||||||
|
- Desktop ProxiFyre screenshot: `docs/goals/windows-client-service-control-ui-system/evidence/02-proxifyre-desktop.png`
|
||||||
|
- Shows ProxiFyre rendered through the shared service-control row pattern.
|
||||||
|
- Shows apply blocker: `ProxiFyre не установлен`.
|
||||||
|
- Shows `Обновить конфиг` disabled instead of green/ready.
|
||||||
|
- Desktop VPN / Proxy screenshot: `docs/goals/windows-client-service-control-ui-system/evidence/03-proxy-desktop.png`
|
||||||
|
- Shows route mode buttons through shared button variants.
|
||||||
|
- Shows apply blocker before route apply.
|
||||||
|
- Narrow ProxiFyre screenshot: `docs/goals/windows-client-service-control-ui-system/evidence/04-proxifyre-narrow.png`
|
||||||
|
- Shows service row and setup checklist reflow at 420px.
|
||||||
|
- Shows compact log dock title instead of long raw runtime error text.
|
||||||
|
|
||||||
|
Captured DOM state from Browser preview:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"proxState": {
|
||||||
|
"disabledApply": true,
|
||||||
|
"blockerText": true,
|
||||||
|
"friendlyError": true,
|
||||||
|
"rawInvokeCurrent": false
|
||||||
|
},
|
||||||
|
"routeState": {
|
||||||
|
"externalButtonClass": "ui-button ui-button--primary ui-button--md",
|
||||||
|
"externalAriaPressed": "true"
|
||||||
|
},
|
||||||
|
"narrowState": {
|
||||||
|
"width": 420,
|
||||||
|
"hasHorizontalOverflow": false,
|
||||||
|
"setupStripOverflow": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Native/elevated install/start/stop/uninstall service actions were not executed in this evidence pass. They are implemented through the existing handlers and shared callbacks, but real elevated service execution remains `implemented but unproven`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Build was run with bundled Node because system `npm` is not in PATH in this Codex shell.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& 'C:\Users\PC\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' '.\node_modules\typescript\bin\tsc'
|
||||||
|
& 'C:\Users\PC\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' '.\node_modules\vite\bin\vite.js' build
|
||||||
|
```
|
||||||
|
|
||||||
|
Relevant output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
vite v7.3.6 building client environment for production...
|
||||||
|
✓ 1800 modules transformed.
|
||||||
|
✓ built in 1.14s
|
||||||
|
```
|
||||||
|
|
||||||
|
Cutover check:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rg -n 'transition:\s*all|className=.*(apply-button|service-button|ghost-button|add-tile|open-config-button)' apps\windows-client\src
|
||||||
|
```
|
||||||
|
|
||||||
|
Relevant output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
no legacy className or transition: all matches
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `pnpm run build` was attempted through the bundled runtime, but it began reinstalling `node_modules` because dependencies were originally installed by another package manager. The process was stopped; no package or lockfile changes were recorded by git.
|
||||||
|
- Browser preview was served from `http://127.0.0.1:5174/`.
|
||||||
|
|
||||||
|
## Review Notes
|
||||||
|
|
||||||
|
- Visual self-review found that active route mode lost its active styling after switching to shared `Button`; CSS was corrected with `.route-switch .ui-button--primary`.
|
||||||
|
- Visual self-review found setup chips still horizontally scrolled at 420px; narrow CSS was corrected to wrap `.setup-strip-items`.
|
||||||
|
- Residual product issue from the earlier audit remains: Summary still has `Состояние загружается` beside `Совпадает`. That state language was outside the implemented high-value slice and should be handled in a follow-up Summary view-model cleanup.
|
||||||
|
- POST plan review: aligned. Implementation stayed inside the approved ownership and contract boundaries: shared UI components are presentational, `App.tsx` still owns orchestration, and Tauri wrappers remain the command boundary.
|
||||||
|
- Correctness review: no blocker found. Real elevated service actions were not executed, so that lane remains `implemented but unproven`.
|
||||||
|
- Maintainability review: no blocker found. Old dominant `className` paths for apply/service/ghost/add/open buttons were displaced from `App.tsx`; stale legacy CSS selectors remain in `app.css` as non-dominant compatibility residue and should be removed in a follow-up CSS pruning pass if desired.
|
||||||
11
docs/goals/windows-client-service-control-ui-system/GOAL.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Goal: Windows Client Service-Control UI System
|
||||||
|
|
||||||
|
Use Krypton Execution to execute `docs/goals/windows-client-service-control-ui-system/PLAN.md`.
|
||||||
|
|
||||||
|
Core rules:
|
||||||
|
- Treat PLAN.md as the source plan.
|
||||||
|
- Preserve intent, ownership, contract, cutover, evidence, and kill criteria.
|
||||||
|
- Do not add a new dominant path without deleting, redirecting, demoting, or shimming the displaced path.
|
||||||
|
- Capture acceptance evidence from the target perspective and record it in EVIDENCE.md.
|
||||||
|
- Say "implemented but unproven" if that evidence cannot be captured.
|
||||||
|
|
||||||
503
docs/goals/windows-client-service-control-ui-system/PLAN.md
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
# Windows Client Service-Control UI System Implementation Plan
|
||||||
|
|
||||||
|
**Intent:** Превратить текущий Windows-клиент в простую service-control утилиту с единой компонентной базой, предсказуемыми состояниями кнопок/табов/служебных строк, readiness-блокерами и аккуратными motion tokens.
|
||||||
|
**Current Behavior:** `apps/windows-client/src/app/App.tsx` и `apps/windows-client/src/styles/app.css` остаются крупным монолитом. Кнопки, табы, service cards, add controls, apply/open actions, log dock, validation и анимации задаются ad hoc через локальные JSX-блоки и классы вроде `service-button`, `apply-button`, `ghost-button`, `add-tile`, `route-switch`, `finder-card`, `log-dock`.
|
||||||
|
**Expected Outcome:** Пользователь видит плотную Windows-утилиту, где ProxiFyre и Local sing-box управляются через один повторяемый service-control паттерн, все кнопки имеют понятные variants/states, apply недоступен до готовности prerequisites, ошибки preview/native команд показываются человечески, а вкладки/меню/loading состояния двигаются одинаково и не создают ощущения зависшего UI.
|
||||||
|
**Target-Perspective Output:** Пользователь открывает клиент и за несколько секунд понимает, какие компоненты установлены, какие службы запущены/остановлены/отсутствуют, какую одну кнопку можно нажать дальше, почему apply заблокирован, и где посмотреть детали. На desktop и узкой ширине интерфейс не обрезает ключевые действия и не показывает raw runtime errors как основной текст.
|
||||||
|
**Truth Owner:** Источник правды для профилей, targets, компонентов, Local sing-box, generated config и apply остается в Rust/Tauri командах и JSON-файлах под `C:\ProgramData\VpnProxy`. Источник правды для UI-паттернов и визуальных контрактов после этого плана: `.interface-design/system.md` и shared UI components под `apps/windows-client/src/ui`.
|
||||||
|
**Contract Boundary:** React UI вызывает typed wrappers из `apps/windows-client/src/api/tauriCommands.ts`. Shared UI components не вызывают Tauri напрямую и не владеют persistent state; они получают props, emit callbacks и отображают states. `App.tsx`/panel containers владеют orchestration, draft state, loading state, readiness blockers и вызовами существующих handlers.
|
||||||
|
**Cutover:** Старые ad hoc JSX/CSS paths для кнопок, табов, service cards, log dock, fields и menus постепенно заменяются shared components. После cutover в `App.tsx` не должно оставаться нового прямого `<button className="apply-button|service-button|ghost-button|add-tile|open-config-button">` как доминирующего паттерна; старые классы либо удалены, либо превращены в internal classes shared components.
|
||||||
|
**Displaced Path:** Вытесняется путь "каждый render-блок сам рисует свою кнопку/таб/service card/loading/error". Также демотируется route-chain-first направление: route chain остается объясняющим элементом, но основная модель UI - управление службами и их состояниями.
|
||||||
|
**Value Density:** Самый ценный срез: `Button`/`IconButton`, `Tabs`, `ServiceControlRow`, `LogDock`, readiness blockers для apply, friendly preview error. Это сразу исправляет доверие к действиям и закладывает базу для дальнейшего разбиения панелей.
|
||||||
|
**Evidence Gate:** Приемка требует `npm run build` плюс пользовательские доказательства: desktop и narrow screenshots для Summary, ProxiFyre, VPN/Proxy; screenshot или DOM-state, что apply disabled с explanation при missing ProxiFyre/empty apps; screenshot, что raw `invoke` preview error не показывается как главный текст.
|
||||||
|
**Acceptance Evidence:** Build проходит; visual evidence показывает единые button variants/states, tabs с keyboard/active behavior, ServiceControlRow для ProxiFyre и Local sing-box, disabled apply blockers, adaptive log dock, friendly preview/native error. Если реальная elevated Tauri установка/удаление служб не проверялась, service execution lane помечается как `implemented but unproven`.
|
||||||
|
**Evidence Lane:** Доказательства исполнения записывать в `docs/goals/windows-client-service-control-ui-system/EVIDENCE.md`.
|
||||||
|
**Kill Criteria:** Нет второго источника UI truth вне shared components; нет прямой записи generated config из UI; нет скрытой установки компонентов при apply/open/refresh; нет новых изменений в root `src/web/*` или `src/server/*`; нет raw preview `Cannot read properties...invoke` как основного user-facing сообщения; нет зеленого primary apply без readiness.
|
||||||
|
**Architecture Slice:** Создать shared UI layer под `apps/windows-client/src/ui`; при необходимости создать view-model helpers под `apps/windows-client/src/app`; модифицировать `apps/windows-client/src/app/App.tsx` и `apps/windows-client/src/styles/app.css`; не менять backend, кроме точечных type/import изменений, если вскроется compile-only необходимость.
|
||||||
|
**Plan Review Gate:** Requires PRE review before execution.
|
||||||
|
|
||||||
|
## Outcome Contract
|
||||||
|
|
||||||
|
Plan title: Windows Client Service-Control UI System
|
||||||
|
|
||||||
|
Intent: стабилизировать UI как простую утилиту управления службами, а не dashboard.
|
||||||
|
|
||||||
|
Current behavior: большие участки JSX и CSS повторяют однотипные buttons, service status cards, tabs, fields, menus и log surfaces без общего контракта.
|
||||||
|
|
||||||
|
Expected outcome: shared UI foundation контролирует внешний вид, states, motion и accessibility для повторяемых элементов; ProxiFyre и Local sing-box отображаются через один service-control паттерн; apply actions gated by readiness.
|
||||||
|
|
||||||
|
Target-perspective output: пользователь видит понятные состояния "установлено/не установлено/запущено/остановлено/проверяю/ошибка", одну главную кнопку на службу, объяснение блокеров и одинаковые transitions при переключении вкладок/меню/loading.
|
||||||
|
|
||||||
|
Truth owner:
|
||||||
|
- Data truth: Rust/Tauri commands + `C:\ProgramData\VpnProxy`.
|
||||||
|
- UI pattern truth: `.interface-design/system.md` + `apps/windows-client/src/ui/*`.
|
||||||
|
- Orchestration truth: `apps/windows-client/src/app/App.tsx` and extracted app-level helpers.
|
||||||
|
|
||||||
|
Contract boundary:
|
||||||
|
- `src/ui/*` is presentational and callback-driven.
|
||||||
|
- `src/app/*` containers compose view models, draft state and command handlers.
|
||||||
|
- `src/api/tauriCommands.ts` remains the typed command boundary.
|
||||||
|
|
||||||
|
Cutover:
|
||||||
|
- First introduce shared components without changing backend behavior.
|
||||||
|
- Then migrate existing render functions to shared components.
|
||||||
|
- Then remove/demote old CSS selectors so new work cannot keep using ad hoc paths.
|
||||||
|
|
||||||
|
Displaced path:
|
||||||
|
- Direct ad hoc buttons/tabs/service-card styles in `App.tsx`/`app.css`.
|
||||||
|
- Raw runtime error text as primary user-facing status.
|
||||||
|
- Always-green apply actions regardless of prerequisites.
|
||||||
|
|
||||||
|
Value density:
|
||||||
|
- Strongest first slice is shared component layer + apply blockers + friendly preview error.
|
||||||
|
- Panel extraction is valuable only after component contracts exist.
|
||||||
|
|
||||||
|
Evidence gate:
|
||||||
|
- Build proof.
|
||||||
|
- Visual proof at desktop and narrow widths.
|
||||||
|
- Interaction proof for disabled apply reasons, tab focus/keyboard behavior, service action loading state and log dock behavior.
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- `cd apps/windows-client && npm run build`.
|
||||||
|
- Browser/Tauri screenshots saved under this goal's evidence folder or linked from `EVIDENCE.md`.
|
||||||
|
- Notes of any unverified elevated service operations.
|
||||||
|
|
||||||
|
Evidence lane:
|
||||||
|
- `docs/goals/windows-client-service-control-ui-system/EVIDENCE.md`.
|
||||||
|
|
||||||
|
Kill criteria:
|
||||||
|
- If old button classes remain public and newly used after migration, the cutover is incomplete.
|
||||||
|
- If `App.tsx` grows with more ad hoc JSX instead of shrinking or delegating to shared components, stop and refactor the slice.
|
||||||
|
- If summary/route work starts before shared buttons/tabs/service rows, stop and restore priority.
|
||||||
|
|
||||||
|
Non-goals:
|
||||||
|
- No backend service rewrite.
|
||||||
|
- No new route modes.
|
||||||
|
- No new installer behavior.
|
||||||
|
- No root web/server redesign.
|
||||||
|
- No full visual rebrand.
|
||||||
|
- No dependency-heavy component library unless explicitly approved.
|
||||||
|
|
||||||
|
Risk if wrong:
|
||||||
|
- The UI may look slightly cleaner but remain structurally unmaintainable, with duplicated states and inconsistent actions. The user will still see green buttons that fail, raw preview errors, and inconsistent loading/disabled/motion states.
|
||||||
|
|
||||||
|
## Architecture Slice
|
||||||
|
|
||||||
|
Files to create:
|
||||||
|
- `apps/windows-client/src/ui/Button.tsx`
|
||||||
|
- `apps/windows-client/src/ui/IconButton.tsx`
|
||||||
|
- `apps/windows-client/src/ui/Tabs.tsx`
|
||||||
|
- `apps/windows-client/src/ui/ServiceControlRow.tsx`
|
||||||
|
- `apps/windows-client/src/ui/StatusPill.tsx`
|
||||||
|
- `apps/windows-client/src/ui/Field.tsx`
|
||||||
|
- `apps/windows-client/src/ui/ActionMenu.tsx`
|
||||||
|
- `apps/windows-client/src/ui/LogDock.tsx`
|
||||||
|
- `apps/windows-client/src/ui/index.ts`
|
||||||
|
- `apps/windows-client/src/app/readiness.ts`
|
||||||
|
- `apps/windows-client/src/app/viewModel.ts` or smaller helpers if they reduce `App.tsx` without creating duplicate truth.
|
||||||
|
|
||||||
|
Files to modify:
|
||||||
|
- `apps/windows-client/src/app/App.tsx`
|
||||||
|
- `apps/windows-client/src/styles/app.css`
|
||||||
|
- `apps/windows-client/src/api/tauriCommands.ts` only for compile-safe preview/error wrapper changes if needed.
|
||||||
|
- `apps/windows-client/src/domain/types.ts` only if component/view-model types need shared aliases.
|
||||||
|
- `docs/goals/windows-client-service-control-ui-system/EVIDENCE.md` during execution.
|
||||||
|
|
||||||
|
Files to avoid:
|
||||||
|
- `src/web/*`
|
||||||
|
- `src/server/*`
|
||||||
|
- Docker/compose/entrypoint files.
|
||||||
|
- Installer scripts.
|
||||||
|
- Rust backend files unless a compile break proves a tiny typed-boundary fix is necessary.
|
||||||
|
- Existing goal packages, except cross-reference notes in evidence.
|
||||||
|
|
||||||
|
Source of truth:
|
||||||
|
- UI system: `.interface-design/system.md`.
|
||||||
|
- Profiles/targets/components/subscription/activity/generated config: Rust/Tauri commands and `C:\ProgramData\VpnProxy`.
|
||||||
|
- Current draft state: React state in app containers only.
|
||||||
|
|
||||||
|
Read path:
|
||||||
|
- `App.tsx` calls `getSavedState`, `getComponents`, `getProxiFyreSetupStatus`, `getSingBoxStatus`, `getSingBoxSetupStatus`.
|
||||||
|
- View-model helpers derive service states, button readiness, route summary and blocker copy from existing state.
|
||||||
|
- Shared components receive already-derived props and do no data fetching.
|
||||||
|
|
||||||
|
Write path:
|
||||||
|
- Existing handlers remain the only write path: install/start/stop/uninstall service handlers, app add/remove handlers, route mode/proxy/subscription/server handlers, `updateConfig`, `openConfig`.
|
||||||
|
- Shared components only call callbacks passed by containers.
|
||||||
|
- Apply actions call `updateConfig` only when readiness says they are actionable.
|
||||||
|
|
||||||
|
Contract boundary:
|
||||||
|
- `Button` owns variants/states/loading/disabled look, not business readiness.
|
||||||
|
- `ServiceControlRow` owns layout for a service, not installing/starting logic.
|
||||||
|
- `Tabs` owns keyboard behavior and active styling, not panel content.
|
||||||
|
- `LogDock` owns display/collapse/history behavior, not logging policy.
|
||||||
|
- `readiness.ts` owns blocker computation and copy.
|
||||||
|
|
||||||
|
Integration points:
|
||||||
|
- ProxiFyre row uses existing `proxyfier`, `setupStatus`, `serviceAction`, `installProxiFyrePackage`, `setProxiFyreServiceRunning`, `uninstallProxiFyrePackage`.
|
||||||
|
- Local sing-box row uses existing `singbox`, `singBoxSetupStatus`, `singBoxStatus`, `singBoxAction`, `installSingBoxPackage`, `setSingBoxServiceRunning`, `uninstallSingBoxPackage`.
|
||||||
|
- App list uses existing `items`, `addItem`, `removeItem`, `pickAndAddItem`, `isProcessInputOpen`, `processInput`.
|
||||||
|
- Route panel uses existing `routeMode`, `proxyInput`, `proxyPing`, `pingExternalProxy`, subscription/server handlers.
|
||||||
|
- Log dock uses existing `logEntries`, `activeLog`, `isLogOpen`.
|
||||||
|
|
||||||
|
Migration/cutover:
|
||||||
|
- Introduce shared components alongside old CSS.
|
||||||
|
- Migrate one component family at a time.
|
||||||
|
- After migration, delete or privatize old selectors from `app.css`.
|
||||||
|
- Keep build green after each task.
|
||||||
|
- Do not split backend concerns into UI components.
|
||||||
|
|
||||||
|
Displaced path:
|
||||||
|
- `renderTabs` local tab button markup.
|
||||||
|
- `renderProxiFyreCard` and `renderSingBoxCard` duplicated finder/service-card markup.
|
||||||
|
- `renderApplyActions` always-green apply command row.
|
||||||
|
- `log-dock` raw error display.
|
||||||
|
- Direct CSS button families: `.ghost-button`, `.service-button`, `.apply-button`, `.open-config-button`, `.add-tile`, `.route-switch button`.
|
||||||
|
|
||||||
|
Acceptance evidence gate:
|
||||||
|
- Visual proof is required because this is UI/system work.
|
||||||
|
- Build proof alone is not enough.
|
||||||
|
- If Browser preview cannot prove native service actions, capture preview proof and mark native actions `implemented but unproven`.
|
||||||
|
|
||||||
|
## UI Contract
|
||||||
|
|
||||||
|
### Button Variants
|
||||||
|
|
||||||
|
`Button` must support:
|
||||||
|
- `primary`: one main ready action; green only when action is safe and ready.
|
||||||
|
- `neutral`: refresh/open/cancel/secondary.
|
||||||
|
- `add`: add process/EXE/folder/target; icon-first.
|
||||||
|
- `danger`: stop/delete/uninstall/destructive.
|
||||||
|
- `icon`: square icon-only action with `aria-label` and `title`/tooltip.
|
||||||
|
|
||||||
|
Required states:
|
||||||
|
- default
|
||||||
|
- hover
|
||||||
|
- active
|
||||||
|
- focus-visible
|
||||||
|
- disabled
|
||||||
|
- loading
|
||||||
|
|
||||||
|
Loading text should name the action: `Проверяю`, `Устанавливаю`, `Запускаю`, `Останавливаю`, `Применяю`, not only `...`.
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
`Tabs` must support:
|
||||||
|
- `role="tablist"`, `role="tab"`, `role="tabpanel"`.
|
||||||
|
- Left/Right arrow navigation.
|
||||||
|
- Enter/Space activation if focus and selection are separated.
|
||||||
|
- Active underline.
|
||||||
|
- 180-240ms panel transition using only `opacity` and `transform`.
|
||||||
|
- Reduced motion support.
|
||||||
|
|
||||||
|
### ServiceControlRow
|
||||||
|
|
||||||
|
`ServiceControlRow` props should express:
|
||||||
|
- service name.
|
||||||
|
- state: `checking | missing | installed | running | stopped | error`.
|
||||||
|
- title and detail.
|
||||||
|
- primary action label/loading/disabled.
|
||||||
|
- optional secondary menu actions.
|
||||||
|
- optional expanded details/checklist.
|
||||||
|
- optional inline actions/details toggle.
|
||||||
|
|
||||||
|
It must be generic enough for both ProxiFyre and Local sing-box.
|
||||||
|
|
||||||
|
### Readiness Blockers
|
||||||
|
|
||||||
|
Apply readiness must be computed before rendering the action:
|
||||||
|
- no apps -> blocked with `Добавь хотя бы одно приложение`.
|
||||||
|
- ProxiFyre missing -> blocked with `Установи ProxiFyre`.
|
||||||
|
- local route + sing-box missing -> blocked with `Установи Local sing-box`.
|
||||||
|
- local route + no selected server -> blocked with `Выбери сервер Local sing-box`.
|
||||||
|
- external route + invalid proxy -> blocked with accepted format copy.
|
||||||
|
- pending service action/apply -> loading and disabled.
|
||||||
|
|
||||||
|
Blocked apply must not be bright green.
|
||||||
|
|
||||||
|
### Friendly Preview Error
|
||||||
|
|
||||||
|
Browser-preview/native command errors should render as:
|
||||||
|
- title: `Desktop-команды недоступны`
|
||||||
|
- text: `Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.`
|
||||||
|
- raw error only inside log details/history.
|
||||||
|
|
||||||
|
### Motion Tokens
|
||||||
|
|
||||||
|
CSS variables:
|
||||||
|
- `--motion-fast: 120ms`
|
||||||
|
- `--motion-standard: 180ms`
|
||||||
|
- `--motion-panel: 220ms`
|
||||||
|
- `--ease-out: cubic-bezier(0.23, 1, 0.32, 1)`
|
||||||
|
- `--ease-standard: cubic-bezier(0.22, 0.72, 0.18, 1)`
|
||||||
|
|
||||||
|
Do not use `transition: all`.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Task 1: Create Shared UI Foundation
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src/ui/Button.tsx`
|
||||||
|
- `apps/windows-client/src/ui/IconButton.tsx`
|
||||||
|
- `apps/windows-client/src/ui/Tabs.tsx`
|
||||||
|
- `apps/windows-client/src/ui/StatusPill.tsx`
|
||||||
|
- `apps/windows-client/src/ui/Field.tsx`
|
||||||
|
- `apps/windows-client/src/ui/index.ts`
|
||||||
|
- `apps/windows-client/src/styles/app.css`
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Create presentational components only.
|
||||||
|
- Add CSS tokens/classes for button variants, tab states, focus-visible, loading and reduced motion.
|
||||||
|
- Do not wire business handlers yet beyond simple props.
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- Components compile under strict TypeScript.
|
||||||
|
- No backend or app state behavior changes.
|
||||||
|
|
||||||
|
Verification command:
|
||||||
|
- `cd apps/windows-client && npm run build`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Build output recorded in `EVIDENCE.md`.
|
||||||
|
- Short note listing component contracts and CSS tokens/classes.
|
||||||
|
|
||||||
|
Parallel-safe: no. This establishes shared write scope.
|
||||||
|
|
||||||
|
### Task 2: Replace Tabs And Basic Buttons
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src/app/App.tsx`
|
||||||
|
- `apps/windows-client/src/styles/app.css`
|
||||||
|
- `apps/windows-client/src/ui/*`
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Replace local `renderTabs` markup with `Tabs`.
|
||||||
|
- Replace header refresh, summary navigation buttons, route switch buttons, neutral/open buttons and add icon buttons with shared `Button`/`IconButton`.
|
||||||
|
- Preserve active panel state and current handlers.
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- Tabs have consistent ARIA and keyboard behavior.
|
||||||
|
- Buttons share variants and loading labels.
|
||||||
|
- No behavior regression in panel switching or refresh.
|
||||||
|
|
||||||
|
Verification command:
|
||||||
|
- `cd apps/windows-client && npm run build`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Screenshot or DOM note showing tab ARIA/active state.
|
||||||
|
- Desktop screenshot showing migrated button variants.
|
||||||
|
|
||||||
|
Parallel-safe: no. Touches `App.tsx` and shared UI.
|
||||||
|
|
||||||
|
### Task 3: Add Readiness Model And Gate Apply Actions
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src/app/readiness.ts`
|
||||||
|
- `apps/windows-client/src/app/App.tsx`
|
||||||
|
- `apps/windows-client/src/styles/app.css`
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Create pure helper functions for apply blockers and action labels.
|
||||||
|
- Replace always-green `renderApplyActions` behavior with readiness-aware disabled/loading/blocker UI.
|
||||||
|
- Keep `updateConfig` validation as backend/handler safety, but do not let obviously blocked actions look ready.
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- Apply disabled with explanation when ProxiFyre is missing, apps empty, external proxy invalid, local route lacks sing-box/server, or service/apply action is running.
|
||||||
|
- Primary green appears only when action is ready.
|
||||||
|
|
||||||
|
Verification command:
|
||||||
|
- `cd apps/windows-client && npm run build`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Screenshot/state showing disabled apply + blocker copy in missing ProxiFyre/empty apps preview state.
|
||||||
|
- Note that `updateConfig` still validates at action time.
|
||||||
|
|
||||||
|
Parallel-safe: no. Depends on shared buttons and touches core apply behavior.
|
||||||
|
|
||||||
|
### Task 4: Implement ServiceControlRow And Migrate ProxiFyre
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src/ui/ServiceControlRow.tsx`
|
||||||
|
- `apps/windows-client/src/ui/ActionMenu.tsx`
|
||||||
|
- `apps/windows-client/src/app/App.tsx`
|
||||||
|
- `apps/windows-client/src/styles/app.css`
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Create generic `ServiceControlRow`.
|
||||||
|
- Migrate `renderProxiFyreCard` and setup strip/details into this pattern.
|
||||||
|
- Preserve install/start/stop/uninstall confirm boundary and loading states.
|
||||||
|
- Keep ProxiFyre setup checklist readable on narrow widths.
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- ProxiFyre service state row uses the generic component.
|
||||||
|
- The old ProxiFyre-specific button/card markup is removed or demoted.
|
||||||
|
|
||||||
|
Verification command:
|
||||||
|
- `cd apps/windows-client && npm run build`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Desktop and narrow screenshots of ProxiFyre panel.
|
||||||
|
- Note showing one primary service action plus overflow menu.
|
||||||
|
|
||||||
|
Parallel-safe: no. Touches `App.tsx`, service UI and CSS.
|
||||||
|
|
||||||
|
### Task 5: Migrate Local Sing-box To ServiceControlRow
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src/app/App.tsx`
|
||||||
|
- `apps/windows-client/src/styles/app.css`
|
||||||
|
- `apps/windows-client/src/ui/ServiceControlRow.tsx`
|
||||||
|
- `apps/windows-client/src/ui/ActionMenu.tsx`
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Reuse `ServiceControlRow` for Local sing-box.
|
||||||
|
- Preserve setup details, info popover/actions, subscription workspace, server list and install note.
|
||||||
|
- Keep Local sing-box controls only in VPN/Proxy context, unless already present by selected route.
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- Local sing-box service controls visually and behaviorally match ProxiFyre.
|
||||||
|
- No duplicate service-control patterns remain for sing-box.
|
||||||
|
|
||||||
|
Verification command:
|
||||||
|
- `cd apps/windows-client && npm run build`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Screenshot of VPN/Proxy local route state or preview missing state.
|
||||||
|
- Note confirming ProxiFyre and sing-box share the same component.
|
||||||
|
|
||||||
|
Parallel-safe: no. Depends on Task 4.
|
||||||
|
|
||||||
|
### Task 6: Friendly LogDock And Preview Error Handling
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src/ui/LogDock.tsx`
|
||||||
|
- `apps/windows-client/src/app/App.tsx`
|
||||||
|
- `apps/windows-client/src/styles/app.css`
|
||||||
|
- `apps/windows-client/src/api/tauriCommands.ts` only if a tiny safe error-normalization helper is required.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Move log dock rendering into shared `LogDock`.
|
||||||
|
- Collapse/adapt it on narrow widths.
|
||||||
|
- Normalize preview/native `invoke` failure to friendly title/text while preserving raw details in history.
|
||||||
|
- Keep `aria-live` concise.
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- Main footer no longer shows raw `Cannot read properties of undefined (reading 'invoke')` as primary text.
|
||||||
|
- Narrow width does not hide the primary app task behind long raw error text.
|
||||||
|
|
||||||
|
Verification command:
|
||||||
|
- `cd apps/windows-client && npm run build`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Browser-preview screenshot showing friendly desktop-command message.
|
||||||
|
- Narrow screenshot showing adaptive/collapsed log behavior.
|
||||||
|
|
||||||
|
Parallel-safe: no. Touches global app surface.
|
||||||
|
|
||||||
|
### Task 7: Extract Panel/View Helpers Without Moving Truth
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src/app/App.tsx`
|
||||||
|
- `apps/windows-client/src/app/viewModel.ts`
|
||||||
|
- Optional pure panel files under `apps/windows-client/src/app/panels/*` if and only if prop boundaries are clear.
|
||||||
|
- `apps/windows-client/src/styles/app.css`
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Extract pure view-model helpers for summary/system state, route labels, service labels and display copy.
|
||||||
|
- Optionally split large panel JSX into pure components that receive props/callbacks.
|
||||||
|
- Do not move Tauri command calls into presentational components.
|
||||||
|
- Do not create duplicate React state for profiles/targets/components.
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- `App.tsx` becomes smaller and more orchestration-focused.
|
||||||
|
- UI components stay reusable and business state remains in containers/helpers.
|
||||||
|
|
||||||
|
Verification command:
|
||||||
|
- `cd apps/windows-client && npm run build`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Note listing extracted helpers/components and confirming no duplicate truth path.
|
||||||
|
|
||||||
|
Parallel-safe: no. Refactor touches core composition.
|
||||||
|
|
||||||
|
### Task 8: Motion, Responsive And CSS Cutover Cleanup
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src/styles/app.css`
|
||||||
|
- `apps/windows-client/src/app/App.tsx`
|
||||||
|
- `apps/windows-client/src/ui/*`
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Replace one-off transitions with motion variables.
|
||||||
|
- Remove or privatize old selectors after migration.
|
||||||
|
- Ensure setup checklists wrap on narrow widths.
|
||||||
|
- Ensure text/path/server names wrap or truncate intentionally.
|
||||||
|
- Ensure no `transition: all`.
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- Consistent animation durations/easing.
|
||||||
|
- No layout overlap on 1280x720, 800x600, 420x720.
|
||||||
|
- Old ad hoc public selector families are not the dominant styling path.
|
||||||
|
|
||||||
|
Verification command:
|
||||||
|
- `cd apps/windows-client && npm run build`
|
||||||
|
- `rg -n "transition:\\s*all|className=\"(apply-button|service-button|ghost-button|add-tile|open-config-button)" apps/windows-client/src`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Desktop + narrow screenshots.
|
||||||
|
- `rg` output or note proving no new ad hoc button paths remain.
|
||||||
|
|
||||||
|
Parallel-safe: no. Final CSS integration pass.
|
||||||
|
|
||||||
|
### Task 9: Final Verification And Evidence
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `docs/goals/windows-client-service-control-ui-system/EVIDENCE.md`
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Record final build/test commands and outputs.
|
||||||
|
- Capture visual evidence for Summary, ProxiFyre, VPN/Proxy at desktop and narrow widths.
|
||||||
|
- Record interactive evidence for tabs keyboard behavior, disabled apply blocker, service action loading state and friendly preview error.
|
||||||
|
- Record any unverified native/elevated service operations as residual risk.
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- `EVIDENCE.md` is sufficient for a reviewer to judge the user-facing outcome.
|
||||||
|
|
||||||
|
Verification command:
|
||||||
|
- `cd apps/windows-client && npm run build`
|
||||||
|
- If Rust/backend changed: `cd apps/windows-client/src-tauri && cargo test`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Evidence file filled with commands, screenshots/state notes and residual risks.
|
||||||
|
|
||||||
|
Parallel-safe: no.
|
||||||
|
|
||||||
|
## PRE Review Prompt
|
||||||
|
|
||||||
|
Use `C:\Users\PC\.agents\skills\krypton-planning\plan-reviewer-prompt.md` with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MODE: PRE
|
||||||
|
|
||||||
|
Plan file:
|
||||||
|
docs/goals/windows-client-service-control-ui-system/PLAN.md
|
||||||
|
|
||||||
|
Original request:
|
||||||
|
Составить план на основании UX/UI-аудита и уточнения, что Windows-клиент должен быть максимально простой service-control утилитой: управлять состояниями служб, устанавливать/удалять их, иметь нормальную компонентную базу для кнопок, табов, добавления, обновления, анимаций и loading states.
|
||||||
|
|
||||||
|
Outcome contract:
|
||||||
|
Windows-клиент становится service-control utility с shared UI foundation, readiness-gated actions, едиными tabs/buttons/service rows/log dock/motion tokens и evidence from target perspective.
|
||||||
|
|
||||||
|
Architecture slice:
|
||||||
|
Create `apps/windows-client/src/ui/*`, optional pure helpers under `apps/windows-client/src/app`, modify `App.tsx` and `app.css`, avoid backend/root web/server unless compile-only boundary fix is necessary.
|
||||||
|
|
||||||
|
Acceptance evidence requirement:
|
||||||
|
Build plus desktop/narrow visual proof of Summary, ProxiFyre and VPN/Proxy; disabled apply blocker; friendly preview error; shared service rows; keyboard/active tab behavior.
|
||||||
|
|
||||||
|
Known non-goals:
|
||||||
|
No backend rewrite, no installer changes, no root web/server changes, no full visual rebrand, no hidden component installation, no route-mode expansion.
|
||||||
|
|
||||||
|
Unsafe paths or layers:
|
||||||
|
Duplicating truth in React, putting Tauri calls in UI components, leaving old ad hoc button paths as dominant, showing raw invoke errors, making apply green while blocked, changing generated config directly.
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 38 KiB |