mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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:
parent
abb5172a8f
commit
3fc80f55bc
7 changed files with 203 additions and 38 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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`(查看)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@ const zh = {
|
||||||
allSource: "全部来源",
|
allSource: "全部来源",
|
||||||
empty: "暂无已同步配置。请先在「配置同步」执行采集。",
|
empty: "暂无已同步配置。请先在「配置同步」执行采集。",
|
||||||
view: "查看",
|
view: "查看",
|
||||||
|
export: "导出",
|
||||||
|
exportBoth: "导出全部",
|
||||||
|
exportOk: "配置已导出",
|
||||||
close: "关闭",
|
close: "关闭",
|
||||||
tabSet: "Set 格式",
|
tabSet: "Set 格式",
|
||||||
tabHier: "层级格式",
|
tabHier: "层级格式",
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue