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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-31 11:02:07 +08:00
parent abb5172a8f
commit 3fc80f55bc
7 changed files with 203 additions and 38 deletions

View file

@ -2,13 +2,17 @@
from __future__ import annotations from __future__ import annotations
from urllib.parse import quote
from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request
from fastapi.responses import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .auth_service import write_audit from .auth_service import write_audit
from .config_sync_runner import dispatch_cycle from .config_sync_runner import dispatch_cycle
from .config_sync_schemas import ConfigSyncCycleCreate, ConfigSyncPolicyUpdate from .config_sync_schemas import ConfigSyncCycleCreate, ConfigSyncPolicyUpdate
from .config_sync_service import ( from .config_sync_service import (
build_snapshot_export,
create_cycle, create_cycle,
dashboard, dashboard,
get_cycle, 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}") @router.get("/snapshots/{source}/{target_id}")
def api_get_snapshot( def api_get_snapshot(
source: str, source: str,

View file

@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import io
import logging import logging
import re
import zipfile
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@ -510,6 +513,42 @@ def get_snapshot_detail(
return NeConfigSnapshotDetailOut(**meta.model_dump(), config_text=primary, config_alt_text=alt) 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( def list_snapshot_history(
db: Session, db: Session,
source: str, source: str,
@ -603,12 +642,10 @@ def finalize_cycle(db: Session, cycle_id: str) -> None:
return return
sync_cycle_progress(db, cycle_id) sync_cycle_progress(db, cycle_id)
db.refresh(cycle) db.refresh(cycle)
if int(cycle.fail_count or 0) > 0 and int(cycle.success_count or 0) == 0: # Cycle outcome is about finishing the run, not per-NE results.
cycle.status = "fail" # Individual task failures stay in fail_count for retry/dashboard.
elif int(cycle.fail_count or 0) > 0: cycle.status = "success"
cycle.status = "fail" if cycle.error_message == "completed_with_failures":
cycle.error_message = cycle.error_message or "completed_with_failures" cycle.error_message = ""
else:
cycle.status = "success"
cycle.ended_at = _utcnow() cycle.ended_at = _utcnow()
db.commit() db.commit()

View file

@ -100,6 +100,7 @@ src/
- 调度:`NETX_CONFIG_SYNC_SCHEDULER_ENABLED`(默认开),周期天数策略可配(默认 3 天) - 调度:`NETX_CONFIG_SYNC_SCHEDULER_ENABLED`(默认开),周期天数策略可配(默认 3 天)
- 默认策略:`enabled=false`(首次无自动任务,需在页面手动开启周期调度或点「立即同步」) - 默认策略:`enabled=false`(首次无自动任务,需在页面手动开启周期调度或点「立即同步」)
- 单飞:同一时刻只允许一个 `running|pending|paused` 周期;上轮未结束时不会开启新周期 - 单飞:同一时刻只允许一个 `running|pending|paused` 周期;上轮未结束时不会开启新周期
- 周期状态:全部任务跑完即为 `success`;单网元失败只计入 `fail_count`,不把整轮标为失败
- 崩溃续跑:启动时把中断的 `running` 任务重新入队并继续,占用单飞槽位,避免与新周期重叠 - 崩溃续跑:启动时把中断的 `running` 任务重新入队并继续,占用单飞槽位,避免与新周期重叠
- 进程启动宽限:`NETX_CONFIG_SYNC_STARTUP_GRACE_SEC`(默认 3600)仅约束**新建**自动周期,不影响续跑 - 进程启动宽限:`NETX_CONFIG_SYNC_STARTUP_GRACE_SEC`(默认 3600)仅约束**新建**自动周期,不影响续跑
- 前端:`/network/tasks/config-sync`(看板)+ `/network/configs`(查看) - 前端:`/network/tasks/config-sync`(看板)+ `/network/configs`(查看)

View file

@ -122,6 +122,9 @@ const en = {
allSource: "All sources", allSource: "All sources",
empty: "No synced configs yet. Run Config sync first.", empty: "No synced configs yet. Run Config sync first.",
view: "View", view: "View",
export: "Export",
exportBoth: "Export all",
exportOk: "Config exported",
close: "Close", close: "Close",
tabSet: "Set format", tabSet: "Set format",
tabHier: "Hierarchical", tabHier: "Hierarchical",

View file

@ -122,6 +122,9 @@ const zh = {
allSource: "全部来源", allSource: "全部来源",
empty: "暂无已同步配置。请先在「配置同步」执行采集。", empty: "暂无已同步配置。请先在「配置同步」执行采集。",
view: "查看", view: "查看",
export: "导出",
exportBoth: "导出全部",
exportOk: "配置已导出",
close: "关闭", close: "关闭",
tabSet: "Set 格式", tabSet: "Set 格式",
tabHier: "层级格式", tabHier: "层级格式",

View file

@ -1,9 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; 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 { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { pageCount } from "../../utils/display"; import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time"; import { formatSystemTime } from "../../utils/time";
@ -16,12 +21,14 @@ function fmtBytes(n: number): string {
export function NetworkConfigsPage() { export function NetworkConfigsPage() {
const { t } = useI18n(); const { t } = useI18n();
const { showOk, showError } = useToast();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [source, setSource] = useState(""); const [source, setSource] = useState("");
const [vendor, setVendor] = useState(""); const [vendor, setVendor] = useState("");
const [selected, setSelected] = useState<{ source: string; id: string } | null>(null); const [selected, setSelected] = useState<{ source: string; id: string } | null>(null);
const [tab, setTab] = useState<"primary" | "alt">("primary"); const [tab, setTab] = useState<"primary" | "alt">("primary");
const [exporting, setExporting] = useState("");
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: queryKeys.networkConfigs(page, keyword, source, vendor), queryKey: queryKeys.networkConfigs(page, keyword, source, vendor),
@ -43,6 +50,23 @@ export function NetworkConfigsPage() {
const detail = detailQuery.data; const detail = detailQuery.data;
const showAlt = Boolean(detail?.has_alt); 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 ( return (
<section className="panel"> <section className="panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
@ -106,33 +130,46 @@ export function NetworkConfigsPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{items.map((row) => ( {items.map((row) => {
<tr key={`${row.source}:${row.target_id}`}> const exportKey = `${row.source}:${row.target_id}:list`;
<td>{row.ne_name || row.target_id}</td> return (
<td>{row.ne_ip}</td> <tr key={`${row.source}:${row.target_id}`}>
<td>{row.vendor || "-"}</td> <td>{row.ne_name || row.target_id}</td>
<td>{row.source}</td> <td>{row.ne_ip}</td>
<td>{fmtBytes(row.plain_size)}</td> <td>{row.vendor || "-"}</td>
<td>{row.collected_at ? formatSystemTime(row.collected_at) : "-"}</td> <td>{row.source}</td>
<td> <td>{fmtBytes(row.plain_size)}</td>
<button <td>{row.collected_at ? formatSystemTime(row.collected_at) : "-"}</td>
type="button" <td className="table-actions">
onClick={() => { <button
setSelected({ source: row.source, id: row.target_id }); type="button"
setTab("primary"); className="link-btn"
}} onClick={() => {
> setSelected({ source: row.source, id: row.target_id });
{t("networkConfigs.view")} setTab("primary");
</button> }}
<Link >
to={`/webcrt?${row.source === "ume" ? "ume_ne_id" : "ne_id"}=${encodeURIComponent(row.target_id)}`} {t("networkConfigs.view")}
style={{ marginLeft: 8 }} </button>
> <button
WebCRT type="button"
</Link> className="link-btn"
</td> disabled={exporting === exportKey || exporting.startsWith(`${row.source}:${row.target_id}:`)}
</tr> onClick={() =>
))} void exportConfig(row.source, row.target_id, row.has_alt ? "both" : "primary")
}
>
{t("networkConfigs.export")}
</button>
<Link
to={`/webcrt?${row.source === "ume" ? "ume_ne_id" : "ne_id"}=${encodeURIComponent(row.target_id)}`}
>
WebCRT
</Link>
</td>
</tr>
);
})}
{!items.length && !listQuery.isLoading ? ( {!items.length && !listQuery.isLoading ? (
<tr> <tr>
<td colSpan={7} className="muted"> <td colSpan={7} className="muted">
@ -164,9 +201,33 @@ export function NetworkConfigsPage() {
({selected.source} / {detail?.ne_ip || "-"}) ({selected.source} / {detail?.ne_ip || "-"})
</span> </span>
</h3> </h3>
<button type="button" onClick={() => setSelected(null)}> <div className="btn-row">
{t("networkConfigs.close")} <button
</button> type="button"
disabled={Boolean(exporting)}
onClick={() =>
void exportConfig(
selected.source,
selected.id,
showAlt ? (tab === "alt" ? "alt" : "primary") : "primary",
)
}
>
{t("networkConfigs.export")}
</button>
{showAlt ? (
<button
type="button"
disabled={Boolean(exporting)}
onClick={() => void exportConfig(selected.source, selected.id, "both")}
>
{t("networkConfigs.exportBoth")}
</button>
) : null}
<button type="button" onClick={() => setSelected(null)}>
{t("networkConfigs.close")}
</button>
</div>
</div> </div>
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{showAlt ? ( {showAlt ? (

View file

@ -745,3 +745,37 @@ export const fetchNeConfigSnapshotDetail = (
apiGet<NeConfigSnapshotDetail>( apiGet<NeConfigSnapshotDetail>(
`/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}?field=${field}`, `/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}?field=${field}`,
); );
export const downloadNeConfigSnapshot = async (
source: string,
targetId: string,
field: "primary" | "alt" | "both" = "primary",
): Promise<void> => {
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);
}
};