diff --git a/frontend/src/__tests__/pages/GCodeViewerPage.test.tsx b/frontend/src/__tests__/pages/GCodeViewerPage.test.tsx new file mode 100644 index 000000000..ede612a0f --- /dev/null +++ b/frontend/src/__tests__/pages/GCodeViewerPage.test.tsx @@ -0,0 +1,120 @@ +/** + * The G-code viewer's frame, when something refuses to let it be embedded (#2787). + * + * Sliced files preview through a full-page route whose body is an iframe of + * /gcode-viewer/; STL and source 3MF use an in-page three.js modal instead. So a + * proxy that injects a framing header breaks exactly one of the two previews, + * and all the user sees is the browser's own "refused to connect" page inside + * our layout shell — no clue what happened, and no hint that the viewer works + * perfectly well in a tab of its own. + */ + +import { describe, it, expect } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { render } from '../utils'; +import { GCodeViewerPage } from '../../pages/GCodeViewerPage'; +import { findFramingRefusal } from '../../utils/framing'; +import { server } from '../mocks/server'; + +const ORIGIN = 'https://printers.example.com'; +const OURS = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'self';"; + +function serveViewer(status: number, headers: Record = {}) { + server.use(http.get('/gcode-viewer/', () => new HttpResponse(null, { status, headers }))); +} + +describe('findFramingRefusal', () => { + it('accepts the headers Bambuddy itself sends', () => { + expect(findFramingRefusal('SAMEORIGIN', OURS, ORIGIN)).toBeNull(); + }); + + it('accepts an origin named explicitly instead of self', () => { + const csp = `frame-ancestors ${ORIGIN};`; + expect(findFramingRefusal(null, csp, ORIGIN)).toBeNull(); + }); + + it('reports a proxy-added policy that intersects ours down to none', () => { + // Two Content-Security-Policy headers arrive as one comma-joined string. + // Both apply, so ours permitting us is not enough. + const refusal = findFramingRefusal('SAMEORIGIN', `${OURS}, frame-ancestors 'none'`, ORIGIN); + expect(refusal).toBe("Content-Security-Policy: frame-ancestors 'none'"); + }); + + it('reports frame-ancestors listing only somebody else', () => { + const refusal = findFramingRefusal(null, "frame-ancestors https://ha.example.com;", ORIGIN); + expect(refusal).toContain('ha.example.com'); + }); + + it('reports X-Frame-Options DENY when no frame-ancestors is present', () => { + expect(findFramingRefusal('DENY', null, ORIGIN)).toBe('X-Frame-Options: DENY'); + }); + + it('reports a second X-Frame-Options appended to ours', () => { + expect(findFramingRefusal('SAMEORIGIN, DENY', null, ORIGIN)).toBe( + 'X-Frame-Options: SAMEORIGIN, DENY', + ); + }); + + it('ignores X-Frame-Options when frame-ancestors permits us, as browsers do', () => { + // CSP supersedes the legacy header outright — flagging this would blame a + // header the browser never consulted. + expect(findFramingRefusal('DENY', OURS, ORIGIN)).toBeNull(); + }); + + it('accepts a response carrying no framing headers at all', () => { + expect(findFramingRefusal(null, null, ORIGIN)).toBeNull(); + }); +}); + +describe('GCodeViewerPage', () => { + it('embeds the viewer when nothing refuses the frame', async () => { + serveViewer(200, { 'X-Frame-Options': 'SAMEORIGIN', 'Content-Security-Policy': OURS }); + + render(); + + expect(await screen.findByTitle('GCode Viewer')).toBeInTheDocument(); + // Give the probe a chance to land and prove it changes nothing. + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); + expect(screen.getByTitle('GCode Viewer')).toBeInTheDocument(); + }); + + it('explains a refused frame and offers the viewer in its own tab', async () => { + serveViewer(200, { 'Content-Security-Policy': "frame-ancestors 'none';" }); + + render(); + + const panel = await screen.findByRole('alert'); + expect(panel).toHaveTextContent(/could not be embedded/i); + // Name the header so the operator can go and find it in their proxy. + expect(panel).toHaveTextContent(/frame-ancestors 'none'/); + // A top-level navigation is not subject to frame-ancestors, so this works. + const link = within(panel).getByRole('link', { name: /new tab/i }); + expect(link).toHaveAttribute('href', '/gcode-viewer/'); + expect(link).toHaveAttribute('target', '_blank'); + expect(screen.queryByTitle('GCode Viewer')).not.toBeInTheDocument(); + }); + + it('reports missing viewer assets rather than showing raw JSON', async () => { + serveViewer(404); + + render(); + + const panel = await screen.findByRole('alert'); + expect(panel).toHaveTextContent(/unavailable/i); + expect(panel).toHaveTextContent(/HTTP 404/); + expect(screen.queryByTitle('GCode Viewer')).not.toBeInTheDocument(); + }); + + it('keeps the frame when the probe itself fails', async () => { + // No evidence either way — the browser's own error page is better than a + // guess at a cause we cannot see. + server.use(http.get('/gcode-viewer/', () => HttpResponse.error())); + + render(); + + expect(await screen.findByTitle('GCode Viewer')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); + expect(screen.getByTitle('GCode Viewer')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 1f08c41b0..101c6394e 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -7014,6 +7014,12 @@ export default { }, }, gcodeViewer: { + blockedTitle: '3D-Vorschau konnte nicht eingebettet werden', + blockedBody: 'Bambuddy erlaubt dieser Seite, den G-Code-Viewer eingebettet anzuzeigen, aber etwas zwischen Ihrem Browser und Bambuddy verweigert das — meist ein Reverse-Proxy oder eine Sicherheitserweiterung, die einen eigenen Frame-Header sendet. Das Öffnen des Viewers in einem eigenen Tab ist davon nicht betroffen.', + unavailableTitle: '3D-Vorschau nicht verfügbar', + unavailableBody: 'Bambuddy konnte die Dateien des G-Code-Viewers nicht ausliefern. Normalerweise fehlt dann das Verzeichnis gcode_viewer in der Installation; das Startprotokoll weist ebenfalls darauf hin.', + problemDetail: 'Meldung des Servers: {{detail}}', + openInNewTab: 'Viewer in neuem Tab öffnen', back: 'Zurück', backToArchives: 'Zurück zum Druckarchiv', backToFiles: 'Zurück zum Dateimanager', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 1d6f13565..ce9ffaf81 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -7063,6 +7063,12 @@ export default { }, }, gcodeViewer: { + blockedTitle: 'The 3D preview could not be embedded', + blockedBody: 'Bambuddy allows this page to show the G-code viewer inline, but something between your browser and Bambuddy is refusing it — usually a reverse proxy or a security add-on sending its own framing header. Opening the viewer in its own tab is not affected.', + unavailableTitle: 'The 3D preview is unavailable', + unavailableBody: 'Bambuddy could not serve the G-code viewer\'s files. This normally means the gcode_viewer directory is missing from the installation; the startup log says so too.', + problemDetail: 'Reported by the server: {{detail}}', + openInNewTab: 'Open the viewer in a new tab', back: 'Back', backToArchives: 'Back to Print Archives', backToFiles: 'Back to File Manager', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index d0824ebd2..3f065ea39 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -7022,6 +7022,12 @@ export default { }, }, gcodeViewer: { + blockedTitle: 'No se pudo incrustar la vista previa 3D', + blockedBody: 'Bambuddy permite que esta página muestre el visor de G-code incrustado, pero algo entre su navegador y Bambuddy lo está rechazando — normalmente un proxy inverso o un complemento de seguridad que envía su propia cabecera de marco. Abrir el visor en su propia pestaña no se ve afectado.', + unavailableTitle: 'La vista previa 3D no está disponible', + unavailableBody: 'Bambuddy no pudo servir los archivos del visor de G-code. Esto suele significar que falta el directorio gcode_viewer en la instalación; el registro de inicio también lo indica.', + problemDetail: 'Informado por el servidor: {{detail}}', + openInNewTab: 'Abrir el visor en una pestaña nueva', back: 'Atrás', backToArchives: 'Volver a los archivos de impresión', backToFiles: 'Volver al gestor de archivos', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 73ca39693..d59eef6d2 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -7003,6 +7003,12 @@ export default { }, }, gcodeViewer: { + blockedTitle: 'L\'aperçu 3D n\'a pas pu être intégré', + blockedBody: 'Bambuddy autorise cette page à afficher la visionneuse G-code en ligne, mais quelque chose entre votre navigateur et Bambuddy le refuse — généralement un reverse proxy ou une extension de sécurité qui envoie son propre en-tête de cadre. L\'ouverture de la visionneuse dans un onglet dédié n\'est pas concernée.', + unavailableTitle: 'L\'aperçu 3D est indisponible', + unavailableBody: 'Bambuddy n\'a pas pu servir les fichiers de la visionneuse G-code. Cela signifie généralement que le répertoire gcode_viewer est absent de l\'installation ; le journal de démarrage l\'indique également.', + problemDetail: 'Signalé par le serveur : {{detail}}', + openInNewTab: 'Ouvrir la visionneuse dans un nouvel onglet', back: 'Retour', backToArchives: 'Retour aux archives d\'impression', backToFiles: 'Retour au gestionnaire de fichiers', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 509bf540f..0079b210f 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -7002,6 +7002,12 @@ export default { }, }, gcodeViewer: { + blockedTitle: 'Impossibile incorporare l\'anteprima 3D', + blockedBody: 'Bambuddy consente a questa pagina di mostrare il visualizzatore G-code incorporato, ma qualcosa tra il browser e Bambuddy lo rifiuta — di solito un reverse proxy o un\'estensione di sicurezza che invia una propria intestazione di frame. L\'apertura del visualizzatore in una scheda dedicata non è interessata.', + unavailableTitle: 'Anteprima 3D non disponibile', + unavailableBody: 'Bambuddy non è riuscito a servire i file del visualizzatore G-code. Di solito significa che la cartella gcode_viewer manca nell\'installazione; anche il log di avvio lo segnala.', + problemDetail: 'Segnalato dal server: {{detail}}', + openInNewTab: 'Apri il visualizzatore in una nuova scheda', back: 'Indietro', backToArchives: 'Torna agli archivi di stampa', backToFiles: 'Torna al gestore file', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index ecb1a765c..5d6973b04 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -7014,6 +7014,12 @@ export default { }, }, gcodeViewer: { + blockedTitle: '3Dプレビューを埋め込めませんでした', + blockedBody: 'BambuddyはこのページにG-codeビューアーを埋め込んで表示することを許可していますが、ブラウザーとBambuddyの間にある何かがそれを拒否しています。多くの場合、独自のフレームヘッダーを送信するリバースプロキシやセキュリティ拡張が原因です。ビューアーを別のタブで開く場合は影響ありません。', + unavailableTitle: '3Dプレビューを利用できません', + unavailableBody: 'BambuddyがG-codeビューアーのファイルを配信できませんでした。通常はインストールに gcode_viewer ディレクトリが存在しないことを意味します。起動ログにも記録されています。', + problemDetail: 'サーバーからの報告: {{detail}}', + openInNewTab: 'ビューアーを新しいタブで開く', back: '戻る', backToArchives: '印刷アーカイブに戻る', backToFiles: 'ファイル管理に戻る', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 350908fe2..854fa3bb7 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -6470,6 +6470,12 @@ export default { } }, gcodeViewer: { + blockedTitle: '3D 미리보기를 삽입할 수 없습니다', + blockedBody: 'Bambuddy는 이 페이지에 G-code 뷰어를 삽입해 표시하도록 허용하지만, 브라우저와 Bambuddy 사이의 무언가가 이를 거부하고 있습니다. 대개 자체 프레임 헤더를 보내는 리버스 프록시나 보안 추가 기능이 원인입니다. 뷰어를 별도 탭에서 여는 것은 영향을 받지 않습니다.', + unavailableTitle: '3D 미리보기를 사용할 수 없습니다', + unavailableBody: 'Bambuddy가 G-code 뷰어 파일을 제공하지 못했습니다. 보통 설치본에 gcode_viewer 디렉터리가 없다는 뜻이며, 시작 로그에도 기록됩니다.', + problemDetail: '서버 보고: {{detail}}', + openInNewTab: '새 탭에서 뷰어 열기', back: '뒤로', backToArchives: '인쇄 아카이브로 돌아가기', backToFiles: '파일 관리자로 돌아가기' diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 1baa47893..34b3d3a2f 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -7002,6 +7002,12 @@ export default { }, }, gcodeViewer: { + blockedTitle: 'Não foi possível incorporar a pré-visualização 3D', + blockedBody: 'O Bambuddy permite que esta página mostre o visualizador de G-code incorporado, mas algo entre o seu navegador e o Bambuddy está recusando — normalmente um proxy reverso ou um complemento de segurança que envia o próprio cabeçalho de quadro. Abrir o visualizador em uma aba própria não é afetado.', + unavailableTitle: 'A pré-visualização 3D está indisponível', + unavailableBody: 'O Bambuddy não conseguiu servir os arquivos do visualizador de G-code. Isso normalmente significa que o diretório gcode_viewer está ausente na instalação; o log de inicialização também informa isso.', + problemDetail: 'Informado pelo servidor: {{detail}}', + openInNewTab: 'Abrir o visualizador em uma nova aba', back: 'Voltar', backToArchives: 'Voltar para os arquivos de impressão', backToFiles: 'Voltar para o gerenciador de arquivos', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index cf229691c..a5efb7fa8 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -6639,6 +6639,12 @@ export default { }, }, gcodeViewer: { + blockedTitle: "Не удалось встроить 3D-предпросмотр", + blockedBody: "Bambuddy разрешает этой странице показывать просмотрщик G-code встроенным, но что-то между браузером и Bambuddy это запрещает — обычно обратный прокси или расширение безопасности, отправляющее собственный заголовок фрейма. Открытие просмотрщика в отдельной вкладке не затрагивается.", + unavailableTitle: "3D-предпросмотр недоступен", + unavailableBody: "Bambuddy не смог отдать файлы просмотрщика G-code. Обычно это значит, что в установке отсутствует каталог gcode_viewer; об этом также сообщает журнал запуска.", + problemDetail: "Сообщение сервера: {{detail}}", + openInNewTab: "Открыть просмотрщик в новой вкладке", back: "Назад", backToArchives: "Вернуться в архив печати", backToFiles: "Вернуться в файловый менеджер", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index c530a0dbd..e64b9e29a 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -6953,6 +6953,12 @@ export default { }, }, gcodeViewer: { + blockedTitle: '3D önizleme gömülemedi', + blockedBody: 'Bambuddy bu sayfanın G-code görüntüleyiciyi gömülü göstermesine izin veriyor, ancak tarayıcınızla Bambuddy arasındaki bir şey bunu reddediyor — genellikle kendi çerçeve başlığını gönderen bir ters proxy veya güvenlik eklentisi. Görüntüleyiciyi kendi sekmesinde açmak bundan etkilenmez.', + unavailableTitle: '3D önizleme kullanılamıyor', + unavailableBody: 'Bambuddy, G-code görüntüleyicinin dosyalarını sunamadı. Bu genellikle kurulumda gcode_viewer dizininin eksik olduğu anlamına gelir; başlangıç günlüğü de bunu belirtir.', + problemDetail: 'Sunucunun bildirdiği: {{detail}}', + openInNewTab: 'Görüntüleyiciyi yeni sekmede aç', back: 'Geri', backToArchives: 'Baskı Arşivlerine Dön', backToFiles: 'Dosya Yöneticisine Dön', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index 7a1f621e3..ca823a16a 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -7057,6 +7057,12 @@ export default { }, }, gcodeViewer: { + blockedTitle: "Не вдалося вбудувати 3D-перегляд", + blockedBody: "Bambuddy дозволяє цій сторінці показувати переглядач G-code вбудованим, але щось між браузером і Bambuddy це відхиляє — зазвичай зворотний проксі або розширення безпеки, яке надсилає власний заголовок фрейму. Відкриття переглядача в окремій вкладці це не зачіпає.", + unavailableTitle: "3D-перегляд недоступний", + unavailableBody: "Bambuddy не зміг віддати файли переглядача G-code. Зазвичай це означає, що в установці бракує каталогу gcode_viewer; журнал запуску також про це повідомляє.", + problemDetail: "Повідомлення сервера: {{detail}}", + openInNewTab: "Відкрити переглядач у новій вкладці", back: "Назад", backToArchives: "Назад до друку архівів", backToFiles: "Назад до файлового менеджера", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 71cd27b8f..591472e3a 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -7001,6 +7001,12 @@ export default { }, }, gcodeViewer: { + blockedTitle: '无法嵌入 3D 预览', + blockedBody: 'Bambuddy 允许此页面内嵌显示 G-code 查看器,但浏览器与 Bambuddy 之间的某个环节拒绝了它 — 通常是发送自有框架标头的反向代理或安全插件。在独立标签页中打开查看器不受影响。', + unavailableTitle: '3D 预览不可用', + unavailableBody: 'Bambuddy 无法提供 G-code 查看器的文件。这通常表示安装中缺少 gcode_viewer 目录;启动日志中也会有相应记录。', + problemDetail: '服务器报告:{{detail}}', + openInNewTab: '在新标签页中打开查看器', back: '返回', backToArchives: '返回打印归档', backToFiles: '返回文件管理器', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 3b7b43bd3..9350e8976 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -7001,6 +7001,12 @@ export default { }, }, gcodeViewer: { + blockedTitle: '無法嵌入 3D 預覽', + blockedBody: 'Bambuddy 允許此頁面內嵌顯示 G-code 檢視器,但瀏覽器與 Bambuddy 之間的某個環節拒絕了它 — 通常是傳送自有框架標頭的反向代理或安全外掛。在獨立分頁中開啟檢視器不受影響。', + unavailableTitle: '3D 預覽無法使用', + unavailableBody: 'Bambuddy 無法提供 G-code 檢視器的檔案。這通常表示安裝中缺少 gcode_viewer 目錄;啟動記錄中也會有相應紀錄。', + problemDetail: '伺服器回報:{{detail}}', + openInNewTab: '在新分頁中開啟檢視器', back: '返回', backToArchives: '返回列印歸檔', backToFiles: '返回檔案管理器', diff --git a/frontend/src/pages/GCodeViewerPage.tsx b/frontend/src/pages/GCodeViewerPage.tsx index bc3294acb..2739479fd 100644 --- a/frontend/src/pages/GCodeViewerPage.tsx +++ b/frontend/src/pages/GCodeViewerPage.tsx @@ -1,16 +1,62 @@ +import { useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { ArrowLeft } from 'lucide-react'; +import { ArrowLeft, ExternalLink, ShieldAlert } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { findFramingRefusal, type FrameProblem } from '../utils/framing'; export function GCodeViewerPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { t } = useTranslation(); + const [problem, setProblem] = useState(null); + + // Forward the outer page's query string (e.g. ?archive=82) to the iframe so + // the adapter inside can pick up the archive to load. The iframe itself must + // keep the trailing slash on /gcode-viewer/ so it hits the raw-viewer route; + // the outer SPA URL uses no trailing slash so a reload falls through to the + // SPA catch-all and keeps the Bambuddy layout shell. + const iframeSrc = `/gcode-viewer/${window.location.search}`; + const embedded = window !== window.top; + + // A frame refused by X-Frame-Options / frame-ancestors still fires `onLoad` — + // the browser commits its own "refused to connect" error page — so the iframe + // itself cannot tell us anything. Ask for the same URL directly instead: it is + // same-origin, so every response header is readable, and it travels through + // whatever proxy the browser reaches Bambuddy by. The iframe is rendered + // straight away regardless and only replaced if this comes back refusing, + // which keeps the working case exactly as fast as before. + useEffect(() => { + if (embedded) return; + const controller = new AbortController(); + (async () => { + try { + const response = await fetch(iframeSrc, { + credentials: 'same-origin', + signal: controller.signal, + }); + if (!response.ok) { + setProblem({ kind: 'unavailable', detail: `HTTP ${response.status}` }); + return; + } + const refusal = findFramingRefusal( + response.headers.get('x-frame-options'), + response.headers.get('content-security-policy'), + window.location.origin, + ); + if (refusal) setProblem({ kind: 'blocked', detail: refusal }); + } catch { + // Aborted, offline, or the probe itself was blocked. The iframe stays; + // guessing at a cause we have no evidence for would be worse than the + // browser's own error page. + } + })(); + return () => controller.abort(); + }, [iframeSrc, embedded]); // Safety guard: if this React app is itself inside an iframe (e.g. the // StaticFiles mount isn't registered and serve_spa returned us here), // don't render another iframe — that would create an infinite loop. - if (window !== window.top) { + if (embedded) { return (
GCode viewer static files not found. Check that the{' '} @@ -39,13 +85,6 @@ export function GCodeViewerPage() { } }; - // Forward the outer page's query string (e.g. ?archive=82) to the iframe so - // the adapter inside can pick up the archive to load. The iframe itself must - // keep the trailing slash on /gcode-viewer/ so it hits the raw-viewer route; - // the outer SPA URL uses no trailing slash so a reload falls through to the - // SPA catch-all and keeps the Bambuddy layout shell. - const iframeSrc = `/gcode-viewer/${window.location.search}`; - return ( // h-14 (3.5 rem) is the fixed header height defined in Layout.tsx. // Subtracting it prevents a double scrollbar inside the layout shell. @@ -60,16 +99,50 @@ export function GCodeViewerPage() { {backLabel}
-