From d93689184862833ee0c113076322939e609686ab Mon Sep 17 00:00:00 2001 From: flosch62 Date: Tue, 28 Jul 2026 10:55:33 +0200 Subject: [PATCH] Improve cable map exports --- cable_map/frontend/dev/pdf.ts | 6 +- cable_map/frontend/src/api/pdf.ts | 6 +- cable_map/frontend/src/download-filename.ts | 10 ++++ .../frontend/src/generated/topology.gen.ts | 1 + cable_map/frontend/src/xlsx.test.ts | 59 +++++++++++++++++++ cable_map/frontend/src/xlsx.ts | 8 ++- .../cable-map/config/deployment.yaml | 8 +-- .../operators/cable-map/internal/eda/store.go | 7 ++- .../cable-map/internal/eda/store_test.go | 29 +++++++++ .../cable-map/internal/server/pdf.go | 32 +++++++--- .../cable-map/internal/server/pdf_jobs.go | 22 +++++-- .../cable-map/internal/server/pdf_test.go | 45 ++++++++++++++ .../internal/server/pdfassets/template.typ | 4 +- .../cable-map/internal/topology/contract.go | 19 +++--- .../cable-map/internal/topology/topology.go | 23 +++++--- .../internal/topology/topology_test.go | 11 +++- 16 files changed, 245 insertions(+), 45 deletions(-) create mode 100644 cable_map/frontend/src/download-filename.ts create mode 100644 cable_map/frontend/src/xlsx.test.ts diff --git a/cable_map/frontend/dev/pdf.ts b/cable_map/frontend/dev/pdf.ts index 222fe01..b34d8aa 100644 --- a/cable_map/frontend/dev/pdf.ts +++ b/cable_map/frontend/dev/pdf.ts @@ -4,6 +4,7 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { promisify } from 'node:util' +import { timestampedDownloadFilename } from '../src/download-filename' const execFileAsync = promisify(execFile) const occupiedColour = '#00A87E' @@ -26,6 +27,7 @@ type DevPdfRow = { type: string lag?: string peer?: string + operationalState?: string lldpPeer?: string } @@ -183,7 +185,7 @@ function tableRows(node: DevPdfNode, remote: Record): string[][] remotePort = remote[`${node.name} ${row.label} port`] || remotePort } if (!remoteNode && row.peer) remoteNode = row.peer - return [row.label, dash(row.desc), type, dash(remoteNode), dash(remotePort), dash(row.lldpPeer)] + return [row.label, dash(row.desc), type, dash(remoteNode), dash(remotePort), dash(row.operationalState), dash(row.lldpPeer)] }) } @@ -298,7 +300,7 @@ function dash(value: string | undefined): string { function pdfFilename(namespace: string): string { const ns = safeFilenamePart(namespace) - return `cable_map${ns ? `_${ns}` : ''}.pdf` + return timestampedDownloadFilename(`cable_map${ns ? `_${ns}` : ''}.pdf`) } function safeFilenamePart(value: string): string { diff --git a/cable_map/frontend/src/api/pdf.ts b/cable_map/frontend/src/api/pdf.ts index ca6bfef..58ad652 100644 --- a/cable_map/frontend/src/api/pdf.ts +++ b/cable_map/frontend/src/api/pdf.ts @@ -45,7 +45,11 @@ export function streamPdfJob( const res = await authenticatedFetch(`api/pdf/jobs/${encodeURIComponent(id)}/status`); if (res.status === 404) { closed = true; - onEvent({ status: 'error', phase: 'PDF export failed', message: 'PDF job expired or was not found' }); + onEvent({ + status: 'error', + phase: 'PDF export failed', + message: 'PDF job is no longer available; the service may have restarted. Please retry.', + }); return; } if (!res.ok) throw new Error(`status ${res.status}`); diff --git a/cable_map/frontend/src/download-filename.ts b/cable_map/frontend/src/download-filename.ts new file mode 100644 index 0000000..a2379c0 --- /dev/null +++ b/cable_map/frontend/src/download-filename.ts @@ -0,0 +1,10 @@ +export function downloadTimestamp(now: Date = new Date()): string { + return `${now.toISOString().slice(0, 19).replace('T', '_').replaceAll(':', '-')}Z`; +} + +export function timestampedDownloadFilename(filename: string, now: Date = new Date()): string { + const dot = filename.lastIndexOf('.'); + const timestamp = downloadTimestamp(now); + if (dot <= 0 || dot === filename.length - 1) return `${filename}_${timestamp}`; + return `${filename.slice(0, dot)}_${timestamp}${filename.slice(dot)}`; +} diff --git a/cable_map/frontend/src/generated/topology.gen.ts b/cable_map/frontend/src/generated/topology.gen.ts index 51238a2..139030b 100644 --- a/cable_map/frontend/src/generated/topology.gen.ts +++ b/cable_map/frontend/src/generated/topology.gen.ts @@ -39,6 +39,7 @@ export interface Row { type: string; lag?: string; // LAG resource name (lag-…) when bundled peer?: string; // endpoint label for edge links with no remote node + operationalState?: string; // physical member state reported by EDA lldpStatus: LldpStatus; // "ok" | "mismatch" | "none" — always emitted lldpPeer?: string; // "node port[, node port]" } diff --git a/cable_map/frontend/src/xlsx.test.ts b/cable_map/frontend/src/xlsx.test.ts new file mode 100644 index 0000000..34568a6 --- /dev/null +++ b/cable_map/frontend/src/xlsx.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Cell } from 'write-excel-file/browser'; +import type { Topology } from './domain/topology/contract'; +import { exportXlsx } from './xlsx'; + +const mocks = vi.hoisted(() => ({ + toFile: vi.fn(), + writeXlsxFile: vi.fn(), +})); + +vi.mock('write-excel-file/browser', () => ({ default: mocks.writeXlsxFile })); + +describe('exportXlsx', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-28T14:35:22.000Z')); + mocks.toFile.mockReset().mockResolvedValue(undefined); + mocks.writeXlsxFile.mockReset().mockResolvedValue({ toFile: mocks.toFile }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('includes member operational state and a UTC timestamp in the filename', async () => { + const topo: Topology = { + nodes: [{ + name: 'leaf1', + platform: '7220 IXR-D3L', + os: 'srl', + role: 'leaf', + table: [{ + label: 'ethernet-1-1', + desc: 'uplink', + remoteNode: 'spine1', + remotePort: 'ethernet-1-1', + type: 'interSwitch', + operationalState: 'Down', + lldpStatus: 'ok', + lldpPeer: 'spine1 ethernet-1-1', + }], + }], + links: [], + lags: {}, + labels: {}, + }; + + await exportXlsx(topo, ['leaf1']); + + const sheets = mocks.writeXlsxFile.mock.calls[0]?.[0] as Array<{ data: Cell[][] }>; + const header = sheets[0]?.data[0] as Array<{ value: string }>; + expect(header.map((cell) => cell.value)).toEqual([ + 'Port', 'Type', 'LAG', 'Remote node', 'Remote port', 'Description', + 'Operational state', 'LLDP neighbor', 'LLDP', + ]); + expect(sheets[0]?.data[1]?.[6]).toBe('Down'); + expect(mocks.toFile).toHaveBeenCalledWith('cable-map_2026-07-28_14-35-22Z.xlsx'); + }); +}); diff --git a/cable_map/frontend/src/xlsx.ts b/cable_map/frontend/src/xlsx.ts index 9da8297..b38cd4c 100644 --- a/cable_map/frontend/src/xlsx.ts +++ b/cable_map/frontend/src/xlsx.ts @@ -1,5 +1,6 @@ import type { Cell } from 'write-excel-file/browser'; import type { LagInfo, Topology } from './domain/topology/contract'; +import { timestampedDownloadFilename } from './download-filename'; import { edgeRemote } from './edge-remote'; // readable LAG classification for the spreadsheet's LAG column @@ -7,8 +8,8 @@ const lagLabel = (lag?: LagInfo): string => !lag ? '' : lag.isl ? 'ISL-LAG' : lag.multihomed ? 'ESI' : 'LAG'; // one sheet per node, so the Node column is redundant (the sheet name is the node) -const HEADERS = ['Port', 'Type', 'LAG', 'Remote node', 'Remote port', 'Description', 'LLDP neighbor', 'LLDP']; -const WIDTHS = [16, 8, 9, 18, 16, 30, 22, 9]; +const HEADERS = ['Port', 'Type', 'LAG', 'Remote node', 'Remote port', 'Description', 'Operational state', 'LLDP neighbor', 'LLDP']; +const WIDTHS = [16, 8, 9, 18, 16, 30, 18, 22, 9]; // Excel sheet names: <=31 chars, none of \ / ? * [ ] :, and unique. Returns a per-call namer. function sheetNamer(): (id: string) => string { @@ -50,6 +51,7 @@ export async function exportXlsx(topo: Topology, nodeIds: string[]): Promise precomputed plan (nil if missing) slottedByName := map[string]bool{} // stencil filename -> slotted cage ids for i, n := range nodes { + if err := ctx.Err(); err != nil { + return nil, err + } panel := "" svgName := n.Platform if s := stencils[n.Name]; s != "" { @@ -258,7 +272,7 @@ func tableRows(n topology.Node, remote map[string]string) [][]string { if rn == "" && r.Peer != "" { rn = r.Peer } - rows = append(rows, []string{r.Label, dash(r.Desc), typ, dash(rn), dash(rp), dash(r.LldpPeer)}) + rows = append(rows, []string{r.Label, dash(r.Desc), typ, dash(rn), dash(rp), dash(r.OperationalState), dash(r.LldpPeer)}) } return rows } @@ -551,11 +565,15 @@ func (s *Server) pdf(w http.ResponseWriter, r *http.Request) { } func pdfFilename(namespace string) string { + return pdfFilenameAt(namespace, time.Now()) +} + +func pdfFilenameAt(namespace string, now time.Time) string { name := "cable_map" if ns := safeFilenamePart(namespace); ns != "" { name += "_" + ns } - return name + ".pdf" + return name + "_" + now.UTC().Format("2006-01-02_15-04-05Z") + ".pdf" } func safeFilenamePart(s string) string { diff --git a/cable_map/operators/cable-map/internal/server/pdf_jobs.go b/cable_map/operators/cable-map/internal/server/pdf_jobs.go index 0778dd7..7cece33 100644 --- a/cable_map/operators/cable-map/internal/server/pdf_jobs.go +++ b/cable_map/operators/cable-map/internal/server/pdf_jobs.go @@ -16,7 +16,10 @@ import ( "cable-map.eda.labs/cable-map/operators/cable-map/internal/topology" ) -const pdfJobTTL = 10 * time.Minute +const ( + pdfJobTTL = 10 * time.Minute + pdfRenderTimeout = 10 * time.Minute +) type pdfJobStatus string @@ -242,8 +245,12 @@ func (s *Server) pdfJobsRoot(w http.ResponseWriter, r *http.Request) { } job := s.pdfJobs.create(pdfFilename(req.Namespace)) + renderBaseContext := context.WithoutCancel(r.Context()) go func() { - pdf, err := s.engine.renderWithProgress(contextWithoutCancel(r.Context()), nodes, stencilDir(), req.Remote, req.Stencils, func(progress pdfRenderProgress) { + renderContext, cancel := context.WithTimeout(renderBaseContext, pdfRenderTimeout) + defer cancel() + + pdf, err := s.engine.renderWithProgress(renderContext, nodes, stencilDir(), req.Remote, req.Stencils, func(progress pdfRenderProgress) { job.update(pdfJobEvent{ Status: pdfJobRunning, Phase: progress.Phase, @@ -253,6 +260,10 @@ func (s *Server) pdfJobsRoot(w http.ResponseWriter, r *http.Request) { }) }) if err != nil { + if errors.Is(renderContext.Err(), context.DeadlineExceeded) { + job.fail(errors.New("PDF export timed out after 10 minutes")) + return + } job.fail(err) return } @@ -287,6 +298,7 @@ func (s *Server) pdfJobStatus(w http.ResponseWriter, r *http.Request, id string) writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } + w.Header().Set("Cache-Control", "no-store") job, ok := s.pdfJobs.get(id) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": "PDF job not found"}) @@ -301,6 +313,7 @@ func (s *Server) pdfJobEvents(w http.ResponseWriter, r *http.Request, id string) writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } + w.Header().Set("Cache-Control", "no-store") job, ok := s.pdfJobs.get(id) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": "PDF job not found"}) @@ -339,6 +352,7 @@ func (s *Server) pdfJobDownload(w http.ResponseWriter, r *http.Request, id strin writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } + w.Header().Set("Cache-Control", "no-store") job, ok := s.pdfJobs.get(id) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": "PDF job not found"}) @@ -390,7 +404,3 @@ func selectPDFNodes(topo topology.Topology, names []string) []topology.Node { } return nodes } - -func contextWithoutCancel(ctx context.Context) context.Context { - return context.WithoutCancel(ctx) -} diff --git a/cable_map/operators/cable-map/internal/server/pdf_test.go b/cable_map/operators/cable-map/internal/server/pdf_test.go index 8531413..4df00ca 100644 --- a/cable_map/operators/cable-map/internal/server/pdf_test.go +++ b/cable_map/operators/cable-map/internal/server/pdf_test.go @@ -1,6 +1,7 @@ package server import ( + "reflect" "strings" "testing" "time" @@ -35,6 +36,29 @@ func TestColourPanelSupportsSingleQuotedDataPorts(t *testing.T) { } } +func TestPDFTableRowsIncludeOperationalState(t *testing.T) { + node := topology.Node{ + Name: "leaf1", + Table: []topology.Row{{ + Label: "ethernet-1-1", + Desc: "to spine1", + RemoteNode: "spine1", + RemotePort: "ethernet-1-2", + Type: "interSwitch", + OperationalState: "Down", + LldpPeer: "spine1 ethernet-1-2", + }}, + } + + got := tableRows(node, nil) + want := [][]string{{ + "ethernet-1-1", "to spine1", "ISL", "spine1", "ethernet-1-2", "Down", "spine1 ethernet-1-2", + }} + if !reflect.DeepEqual(got, want) { + t.Fatalf("PDF table rows = %#v, want %#v", got, want) + } +} + func TestPDFJobPublishesProgressAndReadyResult(t *testing.T) { store := newPDFJobStore() job := store.create("cable_map.pdf") @@ -77,6 +101,27 @@ func TestPDFJobStoreExpiresTerminalJobs(t *testing.T) { } } +func TestRunningPDFJobDoesNotExpire(t *testing.T) { + store := newPDFJobStore() + job := store.create("cable_map.pdf") + + if job.expired(time.Now().Add(24 * time.Hour)) { + t.Fatal("running PDF job expired") + } + if got, ok := store.get(job.id); !ok || got != job { + t.Fatal("running PDF job was removed from the store") + } +} + +func TestPDFFilenameIncludesNamespaceAndUTCTimestamp(t *testing.T) { + now := time.Date(2026, time.July, 28, 16, 35, 22, 0, time.FixedZone("CEST", 2*60*60)) + got := pdfFilenameAt(" tenant/a ", now) + want := "cable_map_tenant_a_2026-07-28_14-35-22Z.pdf" + if got != want { + t.Fatalf("pdf filename = %q, want %q", got, want) + } +} + func TestSelectPDFNodesPreservesRequestedOrder(t *testing.T) { topo := topology.Topology{Nodes: []topology.Node{ {Name: "leaf-a"}, diff --git a/cable_map/operators/cable-map/internal/server/pdfassets/template.typ b/cable_map/operators/cable-map/internal/server/pdfassets/template.typ index 4417130..e3a55cf 100644 --- a/cable_map/operators/cable-map/internal/server/pdfassets/template.typ +++ b/cable_map/operators/cable-map/internal/server/pdfassets/template.typ @@ -26,10 +26,10 @@ v(12pt) } table( - columns: (auto, 1fr, auto, auto, auto, 1fr), + columns: (auto, 1fr, auto, auto, auto, auto, 1fr), inset: 5pt, align: left + horizon, stroke: 0.4pt + rgb("#dddddd"), fill: (_, row) => if row == 0 { rgb("#e9edf2") } else if calc.odd(row) { rgb("#f6f8fa") } else { white }, - table.header([*Port*], [*Description*], [*Type*], [*Remote node*], [*Remote port*], [*LLDP neighbor*]), + table.header([*Port*], [*Description*], [*Type*], [*Remote node*], [*Remote port*], [*Operational state*], [*LLDP neighbor*]), ..node.rows.flatten(), ) } diff --git a/cable_map/operators/cable-map/internal/topology/contract.go b/cable_map/operators/cable-map/internal/topology/contract.go index 67bed24..c20a9ec 100644 --- a/cable_map/operators/cable-map/internal/topology/contract.go +++ b/cable_map/operators/cable-map/internal/topology/contract.go @@ -32,15 +32,16 @@ type Edge struct { } type Row struct { - Label string `json:"label"` - Desc string `json:"desc"` - RemoteNode string `json:"remoteNode"` - RemotePort string `json:"remotePort"` - Type string `json:"type"` - Lag string `json:"lag,omitempty"` // LAG resource name (lag-…) when bundled - Peer string `json:"peer,omitempty"` // endpoint label for edge links with no remote node - LldpStatus string `json:"lldpStatus" tstype:"LldpStatus"` // "ok" | "mismatch" | "none" — always emitted - LldpPeer string `json:"lldpPeer,omitempty"` // "node port[, node port]" + Label string `json:"label"` + Desc string `json:"desc"` + RemoteNode string `json:"remoteNode"` + RemotePort string `json:"remotePort"` + Type string `json:"type"` + Lag string `json:"lag,omitempty"` // LAG resource name (lag-…) when bundled + Peer string `json:"peer,omitempty"` // endpoint label for edge links with no remote node + OperationalState string `json:"operationalState,omitempty"` // physical member state reported by EDA + LldpStatus string `json:"lldpStatus" tstype:"LldpStatus"` // "ok" | "mismatch" | "none" — always emitted + LldpPeer string `json:"lldpPeer,omitempty"` // "node port[, node port]" } type LagMember struct { diff --git a/cable_map/operators/cable-map/internal/topology/topology.go b/cable_map/operators/cable-map/internal/topology/topology.go index add73d2..2975862 100644 --- a/cable_map/operators/cable-map/internal/topology/topology.go +++ b/cable_map/operators/cable-map/internal/topology/topology.go @@ -64,10 +64,11 @@ type IfNeighbor struct { } type IfStatusMember struct { - Node string - Interface string // EDA-normalised name (ethernet-1-a-1-1) - NodeInterface string // node-native name (1/1/c1/1) — the form LLDP neighbours are reported in - Neighbors []IfNeighbor + Node string + Interface string // EDA-normalised name (ethernet-1-a-1-1) + NodeInterface string // node-native name (1/1/c1/1) — the form LLDP neighbours are reported in + OperationalState string // status.members[].operationalState ("Up", "Down", "Degraded", ...) + Neighbors []IfNeighbor } // RawIface is an interfaces.eda.nokia.com/Interface projection. @@ -247,6 +248,8 @@ func BuildTopology(nodes []RawNode, links []RawLink, ifaces []RawIface, breakout // form (1/1/c1/1), but topolink endpoints use the EDA name (ethernet-1-a-1-1); this resolves the // expected remote to its native name so the match is native-vs-native (no naming guesswork). nativeOf := map[string]string{} + // node|interface -> the physical member's reported operational state. + operStateByPort := map[string]string{} for _, f := range ifaces { if f.Description != "" { @@ -266,6 +269,9 @@ func BuildTopology(nodes []RawNode, links []RawLink, ifaces []RawIface, breakout if m.Node != "" && m.Interface != "" && m.NodeInterface != "" { nativeOf[m.Node+"|"+m.Interface] = m.NodeInterface } + if m.Node != "" && m.Interface != "" && m.OperationalState != "" { + operStateByPort[m.Node+"|"+normIf(m.Interface)] = m.OperationalState + } var nbrs []LagMember for _, n := range m.Neighbors { if n.Node != "" { @@ -365,10 +371,11 @@ func BuildTopology(nodes []RawNode, links []RawLink, ifaces []RawIface, breakout desc = descOf[iface] // the endpoint's Interface CR (possibly a LAG) } row := Row{ - Label: portLabel(port), - Desc: desc, - RemoteNode: remote.Node, - Type: typ, + Label: portLabel(port), + Desc: desc, + RemoteNode: remote.Node, + Type: typ, + OperationalState: operStateByPort[node+"|"+normIf(port)], } if remote.Interface != "" { row.RemotePort = portLabel(remote.Interface) diff --git a/cable_map/operators/cable-map/internal/topology/topology_test.go b/cable_map/operators/cable-map/internal/topology/topology_test.go index 51c38aa..df08c4d 100644 --- a/cable_map/operators/cable-map/internal/topology/topology_test.go +++ b/cable_map/operators/cable-map/internal/topology/topology_test.go @@ -50,8 +50,8 @@ func TestBuildTopology(t *testing.T) { Members: []IfMember{{Node: "leaf1", Interface: "ethernet-1-11"}, {Node: "leaf2", Interface: "ethernet-1-11"}}, }, {StatusMembers: []IfStatusMember{ - {Node: "leaf1", Interface: "ethernet-1/49", Neighbors: []IfNeighbor{{Node: "spine1", Interface: "ethernet-1/1"}}}, - {Node: "leaf1", Interface: "ethernet-1/50", Neighbors: []IfNeighbor{{Node: "spine1", Interface: "ethernet-1/9"}}}, + {Node: "leaf1", Interface: "ethernet-1/49", OperationalState: "Up", Neighbors: []IfNeighbor{{Node: "spine1", Interface: "ethernet-1/1"}}}, + {Node: "leaf1", Interface: "ethernet-1/50", OperationalState: "Down", Neighbors: []IfNeighbor{{Node: "spine1", Interface: "ethernet-1/9"}}}, }}, } links := []RawLink{ @@ -117,6 +117,9 @@ func TestBuildTopology(t *testing.T) { if r.LldpStatus != "ok" { t.Errorf("e1-49 lldpStatus: got %q want ok", r.LldpStatus) } + if r.OperationalState != "Up" { + t.Errorf("e1-49 operationalState: got %q want Up", r.OperationalState) + } if r.LldpPeer != "spine1 ethernet-1-1" { t.Errorf("e1-49 lldpPeer: got %q", r.LldpPeer) } @@ -129,6 +132,8 @@ func TestBuildTopology(t *testing.T) { } if r, _ := findRow(leaf1, "ethernet-1-50"); r.LldpStatus != "mismatch" { t.Errorf("e1-50 lldpStatus: got %q want mismatch", r.LldpStatus) + } else if r.OperationalState != "Down" { + t.Errorf("e1-50 operationalState: got %q want Down", r.OperationalState) } else if r.Lag != "" { t.Errorf("e1-50 lag should be empty when interface resource is not a typed LAG, got %q", r.Lag) } else if r.Desc != "cross-link" { @@ -136,6 +141,8 @@ func TestBuildTopology(t *testing.T) { } if r, _ := findRow(leaf1, "ethernet-1-10"); r.Type != "edge" || r.Peer != "server-a" || r.LldpStatus != "none" { t.Errorf("edge row: type=%q peer=%q lldp=%q", r.Type, r.Peer, r.LldpStatus) + } else if r.OperationalState != "" { + t.Errorf("edge row operationalState: got %q want empty", r.OperationalState) } if r, _ := findRow(leaf1, "ethernet-1-11"); r.Lag != "lag1-l1-l2-eth-1-1" { t.Errorf("non-prefixed edge lag: got %q want lag1-l1-l2-eth-1-1", r.Lag)