feat(managed-ne): add bulk import template download with device_type reference sheet

Expose GET /v1/managed-ne/import/template and a workbench button to download xlsx/csv templates listing supported Netmiko device types.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-28 09:41:25 +08:00
parent 042c015045
commit 3024e13812
6 changed files with 69 additions and 0 deletions

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .db import get_db from .db import get_db
@ -9,6 +10,7 @@ from .ne_connect import schedule_connect_tests
from .ne_crypto import credentials_configured from .ne_crypto import credentials_configured
from .ne_schemas import ConnectTestRequest, ManagedNeCreate, ManagedNeUpdate from .ne_schemas import ConnectTestRequest, ManagedNeCreate, ManagedNeUpdate
from .ne_service import ( from .ne_service import (
build_managed_ne_import_template,
create_managed_ne, create_managed_ne,
delete_managed_ne, delete_managed_ne,
get_managed_ne, get_managed_ne,
@ -70,6 +72,16 @@ def api_delete_managed_ne(ne_id: str, db: Session = Depends(get_db)):
return delete_managed_ne(db, ne_id) return delete_managed_ne(db, ne_id)
@router.get("/import/template")
def api_managed_ne_import_template(format: str = Query(default="xlsx")):
filename, payload, media_type = build_managed_ne_import_template(format)
return Response(
content=payload,
media_type=media_type,
headers={"content-disposition": f'attachment; filename="{filename}"'},
)
@router.post("/import") @router.post("/import")
async def api_import_managed_ne(file: UploadFile = File(...), db: Session = Depends(get_db)): async def api_import_managed_ne(file: UploadFile = File(...), db: Session = Depends(get_db)):
content = await file.read() content = await file.read()

View file

@ -195,6 +195,51 @@ def delete_managed_ne(db: Session, ne_id: str) -> dict[str, bool]:
return {"ok": True} return {"ok": True}
def build_managed_ne_import_template(fmt: str = "xlsx") -> tuple[str, bytes, str]:
"""Return (filename, content, media_type) for bulk-import template."""
rows = [
{
"device_type": "cisco_ios",
"ip": "192.168.0.1",
"username": "admin",
"password": "your_password",
"port": 22,
"protocol": "ssh",
"name": "Core-SW1",
"vendor": "Cisco",
},
{
"device_type": "huawei",
"ip": "192.168.0.2",
"username": "admin",
"password": "your_password",
"port": 22,
"protocol": "ssh",
"name": "AGG-01",
"vendor": "Huawei",
},
]
df = pd.DataFrame(rows, columns=list(IMPORT_COLUMNS))
buf = BytesIO()
kind = str(fmt or "xlsx").strip().lower()
if kind == "csv":
df.to_csv(buf, index=False, encoding="utf-8-sig")
return (
"managed_ne_import_template.csv",
buf.getvalue(),
"text/csv; charset=utf-8",
)
device_types_df = pd.DataFrame({"device_type": list(SUPPORTED_DEVICE_TYPES)})
with pd.ExcelWriter(buf, engine="openpyxl") as writer:
df.to_excel(writer, sheet_name="import", index=False)
device_types_df.to_excel(writer, sheet_name="device_type", index=False)
return (
"managed_ne_import_template.xlsx",
buf.getvalue(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
def import_managed_ne(db: Session, content: bytes, filename: str) -> ImportResult: def import_managed_ne(db: Session, content: bytes, filename: str) -> ImportResult:
_require_crypto() _require_crypto()
name = str(filename or "").lower() name = str(filename or "").lower()

View file

@ -102,6 +102,7 @@ const en = {
"NETX_CREDENTIAL_SECRET_KEY is not set. Configure a Fernet key in .env and restart the API to store passwords.", "NETX_CREDENTIAL_SECRET_KEY is not set. Configure a Fernet key in .env and restart the API to store passwords.",
add: "Add NE", add: "Add NE",
importBtn: "Import", importBtn: "Import",
downloadTemplate: "Download template",
importing: "Importing…", importing: "Importing…",
edit: "Edit", edit: "Edit",
delete: "Delete", delete: "Delete",

View file

@ -101,6 +101,7 @@ const zh = {
credsNotConfigured: "未配置 NETX_CREDENTIAL_SECRET_KEY,无法保存密码。请在 .env 中设置 Fernet 密钥后重启 API。", credsNotConfigured: "未配置 NETX_CREDENTIAL_SECRET_KEY,无法保存密码。请在 .env 中设置 Fernet 密钥后重启 API。",
add: "新增网元", add: "新增网元",
importBtn: "批量导入", importBtn: "批量导入",
downloadTemplate: "下载导入模板",
importing: "导入中…", importing: "导入中…",
edit: "编辑", edit: "编辑",
delete: "删除", delete: "删除",

View file

@ -7,6 +7,7 @@ import {
fetchManagedNe, fetchManagedNe,
fetchManagedNeMeta, fetchManagedNeMeta,
importManagedNe, importManagedNe,
managedNeImportTemplateUrl,
updateManagedNe, updateManagedNe,
} from "../services/api"; } from "../services/api";
import { queryKeys } from "../constants/queryKeys"; import { queryKeys } from "../constants/queryKeys";
@ -233,6 +234,12 @@ export function NePage() {
<button type="button" onClick={openCreate} disabled={!credsOk}> <button type="button" onClick={openCreate} disabled={!credsOk}>
{t("managedNe.add")} {t("managedNe.add")}
</button> </button>
<button
type="button"
onClick={() => window.location.assign(managedNeImportTemplateUrl("xlsx"))}
>
{t("managedNe.downloadTemplate")}
</button>
<button <button
type="button" type="button"
onClick={() => importRef.current?.click()} onClick={() => importRef.current?.click()}

View file

@ -129,6 +129,9 @@ export const deleteManagedNe = (id: string) => apiDelete<{ ok: boolean }>(`/v1/m
export const connectTestManagedNe = (ids: string[]) => export const connectTestManagedNe = (ids: string[]) =>
apiPost<{ ok: boolean; submitted: number }>("/v1/managed-ne/connect-test", { ids }); apiPost<{ ok: boolean; submitted: number }>("/v1/managed-ne/connect-test", { ids });
export const managedNeImportTemplateUrl = (format: "xlsx" | "csv" = "xlsx") =>
`/v1/managed-ne/import/template?format=${format}`;
export const importManagedNe = async (file: File): Promise<ManagedNeImportResult> => { export const importManagedNe = async (file: File): Promise<ManagedNeImportResult> => {
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);