2026-07-18 21:56:30 +02:00
(() => {
const $ = ( sel , root = document ) => root . querySelector ( sel );
const STORE_KEY = "cockpit_present_decks_v2" ;
const STORY_SLIDES = [
{
id : "disclaimer" ,
kicker : "Important notice" ,
title : "Disclaimer — please read first" ,
anim : "fade" ,
html : `
<div class="ps-legal">
<p><strong>This OpenManage Cockpit is not an official Dell Technologies product, service, or offering.</strong>
It is an independent ATC demonstration built to show what is possible when OpenManage Enterprise is combined with modern AI tooling.</p>
<ul>
<li>Not endorsed, certified, warranted, or supported by Dell Technologies as a shipping product.</li>
<li>Use is <strong>at your own risk</strong>. No liability for loss, downtime, data loss, or decisions made from this UI.</li>
<li>Always verify critical actions in official Dell / OME consoles and your change processes.</li>
<li>By continuing you acknowledge that Dell Technologies has <strong>no responsibility or obligation</strong> regarding this cockpit.</li>
</ul>
<p class="ps-legal-ack">Press <kbd>→</kbd> or <strong>Next</strong> to continue.</p>
</div>` ,
},
{
id : "agenda" ,
kicker : "Customer briefing" ,
title : "What we will cover" ,
anim : "rise" ,
html : `
<p class="ps-lead">ATC admins asked us to help them see OME data faster and explain it with AI. This briefing shows the result — and why a cockpit like this adds value.</p>
<ol class="ps-steps ps-stagger">
<li><strong>Why</strong> — the gap around OME and why AI alone is not enough.</li>
<li><strong>Architecture</strong> — one design: Browser → Cockpit BFF → OME / AI (with logos).</li>
2026-07-19 00:16:19 +02:00
<li><strong>Apps</strong> — map, network, reports, <em>Console wall</em>, ops desk, copilots.</li>
<li><strong>Remote ops</strong> — power via OME→iDRAC and live consoles in-panel.</li>
2026-07-18 21:56:30 +02:00
<li><strong>People</strong> — who asked, who built, who runs it.</li>
<li><strong>Value</strong> — what customers can take away.</li>
</ol>
<p class="ps-sub">Requested by <strong>Jody van Dongen</strong> & <strong>Laurens Rammers</strong> · delivered by the FDE team (<strong>Mohamed El Kadi</strong> & <strong>Bart Sjerps</strong>).</p>` ,
},
{
id : "problem" ,
kicker : "The ask" ,
title : "OME is strong — operators still lose time" ,
anim : "slide" ,
html : `
<p class="ps-lead">OME remains the management plane of record. The friction is around it: too many screens, slow answers to “what is wrong on this Service Tag?”, and AI that invents facts without live inventory.</p>
<div class="ps-grid3">
<div class="ps-card"><h4>Console sprawl</h4><p>OME, iDRAC, sheets, chat, tickets — same Service Tag, five places.</p></div>
<div class="ps-card"><h4>Context loss</h4><p>Warranty, firmware, and offline state are related — rarely one story.</p></div>
<div class="ps-card"><h4>Ungrounded AI</h4><p>Generic chatbots guess. Useful AI must start from OME.</p></div>
</div>
<p class="ps-sub">Jody and Laurens asked the FDE team to assist — build a practical ops lens on top of OME, not replace OME.</p>` ,
},
{
id : "value" ,
kicker : "Value" ,
title : "Why this cockpit adds value" ,
anim : "rise" ,
html : `
<div class="ps-grid3">
<div class="ps-card"><h4>One Service Tag story</h4><p>Map, inventory, compliance, warranty, fabric, and tickets around the same ST.</p></div>
2026-07-19 00:16:19 +02:00
<div class="ps-card"><h4>Remote ops in UI</h4><p>Power via OME jobs · live iDRAC Console wall (4/8) without tab sprawl.</p></div>
2026-07-18 21:56:30 +02:00
<div class="ps-card"><h4>AI that cites the fleet</h4><p>Copilot answers from live OME facts — or says it does not know.</p></div>
<div class="ps-card"><h4>Faster briefings</h4><p>Click a chart → named systems → hand off in Ops desk.</p></div>
<div class="ps-card"><h4>Admin ↔ Data FDE</h4><p>Clear roles: ATC admins own the estate; Data FDEs deliver the AI/ops surface.</p></div>
<div class="ps-card"><h4>Copyable pattern</h4><p>OME API + BFF + grounded AI — a blueprint, not a Dell SKU.</p></div>
</div>` ,
},
{
id : "arch-design" ,
kicker : "Architecture design" ,
title : "One design · how everything connects" ,
anim : "zoom" ,
html : `
<p class="ps-lead">Every app talks only to the <strong>Cockpit BFF</strong>. The BFF talks to <strong>OME</strong> (truth) and <strong>Open WebUI / vLLM</strong> (AI). Pulse lines show that path.</p>
<div class="ps-arch ps-arch-logos" aria-label="Architecture design">
<svg class="ps-arch-svg" viewBox="0 0 920 420" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient id="archLineBlue" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#0076ce" stop-opacity="0.2"/>
<stop offset="50%" stop-color="#4db8ff" stop-opacity="1"/>
<stop offset="100%" stop-color="#7ef0d8" stop-opacity="0.35"/>
</linearGradient>
<linearGradient id="archLineAmber" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ff9a3c" stop-opacity="0.25"/>
<stop offset="50%" stop-color="#ffb84d" stop-opacity="1"/>
<stop offset="100%" stop-color="#ff9a3c" stop-opacity="0.25"/>
</linearGradient>
<filter id="archGlow"><feGaussianBlur stdDeviation="2.8" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
</defs>
<path class="arch-pulse arch-p1" filter="url(#archGlow)" stroke="url(#archLineAmber)" d="M460 58 L460 360"/>
<path class="arch-pulse arch-p2" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 250 C 360 250, 250 250, 160 310"/>
<path class="arch-pulse arch-p3" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 250 C 460 270, 460 290, 460 330"/>
<path class="arch-pulse arch-p4" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 250 C 560 250, 670 250, 760 310"/>
<path class="arch-pulse arch-p5" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M160 150 C 250 170, 360 190, 460 200"/>
<path class="arch-pulse arch-p6" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M300 150 C 360 170, 410 185, 460 200"/>
<path class="arch-pulse arch-p7" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 150 L460 200"/>
<path class="arch-pulse arch-p8" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M620 150 C 560 170, 510 185, 460 200"/>
<path class="arch-pulse arch-p9" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M760 150 C 670 170, 560 190, 460 200"/>
<circle class="arch-dot d1" r="4" cx="460" cy="80"><animate attributeName="cy" values="70;230;70" dur="2.4s" repeatCount="indefinite"/></circle>
<circle class="arch-dot d2" r="3.5" cx="300" cy="170" style="fill:#7ef0d8"><animate attributeName="cx" values="200;430;200" dur="2.8s" repeatCount="indefinite"/><animate attributeName="cy" values="155;195;155" dur="2.8s" repeatCount="indefinite"/></circle>
<circle class="arch-dot d3" r="3.5" cx="620" cy="170" style="fill:#ffb84d"><animate attributeName="cx" values="720;490;720" dur="2.6s" repeatCount="indefinite"/><animate attributeName="cy" values="155;195;155" dur="2.6s" repeatCount="indefinite"/></circle>
<circle class="arch-dot d4" r="3.5" cx="300" cy="290" style="fill:#4db8ff"><animate attributeName="cx" values="460;180;460" dur="3s" repeatCount="indefinite"/><animate attributeName="cy" values="250;310;250" dur="3s" repeatCount="indefinite"/></circle>
<circle class="arch-dot d5" r="3.5" cx="620" cy="290" style="fill:#ff9a3c"><animate attributeName="cx" values="460;740;460" dur="2.9s" repeatCount="indefinite"/><animate attributeName="cy" values="250;310;250" dur="2.9s" repeatCount="indefinite"/></circle>
</svg>
<div class="ps-arch-layer top">
<div class="ps-arch-node operator">
<div class="ps-logo-row"><img class="ps-logo" src="/logos/browser.svg" alt="Browser"/></div>
<strong>Operator</strong><span>Browser · HTTPS · no secrets</span>
</div>
</div>
<div class="ps-arch-layer apps">
<div class="ps-arch-node app"><strong>Map</strong><span>Topology</span></div>
2026-07-19 00:16:19 +02:00
<div class="ps-arch-node app"><strong>Console</strong><span>iDRAC wall</span></div>
2026-07-18 21:56:30 +02:00
<div class="ps-arch-node app core-app"><strong>Cockpit UI</strong><span>Drawers · KPIs</span></div>
<div class="ps-arch-node app"><strong>Network</strong><span>Fabric · Racks</span></div>
<div class="ps-arch-node app"><strong>Ops / AI</strong><span>Tickets · Copilot</span></div>
</div>
<div class="ps-arch-layer mid">
<div class="ps-arch-node bff">
<div class="ps-logo-row">
<img class="ps-logo" src="/logos/docker.svg" alt="Docker" title="Docker"/>
<img class="ps-logo" src="/logos/fastapi.svg" alt="FastAPI" title="FastAPI"/>
<img class="ps-logo" src="/logos/sqlite.svg" alt="SQLite" title="SQLite ops.db"/>
</div>
<strong>BFF · OME Cockpit API</strong>
<span>Docker · FastAPI · secrets · cache · ground AI · :3090</span>
</div>
</div>
<div class="ps-arch-layer bottom">
<div class="ps-arch-node ome">
<div class="ps-logo-row"><img class="ps-logo" src="/logos/ome.svg" alt="OME"/><img class="ps-logo" src="/dell.png" alt="Dell"/></div>
<strong>Dell OME</strong><span>Truth · inventory · warranty</span>
</div>
<div class="ps-arch-node owui">
<div class="ps-logo-row"><img class="ps-logo" src="/logos/docker.svg" alt="Docker"/><img class="ps-logo" src="/logos/openwebui.svg" alt="Open WebUI"/></div>
<strong>Open WebUI</strong><span>OpenManage AI · :3080</span>
</div>
<div class="ps-arch-node vllm">
<div class="ps-logo-row"><img class="ps-logo" src="/logos/vllm.svg" alt="vLLM"/></div>
<strong>vLLM</strong><span>FDE inference host</span>
</div>
</div>
</div>
<div class="ps-arch-legend">
<span class="lg-blue">Blue pulse · app / API traffic</span>
<span class="lg-amber">Amber pulse · BFF spine</span>
<span class="lg-teal">Packets · live request path</span>
</div>
<p class="ps-sub">Browser never holds OME or AI secrets. Docker hosts Cockpit (:3090) and Open WebUI (:3080) on the portal.</p>` ,
},
{
id : "apps" ,
kicker : "Applications" ,
title : "What you use day to day" ,
anim : "rise" ,
html : `
<div class="ps-apps">
<div class="ps-app"><i></i><strong>Live map</strong><span>Fleet topology · power · inspector</span></div>
2026-07-19 00:16:19 +02:00
<div class="ps-app"><i></i><strong>Console wall</strong><span>4 / 8 live iDRAC embeds · drag</span></div>
2026-07-18 21:56:30 +02:00
<div class="ps-app"><i></i><strong>Network</strong><span>Fabric · 42U racks · VLANs</span></div>
<div class="ps-app"><i></i><strong>Reports</strong><span>Compliance · warranty analytics</span></div>
<div class="ps-app"><i></i><strong>Ops desk</strong><span>Admin ↔ Data FDE tickets</span></div>
<div class="ps-app"><i></i><strong>Copilot</strong><span>Grounded chat on Service Tags</span></div>
</div>
2026-07-19 00:16:19 +02:00
<p class="ps-sub">One join key everywhere: <strong>Service Tag</strong>. Power and console actions ride OME → iDRAC — not a second inventory.</p>` ,
},
{
id : "console-wall" ,
kicker : "Remote ops" ,
title : "Console wall · many iDRACs, one screen" ,
anim : "zoom" ,
html : `
<p class="ps-lead">Operators asked to <strong>see</strong> systems without tab-hopping. The Console tab is a live iDRAC wall: network-aware, draggable, and embedded through Cockpit so browsers are not blocked by <code>X-Frame-Options</code>.</p>
<div class="ps-grid3">
<div class="ps-card"><h4>4 or 8 screens</h4><p>Pick wall size · Small / Medium density · Fullscreen popup per tile.</p></div>
<div class="ps-card"><h4>Drag & + Wall</h4><p>Fleet rail grouped by VLAN · drop onto slots · Auto-fill live BMCs.</p></div>
<div class="ps-card"><h4>Network badges</h4><p>VLAN · subnet · LIVE/OFF on every tile — know where you are looking.</p></div>
<div class="ps-card"><h4>Same-origin proxy</h4><p><code>/api/idrac-proxy/{id}/…</code> strips framing headers · WebSocket bridge for HTML5 console.</p></div>
<div class="ps-card"><h4>Real iDRACs only</h4><p>OOB / BMC endpoints — never bare OS host IPs that cannot speak iDRAC.</p></div>
<div class="ps-card"><h4>Still OME truth</h4><p>Device list and connectivity come from the live fleet snapshot.</p></div>
</div>` ,
},
{
id : "power-idrac" ,
kicker : "Remote ops" ,
title : "Power on / off · without leaving the map" ,
anim : "rise" ,
html : `
<p class="ps-lead">Offline servers with POWER N/A are common in the lab. From Servers KPI, inspector, or Quick Connect you can submit an OME <strong>POWER_CONTROL</strong> job — on, graceful off, or cycle — with confirm for destructive actions.</p>
<div class="ps-seq">
<div class="ps-seq-step"><span>01</span><strong>Select</strong><p>Server / iDRAC on map or KPI list</p></div>
<div class="ps-seq-step"><span>02</span><strong>Power</strong><p>⏻ On · Off · Cycle via OME JobService</p></div>
<div class="ps-seq-step"><span>03</span><strong>Console</strong><p>iDRAC HTML5 in-panel (proxied)</p></div>
<div class="ps-seq-step"><span>04</span><strong>Verify</strong><p>Next fleet poll updates powered state</p></div>
<div class="ps-seq-step"><span>05</span><strong>Escalate</strong><p>Ops desk ticket if change needs a trail</p></div>
</div>
<p class="ps-sub">Demo power with care — production change windows still belong in official OME / iDRAC process.</p>` ,
2026-07-18 21:56:30 +02:00
},
{
id : "ai" ,
kicker : "AI + OME" ,
title : "AI that starts from OME facts" ,
anim : "slide" ,
html : `
2026-07-19 00:16:19 +02:00
<p class="ps-lead">Ask → Cockpit enriches with live OME context → Open WebUI and/or vLLM → answer → act in map, reports, Console, or Ops.</p>
2026-07-18 21:56:30 +02:00
<div class="ps-seq">
<div class="ps-seq-step"><span>01</span><strong>Ask</strong><p>Fleet question in Copilot</p></div>
<div class="ps-seq-step"><span>02</span><strong>Enrich</strong><p>BFF attaches Service Tag facts</p></div>
<div class="ps-seq-step"><span>03</span><strong>Route</strong><p>Open WebUI / vLLM completion</p></div>
<div class="ps-seq-step"><span>04</span><strong>Answer</strong><p>Still tied to the live map</p></div>
2026-07-19 00:16:19 +02:00
<div class="ps-seq-step"><span>05</span><strong>Act</strong><p>Inspector · console · ticket</p></div>
2026-07-18 21:56:30 +02:00
</div>
<p class="ps-sub"><strong>OME for truth · AI for speed · Cockpit for the operator experience.</strong></p>` ,
},
{
id : "origin" ,
kicker : "How this started" ,
title : "Admins set the need · Data FDEs delivered the surface" ,
anim : "rise" ,
html : `
<p class="ps-lead"><strong>Jody van Dongen</strong> and <strong>Laurens Rammers</strong> defined the operational need and asked the FDE team to help. Together they produced a cockpit that sits on OME: topology, reports, network context, tickets, and AI — for ATC use and customer conversations, explicitly not a product SKU.</p>
<div class="ps-cols">
<div>
<h4>ATC Admins · the ask</h4>
<ul class="ps-bullets ps-stagger">
<li><strong>Jody van Dongen</strong> — fleet, racks, warranty / compliance</li>
<li><strong>Laurens Rammers</strong> — datacenter ops & escalation</li>
<li>Better visibility and faster OME storytelling</li>
</ul>
</div>
<div>
<h4>Data FDEs · the delivery</h4>
<ul class="ps-bullets ps-stagger">
<li><strong>Mohamed El Kadi</strong> — cockpit UX/API, OME integration, Copilot, Present</li>
<li><strong>Bart Sjerps</strong> — FDE cluster, Open WebUI / AI workloads, hosting</li>
</ul>
</div>
</div>
<p class="ps-sub">Engineered against live OME data; refined for demos and daily operations.</p>` ,
},
{
id : "benefits" ,
kicker : "Takeaways" ,
title : "What customers walk away with" ,
anim : "rise" ,
html : `
<div class="ps-grid3">
<div class="ps-card"><h4>Clarity</h4><p>One place for connectivity, compliance, warranty, and hardware depth.</p></div>
<div class="ps-card"><h4>Credibility</h4><p>Claims trace to a Service Tag from OME.</p></div>
<div class="ps-card"><h4>Speed</h4><p>AI drafts explanations; humans own change windows.</p></div>
<div class="ps-card"><h4>Collaboration</h4><p>Admins and Data FDEs share one Ops language.</p></div>
<div class="ps-card"><h4>Pattern</h4><p>OME + Docker BFF + grounded AI — repeatable.</p></div>
<div class="ps-card"><h4>Honesty</h4><p>Demo power with an explicit non-product disclaimer.</p></div>
</div>` ,
},
{
id : "close" ,
kicker : "Next steps" ,
title : "See it live" ,
anim : "zoom" ,
html : `
<ol class="ps-steps ps-stagger">
<li>Pick a Service Tag on the map and open the inspector.</li>
2026-07-19 00:16:19 +02:00
<li>Open <strong>Console</strong> — Auto-fill live iDRACs (4 or 8 screens).</li>
<li>From Servers KPI: Power on a cold node · open iDRAC console in-panel.</li>
2026-07-18 21:56:30 +02:00
<li>Open Reports · Analytics and click a colored segment.</li>
<li>Ask Copilot a question that must cite Service Tags.</li>
<li>Switch to <strong>Technical architecture</strong> for API and trust-boundary depth.</li>
</ol>
<p class="ps-sub">Not an official Dell product — verify production decisions in OME. Esc closes · F fullscreen.</p>` ,
},
];
const TECH_SLIDES = [
{
id : "disclaimer" ,
kicker : "Important notice" ,
title : "Disclaimer — please read first" ,
anim : "fade" ,
html : `
<div class="ps-legal">
<p><strong>This OpenManage Cockpit is not an official Dell Technologies product.</strong>
Technical details describe an ATC demonstration architecture — not a supported Dell reference design SKU.</p>
<ul>
<li>Not endorsed or supported by Dell as a shipping product.</li>
<li>Verify production decisions in official OME / iDRAC tooling.</li>
<li>By continuing you accept Dell has no obligation regarding this cockpit.</li>
</ul>
<p class="ps-legal-ack">Press <kbd>→</kbd> for the technical deep dive.</p>
</div>` ,
},
{
id : "tech-agenda" ,
kicker : "Technical deck" ,
title : "Architecture for engineers" ,
anim : "rise" ,
html : `
<p class="ps-lead">How OME, the Cockpit BFF, Open WebUI, and vLLM are wired — and why that wiring keeps AI grounded.</p>
<ol class="ps-steps ps-stagger">
<li>Reference design with logos & pulse paths</li>
<li>Why a BFF (secrets, normalize, ground AI)</li>
<li>OME session / cache behaviour</li>
<li>AI completion path</li>
<li>Portal containers & key <code>/api/*</code> contracts</li>
2026-07-19 00:16:19 +02:00
<li>iDRAC proxy · power jobs · Console wall</li>
2026-07-18 21:56:30 +02:00
</ol>
<p class="ps-sub">Delivered for ATC admins Jody & Laurens by Data FDEs Mo & Bart.</p>` ,
},
{
id : "arch-design" ,
kicker : "Architecture design" ,
title : "Reference design · containers & pulse paths" ,
anim : "zoom" ,
html : `
<p class="ps-lead">Thin UI apps over a FastAPI BFF in Docker. OME is hardware truth. Open WebUI / vLLM are the AI plane.</p>
<div class="ps-arch ps-arch-logos" aria-label="Architecture design">
<svg class="ps-arch-svg" viewBox="0 0 920 420" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient id="archLineBlue" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#0076ce" stop-opacity="0.2"/>
<stop offset="50%" stop-color="#4db8ff" stop-opacity="1"/>
<stop offset="100%" stop-color="#7ef0d8" stop-opacity="0.35"/>
</linearGradient>
<linearGradient id="archLineAmber" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ff9a3c" stop-opacity="0.25"/>
<stop offset="50%" stop-color="#ffb84d" stop-opacity="1"/>
<stop offset="100%" stop-color="#ff9a3c" stop-opacity="0.25"/>
</linearGradient>
<filter id="archGlow"><feGaussianBlur stdDeviation="2.8" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
</defs>
<path class="arch-pulse arch-p1" filter="url(#archGlow)" stroke="url(#archLineAmber)" d="M460 58 L460 360"/>
<path class="arch-pulse arch-p2" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 250 C 360 250, 250 250, 160 310"/>
<path class="arch-pulse arch-p3" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 250 C 460 270, 460 290, 460 330"/>
<path class="arch-pulse arch-p4" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 250 C 560 250, 670 250, 760 310"/>
<path class="arch-pulse arch-p5" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M160 150 C 250 170, 360 190, 460 200"/>
<path class="arch-pulse arch-p6" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M300 150 C 360 170, 410 185, 460 200"/>
<path class="arch-pulse arch-p7" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 150 L460 200"/>
<path class="arch-pulse arch-p8" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M620 150 C 560 170, 510 185, 460 200"/>
<path class="arch-pulse arch-p9" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M760 150 C 670 170, 560 190, 460 200"/>
<circle class="arch-dot d1" r="4" cx="460" cy="80"><animate attributeName="cy" values="70;230;70" dur="2.4s" repeatCount="indefinite"/></circle>
<circle class="arch-dot d2" r="3.5" cx="300" cy="170" style="fill:#7ef0d8"><animate attributeName="cx" values="200;430;200" dur="2.8s" repeatCount="indefinite"/><animate attributeName="cy" values="155;195;155" dur="2.8s" repeatCount="indefinite"/></circle>
<circle class="arch-dot d3" r="3.5" cx="620" cy="170" style="fill:#ffb84d"><animate attributeName="cx" values="720;490;720" dur="2.6s" repeatCount="indefinite"/><animate attributeName="cy" values="155;195;155" dur="2.6s" repeatCount="indefinite"/></circle>
<circle class="arch-dot d4" r="3.5" cx="300" cy="290" style="fill:#4db8ff"><animate attributeName="cx" values="460;180;460" dur="3s" repeatCount="indefinite"/><animate attributeName="cy" values="250;310;250" dur="3s" repeatCount="indefinite"/></circle>
<circle class="arch-dot d5" r="3.5" cx="620" cy="290" style="fill:#ff9a3c"><animate attributeName="cx" values="460;740;460" dur="2.9s" repeatCount="indefinite"/><animate attributeName="cy" values="250;310;250" dur="2.9s" repeatCount="indefinite"/></circle>
</svg>
<div class="ps-arch-layer top">
<div class="ps-arch-node operator">
<div class="ps-logo-row"><img class="ps-logo" src="/logos/browser.svg" alt="Browser"/></div>
<strong>Operator</strong><span>Browser · HTTPS · no secrets</span>
</div>
</div>
<div class="ps-arch-layer apps">
<div class="ps-arch-node app"><strong>Map</strong><span>Topology</span></div>
2026-07-19 00:16:19 +02:00
<div class="ps-arch-node app"><strong>Console</strong><span>iDRAC wall</span></div>
2026-07-18 21:56:30 +02:00
<div class="ps-arch-node app core-app"><strong>Cockpit UI</strong><span>Drawers · KPIs</span></div>
<div class="ps-arch-node app"><strong>Network</strong><span>Fabric · Racks</span></div>
<div class="ps-arch-node app"><strong>Ops / AI</strong><span>Tickets · Copilot</span></div>
</div>
<div class="ps-arch-layer mid">
<div class="ps-arch-node bff">
<div class="ps-logo-row">
<img class="ps-logo" src="/logos/docker.svg" alt="Docker" title="Docker"/>
<img class="ps-logo" src="/logos/fastapi.svg" alt="FastAPI" title="FastAPI"/>
<img class="ps-logo" src="/logos/sqlite.svg" alt="SQLite" title="SQLite ops.db"/>
</div>
<strong>BFF · OME Cockpit API</strong>
<span>Docker · FastAPI · secrets · cache · ground AI · :3090</span>
</div>
</div>
<div class="ps-arch-layer bottom">
<div class="ps-arch-node ome">
<div class="ps-logo-row"><img class="ps-logo" src="/logos/ome.svg" alt="OME"/><img class="ps-logo" src="/dell.png" alt="Dell"/></div>
<strong>Dell OME</strong><span>Truth · inventory · warranty</span>
</div>
<div class="ps-arch-node owui">
<div class="ps-logo-row"><img class="ps-logo" src="/logos/docker.svg" alt="Docker"/><img class="ps-logo" src="/logos/openwebui.svg" alt="Open WebUI"/></div>
<strong>Open WebUI</strong><span>OpenManage AI · :3080</span>
</div>
<div class="ps-arch-node vllm">
<div class="ps-logo-row"><img class="ps-logo" src="/logos/vllm.svg" alt="vLLM"/></div>
<strong>vLLM</strong><span>FDE inference host</span>
</div>
</div>
</div>
<div class="ps-arch-legend">
<span class="lg-blue">Blue pulse · app / API traffic</span>
<span class="lg-amber">Amber pulse · BFF spine</span>
<span class="lg-teal">Packets · live request path</span>
</div>` ,
},
{
id : "why-bff" ,
kicker : "Design choice" ,
title : "Why the BFF sits in the middle" ,
anim : "rise" ,
html : `
<p class="ps-lead">The browser must never hold OME passwords. The Cockpit API is the only component allowed to call AI with enriched Service Tag context.</p>
<div class="ps-grid3">
<div class="ps-card tech"><h4>Normalize</h4><p>OME JSON → stable fleet nodes, NIC PortIds, compliance rows.</p></div>
<div class="ps-card tech"><h4>Cache</h4><p>Fleet & report caches keep demos responsive.</p></div>
<div class="ps-card tech"><h4>Ground AI</h4><p>Inject facts before completions — no invented BIOS levels.</p></div>
</div>
<ul class="ps-why-list">
<li><b>1</b><span><strong>Security</strong> — secrets stay on the portal host.</span></li>
<li><b>2</b><span><strong>Clarity</strong> — one Service Tag model for every app.</span></li>
<li><b>3</b><span><strong>Speed</strong> — cache for live briefings.</span></li>
<li><b>4</b><span><strong>Trust</strong> — AI only speaks from radar (OME) data.</span></li>
</ul>` ,
},
{
id : "ome-plane" ,
kicker : "Data plane · OME" ,
title : "OME remains the only hardware truth" ,
anim : "slide" ,
html : `
<div class="ps-cols">
<div>
<h4>Session lifecycle</h4>
<ol class="ps-steps ps-stagger">
<li><code>POST /api/SessionService/Sessions</code> → token</li>
<li>Devices · power · baselines · warranties</li>
<li>InventoryDetails (memory, NICs, disks, …)</li>
<li>DELETE session when the batch ends</li>
</ol>
</div>
<div>
<h4>Cached for the UI</h4>
<ul class="ps-bullets ps-stagger">
<li><code>/api/fleet</code> — topology + ConnectionState</li>
<li>Compliance / warranty / expansion</li>
<li>Reports vs Dell Online Baseline</li>
<li>Service Tag index for Copilot</li>
</ul>
</div>
</div>
<p class="ps-sub">Rule: if OME did not return it, UI and Copilot must not invent it.</p>` ,
},
{
id : "ai-plane" ,
kicker : "AI plane" ,
title : "Copilot → Open WebUI / vLLM" ,
anim : "slide" ,
html : `
<div class="ps-seq">
<div class="ps-seq-step"><span>01</span><strong>Browser</strong><p>Copilot drawer</p></div>
<div class="ps-seq-step"><span>02</span><strong>/api/chat</strong><p>BFF + OME facts</p></div>
<div class="ps-seq-step"><span>03a</span><strong>Open WebUI</strong><p>Docker · :3080</p></div>
<div class="ps-seq-step"><span>03b</span><strong>vLLM</strong><p>OpenAI-compatible</p></div>
<div class="ps-seq-step"><span>04</span><strong>Reply</strong><p>Back to the drawer</p></div>
</div>
<p class="ps-sub">Two experiences on purpose: grounded Copilot inside Cockpit, and full OpenManage AI (Open WebUI) beside it.</p>` ,
},
{
id : "portal-host" ,
kicker : "Deployment" ,
title : "What runs on the portal" ,
anim : "slide" ,
html : `
<div class="ps-deploy">
<div class="ps-deploy-card">
<div class="ps-logo-row"><img class="ps-logo lg" src="/logos/docker.svg" alt="Docker"/><img class="ps-logo lg" src="/logos/fastapi.svg" alt="FastAPI"/></div>
<h4>ome-cockpit</h4>
<p>Static UI + FastAPI BFF · <code>:3090</code> · ops.db volume</p>
</div>
<div class="ps-deploy-card">
<div class="ps-logo-row"><img class="ps-logo lg" src="/logos/docker.svg" alt="Docker"/><img class="ps-logo lg" src="/logos/openwebui.svg" alt="Open WebUI"/></div>
<h4>openmanage</h4>
<p>Open WebUI · OpenManage AI · <code>:3080</code></p>
</div>
<div class="ps-deploy-card">
<div class="ps-logo-row"><img class="ps-logo lg" src="/logos/ome.svg" alt="OME"/><img class="ps-logo lg" src="/dell.png" alt="Dell"/></div>
<h4>OME appliance</h4>
<p>HTTPS management plane · session API</p>
</div>
<div class="ps-deploy-card">
<div class="ps-logo-row"><img class="ps-logo lg" src="/logos/vllm.svg" alt="vLLM"/></div>
<h4>vLLM (optional)</h4>
<p>FDE inference · OpenAI schema</p>
</div>
</div>
<p class="ps-sub">Users hit the portal; the portal speaks to OME and AI backends.</p>` ,
},
{
id : "apis" ,
kicker : "API surface" ,
title : "Contracts behind the apps" ,
anim : "rise" ,
html : `
<div class="ps-grid3">
<div class="ps-card tech"><h4>/api/fleet</h4><p>Cached device graph for canvas & KPIs.</p></div>
2026-07-19 00:16:19 +02:00
<div class="ps-card tech"><h4>/api/devices/…/power</h4><p>OME JobService POWER_CONTROL · on / off / cycle.</p></div>
<div class="ps-card tech"><h4>/api/idrac-proxy/…</h4><p>Same-origin HTML + WebSocket bridge · strips XFO.</p></div>
2026-07-18 21:56:30 +02:00
<div class="ps-card tech"><h4>/api/reports/*</h4><p>Analytics, firmware, warranty, brief.</p></div>
<div class="ps-card tech"><h4>/api/chat · /api/models</h4><p>Grounded completions · model list.</p></div>
<div class="ps-card tech"><h4>/api/network/*</h4><p>Fabric ports · racks · VLANs.</p></div>
</div>` ,
},
2026-07-19 00:16:19 +02:00
{
id : "idrac-proxy" ,
kicker : "Remote console" ,
title : "Why the iDRAC proxy exists" ,
anim : "slide" ,
html : `
<p class="ps-lead">Browsers refuse to iframe most iDRAC UIs (<code>X-Frame-Options: SAMEORIGIN|DENY</code>). Cockpit proxies the session so the Console wall and in-panel viewer stay on <code>:3090</code>.</p>
<div class="ps-grid3">
<div class="ps-card tech"><h4>HTTP reverse proxy</h4><p>Gzip passthrough · cookie Path rewrite · CSP <code>frame-ancestors *</code>.</p></div>
<div class="ps-card tech"><h4>JS bootstrap</h4><p>Patches fetch / XHR / WebSocket so absolute <code>/sysmgmt</code> paths stay on the proxy prefix.</p></div>
<div class="ps-card tech"><h4>SSRF guard</h4><p>Only fleet management IPs · prefers <code>idrac_ip</code> · rejects bare OS hosts.</p></div>
</div>
<p class="ps-sub">Operators still authenticate to iDRAC with their own credentials inside the embed.</p>` ,
},
2026-07-18 21:56:30 +02:00
{
id : "trust" ,
kicker : "Trust & data" ,
title : "Boundaries to call out" ,
anim : "fade" ,
html : `
<div class="ps-cols">
<div>
2026-07-19 00:16:19 +02:00
<h4>From OME (read + jobs)</h4>
2026-07-18 21:56:30 +02:00
<ul class="ps-bullets ps-stagger">
2026-07-19 00:16:19 +02:00
<li>Devices, power samples, inventory, warranty, baselines</li>
<li>DeviceAction power jobs (on / off / cycle) when operators confirm</li>
2026-07-18 21:56:30 +02:00
</ul>
<h4 style="margin-top:0.85rem">Cockpit-local (write)</h4>
<ul class="ps-bullets ps-stagger">
<li>Fabric / racks / tickets / presentation decks</li>
</ul>
</div>
<div>
<h4>Security notes</h4>
<ul class="ps-bullets ps-stagger">
<li>Secrets only in portal environment variables</li>
2026-07-19 00:16:19 +02:00
<li>iDRAC proxy is an ops convenience — credentials stay with the user</li>
<li>Production change windows still belong in official OME / iDRAC process</li>
2026-07-18 21:56:30 +02:00
</ul>
</div>
</div>` ,
},
{
id : "close" ,
kicker : "Next steps" ,
title : "Back to the live estate" ,
anim : "zoom" ,
html : `
<ol class="ps-steps ps-stagger">
<li>Return to the map and pick a Service Tag.</li>
2026-07-19 00:16:19 +02:00
<li>Open <strong>Console</strong> and drop two iDRACs onto the wall.</li>
2026-07-18 21:56:30 +02:00
<li>Open Reports and click a compliance segment.</li>
<li>Ask Copilot a question that must cite that ST.</li>
<li>Use <strong>Customer story</strong> for business / ops audiences.</li>
</ol>
<p class="ps-sub">Not an official Dell product. Esc closes · F fullscreen.</p>` ,
},
];
2026-07-19 00:16:19 +02:00
const BUILTIN_VERSION = 9 ;
2026-07-18 21:56:30 +02:00
const BUILTIN_DECKS = [
{
id : "story" ,
name : "Customer story" ,
2026-07-19 00:16:19 +02:00
description : "Customer briefing — ask, Console wall, power, architecture, value" ,
2026-07-18 21:56:30 +02:00
builtin : true ,
slides : structuredClone ( STORY_SLIDES ),
},
{
id : "technical" ,
name : "Technical architecture" ,
description : "Engineer deep dive — design, BFF, APIs, trust" ,
builtin : true ,
slides : structuredClone ( TECH_SLIDES ),
},
];
let store = loadStore ();
let activeId = store . activeId || "story" ;
let slides = currentDeck (). slides ;
let index = 0 ;
let mode = "mid" ;
let theme = (() => {
try {
if ( ! localStorage . getItem ( "present_theme_migrated_v1" )) {
localStorage . setItem ( "cockpit_present_theme" , "dark" );
localStorage . setItem ( "present_theme_migrated_v1" , "1" );
}
} catch ( _ ) {}
return localStorage . getItem ( "cockpit_present_theme" ) || "dark" ;
})();
let editing = false ;
let open = false ;
function builtinTemplate ( id ) {
return BUILTIN_DECKS . find (( d ) => d . id === id ) || null ;
}
function defaultStore () {
return {
activeId : "story" ,
decks : BUILTIN_DECKS . map (( d ) => ({
id : d . id ,
name : d . name ,
description : d . description ,
builtin : true ,
version : BUILTIN_VERSION ,
slides : structuredClone ( d . slides ),
})),
};
}
function loadStore () {
try {
const raw = JSON . parse ( localStorage . getItem ( STORE_KEY ) || "null" );
if ( raw && Array . isArray ( raw . decks ) && raw . decks . length ) {
// ensure builtin decks exist (merge missing)
const byId = new Map ( raw . decks . map (( d ) => [ d . id , d ]));
for ( const b of BUILTIN_DECKS ) {
if ( ! byId . has ( b . id )) {
raw . decks . unshift ({
id : b . id ,
name : b . name ,
description : b . description ,
builtin : true ,
version : BUILTIN_VERSION ,
slides : structuredClone ( b . slides ),
});
} else {
const cur = byId . get ( b . id );
cur . builtin = true ;
cur . name = b . name ;
cur . description = b . description ;
if (( cur . version || 0 ) < BUILTIN_VERSION ) {
cur . slides = structuredClone ( b . slides );
cur . version = BUILTIN_VERSION ;
}
}
}
if ( ! raw . activeId || ! raw . decks . some (( d ) => d . id === raw . activeId )) {
raw . activeId = raw . decks [ 0 ]. id ;
}
return raw ;
}
} catch ( _ ) {}
// migrate old single-deck keys if present
try {
for ( const k of [ "cockpit_present_slides_v2" , "cockpit_present_slides_v1" ]) {
const old = JSON . parse ( localStorage . getItem ( k ) || "null" );
if ( Array . isArray ( old ) && old . length ) {
const s = defaultStore ();
s . decks . find (( d ) => d . id === "technical" ). slides = old ;
s . activeId = "technical" ;
return s ;
}
}
} catch ( _ ) {}
return defaultStore ();
}
function saveStore () {
// sync current slides into store
const deck = store . decks . find (( d ) => d . id === activeId );
if ( deck ) deck . slides = slides ;
store . activeId = activeId ;
localStorage . setItem ( STORE_KEY , JSON . stringify ( store ));
}
function currentDeck () {
return store . decks . find (( d ) => d . id === activeId ) || store . decks [ 0 ];
}
function selectDeck ( id ) {
if ( ! store . decks . some (( d ) => d . id === id )) return ;
// persist current
const prev = store . decks . find (( d ) => d . id === activeId );
if ( prev ) prev . slides = slides ;
activeId = id ;
slides = currentDeck (). slides ;
index = 0 ;
editing = false ;
saveStore ();
render ();
}
function createDeck () {
const name = prompt ( "New deck name" , "Custom deck" );
if ( ! name ) return ;
const id = `custom- ${ Date . now () } ` ;
const deck = {
id ,
name : name . trim (). slice ( 0 , 64 ),
description : "Custom presentation" ,
builtin : false ,
slides : [
{
id : "slide-1" ,
kicker : "Custom" ,
title : name . trim (). slice ( 0 , 64 ),
anim : "slide" ,
html : '<p class="ps-lead">Write your first slide. Use Edit to change content.</p>' ,
},
],
};
store . decks . push ( deck );
selectDeck ( id );
}
function duplicateDeck () {
const src = currentDeck ();
const id = `custom- ${ Date . now () } ` ;
store . decks . push ({
id ,
name : ` ${ src . name } (copy)` . slice ( 0 , 64 ),
description : src . description || "" ,
builtin : false ,
slides : structuredClone ( src . slides ),
});
selectDeck ( id );
}
function deleteDeck () {
const d = currentDeck ();
if ( d . builtin ) {
alert ( "Built-in decks cannot be deleted. Reset restores their default slides." );
return ;
}
if ( store . decks . length <= 1 ) {
alert ( "Keep at least one deck." );
return ;
}
if ( ! confirm ( `Delete deck “ ${ d . name } ”?` )) return ;
store . decks = store . decks . filter (( x ) => x . id !== d . id );
activeId = store . decks [ 0 ]. id ;
slides = currentDeck (). slides ;
index = 0 ;
saveStore ();
render ();
}
function resetCurrentDeck () {
const d = currentDeck ();
const tpl = builtinTemplate ( d . id );
if ( tpl ) {
if ( ! confirm ( `Reset “ ${ d . name } ” to the built-in default slides?` )) return ;
d . slides = structuredClone ( tpl . slides );
d . name = tpl . name ;
d . description = tpl . description ;
d . version = BUILTIN_VERSION ;
} else {
if ( ! confirm ( `Clear custom deck “ ${ d . name } ” to a single blank slide?` )) return ;
d . slides = [
{
id : "slide-1" ,
kicker : "Custom" ,
title : d . name ,
anim : "slide" ,
html : '<p class="ps-lead">Blank deck — edit this slide.</p>' ,
},
];
}
slides = d . slides ;
index = 0 ;
editing = false ;
saveStore ();
render ();
}
function escapeHtml ( s ) {
return String ( s ?? "" )
. replace ( /&/g , "&" )
. replace ( /</g , "<" )
. replace ( />/g , ">" )
. replace ( /"/g , """ );
}
function fileToDataUrl ( file ) {
return new Promise (( resolve , reject ) => {
const r = new FileReader ();
r . onload = () => resolve ( r . result );
r . onerror = () => reject ( new Error ( "Failed to read " + file . name ));
r . readAsDataURL ( file );
});
}
async function loadJsZip () {
if ( window . JSZip ) return window . JSZip ;
await new Promise (( resolve , reject ) => {
const s = document . createElement ( "script" );
s . src = "https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js" ;
s . onload = resolve ;
s . onerror = () => reject ( new Error ( "Could not load JSZip for PPTX import" ));
document . head . appendChild ( s );
});
return window . JSZip ;
}
async function slidesFromPptx ( file ) {
const JSZip = await loadJsZip ();
const zip = await JSZip . loadAsync ( file );
const media = Object . keys ( zip . files )
. filter (( p ) => /^ ppt \ / media \ //i.test(p) && !zip.files[p].dir)
. sort (( a , b ) => a . localeCompare ( b , undefined , { numeric : true }));
const out = [];
let n = 0 ;
for ( const path of media ) {
const lower = path . toLowerCase ();
if ( ! /\.(png|jpe?g|gif|webp|bmp)$/i . test ( lower )) continue ;
n += 1 ;
const blob = await zip . files [ path ]. async ( "blob" );
const type = blob . type || ( lower . endsWith ( ".png" ) ? "image/png" : "image/jpeg" );
const dataUrl = await new Promise (( resolve , reject ) => {
const r = new FileReader ();
r . onload = () => resolve ( r . result );
r . onerror = reject ;
r . readAsDataURL ( new Blob ([ blob ], { type }));
});
out . push ({
id : `pptx- ${ Date . now () } - ${ n } ` ,
kicker : "Imported · PPTX" ,
title : `Slide ${ n } ` ,
anim : "fade" ,
layout : "image" ,
image : dataUrl ,
html : "" ,
});
}
if ( ! out . length ) {
throw new Error ( "No images found inside this PPTX. Export slides as images or use a PPTX with embedded pictures." );
}
return out ;
}
async function importMediaFiles ( files ) {
const added = [];
for ( const file of files ) {
const name = ( file . name || "" ). toLowerCase ();
if ( name . endsWith ( ".pptx" ) || file . type . includes ( "presentationml" )) {
added . push (...( await slidesFromPptx ( file )));
} else if ( file . type . startsWith ( "image/" ) || /\.(png|jpe?g|gif|webp|bmp)$/i . test ( name )) {
const dataUrl = await fileToDataUrl ( file );
added . push ({
id : `img- ${ Date . now () } - ${ added . length } ` ,
kicker : "Imported · image" ,
title : file . name . replace ( /\.[^.]+$/ , "" ),
anim : "fade" ,
layout : "image" ,
image : dataUrl ,
html : "" ,
});
}
}
if ( ! added . length ) throw new Error ( "No supported files (use .pptx or images)." );
slides . push (... added );
index = slides . length - added . length ;
saveStore ();
render ();
}
function ensureDom () {
if ( $ ( "#present-root" )) return ;
const root = document . createElement ( "div" );
root . id = "present-root" ;
root . className = "present-root hidden" ;
root . setAttribute ( "aria-hidden" , "true" );
root . innerHTML = `
<div class="present-scrim" data-present-close></div>
<div class="present-stage theme-dark" id="present-stage" role="dialog" aria-modal="true" aria-labelledby="present-title">
<div class="present-ai-bg" aria-hidden="true">
<div class="present-ai-aurora"></div>
<div class="present-ai-grid"></div>
<div class="present-ai-mesh"></div>
<div class="present-ai-orbs">
<span class="orb o1"></span>
<span class="orb o2"></span>
<span class="orb o3"></span>
<span class="orb o4"></span>
</div>
<div class="present-ai-particles">
<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
</div>
<svg class="present-pulse-svg" viewBox="0 0 1100 780" preserveAspectRatio="none">
<defs>
<linearGradient id="pgCyan" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#0076ce" stop-opacity="0"/>
<stop offset="35%" stop-color="#4db8ff" stop-opacity="1"/>
<stop offset="70%" stop-color="#7ef0d8" stop-opacity="1"/>
<stop offset="100%" stop-color="#0076ce" stop-opacity="0"/>
</linearGradient>
<linearGradient id="pgAmber" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ff9a3c" stop-opacity="0"/>
<stop offset="40%" stop-color="#ffb84d" stop-opacity="1"/>
<stop offset="100%" stop-color="#ff9a3c" stop-opacity="0"/>
</linearGradient>
<linearGradient id="pgTeal" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#3dffe0" stop-opacity="0"/>
<stop offset="50%" stop-color="#3dffe0" stop-opacity="1"/>
<stop offset="100%" stop-color="#00a8e8" stop-opacity="0"/>
</linearGradient>
<filter id="pgGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3.5" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<path class="pl pl1" filter="url(#pgGlow)" stroke="url(#pgCyan)" d="M-20 140 C 180 40, 360 220, 540 110 S 860 40, 1120 180"/>
<path class="pl pl2" filter="url(#pgGlow)" stroke="url(#pgTeal)" d="M-40 360 C 200 280, 340 460, 560 340 S 820 260, 1140 420"/>
<path class="pl pl3" filter="url(#pgGlow)" stroke="url(#pgAmber)" d="M-10 580 C 240 500, 420 680, 650 560 S 920 480, 1120 620"/>
<path class="pl pl4" filter="url(#pgGlow)" stroke="url(#pgCyan)" d="M80 -10 C 200 120, 280 260, 360 400 S 520 640, 640 800"/>
<path class="pl pl5" filter="url(#pgGlow)" stroke="url(#pgAmber)" d="M980 -20 C 900 140, 860 300, 780 460 S 620 680, 540 820"/>
<circle class="pn pn1" cx="180" cy="160" r="3.5"/>
<circle class="pn pn2" cx="540" cy="120" r="4"/>
<circle class="pn pn3" cx="860" cy="200" r="3"/>
<circle class="pn pn4" cx="320" cy="400" r="3.5"/>
<circle class="pn pn5" cx="700" cy="360" r="4"/>
<circle class="pn pn6" cx="480" cy="600" r="3"/>
<circle class="pn pn7" cx="900" cy="560" r="3.5"/>
</svg>
<div class="present-ai-scan"></div>
<div class="present-ai-vignette"></div>
</div>
<div class="present-chrome">
<div class="present-bar present-bar-mainui">
<div class="present-brand">
<img src="/dell.png" alt="" width="28" height="28" />
<div class="present-brand-text">
<strong>OME Cockpit · Present</strong>
<span id="present-progress">1 / 1</span>
</div>
</div>
<div class="present-actions top-actions">
<label class="present-deck-inline" title="Choose presentation deck">
<select id="present-deck" class="present-deck-select"></select>
</label>
<button type="button" class="btn ghost" id="present-mode-mid" title="Mid-screen overlay">Mid</button>
<button type="button" class="btn ghost" id="present-mode-full" title="Fullscreen">Full</button>
<button type="button" class="btn ghost" id="present-theme-light" title="Light theme">Light</button>
<button type="button" class="btn ghost" id="present-theme-dark" title="Dark theme">Dark</button>
<button type="button" class="btn ghost" id="present-edit" title="Edit slide content">Edit</button>
<button type="button" class="btn ghost" id="present-reset" title="Reset this deck to defaults">Reset</button>
<button type="button" class="btn ghost" id="present-export" title="Download deck JSON">Export</button>
<button type="button" class="btn ghost" id="present-import" title="Import slides JSON">Import</button>
<button type="button" class="btn ghost" id="present-add-media" title="Add image slides">Images</button>
<button type="button" class="btn ghost" id="present-deck-new" title="Create a new empty deck">New</button>
<button type="button" class="btn ghost" id="present-deck-dup" title="Duplicate current deck">Duplicate</button>
<button type="button" class="btn ghost" id="present-deck-del" title="Delete custom deck">Delete</button>
<button type="button" class="btn danger" id="present-close" title="Close (Esc)">Close</button>
<input type="file" id="present-import-file" accept="application/json,.json" hidden />
<input type="file" id="present-media-file" accept="image/*,.pptx,application/vnd.openxmlformats-officedocument.presentationml.presentation" multiple hidden />
</div>
</div>
<p class="present-deck-desc" id="present-deck-desc"></p>
</div>
<div class="present-progress-bar"><i id="present-bar"></i></div>
<div class="present-slide-wrap">
<article class="present-slide" id="present-slide"></article>
</div>
<div class="present-nav">
<button type="button" class="btn ghost" id="present-prev">Previous</button>
<div class="present-dots" id="present-dots"></div>
<button type="button" class="btn primary" id="present-next">Next</button>
</div>
<div class="present-edit-panel hidden" id="present-edit-panel">
<label>Kicker <input type="text" id="pe-kicker" /></label>
<label>Title <input type="text" id="pe-title" /></label>
<label>Body (HTML allowed)
<textarea id="pe-html" rows="10"></textarea>
</label>
<div class="present-edit-actions">
<button type="button" class="btn" id="pe-add">Add slide after</button>
<button type="button" class="btn danger" id="pe-del">Delete slide</button>
<button type="button" class="btn primary" id="pe-save">Save changes</button>
</div>
<p class="present-edit-hint">Decks & edits stay in this browser (localStorage). Export JSON to share or back up.</p>
</div>
<footer class="present-credit">OME Cockpit · Data Forward Deployed Engineers Mohamed El Kadi & Bart Sjerps · Not an official Dell product</footer>
</div>` ;
document . body . appendChild ( root );
$ ( "#present-close" , root ) ? . addEventListener ( "click" , closePresent );
root . querySelector ( "[data-present-close]" ) ? . addEventListener ( "click" , closePresent );
$ ( "#present-prev" , root ) ? . addEventListener ( "click" , () => go ( - 1 ));
$ ( "#present-next" , root ) ? . addEventListener ( "click" , () => go ( 1 ));
$ ( "#present-mode-mid" , root ) ? . addEventListener ( "click" , () => setMode ( "mid" ));
$ ( "#present-mode-full" , root ) ? . addEventListener ( "click" , () => setMode ( "full" ));
$ ( "#present-theme-light" , root ) ? . addEventListener ( "click" , () => setTheme ( "light" ));
$ ( "#present-theme-dark" , root ) ? . addEventListener ( "click" , () => setTheme ( "dark" ));
$ ( "#present-edit" , root ) ? . addEventListener ( "click" , toggleEdit );
$ ( "#present-deck" , root ) ? . addEventListener ( "change" , ( e ) => selectDeck ( e . target . value ));
$ ( "#present-deck-new" , root ) ? . addEventListener ( "click" , createDeck );
$ ( "#present-deck-dup" , root ) ? . addEventListener ( "click" , duplicateDeck );
$ ( "#present-deck-del" , root ) ? . addEventListener ( "click" , deleteDeck );
$ ( "#present-reset" , root ) ? . addEventListener ( "click" , resetCurrentDeck );
$ ( "#present-export" , root ) ? . addEventListener ( "click" , () => {
const deck = currentDeck ();
const payload = {
id : deck . id ,
name : deck . name ,
description : deck . description ,
slides ,
};
const blob = new Blob ([ JSON . stringify ( payload , null , 2 )], { type : "application/json" });
const a = document . createElement ( "a" );
a . href = URL . createObjectURL ( blob );
a . download = `ome-cockpit-deck- ${ deck . id } .json` ;
a . click ();
URL . revokeObjectURL ( a . href );
});
$ ( "#present-import" , root ) ? . addEventListener ( "click" , () => $ ( "#present-import-file" ) ? . click ());
$ ( "#present-import-file" , root ) ? . addEventListener ( "change" , async ( e ) => {
const file = e . target . files ? .[ 0 ];
if ( ! file ) return ;
try {
const text = await file . text ();
const data = JSON . parse ( text );
const list = Array . isArray ( data ) ? data : data . slides ;
if ( ! Array . isArray ( list ) || ! list . length ) throw new Error ( "Expected slides array or { slides: [...] }" );
slides = list . map (( s , i ) => ({
id : s . id || `slide- ${ i + 1 } ` ,
kicker : s . kicker || "" ,
title : s . title || `Slide ${ i + 1 } ` ,
html : s . html || s . body || "" ,
anim : s . anim || "slide" ,
}));
if ( data . name && ! currentDeck (). builtin ) currentDeck (). name = String ( data . name ). slice ( 0 , 64 );
if ( data . description && ! currentDeck (). builtin ) currentDeck (). description = String ( data . description );
index = 0 ;
saveStore ();
render ();
} catch ( err ) {
alert ( "Import failed: " + err . message );
}
e . target . value = "" ;
});
$ ( "#present-add-media" , root ) ? . addEventListener ( "click" , () => $ ( "#present-media-file" ) ? . click ());
$ ( "#present-media-file" , root ) ? . addEventListener ( "change" , async ( e ) => {
const files = [...( e . target . files || [])];
if ( ! files . length ) return ;
try {
await importMediaFiles ( files );
} catch ( err ) {
alert ( "Media import failed: " + err . message );
}
e . target . value = "" ;
});
$ ( "#pe-save" , root ) ? . addEventListener ( "click" , () => {
const s = slides [ index ];
if ( ! s ) return ;
s . kicker = $ ( "#pe-kicker" ) ? . value || "" ;
s . title = $ ( "#pe-title" ) ? . value || "" ;
s . html = $ ( "#pe-html" ) ? . value || "" ;
saveStore ();
editing = false ;
render ();
});
$ ( "#pe-add" , root ) ? . addEventListener ( "click" , () => {
slides . splice ( index + 1 , 0 , {
id : `custom- ${ Date . now () } ` ,
kicker : "Custom" ,
title : "New slide" ,
anim : "slide" ,
html : '<p class="ps-lead">Write your content here.</p>' ,
});
index += 1 ;
saveStore ();
editing = true ;
render ();
});
$ ( "#pe-del" , root ) ? . addEventListener ( "click" , () => {
if ( slides . length <= 1 ) {
alert ( "Keep at least one slide." );
return ;
}
if ( ! confirm ( "Delete this slide?" )) return ;
slides . splice ( index , 1 );
index = Math . min ( index , slides . length - 1 );
saveStore ();
render ();
});
}
function renderDeckSelect () {
const sel = $ ( "#present-deck" );
if ( ! sel ) return ;
sel . innerHTML = store . decks
. map (
( d ) =>
`<option value=" ${ escapeHtml ( d . id ) } " ${ d . id === activeId ? "selected" : "" } > ${ escapeHtml ( d . name ) }${ d . builtin ? "" : " · custom" } </option>`
)
. join ( "" );
const desc = $ ( "#present-deck-desc" );
if ( desc ) desc . textContent = currentDeck (). description || "" ;
$ ( "#present-deck-del" ) ? . toggleAttribute ( "disabled" , !! currentDeck (). builtin );
}
function render () {
ensureDom ();
const root = $ ( "#present-root" );
const stage = $ ( "#present-stage" );
const slideEl = $ ( "#present-slide" );
const s = slides [ index ] || slides [ 0 ];
if ( ! s || ! slideEl ) return ;
root . classList . toggle ( "hidden" , ! open );
root . setAttribute ( "aria-hidden" , open ? "false" : "true" );
document . body . classList . toggle ( "present-open" , open );
stage ? . classList . toggle ( "mode-mid" , mode === "mid" );
stage ? . classList . toggle ( "mode-full" , mode === "full" );
stage ? . classList . toggle ( "theme-light" , theme === "light" );
stage ? . classList . toggle ( "theme-dark" , theme === "dark" );
const setBtnState = ( id , on ) => {
const el = $ ( id );
if ( ! el ) return ;
el . classList . toggle ( "primary" , on );
el . classList . toggle ( "ghost" , ! on );
};
setBtnState ( "#present-mode-mid" , mode === "mid" );
setBtnState ( "#present-mode-full" , mode === "full" );
setBtnState ( "#present-theme-light" , theme === "light" );
setBtnState ( "#present-theme-dark" , theme === "dark" );
setBtnState ( "#present-edit" , editing );
renderDeckSelect ();
slideEl . classList . remove ( "enter" , "anim-fade" , "anim-zoom" , "anim-rise" , "anim-slide" );
void slideEl . offsetWidth ;
const anim = s . anim || "slide" ;
if ( s . layout === "image" && s . image ) {
slideEl . classList . add ( "is-image" );
slideEl . innerHTML = `
<div class="ps-image-frame">
<img src=" ${ s . image } " alt=" ${ escapeHtml ( s . title || "Slide" ) } " />
<div class="ps-image-caption">
<p class="ps-kicker"> ${ escapeHtml ( s . kicker || "Imported" ) } </p>
<h2 id="present-title"> ${ escapeHtml ( s . title || "" ) } </h2>
</div>
</div>` ;
} else {
slideEl . classList . remove ( "is-image" );
slideEl . innerHTML = `
<p class="ps-kicker"> ${ escapeHtml ( s . kicker ) } </p>
<h2 id="present-title"> ${ escapeHtml ( s . title ) } </h2>
<div class="ps-body"> ${ s . html } </div>` ;
}
slideEl . classList . add ( "enter" , `anim- ${ anim } ` );
const prog = $ ( "#present-progress" );
if ( prog ) prog . textContent = ` ${ currentDeck (). name } · ${ index + 1 } / ${ slides . length } ` ;
const bar = $ ( "#present-bar" );
if ( bar ) bar . style . width = ` ${ (( index + 1 ) / slides . length ) * 100 } %` ;
const dots = $ ( "#present-dots" );
if ( dots ) {
dots . innerHTML = slides
. map (
( _ , i ) =>
`<button type="button" class="present-dot ${ i === index ? "active" : "" } " data-dot=" ${ i } " aria-label="Go to slide ${ i + 1 } "></button>`
)
. join ( "" );
dots . querySelectorAll ( "[data-dot]" ). forEach (( btn ) => {
btn . addEventListener ( "click" , () => {
index = Number ( btn . dataset . dot );
render ();
});
});
}
$ ( "#present-prev" ). disabled = index <= 0 ;
const nextBtn = $ ( "#present-next" );
if ( nextBtn ) nextBtn . textContent = index >= slides . length - 1 ? "Done" : "Next" ;
const panel = $ ( "#present-edit-panel" );
panel ? . classList . toggle ( "hidden" , ! editing );
$ ( "#present-edit" ) ? . classList . toggle ( "is-on" , editing );
if ( editing ) {
$ ( "#pe-kicker" ). value = s . kicker || "" ;
$ ( "#pe-title" ). value = s . title || "" ;
$ ( "#pe-html" ). value = s . html || "" ;
}
}
function go ( delta ) {
if ( delta > 0 && index >= slides . length - 1 ) {
closePresent ();
return ;
}
index = Math . max ( 0 , Math . min ( slides . length - 1 , index + delta ));
render ();
}
function setTheme ( next ) {
theme = next === "dark" ? "dark" : "light" ;
try { localStorage . setItem ( "cockpit_present_theme" , theme ); } catch ( _ ) {}
render ();
}
async function setMode ( next ) {
mode = next === "full" ? "full" : "mid" ;
const stage = $ ( "#present-stage" );
if ( mode === "full" ) {
try {
if ( stage && ! document . fullscreenElement ) await stage . requestFullscreen ();
} catch ( _ ) {}
} else if ( document . fullscreenElement ) {
try {
await document . exitFullscreen ();
} catch ( _ ) {}
}
render ();
}
function toggleEdit () {
editing = ! editing ;
render ();
}
function openPresent () {
ensureDom ();
open = true ;
index = 0 ;
editing = false ;
mode = "mid" ;
if ( ! localStorage . getItem ( "cockpit_present_theme" )) theme = "dark" ;
slides = currentDeck (). slides ;
render ();
}
function closePresent () {
open = false ;
editing = false ;
saveStore ();
if ( document . fullscreenElement ) {
document . exitFullscreen (). catch (() => {});
}
render ();
}
document . addEventListener ( "fullscreenchange" , () => {
if ( ! open ) return ;
mode = document . fullscreenElement ? "full" : "mid" ;
render ();
});
document . addEventListener ( "keydown" , ( e ) => {
if ( ! open ) return ;
if ( e . key === "Escape" ) {
e . preventDefault ();
closePresent ();
return ;
}
if ( editing && e . target ? . closest ? .( "#present-edit-panel" )) return ;
if ( e . target ? . closest ? .( "#present-deck" )) return ;
if ( e . key === "ArrowRight" || e . key === "PageDown" || e . key === " " ) {
e . preventDefault ();
go ( 1 );
} else if ( e . key === "ArrowLeft" || e . key === "PageUp" ) {
e . preventDefault ();
go ( - 1 );
} else if ( e . key === "f" || e . key === "F" ) {
e . preventDefault ();
setMode ( mode === "full" ? "mid" : "full" );
} else if ( e . key === "e" || e . key === "E" ) {
e . preventDefault ();
toggleEdit ();
}
});
function init () {
$ ( "#btn-present" ) ? . addEventListener ( "click" , openPresent );
window . cockpitPresent = { open : openPresent , close : closePresent , decks : () => store . decks };
}
if ( document . readyState === "loading" ) document . addEventListener ( "DOMContentLoaded" , init );
else init ();
})();