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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/users/concepts/controlplane-demo.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
sidebar_position: 10
id: controlplane-demo
title: Interactive Demo — ControlPlane
---

import ControlPlaneDemo from '@site/src/components/ControlPlaneDemo';

# Interactive Demo: ControlPlane Lifecycle

Experience how **OpenControlPlane** provisions and manages a `ControlPlane` — entirely in your browser, no cluster required.

This demo walks you through the full lifecycle:

1. **Apply** a `ControlPlane` manifest to the Onboarding Cluster
2. **Watch** the operator reconcile and delegate to the Cluster Provider
3. **See** the new ControlPlane cluster come up
4. **Connect** and start deploying your resources

---

<ControlPlaneDemo />

---

## What just happened?

- You wrote a **single manifest** declaring the desired state of a `ControlPlane`.
- The **openmcp-operator** on the Platform Cluster picked it up and orchestrated cluster creation via the configured Cluster Provider (e.g. Gardener).
- Once ready, a dedicated Kubernetes cluster was spun up — your **ControlPlane** — isolated from other tenants.
- You connected to it directly using `kubectl` with the context written by the operator.

## Next steps

- [ControlPlane CRD reference](/reference/core/controlplane)
- [Getting started guide](/users/getting-started)
- [Configure a Cluster Provider](/operators/overview)
197 changes: 197 additions & 0 deletions src/components/ControlPlaneDemo/ArchDiagram.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import React, { useEffect, useRef } from 'react';
import styles from './styles.module.css';

const COLORS = {
onboarding: '#2CE0BF',
platform: '#60a5fa',
clusterProvider: '#f59e0b',
controlplane: '#a78bfa',
terminal: '#98989f',
};

// Box layout (viewBox 100x70)
// Left col: Onboarding (top), Platform (mid)
// Right col: ControlPlane (tall)
// Bottom: ClusterProvider spans full width
const BOXES = {
onboarding: { x: 2, y: 2, w: 44, h: 22 },
platform: { x: 2, y: 28, w: 44, h: 22 },
clusterProvider: { x: 2, y: 56, w: 96, h: 12 },
controlplane: { x: 54, y: 2, w: 44, h: 48 },
};

function boxCenter(key) {
// 'terminal' is below the diagram — map it to just outside the bottom of onboarding box
if (key === 'terminal') {
const ob = BOXES.onboarding;
return { x: ob.x + ob.w / 2, y: 75 }; // below viewBox bottom (70), ball animates in from below
}
const b = BOXES[key];
if (!b) return { x: 50, y: 35 };
return { x: b.x + b.w / 2, y: b.y + b.h / 2 };
}

// Animated ball as a React component using requestAnimationFrame
function Ball({ from, to, color, onDone }) {
const circleRef = useRef(null);
const startRef = useRef(null);
const DURATION = 900; // ms

useEffect(() => {
const fc = boxCenter(from);
const tc = boxCenter(to);

function frame(ts) {
if (!startRef.current) startRef.current = ts;
const elapsed = ts - startRef.current;
const t = Math.min(elapsed / DURATION, 1);
// ease in-out
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
const cx = fc.x + (tc.x - fc.x) * ease;
const cy = fc.y + (tc.y - fc.y) * ease;
const opacity = t < 0.85 ? 1 : (1 - t) / 0.15;
if (circleRef.current) {
circleRef.current.setAttribute('cx', cx);
circleRef.current.setAttribute('cy', cy);
circleRef.current.setAttribute('opacity', opacity);
}
if (t < 1) {
requestAnimationFrame(frame);
} else {
if (onDone) onDone();
}
}
requestAnimationFrame(frame);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const fc = boxCenter(from);
return (
<circle
ref={circleRef}
cx={fc.x}
cy={fc.y}
r="2"
fill={color}
/>
);
}

function BoxSvg({ id, show, spawned, healthy, applied }) {
if (!show && !spawned) return null;
const b = BOXES[id];
const color = COLORS[id];

return (
<g className={spawned && !show ? styles.svgPopIn : undefined}>
<rect
x={b.x} y={b.y} width={b.w} height={b.h}
rx="2"
fill="#1a1a1f"
stroke={color}
strokeWidth="0.8"
opacity="0.95"
/>
{/* label */}
<text
x={b.x + 2} y={b.y + 5}
fontSize="3"
fontWeight="bold"
fill={color}
style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}
>
{id === 'onboarding' ? 'Onboarding Cluster' :
id === 'platform' ? 'Platform Cluster' :
id === 'clusterProvider' ? 'Cluster Provider' :
'ControlPlane Cluster'}
</text>
{/* CR resource card inside onboarding box after apply */}
{id === 'onboarding' && applied && (
<g className={styles.svgPopIn}>
<rect x={b.x + 2} y={b.y + 8} width={b.w - 4} height="12" rx="1.5" fill="#0d1a14" stroke="#2CE0BF" strokeWidth="0.5" />
<text x={b.x + 4} y={b.y + 12} fontSize="2.2" fill="#2CE0BF" fontWeight="bold">ManagedControlPlane</text>
<text x={b.x + 4} y={b.y + 15.5} fontSize="2.2" fill="#dfdfd6">name: my-control-plane</text>
<text x={b.x + 4} y={b.y + 18.5} fontSize="2" fill="#98989f">namespace: my-workspace</text>
</g>
)}
{id === 'clusterProvider' && (
<>
<text x={b.x + 2} y={b.y + 9} fontSize="2.4" fill="#6a6a71">e.g. Gardener</text>
<TagRect x={b.x + 24} y={b.y + 5.5} label="provisions K8s clusters" />
</>
)}
{/* tags */}
{id === 'onboarding' && (
<>
<TagRect x={b.x + 2} y={b.y + 11} label="kubectl" />
<TagRect x={b.x + 2} y={b.y + 17} label="ManagedControlPlane CR" />
</>
)}
{id === 'platform' && (
<TagRect x={b.x + 2} y={b.y + 11} label="openmcp-operator" />
)}
{id === 'controlplane' && (
<>
<TagRect x={b.x + 2} y={b.y + 11} label="Kubernetes API" />
<TagRect x={b.x + 2} y={b.y + 17} label="your resources" />
{healthy && (
<>
<circle cx={b.x + 3} cy={b.y + 24} r="1.2" fill="#2CE0BF" />
<text x={b.x + 6} y={b.y + 25.5} fontSize="2.5" fill="#2CE0BF" fontWeight="bold">crossplane — healthy</text>
</>
)}
</>
)}
</g>
);
}

function TagRect({ x, y, label }) {
return (
<g>
<rect x={x} y={y} width={label.length * 1.55 + 2} height="4.5" rx="1" fill="#2a2a30" />
<text x={x + 1} y={y + 3.2} fontSize="2.4" fill="#98989f">{label}</text>
</g>
);
}

export default function ArchDiagram({
show = {},
ballStep = -1,
spawnedCP = false,
crossplaneHealthy = false,
ballSequence = [],
applied = false,
}) {
const currentBall = ballStep >= 0 && ballStep < ballSequence.length
? ballSequence[ballStep]
: null;

return (
<div className={styles.archWrapper}>
<svg
viewBox="0 0 100 70"
preserveAspectRatio="none"
style={{ width: '100%', height: '100%', display: 'block' }}
>
<BoxSvg id="clusterProvider" show={show.clusterProvider} spawned={false} applied={applied} />
<BoxSvg id="onboarding" show={show.onboarding} spawned={false} applied={applied} />
<BoxSvg id="platform" show={show.platform} spawned={false} applied={applied} />
<BoxSvg id="controlplane" show={false} spawned={spawnedCP} healthy={crossplaneHealthy} applied={applied} />

{currentBall && (
<Ball
key={ballStep}
from={currentBall.from}
to={currentBall.to}
color={COLORS[currentBall.from] || '#fff'}
/>
)}
</svg>

{currentBall && (
<div className={styles.ballLabel}>{currentBall.label}</div>
)}
</div>
);
}
113 changes: 113 additions & 0 deletions src/components/ControlPlaneDemo/FakeTerminal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React, { useState, useRef, useEffect } from 'react';
import styles from './styles.module.css';

const TARGET = 'kubectl apply -f controlplane.yaml';

export default function FakeTerminal({ onApply, applied, crReady, onReset }) {
const [typed, setTyped] = useState('');
const [submitted, setSubmitted] = useState(false);
const termRef = useRef(null);

// Reset when parent resets
useEffect(() => {
if (!applied) {
setTyped('');
setSubmitted(false);
}
}, [applied]);

function handleKey(e) {
if (submitted) return;

// Prevent ALL default browser actions (scroll, tab, etc.)
if (!e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
}

if (e.key === 'Enter') {
if (typed.trim() === TARGET) {
setSubmitted(true);
onApply();
} else {
setTyped(''); // wrong — clear
}
return;
}
if (e.key === 'Backspace') {
setTyped(t => t.slice(0, -1));
return;
}
if (e.key.length === 1) {
setTyped(t => t + e.key);
}
}

function handleReset() {
setTyped('');
setSubmitted(false);
onReset();
setTimeout(() => termRef.current?.focus(), 50);
}

const isCorrectSoFar = TARGET.startsWith(typed);
const focused = !submitted;

return (
<div
ref={termRef}
className={styles.terminalPane}
tabIndex={0}
onKeyDown={handleKey}
onClick={() => !submitted && termRef.current?.focus()}
style={{ cursor: submitted ? 'default' : 'text', outline: 'none' }}
>
<div className={styles.terminalBar}>
<span className={styles.termDot} style={{ background: '#ff5f56' }} />
<span className={styles.termDot} style={{ background: '#ffbd2e' }} />
<span className={styles.termDot} style={{ background: '#27c93f' }} />
<span className={styles.termTitle} style={{ color: '#2CE0BF' }}>onboarding-cluster</span>
</div>
<div className={styles.terminalBody}>
{!submitted && (
<>
<div className={styles.termLine}>
<span className={styles.termPrompt} style={{ color: '#2CE0BF' }}>$ </span>
<span
className={styles.termCmd}
style={{ color: isCorrectSoFar ? '#dfdfd6' : '#ef4444' }}
>
{typed}
</span>
<span className={styles.cursor}>█</span>
</div>
{typed.length === 0 && (
<div className={styles.termHint}>
Click here, then type: <code>{TARGET}</code>
</div>
)}
</>
)}
{submitted && (
<>
<div className={styles.termLine}>
<span className={styles.termPrompt} style={{ color: '#2CE0BF' }}>$ </span>
<span className={styles.termCmd}>{TARGET}</span>
</div>
<div className={styles.termOutput}>
managedcontrolplane.core.openmcp.cloud/my-control-plane created
</div>
{crReady && (
<div className={styles.termSuccess}>✓ ManagedControlPlane is Ready</div>
)}
{!crReady && (
<div className={styles.termPending}>⟳ Waiting for reconciliation…</div>
)}
<button className={styles.btnSecondary} style={{ marginTop: '0.6rem' }} onClick={handleReset}>
↺ Reset
</button>
</>
)}
</div>
</div>
);
}
Loading