Files
ome-cockpit/ui/present.js
T
root b4714c70d1 Add ATC Team chat, soft identity, and update Present decks.
Ship team/DM chat with files, avatars, unread alerts, and a first-open identity gate (including Guest); fold Team into Ops desk; clarify Datacenter Engineer vs "Data Plumbers" FDE roles; refresh Present story/tech slides to match.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 01:42:00 +02:00

1304 lines
63 KiB
JavaScript

(() => {
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">Datacenter Engineers 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>
<li><strong>Apps</strong> — map, network, reports, Console wall, Ops + Team chat, copilots.</li>
<li><strong>Remote ops</strong> — power via OME→iDRAC and live consoles in-panel.</li>
<li><strong>Team</strong> — soft identity, chat, files, avatars, live unread alerts.</li>
<li><strong>People</strong> — Datacenter Engineers &amp; Data FDEs (<em>"Data Plumbers"</em> 😉).</li>
<li><strong>Value</strong> — what customers can take away.</li>
</ol>
<p class="ps-sub">Requested by <strong>Jody van Dongen</strong> &amp; <strong>Laurens Rammers</strong> · delivered by the FDE team (<strong>Mo</strong> &amp; <strong>Bart</strong> — <em>"Data Plumbers"</em> 😉).</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 (Datacenter Engineers) 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>
<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>
<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>Ops + Team chat</h4><p>Tickets and live team / DM chat with files, avatars, and unread alerts.</p></div>
<div class="ps-card"><h4>DC Engineer ↔ Data FDE</h4><p>Jody &amp; Laurens own the estate; Mo &amp; Bart (<em>"Data Plumbers"</em> 😉) deliver the 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>
<div class="ps-arch-node app"><strong>Console</strong><span>iDRAC wall</span></div>
<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 / Team</strong><span>Tickets · chat</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>
<div class="ps-app"><i></i><strong>Console wall</strong><span>4 / 8 live iDRAC embeds · drag</span></div>
<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 + Team</strong><span>Tickets · chat · files · avatars</span></div>
<div class="ps-app"><i></i><strong>Copilot</strong><span>Grounded chat on Service Tags</span></div>
</div>
<p class="ps-sub">One join key everywhere: <strong>Service Tag</strong>. Soft identity on open so Jody, Laurens, Mo, Bart (or Guest) act as themselves.</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 &amp; + 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>`,
},
{
id: "team-chat",
kicker: "Collaboration",
title: "Team chat · who you are, what you share",
anim: "rise",
html: `
<p class="ps-lead">First popup on open: pick yourself (or Guest). Then Ops desk and Team chat share one soft identity — tickets, DMs, and files stay attributable without SSO.</p>
<div class="ps-grid3">
<div class="ps-card"><h4>Identity gate</h4><p>Jody · Laurens · Mo · Bart · Guest — profile photo upload optional.</p></div>
<div class="ps-card"><h4>Team + DMs</h4><p>Shared ATC room and 1:1 chats · WebSocket live presence.</p></div>
<div class="ps-card"><h4>Files &amp; avatars</h4><p>Drop files into chat · avatars on bubbles, presence, and the Team chip.</p></div>
<div class="ps-card"><h4>Unread alerts</h4><p>Badge on Ops|Team · room counters · browser notify when the tab is in the background.</p></div>
<div class="ps-card"><h4>Ops|Team combo</h4><p>One topbar control: tickets left, Team + who-you-are right — KPIs keep a single row.</p></div>
<div class="ps-card"><h4>Roles that match reality</h4><p>Datacenter Engineers own rack &amp; stack; FDEs are <em>"Data Plumbers"</em> 😉.</p></div>
</div>`,
},
{
id: "ai",
kicker: "AI + OME",
title: "AI that starts from OME facts",
anim: "slide",
html: `
<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>
<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>
<div class="ps-seq-step"><span>05</span><strong>Act</strong><p>Inspector · console · ticket</p></div>
</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: "Datacenter Engineers 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> (Datacenter Engineers — storage, servers, network, rack &amp; stack, installs) 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>Datacenter Engineers · the ask</h4>
<ul class="ps-bullets ps-stagger">
<li><strong>Jody van Dongen</strong> — storage, servers, network, rack &amp; stack, installs</li>
<li><strong>Laurens Rammers</strong> — storage, servers, network, rack &amp; stack, installs</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 · <em>"Data Plumbers"</em> 😉</li>
<li><strong>Bart Sjerps</strong> — FDE cluster, Open WebUI / AI workloads, hosting · <em>"Data Plumbers"</em> 😉</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>Datacenter Engineers and Data FDEs (<em>"Data Plumbers"</em> 😉) share Ops + Team chat.</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 yourself on the identity gate (or Guest) when the UI opens.</li>
<li>Pick a Service Tag on the map and open the inspector.</li>
<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>
<li>Open <strong>Ops|Team</strong> — create a ticket, then send a Team or DM message with a file.</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 &amp; 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 &amp; key <code>/api/*</code> contracts</li>
<li>iDRAC proxy · power jobs · Console wall</li>
<li>Team chat · soft identity · WebSocket · file / avatar store</li>
</ol>
<p class="ps-sub">Delivered for Datacenter Engineers Jody &amp; Laurens by Data FDEs Mo &amp; Bart (<em>"Data Plumbers"</em> 😉).</p>`,
},
{
id: "arch-design",
kicker: "Architecture design",
title: "Reference design · containers &amp; 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>
<div class="ps-arch-node app"><strong>Console</strong><span>iDRAC wall</span></div>
<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 / Team</strong><span>Tickets · chat</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 &amp; 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 &amp; KPIs.</p></div>
<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>
<div class="ps-card tech"><h4>/api/team/* · /ws/team-chat</h4><p>Rooms, DMs, files, avatars · live presence &amp; messages.</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>`,
},
{
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>`,
},
{
id: "trust",
kicker: "Trust &amp; data",
title: "Boundaries to call out",
anim: "fade",
html: `
<div class="ps-cols">
<div>
<h4>From OME (read + jobs)</h4>
<ul class="ps-bullets ps-stagger">
<li>Devices, power samples, inventory, warranty, baselines</li>
<li>DeviceAction power jobs (on / off / cycle) when operators confirm</li>
</ul>
<h4 style="margin-top:0.85rem">Cockpit-local (write)</h4>
<ul class="ps-bullets ps-stagger">
<li>Fabric / racks / tickets / presentation decks</li>
<li>Team chat messages, files, and profile avatars (<code>/data/team-chat</code>)</li>
</ul>
</div>
<div>
<h4>Security notes</h4>
<ul class="ps-bullets ps-stagger">
<li>Secrets only in portal environment variables</li>
<li>Soft identity is localStorage (ops convenience — not SSO)</li>
<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>
</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>
<li>Open <strong>Console</strong> and drop two iDRACs onto the wall.</li>
<li>Open <strong>Ops|Team</strong> — ticket + a chat message with unread badge.</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>`,
},
];
const BUILTIN_VERSION = 10;
const BUILTIN_DECKS = [
{
id: "story",
name: "Customer story",
description: "Customer briefing — Console, power, Team chat, roles, value",
builtin: true,
slides: structuredClone(STORY_SLIDES),
},
{
id: "technical",
name: "Technical architecture",
description: "Engineer deep dive — design, BFF, APIs, Team chat, 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 &amp; 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 &amp; 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();
})();