Skip to content
Merged
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
22 changes: 16 additions & 6 deletions cable_map/frontend/dev/pdf.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
Expand Down Expand Up @@ -77,9 +78,10 @@ export async function renderDevPdf({ topology, url, frontpanelDir, pdfAssetsDir,
onProgress?.({ phase: 'Preparing assets', detail: 'Template ready', percent: 10 })

let renderedPanels = 0
const panelByHash = new Map<string, string>()
const data = {
nodes: await Promise.all(nodes.map(async (node, index) => {
const panel = await writePanel({ node, index, work, frontpanelDir, svgName: stencils[node.name] || node.platform })
nodes: await Promise.all(nodes.map(async (node) => {
const panel = await writePanel({ node, work, frontpanelDir, svgName: stencils[node.name] || node.platform, panelByHash })
renderedPanels += 1
onProgress?.({
phase: 'Rendering panels',
Expand Down Expand Up @@ -113,16 +115,16 @@ export class DevPdfRequestError extends Error {

async function writePanel({
node,
index,
work,
frontpanelDir,
svgName,
panelByHash,
}: {
node: DevPdfNode
index: number
work: string
frontpanelDir: string
svgName: string
panelByHash: Map<string, string>
}): Promise<string> {
const svgPath = panelPath(frontpanelDir, svgName)
if (!svgPath) return ''
Expand All @@ -141,8 +143,16 @@ async function writePanel({
if (cage) occupied.add(cage)
}

const panel = `panel-${index}.svg`
await fs.writeFile(path.join(work, panel), colourPanel(svg, occupied))
// Nodes sharing a stencil and cabled-cage set produce identical panels; write each
// distinct one once so Typst embeds a single shared resource (matches operator pdf.go).
const coloured = colourPanel(svg, occupied)
const key = createHash('sha256').update(coloured).digest('hex').slice(0, 24)
let panel = panelByHash.get(key)
if (!panel) {
panel = `panel-${key}.svg`
panelByHash.set(key, panel)
await fs.writeFile(path.join(work, panel), coloured)
}
return panel
}

Expand Down
34 changes: 23 additions & 11 deletions cable_map/frontend/src/api/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,32 +28,44 @@ export async function startPdfJob(request: PdfJobRequest): Promise<string> {
return body.id;
}

// streamPdfJob polls the job status until it reaches a terminal state. We poll rather than use SSE
// because the EDA httpproxy in front of the app buffers/drops streamed responses, which made the
// progress stream endlessly "reconnect" on the deployed path.
export function streamPdfJob(
id: string,
onEvent: (event: PdfProgressEvent) => void,
onReconnect: (message: string) => void,
): () => void {
let closed = false;
const es = new EventSource(authenticatedUrl(`api/pdf/jobs/${encodeURIComponent(id)}/events`));
es.addEventListener('message', (ev) => {
if (closed || !ev.data) return;
let timer: ReturnType<typeof setTimeout> | undefined;

const poll = async () => {
if (closed) return;
try {
const event = JSON.parse(ev.data) as PdfProgressEvent;
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' });
return;
}
if (!res.ok) throw new Error(`status ${res.status}`);
const event = await res.json() as PdfProgressEvent;
if (closed) return;
onEvent(event);
if (event.status !== 'running') {
closed = true;
es.close();
return;
}
} catch {
onReconnect('PDF progress update was unreadable');
if (!closed) onReconnect('Waiting for PDF…');
}
});
es.addEventListener('error', () => {
if (!closed) onReconnect('PDF progress stream reconnecting...');
});
if (!closed) timer = setTimeout(() => void poll(), 700);
};
void poll();

return () => {
closed = true;
es.close();
if (timer) clearTimeout(timer);
};
}

Expand Down
61 changes: 13 additions & 48 deletions cable_map/frontend/src/components/PdfExportProgress.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { memo, useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Fade from '@mui/material/Fade';
import LinearProgress from '@mui/material/LinearProgress';
import Paper from '@mui/material/Paper';
Expand All @@ -25,14 +24,13 @@ function PdfExportProgress({ state }: { state: PdfState }) {
return () => window.clearInterval(timer);
}, [loading]);

// Title = the current phase; subtitle = its detail (e.g. "Typst compile", "5 / 1000 nodes").
// The metric (top-right) carries the % or elapsed, so neither is repeated below.
const phase = loading ? state.phase : '';
const detail = loading ? state.detail : '';
const determinate = loading && !indeterminate;
const roundedPercent = determinate ? Math.min(100, Math.max(0, Math.round(percent ?? 0))) : undefined;
const metric = determinate ? `${roundedPercent}%` : formatElapsed(elapsedSeconds);
const progressDetail = indeterminate
? [detail, `Elapsed ${formatElapsed(elapsedSeconds)}`].filter(Boolean).join(' · ')
: detail;

return (
<Fade in={loading} mountOnEnter unmountOnExit timeout={180}>
Expand Down Expand Up @@ -61,65 +59,32 @@ function PdfExportProgress({ state }: { state: PdfState }) {
border: 1,
borderColor: 'divider',
overflow: 'hidden',
position: 'relative',
pointerEvents: 'auto',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
<CircularProgress
variant={determinate ? 'determinate' : 'indeterminate'}
value={determinate ? roundedPercent : undefined}
size={34}
thickness={4}
color="primary"
sx={{ flexShrink: 0 }}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5, minWidth: 0 }}>
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0, fontWeight: 600 }}>
Rendering PDF
</Typography>
<Typography variant="h6" sx={{ lineHeight: 1, fontWeight: 300, flexShrink: 0 }}>
{metric}
</Typography>
</Box>
<Typography variant="body2" noWrap sx={{ color: 'text.secondary', mt: 0.25 }}>
{phase}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5, minWidth: 0 }}>
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0, fontWeight: 600 }}>
{phase}
</Typography>
<Typography variant="h6" sx={{ lineHeight: 1, fontWeight: 300, flexShrink: 0 }}>
{metric}
</Typography>
</Box>
{progressDetail && (
<Typography variant="caption" noWrap sx={{ display: 'block', mt: 0.25, color: 'text.secondary' }}>
{progressDetail}
{detail && (
<Typography variant="body2" noWrap sx={{ color: 'text.secondary', mt: 0.25 }}>
{detail}
</Typography>
)}
<LinearProgress
variant={determinate ? 'determinate' : 'indeterminate'}
value={determinate ? roundedPercent : undefined}
sx={{
mt: 1.25,
mt: 1.5,
height: 6,
borderRadius: 1,
...(determinate ? { '& .MuiLinearProgress-bar': { transition: 'transform 420ms ease-out' } } : {}),
}}
/>
{loading && indeterminate && (
<Box
sx={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 2,
bgcolor: 'primary.main',
animation: 'pdf-export-scan 1.1s ease-in-out infinite',
'@keyframes pdf-export-scan': {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(100%)' },
},
}}
/>
)}
</Paper>
</Box>
</Fade>
Expand Down
6 changes: 5 additions & 1 deletion cable_map/frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,14 +223,18 @@ const mockApiPlugin = (): Plugin => ({
return
}
const url = new URL(req.url ?? '/', 'http://localhost')
const pdfJobMatch = /^\/api\/pdf\/jobs\/([^/]+)\/(events|download)$/.exec(url.pathname)
const pdfJobMatch = /^\/api\/pdf\/jobs\/([^/]+)\/(status|events|download)$/.exec(url.pathname)
if (pdfJobMatch) {
cleanupPdfJobs()
const job = pdfJobs.get(pdfJobMatch[1])
if (!job) {
sendJSON(res, 404, { error: 'PDF job not found' })
return
}
if (pdfJobMatch[2] === 'status') {
sendJSON(res, 200, job.event)
return
}
if (pdfJobMatch[2] === 'events') {
sendPdfJobSSE(req, res, job)
return
Expand Down
2 changes: 1 addition & 1 deletion cable_map/operators/cable-map/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ FROM ${TARGET_IMAGE}
# The /pdf endpoint renders with Typst (the static musl binary below). The operator embeds the
# NokiaPureText fonts + template and passes them via --font-path; font-noto is just a glyph fallback.
ARG TYPST_VERSION=0.15.0
RUN apk add --no-cache ca-certificates font-noto \
RUN apk add --no-cache ca-certificates font-noto qpdf \
&& apk add --no-cache --virtual .typst-dl wget xz \
&& wget -qO /tmp/typst.tar.xz "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-x86_64-unknown-linux-musl.tar.xz" \
&& xz -dc /tmp/typst.tar.xz | tar -x -C /tmp \
Expand Down
Loading
Loading