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>
|
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 (
|
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)))}
|
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" />
|
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">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{switchCount > 0 && (
|
{switchCount > 0 && (
|
||||||
<Chip color="bg-warning/10 text-warning border-warning/20">
|
<Chip color="bg-warning/10 text-warning border-warning/20">
|
||||||
{switchCount} переключений
|
Рекомендаций: {switchCount}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
{bestRoute && (
|
{bestRoute && (
|
||||||
@@ -771,7 +771,7 @@ function RouteOptimizerContent({requestedServer,onSelectServer}:{requestedServer
|
|||||||
<SettingsIcon aria-hidden="true" className="size-4 text-muted-foreground shrink-0" />
|
<SettingsIcon aria-hidden="true" className="size-4 text-muted-foreground shrink-0" />
|
||||||
<span className="text-sm font-medium flex-1">Настройки оптимизатора</span>
|
<span className="text-sm font-medium flex-1">Настройки оптимизатора</span>
|
||||||
<span className="text-[11px] text-muted-foreground mr-2">
|
<span className="text-[11px] text-muted-foreground mr-2">
|
||||||
порог {settings.switchThreshold}% · ping {settings.pingWeight}% · зондирование {settings.probeIntervalMin} мин
|
порог {settings.switchThreshold} б. · ping {settings.pingWeight}% · обновление {settings.probeIntervalMin} мин
|
||||||
</span>
|
</span>
|
||||||
{showSettings ? <ChevronUpIcon aria-hidden="true" className="size-4 text-muted-foreground" /> : <ChevronDownIcon aria-hidden="true" className="size-4 text-muted-foreground" />}
|
{showSettings ? <ChevronUpIcon aria-hidden="true" className="size-4 text-muted-foreground" /> : <ChevronDownIcon aria-hidden="true" className="size-4 text-muted-foreground" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -781,12 +781,13 @@ function RouteOptimizerContent({requestedServer,onSelectServer}:{requestedServer
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Пороги переключения</p>
|
<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} />
|
<NInput label="Порог переключения" value={settings.switchThreshold} onChange={v => set("switchThreshold", v)} min={0} max={50} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="Гистерезис" unit="%">
|
<SettingRow label="Гистерезис" unit="б.">
|
||||||
<NInput label="Порог гистерезиса" value={settings.hysteresisThreshold} onChange={v => set("hysteresisThreshold", v)} min={0} max={50} />
|
<NInput disabled label="Порог гистерезиса" value={settings.hysteresisThreshold} onChange={v => set("hysteresisThreshold", v)} min={0} max={50} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<p className="text-[10px] text-muted-foreground">Гистерезис не участвует в этом расчёте.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Веса метрик</p>
|
<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>Ping {settings.pingWeight}%</span>
|
||||||
<span>BW {100 - settings.pingWeight}%</span>
|
<span>BW {100 - settings.pingWeight}%</span>
|
||||||
</div>
|
</div>
|
||||||
<SettingRow label="Интервал зондирования">
|
<SettingRow label="Обновление расчёта">
|
||||||
<div className="flex rounded-md border border-input overflow-hidden h-7">
|
<div className="flex rounded-md border border-input overflow-hidden h-7">
|
||||||
{[5, 10, 15, 30].map(v => (
|
{[5, 10, 15, 30].map(v => (
|
||||||
<button type="button" aria-pressed={settings.probeIntervalMin===v} key={v} onClick={() => set("probeIntervalMin", v)}
|
<button type="button" aria-pressed={settings.probeIntervalMin===v} key={v} onClick={() => set("probeIntervalMin", v)}
|
||||||
|
|||||||
@@ -26,11 +26,18 @@
|
|||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 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А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 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)
|
## Блок 10.42 — WAN только из live каталога (2026-10-01)
|
||||||
|
|
||||||
- Live topology не ищет WAN по совпадению name/host/id в lib/data и не создаёт WAN1/1000 автоматически. Нет uplinks — нет цепочек/лучшего маршрута; пояснение ведёт к /servers?server=<id>. Для существующих WAN формула ранжирования прежняя; лимит модели явно назван расчётным.
|
- 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 без подстановки
|
## 10.42. Live WAN без подстановки
|
||||||
|
|
||||||
Нет mock catalog/synthetic WAN в live topology; отсутствие uplinks объяснено с переходом к серверу. 6 unit, 16 E2E, TS/lint прошли. Далее подписи и Community scope перед визуальной приёмкой.
|
Нет 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)
|
sorted.sort((a, b) => b.probabilityOptimal - a.probabilityOptimal)
|
||||||
const best = sorted[0] ?? null
|
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 curProb = 38 + (rule.community.length % 12)
|
||||||
const recProb = best ? Math.min(92, curProb + 18 + (best.score % 10)) : curProb
|
const recProb = best ? Math.min(92, curProb + 18 + (best.score % 10)) : curProb
|
||||||
const rec = best
|
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)
|
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 settings.focus();await page.keyboard.press("Enter");await expect(settings).toHaveAttribute("aria-expanded","true")
|
||||||
await expect(page.getByRole("spinbutton",{name:"Вес задержки",exact:true})).toBeVisible()
|
await expect(page.getByRole("spinbutton",{name:"Вес задержки",exact:true})).toBeVisible()
|
||||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
|
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 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")
|
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()
|
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 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)
|
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)
|
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"})
|
await page.screenshot({path:"docs/ux-ui/screens/route-optimizer-community-"+device+".png",animations:"disabled"})
|
||||||
})
|
})
|
||||||