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"
initial: RuleForm; serverId: string
onSave: (f: RuleForm) => void; onClose: () => void
onSave: (f: RuleForm) => Promise<string | null>; onClose: () => void
serversList: Server[]
tunnelsList: GreTunnel[]
communityNameMap: Record<string, string>
@@ -480,6 +480,9 @@ function RuleSheet({
const [form, setForm] = useState<RuleForm>(initial)
const [error, setError] = useState<string | null>(null)
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]) => {
setForm(f => ({ ...f, [k]: v }))
@@ -507,19 +510,31 @@ function RuleSheet({
}
setError(null); return true
}
const handleSave = () => {
const handleSave = async () => {
if (saveLock.current) return
setTouched(true)
if (!validate_(form)) return
onSave(form.action === "blackhole"
? { ...form, gateway: "", gatewayTunnelId: "", gatewayKind: "gre" }
: form
)
saveLock.current = true
setSaving(true)
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"
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}>
<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}
</SheetDescription>
</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" />
</Button>
</div>
</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 ── */}
<div className="flex flex-col gap-1.5">
@@ -719,7 +734,7 @@ function RuleSheet({
<div className="flex flex-col gap-1.5">
<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 через Франкфурт"}
className="h-9 text-sm" />
</div>
@@ -729,16 +744,19 @@ function RuleSheet({
<AlertCircleIcon className="size-4 shrink-0" />{error}
</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">
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button>
<Button variant="outline" onClick={close} disabled={saving} className="flex-1">Отмена</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")}
>
<CheckIcon className="size-4" />
{mode === "create" ? "Добавить" : "Сохранить"}
{saving ? "Сохранение…" : mode === "create" ? "Добавить" : "Сохранить"}
</Button>
</SheetFooter>
</SheetContent>
@@ -1331,6 +1349,7 @@ export default function FiltersPage() {
const [previewOpen, setPreviewOpen] = useState(false)
const [copyOpen, setCopyOpen] = useState(false)
const [applyBusy, setApplyBusy] = useState(false)
const applyLock = useRef(false)
const [liveStale, setLiveStale] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [historyLoading, setHistoryLoading] = useState(false)
@@ -1428,7 +1447,7 @@ export default function FiltersPage() {
const allTunnels = !isLive || liveLoadState === "error" ? greTunnels : (greByServer[selectedServerId] ?? [])
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[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[]>(() => (
allServers.map((s) => ({
@@ -1445,6 +1464,8 @@ export default function FiltersPage() {
), [allServers, rulesets])
const handleSelectServer = useCallback((id: string) => {
if (applyLock.current) return
setSheetOpen(false)
setSelectedServerId(id)
setSearch("")
}, [])
@@ -1470,31 +1491,36 @@ export default function FiltersPage() {
nextRules: FilterRule[],
source: "apply" | "copy" = "apply",
) => {
const prev = rulesets
setRulesets((p) => p.map((rs) => rs.serverId === serverId ? { ...rs, rules: nextRules } : rs))
if (!isLive) return
if (liveStale) {
setRulesets(prev)
toast.error("Роутер недоступен — изменения заблокированы")
return
if (applyLock.current || applyBusy || historyRestoring) return { ok: false, error: "Дождитесь завершения текущей операции" }
if (isLive && (liveStale || liveLoadState !== "idle")) {
const error = "Роутер недоступен — изменения заблокированы"
toast.error(error)
return { ok: false, error }
}
if (!isLive) {
setRulesets(p => p.map(rs => rs.serverId === serverId ? { ...rs, rules: nextRules } : rs))
return { ok: true }
}
applyLock.current = true
setApplyBusy(true)
try {
const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>("/api/filters/rules", {
method: "PUT",
body: JSON.stringify({ serverId, rules: nextRules, source }),
})
if (res.rules) {
setRulesets((p) => p.map((rs) => rs.serverId === serverId ? { ...rs, rules: res.rules ?? nextRules } : rs))
}
if (res?.ok !== true) throw new Error("Сервер не подтвердил применение правил")
setRulesets(p => p.map(rs => rs.serverId === serverId ? { ...rs, rules: res.rules ?? nextRules } : rs))
toast.success("Правила применены на роутер")
return { ok: true }
} catch (err) {
setRulesets(prev)
toast.error("Не удалось применить правила на роутер", { description: String(err) })
const error = err instanceof Error ? err.message : "Не удалось применить правила на роутер"
toast.error("Не удалось применить правила на роутер", { description: error })
return { ok: false, error }
} finally {
applyLock.current = false
setApplyBusy(false)
}
}, [isLive, apiFetch, rulesets, liveStale])
}, [isLive, apiFetch, applyBusy, historyRestoring, liveStale, liveLoadState])
const refreshFromRouter = useCallback(async () => {
if (!isLive || !selectedServerId || applyBusy) return
@@ -1557,13 +1583,15 @@ export default function FiltersPage() {
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
const current = rulesets.find((rs) => rs.serverId === serverId)?.rules ?? []
void applyRules(serverId, updater(current))
return applyRules(serverId, updater(current))
}, [rulesets, applyRules])
const openCreate = () => {
if (mutationsLocked || applyLock.current) return
setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true)
}
const openEdit = (rule: FilterRule) => {
if (mutationsLocked || applyLock.current) return
setSheetInitial({
action: rule.action ?? "route",
community: rule.community,
@@ -1575,23 +1603,26 @@ export default function FiltersPage() {
setSheetMode("edit"); setEditingId(rule.id); setSheetOpen(true)
}
const handleSave = (form: RuleForm) => {
if (mutationsLocked) return
const handleSave = async (form: RuleForm): Promise<string | null> => {
if (mutationsLocked) return "Изменения заблокированы — обновите данные или дождитесь завершения операции"
const { gatewayKind: _gk, ...payload } = form
let result
if (sheetMode === "create") {
updateRules(selectedServerId, rules => [
result = await updateRules(selectedServerId, rules => [
...rules,
{ id: newId(), ...payload, communityName: communityNameMap[payload.community] ?? "" },
])
} else if (editingId) {
updateRules(selectedServerId, rules =>
result = await updateRules(selectedServerId, rules =>
rules.map(r => r.id === editingId
? { ...r, ...payload, communityName: communityNameMap[payload.community] ?? "" }
: r
)
)
}
if (!result?.ok) return result?.error ?? "Не удалось сохранить правило"
setSheetOpen(false)
return null
}
const handleDelete = (id: string) => {
@@ -1847,7 +1878,7 @@ export default function FiltersPage() {
</ServerRailLayout>
<RuleSheet
key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
key={`${sheetOpen}-${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
open={sheetOpen}
mode={sheetMode}
initial={sheetInitial}