fix(ui): подтверждать сохранение правил Filters

This commit is contained in:
Denozordec committed 2026-09-30 11:17:32 +07:00
1 parent 4b01f20558
commit 1cf7297d64
6 files changed
+162 -36

No files matched your search

+65 -34
View File
@@ -469,7 +469,7 @@ function RuleSheet({
}: { }: {
open: boolean; mode: "create" | "edit" open: boolean; mode: "create" | "edit"
initial: RuleForm; serverId: string initial: RuleForm; serverId: string
onSave: (f: RuleForm) => void; onClose: () => void onSave: (f: RuleForm) => Promise<string | null>; onClose: () => void
serversList: Server[] serversList: Server[]
tunnelsList: GreTunnel[] tunnelsList: GreTunnel[]
communityNameMap: Record<string, string> communityNameMap: Record<string, string>
@@ -480,6 +480,9 @@ function RuleSheet({
const [form, setForm] = useState<RuleForm>(initial) const [form, setForm] = useState<RuleForm>(initial)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [touched, setTouched] = useState(false) const [touched, setTouched] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const saveLock = useRef(false)
const set = <K extends keyof RuleForm>(k: K, v: RuleForm[K]) => { const set = <K extends keyof RuleForm>(k: K, v: RuleForm[K]) => {
setForm(f => ({ ...f, [k]: v })) setForm(f => ({ ...f, [k]: v }))
@@ -507,19 +510,31 @@ function RuleSheet({
} }
setError(null); return true setError(null); return true
} }
const handleSave = () => { const handleSave = async () => {
if (saveLock.current) return
setTouched(true) setTouched(true)
if (!validate_(form)) return if (!validate_(form)) return
onSave(form.action === "blackhole" saveLock.current = true
? { ...form, gateway: "", gatewayTunnelId: "", gatewayKind: "gre" } setSaving(true)
: form setSaveError(null)
) try {
setSaveError(await onSave(form.action === "blackhole"
? { ...form, gateway: "", gatewayTunnelId: "", gatewayKind: "gre" }
: form
))
} catch (error) {
setSaveError(error instanceof Error ? error.message : "Не удалось сохранить правило")
} finally {
saveLock.current = false
setSaving(false)
}
} }
const close = () => { if (!saveLock.current) onClose() }
const isBlackhole = form.action === "blackhole" const isBlackhole = form.action === "blackhole"
return ( return (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}> <Sheet open={open} onOpenChange={v => { if (!v) close() }}>
<SheetContent side="right" className="sm:max-w-[440px] p-0 flex flex-col" showCloseButton={false}> <SheetContent side="right" className="sm:max-w-[440px] p-0 flex flex-col" showCloseButton={false}>
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0"> <SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
@@ -538,13 +553,13 @@ function RuleSheet({
· {serversList.find(s => s.id === serverId)?.name} · {serversList.find(s => s.id === serverId)?.name}
</SheetDescription> </SheetDescription>
</div> </div>
<Button variant="ghost" size="icon-sm" onClick={onClose} className="shrink-0 mt-0.5"> <Button variant="ghost" size="icon-sm" onClick={close} disabled={saving} aria-label="Закрыть форму" className="shrink-0 mt-0.5">
<XIcon className="size-4" /> <XIcon className="size-4" />
</Button> </Button>
</div> </div>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5"> <fieldset disabled={saving} aria-busy={saving} className="flex-1 min-h-0 overflow-y-auto px-5 py-5 flex flex-col gap-5">
{/* ── BGP Community ── */} {/* ── BGP Community ── */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -719,7 +734,7 @@ function RuleSheet({
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">Описание (опционально)</label> <label className="text-xs font-medium text-muted-foreground">Описание (опционально)</label>
<Input value={form.description} onChange={e => set("description", e.target.value)} <Input aria-label="Описание правила" value={form.description} onChange={e => set("description", e.target.value)}
placeholder={isBlackhole ? "RTBH — нулевая маршрутизация DDoS-трафика" : "YouTube bypass через Франкфурт"} placeholder={isBlackhole ? "RTBH — нулевая маршрутизация DDoS-трафика" : "YouTube bypass через Франкфурт"}
className="h-9 text-sm" /> className="h-9 text-sm" />
</div> </div>
@@ -729,16 +744,19 @@ function RuleSheet({
<AlertCircleIcon className="size-4 shrink-0" />{error} <AlertCircleIcon className="size-4 shrink-0" />{error}
</div> </div>
)} )}
</div> </fieldset>
{saveError && <p role="alert" className="px-5 py-3 text-sm text-destructive">{saveError}</p>}
{saving && <p role="status" className="px-5 py-2 text-sm text-muted-foreground">Ожидание подтверждения сервера…</p>}
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2"> <SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button> <Button variant="outline" onClick={close} disabled={saving} className="flex-1">Отмена</Button>
<Button <Button
onClick={handleSave} onClick={() => void handleSave()}
disabled={saving}
className={cn("flex-1", isBlackhole && "bg-red-600 hover:bg-red-700 text-white border-0")} className={cn("flex-1", isBlackhole && "bg-red-600 hover:bg-red-700 text-white border-0")}
> >
<CheckIcon className="size-4" /> <CheckIcon className="size-4" />
{mode === "create" ? "Добавить" : "Сохранить"} {saving ? "Сохранение…" : mode === "create" ? "Добавить" : "Сохранить"}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
@@ -1331,6 +1349,7 @@ export default function FiltersPage() {
const [previewOpen, setPreviewOpen] = useState(false) const [previewOpen, setPreviewOpen] = useState(false)
const [copyOpen, setCopyOpen] = useState(false) const [copyOpen, setCopyOpen] = useState(false)
const [applyBusy, setApplyBusy] = useState(false) const [applyBusy, setApplyBusy] = useState(false)
const applyLock = useRef(false)
const [liveStale, setLiveStale] = useState(false) const [liveStale, setLiveStale] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false) const [historyOpen, setHistoryOpen] = useState(false)
const [historyLoading, setHistoryLoading] = useState(false) const [historyLoading, setHistoryLoading] = useState(false)
@@ -1428,7 +1447,7 @@ export default function FiltersPage() {
const allTunnels = !isLive || liveLoadState === "error" ? greTunnels : (greByServer[selectedServerId] ?? []) const allTunnels = !isLive || liveLoadState === "error" ? greTunnels : (greByServer[selectedServerId] ?? [])
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0] const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0) const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
const mutationsLocked = isLive && (applyBusy || liveStale || liveLoadState === "error") const mutationsLocked = isLive && (applyBusy || historyRestoring || liveStale || liveLoadState !== "idle")
const filterRailItems = useMemo<ServerTileItem[]>(() => ( const filterRailItems = useMemo<ServerTileItem[]>(() => (
allServers.map((s) => ({ allServers.map((s) => ({
@@ -1445,6 +1464,8 @@ export default function FiltersPage() {
), [allServers, rulesets]) ), [allServers, rulesets])
const handleSelectServer = useCallback((id: string) => { const handleSelectServer = useCallback((id: string) => {
if (applyLock.current) return
setSheetOpen(false)
setSelectedServerId(id) setSelectedServerId(id)
setSearch("") setSearch("")
}, []) }, [])
@@ -1470,31 +1491,36 @@ export default function FiltersPage() {
nextRules: FilterRule[], nextRules: FilterRule[],
source: "apply" | "copy" = "apply", source: "apply" | "copy" = "apply",
) => { ) => {
const prev = rulesets if (applyLock.current || applyBusy || historyRestoring) return { ok: false, error: "Дождитесь завершения текущей операции" }
setRulesets((p) => p.map((rs) => rs.serverId === serverId ? { ...rs, rules: nextRules } : rs)) if (isLive && (liveStale || liveLoadState !== "idle")) {
if (!isLive) return const error = "Роутер недоступен — изменения заблокированы"
if (liveStale) { toast.error(error)
setRulesets(prev) return { ok: false, error }
toast.error("Роутер недоступен — изменения заблокированы")
return
} }
if (!isLive) {
setRulesets(p => p.map(rs => rs.serverId === serverId ? { ...rs, rules: nextRules } : rs))
return { ok: true }
}
applyLock.current = true
setApplyBusy(true) setApplyBusy(true)
try { try {
const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>("/api/filters/rules", { const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>("/api/filters/rules", {
method: "PUT", method: "PUT",
body: JSON.stringify({ serverId, rules: nextRules, source }), body: JSON.stringify({ serverId, rules: nextRules, source }),
}) })
if (res.rules) { if (res?.ok !== true) throw new Error("Сервер не подтвердил применение правил")
setRulesets((p) => p.map((rs) => rs.serverId === serverId ? { ...rs, rules: res.rules ?? nextRules } : rs)) setRulesets(p => p.map(rs => rs.serverId === serverId ? { ...rs, rules: res.rules ?? nextRules } : rs))
}
toast.success("Правила применены на роутер") toast.success("Правила применены на роутер")
return { ok: true }
} catch (err) { } catch (err) {
setRulesets(prev) const error = err instanceof Error ? err.message : "Не удалось применить правила на роутер"
toast.error("Не удалось применить правила на роутер", { description: String(err) }) toast.error("Не удалось применить правила на роутер", { description: error })
return { ok: false, error }
} finally { } finally {
applyLock.current = false
setApplyBusy(false) setApplyBusy(false)
} }
}, [isLive, apiFetch, rulesets, liveStale]) }, [isLive, apiFetch, applyBusy, historyRestoring, liveStale, liveLoadState])
const refreshFromRouter = useCallback(async () => { const refreshFromRouter = useCallback(async () => {
if (!isLive || !selectedServerId || applyBusy) return if (!isLive || !selectedServerId || applyBusy) return
@@ -1557,13 +1583,15 @@ export default function FiltersPage() {
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => { const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
const current = rulesets.find((rs) => rs.serverId === serverId)?.rules ?? [] const current = rulesets.find((rs) => rs.serverId === serverId)?.rules ?? []
void applyRules(serverId, updater(current)) return applyRules(serverId, updater(current))
}, [rulesets, applyRules]) }, [rulesets, applyRules])
const openCreate = () => { const openCreate = () => {
if (mutationsLocked || applyLock.current) return
setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true) setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true)
} }
const openEdit = (rule: FilterRule) => { const openEdit = (rule: FilterRule) => {
if (mutationsLocked || applyLock.current) return
setSheetInitial({ setSheetInitial({
action: rule.action ?? "route", action: rule.action ?? "route",
community: rule.community, community: rule.community,
@@ -1575,23 +1603,26 @@ export default function FiltersPage() {
setSheetMode("edit"); setEditingId(rule.id); setSheetOpen(true) setSheetMode("edit"); setEditingId(rule.id); setSheetOpen(true)
} }
const handleSave = (form: RuleForm) => { const handleSave = async (form: RuleForm): Promise<string | null> => {
if (mutationsLocked) return if (mutationsLocked) return "Изменения заблокированы — обновите данные или дождитесь завершения операции"
const { gatewayKind: _gk, ...payload } = form const { gatewayKind: _gk, ...payload } = form
let result
if (sheetMode === "create") { if (sheetMode === "create") {
updateRules(selectedServerId, rules => [ result = await updateRules(selectedServerId, rules => [
...rules, ...rules,
{ id: newId(), ...payload, communityName: communityNameMap[payload.community] ?? "" }, { id: newId(), ...payload, communityName: communityNameMap[payload.community] ?? "" },
]) ])
} else if (editingId) { } else if (editingId) {
updateRules(selectedServerId, rules => result = await updateRules(selectedServerId, rules =>
rules.map(r => r.id === editingId rules.map(r => r.id === editingId
? { ...r, ...payload, communityName: communityNameMap[payload.community] ?? "" } ? { ...r, ...payload, communityName: communityNameMap[payload.community] ?? "" }
: r : r
) )
) )
} }
if (!result?.ok) return result?.error ?? "Не удалось сохранить правило"
setSheetOpen(false) setSheetOpen(false)
return null
} }
const handleDelete = (id: string) => { const handleDelete = (id: string) => {
@@ -1847,7 +1878,7 @@ export default function FiltersPage() {
</ServerRailLayout> </ServerRailLayout>
<RuleSheet <RuleSheet
key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`} key={`${sheetOpen}-${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
open={sheetOpen} open={sheetOpen}
mode={sheetMode} mode={sheetMode}
initial={sheetInitial} initial={sheetInitial}
@@ -160,6 +160,7 @@ function FilterRowActions({
variant="ghost" variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground" className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onEdit} onClick={onEdit}
aria-label="Редактировать правило"
> >
<PencilIcon className="size-3.5" /> <PencilIcon className="size-3.5" />
</Button> </Button>
+1 -1
View File
@@ -88,7 +88,7 @@ flowchart LR
Ближайший оставшийся объём: Ближайший оставшийся объём:
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. В 9.23 backend сообщает частичные сбои VXLAN, UI сохраняет данные неответивших устройств и показывает неизвестное состояние. В 9.24 добавлены схемы payload и тесты гонок VXLAN. В 9.25 подтверждены включение/отключение правил Firewall и защита повторов. В 9.26 подтверждены включение/отключение address-list с общим замком переключений. В 9.27 удаление правил/address-list перенесено в диалог с подтверждением API и защитой повторов. В 9.28 создание/редактирование правил и address-list требуют подтверждения API, сохраняют ввод/ошибку и блокируют повторы/закрытие. В 9.29 перемещение Firewall требует ok=true, откатывает порядок при ошибке и использует общий замок. В 9.30 восстановление истории подключено к общему замку, ручное обновление и открытие истории заблокированы во время записи. В 9.31 добавлены схемы ответа Firewall/серверов, сохранение последних данных при ошибке и поколение чтения с отменой применения при начале записи. В 9.32 backend сообщает частичные сбои по устройству/семейству/таблице, UI сохраняет недоступные срезы и блокирует изменения неполного среза. В 9.33 состояние изолировано по backend/режиму; поздние операции старого экрана не запускают чтение и не показывают уведомления в новом контексте. В 9.34 показано время получения срезов Firewall: успешные обновляются, недоступные сохраняют прежнее время или остаются неизвестными. Далее — операции Filters; ограничения счётчика, свежести и записи VXLAN остаются открытыми; загрузка данных IPsec Line truncated 1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. В 9.23 backend сообщает частичные сбои VXLAN, UI сохраняет данные неответивших устройств и показывает неизвестное состояние. В 9.24 добавлены схемы payload и тесты гонок VXLAN. В 9.25 подтверждены включение/отключение правил Firewall и защита повторов. В 9.26 подтверждены включение/отключение address-list с общим замком переключений. В 9.27 удаление правил/address-list перенесено в диалог с подтверждением API и защитой повторов. В 9.28 создание/редактирование правил и address-list требуют подтверждения API, сохраняют ввод/ошибку и блокируют повторы/закрытие. В 9.29 перемещение Firewall требует ok=true, откатывает порядок при ошибке и использует общий замок. В 9.30 восстановление истории подключено к общему замку, ручное обновление и открытие истории заблокированы во время записи. В 9.31 добавлены схемы ответа Firewall/серверов, сохранение последних данных при ошибке и поколение чтения с отменой применения при начале записи. В 9.32 backend сообщает частичные сбои по устройству/семейству/таблице, UI сохраняет недоступные срезы и блокирует изменения неполного среза. В 9.33 состояние изолировано по backend/режиму; поздние операции старого экрана не запускают чтение и не показывают уведомления в новом контексте. В 9.34 показано время получения срезов Firewall: успешные обновляются, недоступные сохраняют прежнее время или остаются неизвестными. В 9.35 сохранение Filters ожидает ok=true, сохраняет ввод при ошибке и блокирует повторную отправку/закрытие; live-спLine truncated
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения. 2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации. 3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
+9 -1
View File
@@ -25,12 +25,20 @@
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
| 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 | В работе: блоки 9.1–9.34 — IPsec/GRE/VXLAN и переключение Firewall | | 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.35 — IPsec/GRE/VXLAN, Firewall и сохранение Filters |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 9.35 — подтверждённое сохранение Filters (2026-09-30)
- Создание/редактирование правила ожидает результат PUT /api/filters/rules. applyRules возвращает результат, требует ok=true и обновляет live-список только после подтверждения; при ошибке прежний список остаётся. Подтверждённые rules из ответа имеют приоритет над отправленным списком. Общий путь применяется также к копированию/удалению/переносу, но их отдельный UX ещё не принят.
- Форма сохраняет введённые данные и показывает API-ошибку в alert. Во время запроса fieldset, кнопки отмены/закрытия/сохранения заблокированы, Escape не закрывает форму; status сообщает ожидание. Синхронные замки формы и applyRules предотвращают повторный PUT. Выбор устройства не переключается во время применения. Повторное открытие формы начинает новый сеанс с актуальными начальными значениями. Кнопка редактирования и поле описания получили доступные имена. Используются существующие Sheet/Button/Input и ReUI Data Grid, композиция base-nova сохранена.
- Проверки: пять E2E прошли — создание и редактирование (задержка, двойное нажатие, 403, пустой ответ, ok=false, повтор, подтверждённые сервером данные, повторное открытие), таблица Filters и два сценария истории. TypeScript прошёл. Новые тесты и таблица lint-чистые; исходные 2 ошибки/13 предупреждений страницы Filters совпадают с HEAD. Последние production build: frontend — 9.31, backend — 9.32; в этом блоке не повторялись.
- Следующий точный шаг: CopyRulesSheet и handleCopyRules — ожидать applyRules, сохранять выбранные цель/правила и показывать ошибку вместо преждевременного done. Затем подтверждение удаления/перемещения и общий замок обновления/восстановления. Открыты схемы ответа/правил, гонки чтения и смены backend/режима; первоначальная live-ошибка ещё подставляет встроенные моки, loadLiveRules может выбирать первый чужой ruleset и скрывает ошибку. Эти ограничения не считать исправленными данным блоком. Реальные RouterOS/JWT/БД не проверены; этап 9 открыт.
- Квота: 54% пятичасовой/8% недельной на старте, 58%/9% после проверок. Локальный коммит без push; чужие изменения сохранены.
## Блок 9.34 — свежесть данных Firewall (2026-09-30) ## Блок 9.34 — свежесть данных Firewall (2026-09-30)
- Время успешного получения хранится для каждого включённого устройства × IP-семейства × таблицы/address-list. Успешный пустой срез тоже обновляет время, недоступный сохраняет прежнее либо null. Отключённые устройства исключаются из набора; смена источника очищает времена вместе с данными. - Время успешного получения хранится для каждого включённого устройства × IP-семейства × таблицы/address-list. Успешный пустой срез тоже обновляет время, недоступный сохраняет прежнее либо null. Отключённые устройства исключаются из набора; смена источника очищает времена вместе с данными.
+4
View File
@@ -177,3 +177,7 @@ Backend сообщает failures по срезу, неполный snapshot о
## Блок 9.34 — свежесть Firewall (2026-09-30) ## Блок 9.34 — свежесть Firewall (2026-09-30)
Время получения сохраняется по срезам: успешные получают новое время, недоступные сохраняют прежнее или остаются неизвестными. Экран показывает диапазон, неизвестные срезы и время данных в предупреждении. Это время браузерного получения, не измерения RouterOS. Все 37 E2E и TypeScript прошли, два профильных теста диапазона повторены; новых замечаний lint нет. Далее — операции Filters. Реальные устройства не проверены; frontend build — 9.31, backend — 9.32. Время получения сохраняется по срезам: успешные получают новое время, недоступные сохраняют прежнее или остаются неизвестными. Экран показывает диапазон, неизвестные срезы и время данных в предупреждении. Это время браузерного получения, не измерения RouterOS. Все 37 E2E и TypeScript прошли, два профильных теста диапазона повторены; новых замечаний lint нет. Далее — операции Filters. Реальные устройства не проверены; frontend build — 9.31, backend — 9.32.
## Блок 9.35 — сохранение Filters (2026-09-30)
Создание и редактирование ожидают ok=true, ошибки сохраняют форму и прежние строки. Блокируются повторы/закрытие, есть inline-ошибка и статус ожидания. Повторное открытие начинает новую форму. Пять профильных E2E, TypeScript и lint новых файлов прошли; исходные замечания страницы сохранены. Далее — CopyRulesSheet: убрать преждевременный done, дождаться применения и сохранить выбор при ошибке. Загрузка/схемы/гонки Filters и реальные устройства ещё не проверены; frontend build — 9.31, backend — 9.32.
+82
View File
@@ -0,0 +1,82 @@
import { test, expect } from "@playwright/test"
import type { FilterRule } from "../../lib/data"
for (const editing of [false, true]) {
test(`Filters: ${editing ? "редактирование" : "создание"} ждёт подтверждения и сохраняет ввод`, async ({ page }) => {
let attempts = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
let rules: FilterRule[] = [{ id: "rule-1", community: "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "Исходное правило" }]
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", async route => {
const path = new URL(route.request().url()).pathname
if (path === "/api/filters/rules" && route.request().method() === "PUT") {
attempts++
const body = route.request().postDataJSON()
expect(body.serverId).toBe("1")
expect(body.source).toBe("apply")
expect(body.rules).toHaveLength(editing ? 1 : 2)
expect(body.rules.find((r: FilterRule) => r.description === "Сохранить ввод")).toMatchObject({ community: editing ? "65001:101" : "65001:999", action: "blackhole", gateway: "", gatewayTunnelId: "" })
if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Сохранение запрещено тест" } }) }
if (attempts === 2) return route.fulfill({ json: {} })
if (attempts === 3) return route.fulfill({ json: { ok: false } })
rules = body.rules.map((r: FilterRule) => ({ ...r, description: r.description === "Сохранить ввод" ? "Подтверждено API" : r.description }))
return route.fulfill({ json: { ok: true, rules } })
}
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules }], greTunnels: [], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Тестовый роутер", host: "192.0.2.1", type: "exit-node", site: "TEST", country: "RU", asn: "", enabled: true, status: "online", latency: 1 }]
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(1)
const open = async () => {
if (editing) {
await rows.first().hover()
await rows.first().getByRole("button", { name: "Редактировать правило", exact: true }).click()
} else await page.getByRole("button", { name: "Новое правило", exact: true }).click()
}
await open()
const dialog = page.getByRole("dialog")
if (!editing) {
await dialog.getByPlaceholder("65001:100").fill("65001:999")
await dialog.getByRole("button", { name: /⊘ Blackhole/ }).click()
}
const description = dialog.getByRole("textbox", { name: "Описание правила" })
await description.fill("Сохранить ввод")
const submit = dialog.getByRole("button", { name: editing ? "Сохранить" : "Добавить", exact: true })
await submit.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => attempts).toBe(1)
await expect(dialog.getByRole("status")).toContainText("Ожидание подтверждения сервера")
await expect(description).toBeDisabled()
await expect(dialog.getByPlaceholder("65001:100")).toBeDisabled()
await expect(dialog.getByRole("button", { name: /⊘ Blackhole/ })).toBeDisabled()
await expect(dialog.getByRole("button", { name: "Отмена", exact: true })).toBeDisabled()
await expect(dialog.getByRole("button", { name: "Закрыть форму", exact: true })).toBeDisabled()
await page.keyboard.press("Escape")
await expect(dialog).toBeVisible()
await expect(rows).toHaveCount(1)
await expect(rows.first()).toContainText("Исходное правило")
release!()
await expect(dialog.getByRole("alert")).toContainText("Сохранение запрещено тест")
await expect(description).toHaveValue("Сохранить ввод")
for (let i = 2; i <= 3; i++) {
await submit.click()
await expect.poll(() => attempts).toBe(i)
await expect(dialog.getByRole("alert")).toContainText("Сервер не подтвердил применение правил")
await expect(description).toHaveValue("Сохранить ввод")
await expect(rows).toHaveCount(1)
await expect(page.getByText("Правила применены на роутер", { exact: true })).toHaveCount(0)
}
await submit.click()
await expect(dialog).toHaveCount(0)
await expect(rows).toHaveCount(editing ? 1 : 2)
await expect(page.getByText("Подтверждено API", { exact: true })).toBeVisible()
expect(attempts).toBe(4)
await open()
await expect(dialog.getByRole("alert")).toHaveCount(0)
await expect(description).toHaveValue(editing ? "Подтверждено API" : "")
})
}