From 3fc80f55bc6b37f212d4e1203e87ccb0cd819a1d Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 31 Jul 2026 11:02:07 +0800 Subject: [PATCH] Add config snapshot export and treat finished cycles as success. Per-NE failures stay in fail_count for retry; completed cycles no longer fail the whole run. Configurations page can download txt/zip snapshots. Co-authored-by: Cursor --- netx_api/config_sync_router.py | 26 ++++ netx_api/config_sync_service.py | 51 ++++++-- web/WEB.md | 1 + web/src/i18n/en.ts | 3 + web/src/i18n/zh.ts | 3 + web/src/pages/network/NetworkConfigsPage.tsx | 123 ++++++++++++++----- web/src/services/api.ts | 34 +++++ 7 files changed, 203 insertions(+), 38 deletions(-) diff --git a/netx_api/config_sync_router.py b/netx_api/config_sync_router.py index 234827f..a33dd58 100644 --- a/netx_api/config_sync_router.py +++ b/netx_api/config_sync_router.py @@ -2,13 +2,17 @@ from __future__ import annotations +from urllib.parse import quote + from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request +from fastapi.responses import Response from sqlalchemy.orm import Session from .auth_service import write_audit from .config_sync_runner import dispatch_cycle from .config_sync_schemas import ConfigSyncCycleCreate, ConfigSyncPolicyUpdate from .config_sync_service import ( + build_snapshot_export, create_cycle, dashboard, get_cycle, @@ -147,6 +151,28 @@ def api_list_snapshots( ) +@router.get("/snapshots/{source}/{target_id}/download") +def api_download_snapshot( + source: str, + target_id: str, + field: str = Query(default="primary"), + db: Session = Depends(get_db), +): + filename, payload, media_type = build_snapshot_export(db, source, target_id, field=field) + # ASCII fallback + UTF-8 filename for CJK device names + safe_ascii = filename.encode("ascii", errors="replace").decode("ascii").replace("?", "_") + return Response( + content=payload, + media_type=media_type, + headers={ + "content-disposition": ( + f'attachment; filename="{safe_ascii}"; ' + f"filename*=UTF-8''{quote(filename)}" + ) + }, + ) + + @router.get("/snapshots/{source}/{target_id}") def api_get_snapshot( source: str, diff --git a/netx_api/config_sync_service.py b/netx_api/config_sync_service.py index 5c1fbb3..1f5cd9f 100644 --- a/netx_api/config_sync_service.py +++ b/netx_api/config_sync_service.py @@ -2,7 +2,10 @@ from __future__ import annotations +import io import logging +import re +import zipfile from datetime import datetime, timedelta from typing import Any from uuid import uuid4 @@ -510,6 +513,42 @@ def get_snapshot_detail( return NeConfigSnapshotDetailOut(**meta.model_dump(), config_text=primary, config_alt_text=alt) +def _safe_export_part(text: str) -> str: + s = re.sub(r'[<>:"/\\|?*\s]+', "_", str(text or "").strip()) + return (s[:80] or "ne").strip("._") or "ne" + + +def build_snapshot_export( + db: Session, + source: str, + target_id: str, + *, + field: str = "primary", +) -> tuple[str, bytes, str]: + """Return (filename, payload, media_type) for download.""" + detail = get_snapshot_detail(db, source, target_id, field="both") + name = _safe_export_part(detail.ne_name or detail.target_id) + ip = _safe_export_part(detail.ne_ip or "ip") + base = f"{name}-{ip}-{detail.source}" + f = str(field or "primary").strip().lower() + + if f == "alt": + if not detail.has_alt or not detail.config_alt_text: + raise HTTPException(status_code=404, detail="alt_config_not_found") + filename = f"{base}-hierarchical.txt" + return filename, detail.config_alt_text.encode("utf-8"), "text/plain; charset=utf-8" + + if f == "both" and detail.has_alt and detail.config_alt_text: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr(f"{base}-set.txt", detail.config_text or "") + zf.writestr(f"{base}-hierarchical.txt", detail.config_alt_text or "") + return f"{base}-configs.zip", buf.getvalue(), "application/zip" + + filename = f"{base}-config.txt" + return filename, (detail.config_text or "").encode("utf-8"), "text/plain; charset=utf-8" + + def list_snapshot_history( db: Session, source: str, @@ -603,12 +642,10 @@ def finalize_cycle(db: Session, cycle_id: str) -> None: return sync_cycle_progress(db, cycle_id) db.refresh(cycle) - if int(cycle.fail_count or 0) > 0 and int(cycle.success_count or 0) == 0: - cycle.status = "fail" - elif int(cycle.fail_count or 0) > 0: - cycle.status = "fail" - cycle.error_message = cycle.error_message or "completed_with_failures" - else: - cycle.status = "success" + # Cycle outcome is about finishing the run, not per-NE results. + # Individual task failures stay in fail_count for retry/dashboard. + cycle.status = "success" + if cycle.error_message == "completed_with_failures": + cycle.error_message = "" cycle.ended_at = _utcnow() db.commit() diff --git a/web/WEB.md b/web/WEB.md index aceb178..b381f9f 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -100,6 +100,7 @@ src/ - 调度:`NETX_CONFIG_SYNC_SCHEDULER_ENABLED`(默认开),周期天数策略可配(默认 3 天) - 默认策略:`enabled=false`(首次无自动任务,需在页面手动开启周期调度或点「立即同步」) - 单飞:同一时刻只允许一个 `running|pending|paused` 周期;上轮未结束时不会开启新周期 +- 周期状态:全部任务跑完即为 `success`;单网元失败只计入 `fail_count`,不把整轮标为失败 - 崩溃续跑:启动时把中断的 `running` 任务重新入队并继续,占用单飞槽位,避免与新周期重叠 - 进程启动宽限:`NETX_CONFIG_SYNC_STARTUP_GRACE_SEC`(默认 3600)仅约束**新建**自动周期,不影响续跑 - 前端:`/network/tasks/config-sync`(看板)+ `/network/configs`(查看) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index c4f1d4a..139b877 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -122,6 +122,9 @@ const en = { allSource: "All sources", empty: "No synced configs yet. Run Config sync first.", view: "View", + export: "Export", + exportBoth: "Export all", + exportOk: "Config exported", close: "Close", tabSet: "Set format", tabHier: "Hierarchical", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 8906f29..5e0fb01 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -122,6 +122,9 @@ const zh = { allSource: "全部来源", empty: "暂无已同步配置。请先在「配置同步」执行采集。", view: "查看", + export: "导出", + exportBoth: "导出全部", + exportOk: "配置已导出", close: "关闭", tabSet: "Set 格式", tabHier: "层级格式", diff --git a/web/src/pages/network/NetworkConfigsPage.tsx b/web/src/pages/network/NetworkConfigsPage.tsx index 4604878..7bc991f 100644 --- a/web/src/pages/network/NetworkConfigsPage.tsx +++ b/web/src/pages/network/NetworkConfigsPage.tsx @@ -1,9 +1,14 @@ import { useState } from "react"; import { Link } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { fetchNeConfigSnapshotDetail, fetchNeConfigSnapshots } from "../../services/api"; +import { + downloadNeConfigSnapshot, + fetchNeConfigSnapshotDetail, + fetchNeConfigSnapshots, +} from "../../services/api"; import { queryKeys } from "../../constants/queryKeys"; import { useI18n } from "../../i18n"; +import { useToast } from "../../hooks/useToast"; import { pageCount } from "../../utils/display"; import { formatSystemTime } from "../../utils/time"; @@ -16,12 +21,14 @@ function fmtBytes(n: number): string { export function NetworkConfigsPage() { const { t } = useI18n(); + const { showOk, showError } = useToast(); const [page, setPage] = useState(1); const [keyword, setKeyword] = useState(""); const [source, setSource] = useState(""); const [vendor, setVendor] = useState(""); const [selected, setSelected] = useState<{ source: string; id: string } | null>(null); const [tab, setTab] = useState<"primary" | "alt">("primary"); + const [exporting, setExporting] = useState(""); const listQuery = useQuery({ queryKey: queryKeys.networkConfigs(page, keyword, source, vendor), @@ -43,6 +50,23 @@ export function NetworkConfigsPage() { const detail = detailQuery.data; const showAlt = Boolean(detail?.has_alt); + const exportConfig = async ( + src: string, + id: string, + field: "primary" | "alt" | "both", + ) => { + const key = `${src}:${id}:${field}`; + setExporting(key); + try { + await downloadNeConfigSnapshot(src, id, field); + showOk(t("networkConfigs.exportOk")); + } catch (err) { + showError(String(err)); + } finally { + setExporting(""); + } + }; + return (
@@ -106,33 +130,46 @@ export function NetworkConfigsPage() { - {items.map((row) => ( - - {row.ne_name || row.target_id} - {row.ne_ip} - {row.vendor || "-"} - {row.source} - {fmtBytes(row.plain_size)} - {row.collected_at ? formatSystemTime(row.collected_at) : "-"} - - - - WebCRT - - - - ))} + {items.map((row) => { + const exportKey = `${row.source}:${row.target_id}:list`; + return ( + + {row.ne_name || row.target_id} + {row.ne_ip} + {row.vendor || "-"} + {row.source} + {fmtBytes(row.plain_size)} + {row.collected_at ? formatSystemTime(row.collected_at) : "-"} + + + + + WebCRT + + + + ); + })} {!items.length && !listQuery.isLoading ? ( @@ -164,9 +201,33 @@ export function NetworkConfigsPage() { ({selected.source} / {detail?.ne_ip || "-"}) - +
+ + {showAlt ? ( + + ) : null} + +
{detailQuery.isLoading ?

{t("common.refreshing")}

: null} {showAlt ? ( diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 054efe1..6ad473c 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -745,3 +745,37 @@ export const fetchNeConfigSnapshotDetail = ( apiGet( `/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}?field=${field}`, ); + +export const downloadNeConfigSnapshot = async ( + source: string, + targetId: string, + field: "primary" | "alt" | "both" = "primary", +): Promise => { + const path = + `/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}` + + `/download?field=${encodeURIComponent(field)}`; + const res = await fetch(path, { headers: authHeaders() }); + if (res.status === 401) { + handleUnauthorized(path); + throw new Error("unauthorized"); + } + if (!res.ok) throw new Error(`${res.status} download`); + const blob = await res.blob(); + const cd = res.headers.get("content-disposition") || ""; + const star = /filename\*=UTF-8''([^;]+)/i.exec(cd); + const plain = /filename="?([^";]+)"?/i.exec(cd); + const filename = star + ? decodeURIComponent(star[1]) + : plain + ? plain[1] + : `ne-config-${source}-${targetId}.txt`; + const url = URL.createObjectURL(blob); + try { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + } finally { + URL.revokeObjectURL(url); + } +};