Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions cable_map/frontend/dev/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -26,6 +27,7 @@ type DevPdfRow = {
type: string
lag?: string
peer?: string
operationalState?: string
lldpPeer?: string
}

Expand Down Expand Up @@ -183,7 +185,7 @@ function tableRows(node: DevPdfNode, remote: Record<string, string>): 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)]
})
}

Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion cable_map/frontend/src/api/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
10 changes: 10 additions & 0 deletions cable_map/frontend/src/download-filename.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
}
1 change: 1 addition & 0 deletions cable_map/frontend/src/generated/topology.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
}
Expand Down
59 changes: 59 additions & 0 deletions cable_map/frontend/src/xlsx.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
8 changes: 5 additions & 3 deletions cable_map/frontend/src/xlsx.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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
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 {
Expand Down Expand Up @@ -50,6 +51,7 @@ export async function exportXlsx(topo: Topology, nodeIds: string[]): Promise<voi
r.type === 'edge' ? (edgeRemote.get(id, r.label, 'node') || null) : (r.remoteNode || r.peer || null),
r.type === 'edge' ? (edgeRemote.get(id, r.label, 'port') || null) : (r.remotePort || null),
r.desc || null,
r.operationalState || null,
r.lldpPeer || null,
r.lldpStatus && r.lldpStatus !== 'none' ? r.lldpStatus : null,
]),
Expand All @@ -58,5 +60,5 @@ export async function exportXlsx(topo: Topology, nodeIds: string[]): Promise<voi

const tag = nodeIds.length && nodeIds.length < topo.nodes.length ? `-${nodeIds.join('_')}` : '';
const file = await writeXlsxFile(sheets);
await file.toFile(`cable-map${tag}.xlsx`);
await file.toFile(timestampedDownloadFilename(`cable-map${tag}.xlsx`));
}
8 changes: 4 additions & 4 deletions cable_map/operators/cable-map/config/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@ spec:
periodSeconds: 20
resources:
limits:
cpu: "1"
memory: 1Gi
cpu: "2"
memory: 4Gi
requests:
cpu: 100m
memory: 256Mi
cpu: 250m
memory: 512Mi
# EDK reads EDA state over gRPC/mTLS from the State Aggregator; these are the
# internal certs (client cert from cert-manager + trust bundle), mounted exactly
# as EDA core operators (e.g. eda-cx) receive them.
Expand Down
7 changes: 6 additions & 1 deletion cable_map/operators/cable-map/internal/eda/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,12 @@ func projIface(u *unstructured.Unstructured) topology.RawIface {
if !ok {
continue
}
sm := topology.IfStatusMember{Node: mstr(m, "node"), Interface: mstr(m, "interface"), NodeInterface: mstr(m, "nodeInterface")}
sm := topology.IfStatusMember{
Node: mstr(m, "node"),
Interface: mstr(m, "interface"),
NodeInterface: mstr(m, "nodeInterface"),
OperationalState: mstr(m, "operationalState"),
}
if nbrs, ok := m["neighbors"].([]interface{}); ok {
for _, ni := range nbrs {
if nm, ok := ni.(map[string]interface{}); ok {
Expand Down
29 changes: 29 additions & 0 deletions cable_map/operators/cable-map/internal/eda/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,35 @@ func TestProjLink(t *testing.T) {
}
}

func TestProjIfaceIncludesMemberOperationalState(t *testing.T) {
u := &unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{"name": "leaf1-ethernet-1-1"},
"status": map[string]any{"members": []any{
map[string]any{
"node": "leaf1",
"interface": "ethernet-1-1",
"nodeInterface": "ethernet-1/1",
"operationalState": "Degraded",
"neighbors": []any{
map[string]any{"node": "spine1", "interface": "ethernet-1/2"},
},
},
}},
}}

got := projIface(u)
if len(got.StatusMembers) != 1 {
t.Fatalf("status members: %+v", got.StatusMembers)
}
member := got.StatusMembers[0]
if member.Node != "leaf1" || member.Interface != "ethernet-1-1" || member.NodeInterface != "ethernet-1/1" || member.OperationalState != "Degraded" {
t.Errorf("status member: %+v", member)
}
if len(member.Neighbors) != 1 || member.Neighbors[0].Node != "spine1" || member.Neighbors[0].Interface != "ethernet-1/2" {
t.Errorf("neighbors: %+v", member.Neighbors)
}
}

func TestProjGrouping(t *testing.T) {
u := &unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{"name": "role", "namespace": "eda-system"},
Expand Down
32 changes: 25 additions & 7 deletions cable_map/operators/cable-map/internal/server/pdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"sort"
"strings"
"sync"
"time"

"cable-map.eda.labs/cable-map/operators/cable-map/internal/topology"
)
Expand All @@ -31,10 +32,11 @@ const occupiedColour = "#00A87E" // fill for a cabled cage
// pdfEngine shells out to `typst`. Fonts are extracted once to a temp dir (--font-path); the
// template + per-request data are written per render.
type pdfEngine struct {
bin string
once sync.Once
fontsDir string
initErr error
bin string
renderSlot chan struct{}
once sync.Once
fontsDir string
initErr error
}

type pdfRenderProgress struct {
Expand All @@ -49,7 +51,7 @@ func newPDFEngine() *pdfEngine {
if bin == "" {
bin = "typst"
}
return &pdfEngine{bin: bin}
return &pdfEngine{bin: bin, renderSlot: make(chan struct{}, 1)}
}

func (e *pdfEngine) fonts() (string, error) {
Expand Down Expand Up @@ -89,6 +91,15 @@ func (e *pdfEngine) render(ctx context.Context, nodes []topology.Node, stencilDi
}

func (e *pdfEngine) renderWithProgress(ctx context.Context, nodes []topology.Node, stencilDir string, remote, stencils map[string]string, progress func(pdfRenderProgress)) ([]byte, error) {
// Typst can consume multiple GiB for thousand-node documents. Serialize renders so concurrent
// users cannot multiply that peak and OOM the pod while their job records are still in memory.
select {
case e.renderSlot <- struct{}{}:
defer func() { <-e.renderSlot }()
case <-ctx.Done():
return nil, ctx.Err()
}

reportPDFProgress(progress, "Preparing PDF", "", intPtr(0), false)
fontsDir, err := e.fonts()
if err != nil {
Expand Down Expand Up @@ -127,6 +138,9 @@ func (e *pdfEngine) renderWithProgress(ctx context.Context, nodes []topology.Nod
plans := map[string]*stencilPlan{} // stencil filename -> 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 != "" {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 16 additions & 6 deletions cable_map/operators/cable-map/internal/server/pdf_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -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"})
Expand All @@ -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"})
Expand Down Expand Up @@ -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"})
Expand Down Expand Up @@ -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)
}
Loading
Loading