fix(ui): согласовать настройки и контекст Community
No files matched your search
@@ -245,9 +245,9 @@ function ConfChip({ conf }: { conf: string }) {
|
||||
return <Chip color={map[conf] ?? map.LOW}>{conf}</Chip>
|
||||
}
|
||||
|
||||
function NInput({ value, onChange, min, max, label }: { label: string; value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||
function NInput({ value, onChange, min, max, label, disabled }: { disabled?: boolean; label: string; value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||
return (
|
||||
<Input aria-label={label} type="number" value={value} min={min} max={max}
|
||||
<Input disabled={disabled} aria-label={label} type="number" value={value} min={min} max={max}
|
||||
onChange={e => onChange(Math.max(min ?? 0, Math.min(max ?? 9999, parseInt(e.target.value) || 0)))}
|
||||
className="w-16 h-7 text-xs font-mono text-right" />
|
||||
)
|
||||
@@ -315,7 +315,7 @@ function HomeRouterCard({ entry, jumpHosts, pinned, applied, applying, onPin, on
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{switchCount > 0 && (
|
||||
<Chip color="bg-warning/10 text-warning border-warning/20">
|
||||
{switchCount} переключений
|
||||
Рекомендаций: {switchCount}
|
||||
</Chip>
|
||||
)}
|
||||
{bestRoute && (
|
||||
@@ -771,7 +771,7 @@ function RouteOptimizerContent({requestedServer,onSelectServer}:{requestedServer
|
||||
<SettingsIcon aria-hidden="true" className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium flex-1">Настройки оптимизатора</span>
|
||||
<span className="text-[11px] text-muted-foreground mr-2">
|
||||
порог {settings.switchThreshold}% · ping {settings.pingWeight}% · зондирование {settings.probeIntervalMin} мин
|
||||
порог {settings.switchThreshold} б. · ping {settings.pingWeight}% · обновление {settings.probeIntervalMin} мин
|
||||
</span>
|
||||
{showSettings ? <ChevronUpIcon aria-hidden="true" className="size-4 text-muted-foreground" /> : <ChevronDownIcon aria-hidden="true" className="size-4 text-muted-foreground" />}
|
||||
</button>
|
||||
@@ -781,12 +781,13 @@ function RouteOptimizerContent({requestedServer,onSelectServer}:{requestedServer
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Пороги переключения</p>
|
||||
<SettingRow label="Мин. выигрыш" unit="%">
|
||||
<SettingRow label="Мин. выигрыш" unit="б.">
|
||||
<NInput label="Порог переключения" value={settings.switchThreshold} onChange={v => set("switchThreshold", v)} min={0} max={50} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Гистерезис" unit="%">
|
||||
<NInput label="Порог гистерезиса" value={settings.hysteresisThreshold} onChange={v => set("hysteresisThreshold", v)} min={0} max={50} />
|
||||
<SettingRow label="Гистерезис" unit="б.">
|
||||
<NInput disabled label="Порог гистерезиса" value={settings.hysteresisThreshold} onChange={v => set("hysteresisThreshold", v)} min={0} max={50} />
|
||||
</SettingRow>
|
||||
<p className="text-[10px] text-muted-foreground">Гистерезис не участвует в этом расчёте.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Веса метрик</p>
|
||||
@@ -800,7 +801,7 @@ function RouteOptimizerContent({requestedServer,onSelectServer}:{requestedServer
|
||||
<span>Ping {settings.pingWeight}%</span>
|
||||
<span>BW {100 - settings.pingWeight}%</span>
|
||||
</div>
|
||||
<SettingRow label="Интервал зондирования">
|
||||
<SettingRow label="Обновление расчёта">
|
||||
<div className="flex rounded-md border border-input overflow-hidden h-7">
|
||||
{[5, 10, 15, 30].map(v => (
|
||||
<button type="button" aria-pressed={settings.probeIntervalMin===v} key={v} onClick={() => set("probeIntervalMin", v)}
|
||||
|
||||
@@ -26,11 +26,18 @@
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.42; live WAN без моков/синтетики, далее подписи и локальная приёмка |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.43; единицы/Community scope исправлены, далее финальная локальная приёмка |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 10.43 — смысл настроек и scope Community (2026-10-01)
|
||||
|
||||
- Порог выигрыша — баллы, не проценты; счётчик обозначает рекомендации, не выполненные переключения. Интервал обновляет расчёт, не запускает измерения. Неиспользуемый гистерезис недоступен и явно пояснён; значения сохранённых настроек совместимы.
|
||||
- Live правила Community фильтруются по sourceServerId домашнего устройства до limit/ranking; правила разных home/exit не смешиваются.
|
||||
- 4 observed unit, 10 E2E layout/tables/trust, TS/lint и ReUI consumer audit прошли. Девять снимков обновлены/просмотрены; форма 320 px теперь снята с начала панели. При просмотре заметен риск обрезки header badges/длинных имён — исправить до приёмки.
|
||||
- Далее окончательный layout header/схема цепочки, общая optimizer regression и локальная приёмка.
|
||||
|
||||
## Блок 10.42 — WAN только из live каталога (2026-10-01)
|
||||
|
||||
- Live topology не ищет WAN по совпадению name/host/id в lib/data и не создаёт WAN1/1000 автоматически. Нет uplinks — нет цепочек/лучшего маршрута; пояснение ведёт к /servers?server=<id>. Для существующих WAN формула ранжирования прежняя; лимит модели явно назван расчётным.
|
||||
|
||||
@@ -220,3 +220,7 @@ Shared контракт и backend observed исходные значения/д
|
||||
## 10.42. Live WAN без подстановки
|
||||
|
||||
Нет mock catalog/synthetic WAN в live topology; отсутствие uplinks объяснено с переходом к серверу. 6 unit, 16 E2E, TS/lint прошли. Далее подписи и Community scope перед визуальной приёмкой.
|
||||
|
||||
## 10.43. Настройки/Community scope
|
||||
|
||||
Порог в баллах, обновление расчёта, инертный гистерезис недоступен; рекомендации только своего home. 4 unit, 10 E2E, TS/lint прошли. Девять снимков обновлены; header clipping ещё исправить перед acceptance.
|
||||
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 47 KiB |
@@ -518,7 +518,7 @@ export function buildLiveOptimizerData(
|
||||
sorted.sort((a, b) => b.probabilityOptimal - a.probabilityOptimal)
|
||||
const best = sorted[0] ?? null
|
||||
|
||||
const commRecs: CommRec[] = flatRules.slice(0, 24).map((rule) => {
|
||||
const commRecs: CommRec[] = flatRules.filter(rule=>rule.sourceServerId===home.id).slice(0, 24).map((rule) => {
|
||||
const curProb = 38 + (rule.community.length % 12)
|
||||
const recProb = best ? Math.min(92, curProb + 18 + (best.score % 10)) : curProb
|
||||
const rec = best
|
||||
|
||||
@@ -32,3 +32,11 @@ test("live topology never imports mock WAN or creates a synthetic uplink",()=>{
|
||||
assert.equal(result.homes[0].bestRoute,null)
|
||||
}
|
||||
})
|
||||
|
||||
test("Community recommendations belong to their source home only",()=>{
|
||||
const inventory=[...rows,{...rows[0],id:4,name:"Second home"}]
|
||||
const rule=(id:string,community:string)=>({id,community,communityName:community,gateway:"192.0.2.2",gatewayTunnelId:"",description:""})
|
||||
const result=buildLiveOptimizerData(inventory,[{serverId:"1",rules:[rule("a","65001:1")]},{serverId:"4",rules:[rule("b","65001:4")]},{serverId:"3",rules:[rule("c","65001:3")]}],DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS)
|
||||
assert.deepEqual(result.homes[0].commRecs.map(r=>r.community),["65001:1"])
|
||||
assert.deepEqual(result.homes[1].commRecs.map(r=>r.community),["65001:4"])
|
||||
})
|
||||
@@ -15,7 +15,8 @@ for(const [device,width,height] of [["desktop",1440,1000],["mobile",390,844],["n
|
||||
await settings.focus();await page.keyboard.press("Enter");await expect(settings).toHaveAttribute("aria-expanded","true")
|
||||
await expect(page.getByRole("spinbutton",{name:"Вес задержки",exact:true})).toBeVisible()
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
|
||||
await page.getByRole("spinbutton",{name:"Вес задержки",exact:true}).scrollIntoViewIfNeeded();
|
||||
await expect(page.getByRole("spinbutton",{name:"Порог гистерезиса",exact:true})).toBeDisabled();
|
||||
await settings.evaluate(element=>element.scrollIntoView({block:"start"}));
|
||||
await page.screenshot({path:"docs/ux-ui/screens/route-optimizer-settings-"+device+".png",animations:"disabled"})
|
||||
await settings.focus();await page.keyboard.press(" ");await expect(settings).toHaveAttribute("aria-expanded","false")
|
||||
const slider=page.getByRole("slider",{name:"Максимальное число путей ECMP",exact:true});await expect(slider).toBeDisabled()
|
||||
@@ -23,6 +24,6 @@ for(const [device,width,height] of [["desktop",1440,1000],["mobile",390,844],["n
|
||||
const community=page.getByRole("button",{name:"BGP community (1)",exact:true});await community.focus();await page.keyboard.press("Enter");await expect(community).toHaveAttribute("aria-pressed","true")
|
||||
const grid=page.getByRole("table").filter({hasText:"Community"});await expect(grid.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
|
||||
await grid.scrollIntoViewIfNeeded();
|
||||
await community.evaluate(element=>element.scrollIntoView({block:"start"}));
|
||||
await page.screenshot({path:"docs/ux-ui/screens/route-optimizer-community-"+device+".png",animations:"disabled"})
|
||||
})
|
||||