diff --git a/index.html b/index.html index 7eef41a..9c3dfc7 100644 --- a/index.html +++ b/index.html @@ -87,6 +87,11 @@ + + + +
/ @@ -170,6 +187,27 @@ Help
+
Workloads
@@ -495,6 +533,13 @@ this.dragGhost = null; this.resourceCounter = {}; this.placedPositions = new Map(); + this.renderFilters = { + search: '', + namespace: '', + status: '', + kinds: new Map(), + }; + this._filterRefreshTimer = null; this.settings = this.loadSettings(); } @@ -525,6 +570,7 @@ this.bindGameEvents(); this.bindKeyboard(); this.bindPalette(); + this.bindRenderFilters(); this.buildLevelSelect(); this.buildChallengeSelect(); this.buildAchievements(); @@ -567,6 +613,7 @@ if (!this.renderer.resourceMeshes.has(uid)) { const pos = this.placedPositions.get(uid) || this.getPlacementPosition(); this.renderer.addResource(toRenderable(data.resource, pos.x, pos.y, pos.z)); + this.scheduleRenderFilterRefresh(); } } }); @@ -576,6 +623,18 @@ if (uid) { this.renderer.removeResource(uid); this.placedPositions.delete(uid); + this.scheduleRenderFilterRefresh(); + } + }); + + this.engine.eventBus.on('cluster:readonly-changed', (data) => { + this._setReadOnlyUI(data.readOnly); + }); + + this.engine.eventBus.on('cluster:imported', (data) => { + this.scheduleRenderFilterRefresh(); + if (data.count > 0) { + setTimeout(() => this.autoAlignResources(), 50); } }); @@ -784,6 +843,10 @@

WebGL Not Avai case 'Delete': case 'Backspace': if (this.renderer && this.renderer.selectedResource) { + if (this._isClusterReadOnly()) { + this._notifyReadOnly('delete'); + return; + } const uid = this.renderer.selectedResource; this.engine.cluster.remove(uid); } @@ -827,6 +890,10 @@

WebGL Not Avai } selectPaletteResource(kind) { + if (this._isClusterReadOnly()) { + this._notifyReadOnly('select-palette'); + return; + } document.querySelectorAll('.palette-item').forEach(item => { item.classList.toggle('selected', item.dataset.resource === kind); }); @@ -838,6 +905,11 @@

WebGL Not Avai if (!palette) return; palette.addEventListener('dragstart', (e) => { + if (this._isClusterReadOnly()) { + e.preventDefault(); + this._notifyReadOnly('drag-create'); + return; + } const item = e.target.closest('.palette-item'); if (!item) return; this.dragResource = item.dataset.resource; @@ -862,6 +934,10 @@

WebGL Not Avai palette.addEventListener('click', (e) => { const item = e.target.closest('.palette-item'); if (!item) return; + if (this._isClusterReadOnly()) { + this._notifyReadOnly('palette-create'); + return; + } const kind = item.dataset.resource; const rect = canvas.getBoundingClientRect(); const cx = rect.left + rect.width / 2; @@ -871,12 +947,17 @@

WebGL Not Avai const canvas = document.getElementById('game-canvas'); canvas.addEventListener('dragover', (e) => { + if (this._isClusterReadOnly()) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }); canvas.addEventListener('drop', (e) => { e.preventDefault(); + if (this._isClusterReadOnly()) { + this._notifyReadOnly('drop-create'); + return; + } const kind = e.dataTransfer.getData('text/plain'); if (kind && RESOURCE_CLASSES[kind]) { this.placeResource(kind, e.clientX, e.clientY); @@ -903,8 +984,12 @@

WebGL Not Avai this.autoAlignResources(); }); + document.getElementById('btn-filter-resources')?.addEventListener('click', () => { + this.toggleRenderFilterPanel(); + }); + document.getElementById('btn-reset-camera')?.addEventListener('click', () => { - if (this.renderer) this.renderer.resetCamera(); + if (this.renderer) this.renderer.frameAllResources(500); }); document.getElementById('btn-help')?.addEventListener('click', () => { @@ -927,9 +1012,17 @@

WebGL Not Avai URL.revokeObjectURL(url); } }); + + document.getElementById('btn-mirror-cluster')?.addEventListener('click', () => { + this.showMirrorDialog(); + }); } placeResource(kind, screenX, screenY) { + if (this._isClusterReadOnly()) { + this._notifyReadOnly('create'); + return; + } const ResourceClass = RESOURCE_CLASSES[kind]; if (!ResourceClass) return; @@ -976,7 +1069,216 @@

WebGL Not Avai }); } + _isClusterReadOnly() { + return this.engine?.cluster?.isReadOnly?.() || false; + } + + _notifyReadOnly(action) { + this.engine?.eventBus?.emit('cluster:write-blocked', { + action, + reason: this.engine.cluster.readOnlyReason || 'Mirrored cluster is read-only', + }); + } + + _setReadOnlyUI(readOnly) { + document.getElementById('game-container')?.classList.toggle('mirror-readonly', readOnly); + const badge = document.getElementById('mirror-readonly-badge'); + if (badge) { + badge.classList.toggle('hidden', !readOnly); + badge.classList.toggle('flex', readOnly); + } + document.querySelectorAll('.palette-item').forEach((item) => { + item.draggable = !readOnly; + item.setAttribute('aria-disabled', readOnly ? 'true' : 'false'); + item.classList.toggle('disabled', readOnly); + }); + } + + bindRenderFilters() { + const panel = document.getElementById('render-filter-panel'); + if (!panel) return; + + const search = document.getElementById('filter-search'); + const namespace = document.getElementById('filter-namespace'); + const status = document.getElementById('filter-status'); + + search?.addEventListener('input', () => { + this.renderFilters.search = search.value.trim().toLowerCase(); + this.applyRenderFilters(); + }); + namespace?.addEventListener('change', () => { + this.renderFilters.namespace = namespace.value; + this.applyRenderFilters(); + }); + status?.addEventListener('change', () => { + this.renderFilters.status = status.value; + this.applyRenderFilters(); + }); + + document.getElementById('filter-all')?.addEventListener('click', () => { + for (const kind of this.engine.cluster.getAllKinds()) { + this.renderFilters.kinds.set(kind, true); + } + this.refreshRenderFilterOptions(); + this.applyRenderFilters(); + }); + document.getElementById('filter-none')?.addEventListener('click', () => { + for (const kind of this.engine.cluster.getAllKinds()) { + this.renderFilters.kinds.set(kind, false); + } + this.refreshRenderFilterOptions(); + this.applyRenderFilters(); + }); + document.getElementById('filter-fit')?.addEventListener('click', () => { + this.renderer?.frameAllResources(500); + }); + } + + toggleRenderFilterPanel() { + const panel = document.getElementById('render-filter-panel'); + if (!panel) return; + panel.classList.toggle('hidden'); + this.refreshRenderFilterOptions(); + this.applyRenderFilters(); + } + + scheduleRenderFilterRefresh() { + if (this._filterRefreshTimer) { + clearTimeout(this._filterRefreshTimer); + } + this._filterRefreshTimer = setTimeout(() => { + this._filterRefreshTimer = null; + this.refreshRenderFilterOptions(); + this.applyRenderFilters(); + }, 80); + } + + refreshRenderFilterOptions() { + const cluster = this.engine?.cluster; + if (!cluster) return; + + const resources = cluster.getAllResources(); + const namespaces = [...new Set(resources.map(r => r.metadata?.namespace || '').filter(Boolean))].sort(); + const statuses = [...new Set(resources.map(r => this._resourceStatus(r)).filter(Boolean))].sort(); + const kinds = [...new Set(resources.map(r => r.kind))].sort(); + + const namespaceSelect = document.getElementById('filter-namespace'); + if (namespaceSelect) { + const current = this.renderFilters.namespace; + namespaceSelect.innerHTML = `${namespaces.map(ns => ``).join('')}`; + namespaceSelect.value = namespaces.includes(current) ? current : ''; + this.renderFilters.namespace = namespaceSelect.value; + } + + const statusSelect = document.getElementById('filter-status'); + if (statusSelect) { + const current = this.renderFilters.status; + statusSelect.innerHTML = `${statuses.map(st => ``).join('')}`; + statusSelect.value = statuses.includes(current) ? current : ''; + this.renderFilters.status = statusSelect.value; + } + + for (const kind of kinds) { + if (!this.renderFilters.kinds.has(kind)) { + this.renderFilters.kinds.set(kind, true); + } + } + + const kindsEl = document.getElementById('filter-kinds'); + if (kindsEl) { + kindsEl.innerHTML = kinds.map(kind => { + const count = resources.filter(r => r.kind === kind).length; + const checked = this.renderFilters.kinds.get(kind) !== false ? 'checked' : ''; + return ` + `; + }).join(''); + kindsEl.querySelectorAll('.filter-kind-checkbox').forEach((input) => { + input.addEventListener('change', () => { + this.renderFilters.kinds.set(input.dataset.kind, input.checked); + this.applyRenderFilters(); + }); + }); + } + } + + applyRenderFilters() { + const cluster = this.engine?.cluster; + if (!cluster || !this.renderer) return; + + const visible = new Set(); + const all = cluster.getAllResources(); + for (const resource of all) { + if (!this._resourceMatchesFilters(resource)) continue; + visible.add(resource.uid || resource.metadata?.uid); + } + + this.renderer.setVisibleResources(visible); + const count = document.getElementById('filter-count'); + if (count) count.textContent = `${visible.size} / ${all.length}`; + + if (this.renderer.selectedResource && !visible.has(this.renderer.selectedResource)) { + this.renderer._setSelected?.(null); + } + this.syncRenderState(); + this._updateNamespacePlanes(); + } + + _resourceMatchesFilters(resource) { + const filters = this.renderFilters; + const name = (resource.metadata?.name || resource.name || '').toLowerCase(); + const namespace = resource.metadata?.namespace || ''; + const status = this._resourceStatus(resource); + + if (filters.search && !name.includes(filters.search)) return false; + if (filters.namespace && namespace !== filters.namespace) return false; + if (filters.status && status !== filters.status) return false; + if (filters.kinds.get(resource.kind) === false) return false; + + return true; + } + + _resourceStatus(resource) { + return resource.status?.phase || resource.phase || 'Unknown'; + } + + _updateNamespacePlanes() { + if (!this.renderer) return; + const boundsByNamespace = new Map(); + for (const resource of this.engine.cluster.getAllResources()) { + const ns = resource.metadata?.namespace || ''; + if (!ns) continue; + const uid = resource.uid || resource.metadata?.uid; + const mesh = this.renderer.resourceMeshes.get(uid); + if (!mesh || !mesh.visible) continue; + const current = boundsByNamespace.get(ns) || { + minX: Infinity, maxX: -Infinity, minZ: Infinity, maxZ: -Infinity, + }; + current.minX = Math.min(current.minX, mesh.position.x); + current.maxX = Math.max(current.maxX, mesh.position.x); + current.minZ = Math.min(current.minZ, mesh.position.z); + current.maxZ = Math.max(current.maxZ, mesh.position.z); + boundsByNamespace.set(ns, current); + } + + for (const [ns, entry] of this.renderer.namespacePlanes) { + const bounds = boundsByNamespace.get(ns); + entry.mesh.visible = Boolean(bounds); + if (bounds) this.renderer.updateNamespaceBounds(ns, bounds); + } + } + + _escapeHTML(str) { + const div = document.createElement('div'); + div.textContent = String(str ?? ''); + return div.innerHTML; + } + _showIngressPathPicker(ingress) { + if (this._isClusterReadOnly()) return; const services = this.engine.cluster.getByKind('Service'); if (services.length === 0) return; @@ -1060,6 +1362,7 @@

WebGL Not Avai } _showSelectorPicker(service) { + if (this._isClusterReadOnly()) return; const deployments = this.engine.cluster.getByKind('Deployment'); const statefulsets = this.engine.cluster.getByKind('StatefulSet'); const targets = [...deployments, ...statefulsets]; @@ -1188,6 +1491,10 @@

WebGL Not Avai } _showEditDialog(uid) { + if (this._isClusterReadOnly()) { + this._notifyReadOnly('edit'); + return; + } const resource = this.engine.cluster.getResource(uid); if (!resource) return; @@ -1310,6 +1617,10 @@

WebGL Not Avai } _showScaleDialog(data) { + if (this._isClusterReadOnly()) { + this._notifyReadOnly('scale'); + return; + } const resource = this.engine.cluster.getResource(data.uid); if (!resource) return; const current = resource.spec?.replicas ?? 1; @@ -1365,47 +1676,57 @@

WebGL Not Avai const allResources = cluster.getAllResources(); if (allResources.length === 0) return; - const tiers = { - namespace: { kinds: ['Namespace'], z: -10, spacing: 5 }, - node: { kinds: ['Node'], z: -5, spacing: 6 }, - workload: { kinds: ['Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet'], z: 0, spacing: 4 }, - batch: { kinds: ['Job', 'CronJob'], z: 3, spacing: 3.5 }, - pod: { kinds: ['Pod'], z: 6, spacing: 2.5 }, - network: { kinds: ['Service', 'Ingress', 'NetworkPolicy'], z: 10, spacing: 4 }, - config: { kinds: ['ConfigMap', 'Secret'], z: 14, spacing: 3.5 }, - storage: { kinds: ['PersistentVolumeClaim', 'PersistentVolume', 'StorageClass'], z: 17, spacing: 4 }, - scaling: { kinds: ['HorizontalPodAutoscaler', 'ResourceQuota', 'LimitRange', 'PodDisruptionBudget'], z: 20, spacing: 3.5 }, - rbac: { kinds: ['ServiceAccount', 'Role', 'ClusterRole', 'RoleBinding', 'ClusterRoleBinding'], z: 23, spacing: 3 }, - }; + const layerDefs = [ + { name: 'cluster', kinds: ['Namespace', 'Node', 'PersistentVolume', 'StorageClass'], y: 0, z: -10, spacing: 4.5 }, + { name: 'controllers', kinds: ['Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob'], y: 4.5, z: -2, spacing: 3.4 }, + { name: 'pods', kinds: ['Pod'], y: 9, z: 6, spacing: 2.2 }, + { name: 'traffic', kinds: ['Service', 'Ingress', 'NetworkPolicy'], y: 13.5, z: 13, spacing: 3.2 }, + { name: 'support', kinds: ['ConfigMap', 'Secret', 'PersistentVolumeClaim', 'HorizontalPodAutoscaler', 'ResourceQuota', 'LimitRange', 'PodDisruptionBudget', 'ServiceAccount', 'Role', 'ClusterRole', 'RoleBinding', 'ClusterRoleBinding'], y: 18, z: 20, spacing: 2.8 }, + ]; const targets = new Map(); - - for (const [, tier] of Object.entries(tiers)) { - const resources = allResources.filter(r => tier.kinds.includes(r.kind)); - if (resources.length === 0) continue; - - const totalWidth = (resources.length - 1) * tier.spacing; - const startX = -totalWidth / 2; - - resources.forEach((r, i) => { - const uid = r.uid || r.metadata?.uid; - targets.set(uid, { - x: startX + i * tier.spacing, - y: 0, - z: tier.z, + const namespaces = [...new Set(allResources.map(r => r.metadata?.namespace || '').filter(Boolean))].sort(); + const namespaceOrder = new Map(namespaces.map((ns, index) => [ns, index])); + const namespaceSpacing = 18; + const centerOffset = (Math.max(namespaces.length, 1) - 1) * namespaceSpacing / 2; + + const placeGrid = (resources, baseX, baseY, baseZ, spacing) => { + const cols = Math.max(1, Math.ceil(Math.sqrt(resources.length))); + const rows = Math.ceil(resources.length / cols); + const width = (cols - 1) * spacing; + const depth = (rows - 1) * spacing; + resources.forEach((resource, index) => { + const col = index % cols; + const row = Math.floor(index / cols); + targets.set(resource.uid || resource.metadata?.uid, { + x: baseX + col * spacing - width / 2, + y: baseY, + z: baseZ + row * spacing - depth / 2, }); }); + }; + + for (const layer of layerDefs) { + for (const ns of namespaces) { + const resources = allResources + .filter(r => layer.kinds.includes(r.kind)) + .filter(r => (r.metadata?.namespace || '') === ns); + if (resources.length === 0) continue; + const baseX = (namespaceOrder.get(ns) * namespaceSpacing) - centerOffset; + placeGrid(resources, baseX, layer.y, layer.z, layer.spacing); + } + + const clusterScoped = allResources + .filter(r => layer.kinds.includes(r.kind)) + .filter(r => !(r.metadata?.namespace || '')); + if (clusterScoped.length > 0) { + placeGrid(clusterScoped, -centerOffset - namespaceSpacing, layer.y, layer.z, layer.spacing); + } } - const unmatched = allResources.filter(r => { - const uid = r.uid || r.metadata?.uid; - return !targets.has(uid); - }); + const unmatched = allResources.filter(r => !targets.has(r.uid || r.metadata?.uid)); if (unmatched.length > 0) { - const startX = -(unmatched.length - 1) * 3 / 2; - unmatched.forEach((r, i) => { - targets.set(r.uid || r.metadata?.uid, { x: startX + i * 3, y: 0, z: 26 }); - }); + placeGrid(unmatched, centerOffset + namespaceSpacing, 21.5, 26, 3); } this.renderer.animateToPositions(targets, 800); @@ -1415,8 +1736,9 @@

WebGL Not Avai } setTimeout(() => { - this.renderer.resetCamera(); - }, 200); + this.renderer.frameAllResources(600); + this._updateNamespacePlanes(); + }, 850); } startMode(mode) { @@ -1489,6 +1811,10 @@

WebGL Not Avai this.modeInstance = new ChallengeMode(this.engine, this.incidentEngine, this.scoringEngine); this.modeInstance.startChallenge(levelOrChallenge || 1); break; + case 'mirror': + this.modeInstance = null; + this.showMirrorDialog(); + break; } } catch (err) { console.error(`Failed to start ${mode} mode:`, err); @@ -1497,6 +1823,154 @@

WebGL Not Avai this._initObjectivesPanel(mode); } + showMirrorDialog() { + const existing = document.getElementById('mirror-dialog-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'mirror-dialog-overlay'; + overlay.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm'; + overlay.innerHTML = ` +
+
+
+
Mirror Kubernetes Cluster
+
Paste or load a kubectl JSON snapshot. Imported resources become read-only.
+
+ +
+
+
Snapshot command
+
kubectl get nodes,namespaces,pods,deployments,replicasets,statefulsets,daemonsets,jobs,cronjobs,services,ingress,networkpolicies,configmaps,secrets,pv,pvc,storageclasses,hpa,roles,clusterroles,rolebindings,clusterrolebindings,serviceaccounts,pdb,resourcequotas -A -o json
+
+ + +
+ + + +
+
+
+ `; + document.body.appendChild(overlay); + + const close = () => overlay.remove(); + const input = document.getElementById('mirror-json-input'); + const error = document.getElementById('mirror-error'); + const status = document.getElementById('mirror-current-status'); + const updateStatus = () => { + const count = this.engine.cluster.mirrorInfo?.resourceCount; + status.textContent = count ? `${count} mirrored resources` : ''; + }; + updateStatus(); + + const showError = (message) => { + error.textContent = message; + error.classList.remove('hidden'); + }; + + document.getElementById('mirror-dialog-close').addEventListener('click', close); + document.getElementById('mirror-import-cancel').addEventListener('click', close); + document.getElementById('mirror-file-input').addEventListener('change', async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + input.value = await this._readSnapshotFile(file); + }); + document.getElementById('mirror-import-ok').addEventListener('click', () => { + try { + const imported = this.importMirrorSnapshot(input.value.trim(), 'kubectl JSON snapshot'); + if (imported.length === 0) { + showError('No Kubernetes resources found in this JSON.'); + return; + } + close(); + } catch (err) { + showError(err.message || 'Could not import this JSON snapshot.'); + } + }); + + requestAnimationFrame(() => input.focus()); + } + + importMirrorSnapshot(raw, source) { + if (!raw) throw new Error('Paste JSON from kubectl first.'); + raw = this._normalizeSnapshotText(raw); + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('This mirror importer accepts kubectl JSON. Run kubectl with -o json and paste the output here.'); + } + + const items = this._extractKubernetesItems(parsed); + if (items.length === 0) { + throw new Error('No Kubernetes resources found in this JSON.'); + } + const imported = this.engine.cluster.importSnapshot(items, { + readOnly: true, + source, + reason: 'Mirrored cluster is read-only', + }); + this._setReadOnlyUI(true); + return imported; + } + + async _readSnapshotFile(file) { + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + if (bytes.length >= 2) { + if (bytes[0] === 0xff && bytes[1] === 0xfe) { + return new TextDecoder('utf-16le').decode(bytes); + } + if (bytes[0] === 0xfe && bytes[1] === 0xff) { + return new TextDecoder('utf-16be').decode(bytes); + } + } + + const sample = bytes.slice(0, Math.min(bytes.length, 200)); + let zeroOdd = 0; + let zeroEven = 0; + for (let i = 0; i < sample.length; i++) { + if (sample[i] !== 0) continue; + if (i % 2 === 0) zeroEven++; + else zeroOdd++; + } + + if (zeroOdd > sample.length * 0.2) { + return new TextDecoder('utf-16le').decode(bytes); + } + if (zeroEven > sample.length * 0.2) { + return new TextDecoder('utf-16be').decode(bytes); + } + + return new TextDecoder('utf-8').decode(bytes); + } + + _normalizeSnapshotText(raw) { + return String(raw || '') + .replace(/^\uFEFF/, '') + .replace(/\u0000/g, '') + .trim(); + } + + _extractKubernetesItems(parsed) { + if (Array.isArray(parsed)) { + return parsed.flatMap((item) => this._extractKubernetesItems(item)); + } + if (!parsed || typeof parsed !== 'object') return []; + if (Array.isArray(parsed.items)) { + return parsed.items.flatMap((item) => this._extractKubernetesItems(item)); + } + if (parsed.kind && parsed.metadata?.name) { + return [parsed]; + } + return []; + } + _getLevelDef() { return this.modeInstance?.currentLevelDef || this.modeInstance?.currentChallengeDef || null; } @@ -1618,6 +2092,10 @@

WebGL Not Avai clearInterval(this._objUpdateInterval); this._objUpdateInterval = null; } + if (this._filterRefreshTimer) { + clearTimeout(this._filterRefreshTimer); + this._filterRefreshTimer = null; + } const panel = document.getElementById('objectives-panel'); if (panel) panel.style.display = 'none'; this.engine.stop(); @@ -1629,9 +2107,11 @@

WebGL Not Avai } this.modeInstance = null; this.currentMode = null; + this._setReadOnlyUI(false); document.getElementById('game-container').classList.remove('active'); document.getElementById('main-menu').classList.remove('hidden'); + document.getElementById('render-filter-panel')?.classList.add('hidden'); document.querySelectorAll('#hud, #command-bar, #inspector-panel, #context-menu-container, #metrics-dashboard, #incident-panel, #minimap-container').forEach(el => el?.remove()); @@ -1651,12 +2131,14 @@

WebGL Not Avai } const connections = new Map(); + const isVisible = (uid) => this.renderer.resourceMeshes.get(uid)?.visible !== false; for (const [parentUid, childUids] of cluster.relationships) { if (!cluster.resources.has(parentUid)) continue; for (const childUid of childUids) { if (!cluster.resources.has(childUid)) continue; if (!this.renderer.resourceMeshes.has(parentUid) || !this.renderer.resourceMeshes.has(childUid)) continue; + if (!isVisible(parentUid) || !isVisible(childUid)) continue; const id = `own:${parentUid}:${childUid}`; connections.set(id, { id, sourceId: parentUid, targetId: childUid, type: 'ownership', trafficVolume: 1 }); } @@ -1666,11 +2148,13 @@

WebGL Not Avai for (const svc of services) { if (!svc.spec?.selector || Object.keys(svc.spec.selector).length === 0) continue; if (!this.renderer.resourceMeshes.has(svc.uid)) continue; + if (!isVisible(svc.uid)) continue; const matchingPods = cluster.selectByLabels('Pod', svc.spec.selector) .filter(p => p.metadata.namespace === svc.metadata.namespace); for (const pod of matchingPods) { if (!this.renderer.resourceMeshes.has(pod.uid)) continue; + if (!isVisible(pod.uid)) continue; const id = `net:${svc.uid}:${pod.uid}`; connections.set(id, { id, sourceId: svc.uid, targetId: pod.uid, type: 'network', trafficVolume: 2 }); } @@ -1681,6 +2165,7 @@

WebGL Not Avai }); for (const dep of matchingDeploys) { if (!this.renderer.resourceMeshes.has(dep.uid)) continue; + if (!isVisible(dep.uid)) continue; const id = `net:${svc.uid}:${dep.uid}`; if (!connections.has(id)) { connections.set(id, { id, sourceId: svc.uid, targetId: dep.uid, type: 'network', trafficVolume: 3 }); @@ -1709,13 +2194,14 @@

WebGL Not Avai const existing = document.getElementById('tutorial-overlay'); if (existing) existing.remove(); - const modeNames = { sandbox: 'Sandbox Mode', chaos: 'Chaos Mode', campaign: 'Campaign', challenge: 'Challenge' }; + const modeNames = { sandbox: 'Sandbox Mode', chaos: 'Chaos Mode', campaign: 'Campaign', challenge: 'Challenge', mirror: 'Mirror Cluster' }; const modeGoals = { sandbox: 'Build any cluster you want. The Architecture Advisor scores your design 0-100 across HA, security, scalability, and cost.', chaos: 'Survive as long as possible. Incidents escalate over time. If cluster health hits zero, game over. Fix issues fast for combo XP.', campaign: 'Complete objectives to pass each level. Faster completion with fewer resources earns more stars (3 max).', challenge: 'Beat the clock. Deploy and configure the required resources before time runs out.', + mirror: 'Import a Kubernetes JSON snapshot and explore it as a read-only 3D cluster.', }; const controls = [ @@ -1762,6 +2248,12 @@

WebGL Not Avai 'Click the lightbulb (Show Hint) on the objectives panel for step-by-step guidance', 'Right-click any resource for actions: scale, delete, logs, restart', ], + mirror: [ + 'Import kubectl JSON from a real cluster', + 'Inspect resources, relationships, YAML, metrics, and topology', + 'Create, edit, delete, scale, drain, and rollout commands are blocked', + 'Use the Mirror button to replace the snapshot with a fresh one', + ], }; const rules = modeRules[mode] || modeRules.sandbox; diff --git a/js/engine/ClusterState.js b/js/engine/ClusterState.js index 76b15c3..aa8a6ad 100644 --- a/js/engine/ClusterState.js +++ b/js/engine/ClusterState.js @@ -39,24 +39,58 @@ export class ClusterState { this._watchCallbacks = new Map(); this._batchMode = false; this._batchedEvents = []; + this.readOnly = false; + this.readOnlyReason = ''; + this.mirrorInfo = null; for (const kind of RESOURCE_KINDS) { this.kindIndex.set(kind, new Set()); } - this._initDefaultNamespaces(); + this._initDefaultNamespaces({ force: true }); } - _initDefaultNamespaces() { + _initDefaultNamespaces(options = {}) { const defaults = ['default', 'kube-system', 'kube-public', 'kube-node-lease']; for (const ns of defaults) { const resource = new ResourceBase('Namespace', 'v1', ns, ''); resource.setPhase('Active'); resource.setLabel('kubernetes.io/metadata.name', ns); - this.add(resource); + this.add(resource, options); } } + setReadOnly(enabled, reason = '') { + this.readOnly = Boolean(enabled); + this.readOnlyReason = this.readOnly ? reason : ''; + if (!this.readOnly) { + this.mirrorInfo = null; + } + if (this.eventBus) { + this.eventBus.emit('cluster:readonly-changed', { + readOnly: this.readOnly, + reason: this.readOnlyReason, + mirrorInfo: this.mirrorInfo, + }); + } + } + + isReadOnly() { + return this.readOnly; + } + + _canWrite(action, options = {}) { + if (!this.readOnly || options.force) return true; + if (this.eventBus) { + this.eventBus.emit('cluster:write-blocked', { + action, + reason: this.readOnlyReason || 'Cluster is read-only', + mirrorInfo: this.mirrorInfo, + }); + } + return false; + } + get totalResources() { return this.resources.size; } @@ -99,13 +133,14 @@ export class ClusterState { } } - add(resource) { + add(resource, options = {}) { + if (!this._canWrite('add', options)) return null; if (!(resource instanceof ResourceBase)) { throw new Error('Resource must be an instance of ResourceBase'); } const existing = this.resources.get(resource.uid); if (existing) { - return this.update(resource.uid, resource); + return this.update(resource.uid, resource, options); } this.resources.set(resource.uid, resource); @@ -124,7 +159,8 @@ export class ClusterState { return resource; } - update(uid, updates) { + update(uid, updates, options = {}) { + if (!this._canWrite('update', options)) return null; const resource = this.resources.get(uid); if (!resource) return null; @@ -168,7 +204,8 @@ export class ClusterState { return resource; } - remove(uid) { + remove(uid, options = {}) { + if (!this._canWrite('remove', options)) return null; const resource = this.resources.get(uid); if (!resource) return null; @@ -180,7 +217,7 @@ export class ClusterState { const children = this.getChildren(uid); for (const child of children) { - this.remove(child.uid); + this.remove(child.uid, options); } this._removeFromKindIndex(resource); @@ -696,7 +733,8 @@ export class ClusterState { }; } - clear() { + clear(options = {}) { + if (!this._canWrite('clear', options)) return false; this.resources.clear(); this.relationships.clear(); this.reverseRelationships.clear(); @@ -712,7 +750,9 @@ export class ClusterState { set.clear(); } - this._initDefaultNamespaces(); + if (!options.skipDefaultNamespaces) { + this._initDefaultNamespaces({ force: true }); + } if (this.eventBus) { this.eventBus.emit('cluster:reset', { timestamp: Date.now() }); @@ -727,25 +767,41 @@ export class ClusterState { return [...RESOURCE_KINDS]; } - addResource(obj) { + addResource(obj, options = {}) { + if (!this._canWrite('addResource', options)) return null; const ns = isClusterScopedKind(obj.kind) ? '' : (obj.metadata?.namespace || 'default'); const resource = new ResourceBase(obj.kind, { + apiVersion: obj.apiVersion || 'v1', name: obj.name || obj.metadata?.name || 'unnamed', namespace: ns, labels: obj.metadata?.labels || {}, annotations: obj.metadata?.annotations || {}, + ownerReferences: obj.metadata?.ownerReferences || [], + finalizers: obj.metadata?.finalizers || [], }); + if (obj.metadata?.uid) resource.metadata.uid = obj.metadata.uid; + if (obj.metadata?.creationTimestamp) resource.metadata.creationTimestamp = obj.metadata.creationTimestamp; + if (obj.metadata?.generation) resource.metadata.generation = obj.metadata.generation; + if (obj.metadata?.resourceVersion) resource.metadata.resourceVersion = obj.metadata.resourceVersion; if (obj.spec) { - resource.spec = typeof obj.spec === 'object' ? { ...obj.spec } : {}; - } - if (obj.status?.phase) { - resource.setPhase(obj.status.phase); + resource.spec = typeof obj.spec === 'object' ? JSON.parse(JSON.stringify(obj.spec)) : {}; + } + if (obj.status) { + resource.status = { + ...resource.status, + ...JSON.parse(JSON.stringify(obj.status)), + phase: obj.status.phase || this._derivePhase(obj), + conditions: Array.isArray(obj.status.conditions) ? JSON.parse(JSON.stringify(obj.status.conditions)) : [], + }; + } else { + resource.status.phase = this._derivePhase(obj); } - return this.add(resource); + return this.add(resource, options); } removeResource(kind, name, namespace = 'default') { + if (!this._canWrite('removeResource')) return null; const key = `${kind}:${namespace}:${name}`; const uid = this.nameIndex.get(key); if (uid) { @@ -753,6 +809,96 @@ export class ClusterState { } return null; } + + importSnapshot(objects, options = {}) { + const previousReadOnly = this.readOnly; + const previousReason = this.readOnlyReason; + + this.setReadOnly(false); + this.clear({ force: true, skipDefaultNamespaces: true }); + + const imported = []; + for (const obj of objects) { + const normalized = this._normalizeKubernetesObject(obj); + if (!normalized) continue; + const resource = this.addResource(normalized, { force: true }); + if (resource) imported.push(resource); + } + + const readOnly = options.readOnly ?? previousReadOnly; + if (readOnly) { + this.mirrorInfo = { + source: options.source || 'Kubernetes snapshot', + importedAt: Date.now(), + resourceCount: imported.length, + }; + this.setReadOnly(true, options.reason || previousReason || 'Mirrored cluster is read-only'); + } + + if (this.eventBus) { + this.eventBus.emit('cluster:imported', { + resources: imported, + count: imported.length, + readOnly: this.readOnly, + mirrorInfo: this.mirrorInfo, + }); + } + + return imported; + } + + _normalizeKubernetesObject(obj) { + if (!obj || typeof obj !== 'object' || !obj.kind || !obj.metadata?.name) return null; + const metadata = obj.metadata || {}; + const spec = obj.spec && typeof obj.spec === 'object' ? JSON.parse(JSON.stringify(obj.spec)) : {}; + for (const [key, value] of Object.entries(obj)) { + if (['apiVersion', 'kind', 'metadata', 'spec', 'status'].includes(key)) continue; + if (key === 'managedFields') continue; + if (value !== undefined && spec[key] === undefined) { + spec[key] = JSON.parse(JSON.stringify(value)); + } + } + return { + apiVersion: obj.apiVersion || 'v1', + kind: obj.kind, + name: metadata.name, + metadata: { + ...metadata, + namespace: isClusterScopedKind(obj.kind) ? '' : (metadata.namespace || 'default'), + labels: metadata.labels || {}, + annotations: metadata.annotations || {}, + ownerReferences: metadata.ownerReferences || [], + finalizers: metadata.finalizers || [], + }, + spec, + status: obj.status || { phase: this._derivePhase(obj) }, + }; + } + + _derivePhase(obj) { + if (obj.status?.phase) return obj.status.phase; + if (obj.kind === 'Namespace') return obj.status?.phase || 'Active'; + if (obj.kind === 'Node') { + const ready = (obj.status?.conditions || []).find((c) => c.type === 'Ready'); + return ready?.status === 'True' ? 'Running' : 'NotReady'; + } + if (['Deployment', 'StatefulSet', 'ReplicaSet'].includes(obj.kind)) { + const desired = obj.spec?.replicas ?? 1; + const ready = obj.status?.readyReplicas ?? obj.status?.availableReplicas ?? 0; + return ready >= desired ? 'Running' : 'Pending'; + } + if (obj.kind === 'DaemonSet') { + const desired = obj.status?.desiredNumberScheduled ?? 0; + const ready = obj.status?.numberReady ?? 0; + return desired > 0 && ready >= desired ? 'Running' : 'Pending'; + } + if (obj.kind === 'Job') { + if (obj.status?.failed) return 'Failed'; + if (obj.status?.succeeded) return 'Succeeded'; + return obj.status?.active ? 'Running' : 'Pending'; + } + return 'Running'; + } } export default ClusterState; diff --git a/js/engine/GameEngine.js b/js/engine/GameEngine.js index 520c73d..966f589 100644 --- a/js/engine/GameEngine.js +++ b/js/engine/GameEngine.js @@ -123,6 +123,7 @@ const GAME_MODES = { SANDBOX: 'sandbox', CHAOS: 'chaos', CHALLENGE: 'challenge', + MIRROR: 'mirror', }; const GAME_STATES = { @@ -242,6 +243,10 @@ export class GameEngine { this._score -= 5; } }); + + this.eventBus.on('cluster:write-blocked', (data) => { + this._addNotification('warning', data.reason || 'Mirrored cluster is read-only'); + }); } start() { @@ -403,6 +408,13 @@ export class GameEngine { this.eventBus.emit('command:executed', command); + if (this.cluster.isReadOnly?.() && this._isMutatingCommand(command)) { + const message = this.cluster.readOnlyReason || 'Mirrored cluster is read-only'; + this.eventBus.emit('command:blocked', { command, message }); + this.eventBus.emit('cluster:write-blocked', { action: command.type, reason: message }); + return { success: false, message }; + } + switch (command.type) { case 'create': return this._handleCreate(command); @@ -430,6 +442,14 @@ export class GameEngine { } } + _isMutatingCommand(command) { + if (!command) return false; + if (command.type === 'rollout') { + return command.subcommand !== 'status' && command.subcommand !== 'history'; + } + return ['create', 'delete', 'scale', 'apply', 'cordon', 'uncordon', 'drain'].includes(command.type); + } + _handleCreate(command) { const { kind, name, namespace, spec } = command; const existing = this.cluster.getByName(kind, name, namespace); @@ -810,7 +830,8 @@ export class GameEngine { reset() { this.stop(); - this.cluster.clear(); + this.cluster.setReadOnly(false); + this.cluster.clear({ force: true }); this.simulation.reset(); this.eventBus.clearHistory(); diff --git a/js/engine/SimulationTick.js b/js/engine/SimulationTick.js index 1b1cc93..1dc8bcf 100644 --- a/js/engine/SimulationTick.js +++ b/js/engine/SimulationTick.js @@ -44,6 +44,18 @@ export class SimulationTick { this.tickCount++; const dt = deltaTime * this.tickRate; + if (this.cluster.isReadOnly?.()) { + if (this.eventBus) { + this.eventBus.emit('simulation:tick', { + tickCount: this.tickCount, + deltaTime: dt, + stats: this.cluster.getClusterStats(), + readOnly: true, + }); + } + return; + } + this.cluster.startBatch(); this._tickNodes(dt); diff --git a/js/rendering/ClusterRenderer.js b/js/rendering/ClusterRenderer.js index 6e35bb7..76304fb 100644 --- a/js/rendering/ClusterRenderer.js +++ b/js/rendering/ClusterRenderer.js @@ -6,8 +6,8 @@ import { ParticleTrafficSystem } from './ParticleTraffic.js'; const BACKGROUND_COLOR = 0x0d1117; const K8S_BLUE = 0x326CE5; -const GRID_SIZE = 40; -const GRID_DIVISIONS = 20; +const GRID_SIZE = 120; +const GRID_DIVISIONS = 40; const HIGHLIGHT_COLOR = 0x58a6ff; const SELECT_COLOR = 0xffa657; const NAMESPACE_OPACITY = 0.04; @@ -57,7 +57,7 @@ export class ClusterRenderer { _initCamera() { const aspect = this.canvas.clientWidth / this.canvas.clientHeight; - this.camera = new THREE.PerspectiveCamera(45, aspect, 0.1, 500); + this.camera = new THREE.PerspectiveCamera(45, aspect, 0.1, 2000); this.camera.position.set(18, 14, 18); this.camera.lookAt(0, 0, 0); } @@ -86,10 +86,10 @@ export class ClusterRenderer { this.controls.enablePan = true; this.controls.rotateSpeed = 0.8; this.controls.zoomSpeed = 1.2; - this.controls.panSpeed = 0.8; + this.controls.panSpeed = 1.4; this.controls.minDistance = 5; - this.controls.maxDistance = 80; - this.controls.maxPolarAngle = Math.PI / 2.1; + this.controls.maxDistance = 350; + this.controls.maxPolarAngle = Math.PI / 1.75; this.controls.minPolarAngle = 0.1; this.controls.target.set(0, 0, 0); this.controls.mouseButtons = { @@ -290,7 +290,7 @@ export class ClusterRenderer { this.canvas.style.cursor = 'default'; const group = this.resourceMeshes.get(rid); if (group && this.onResourceMoved) { - this.onResourceMoved(rid, { x: group.position.x, y: 0, z: group.position.z }); + this.onResourceMoved(rid, { x: group.position.x, y: group.position.y, z: group.position.z }); } this._didDrag = false; return; @@ -432,6 +432,7 @@ export class ClusterRenderer { group.userData.resourceId = resource.id; group.userData.resourceType = resource.type; + group.userData.baseY = resource.y || 0; group.position.set(resource.x || 0, resource.y || 0, resource.z || 0); group.traverse((child) => { @@ -484,7 +485,10 @@ export class ClusterRenderer { if (!group) return; if (resource.x !== undefined) group.position.x = resource.x; - if (resource.y !== undefined) group.position.y = resource.y; + if (resource.y !== undefined) { + group.position.y = resource.y; + group.userData.baseY = resource.y; + } if (resource.z !== undefined) group.position.z = resource.z; if (resource.status) { @@ -495,6 +499,7 @@ export class ClusterRenderer { _rebuildPickableList() { this.pickableObjects = []; for (const group of this.resourceMeshes.values()) { + if (!group.visible) continue; group.traverse((child) => { if (child.isMesh && !child.userData.isLabel) { this.pickableObjects.push(child); @@ -503,6 +508,21 @@ export class ClusterRenderer { } } + setVisibleResources(visibleIds) { + const allowed = visibleIds instanceof Set ? visibleIds : new Set(visibleIds || []); + for (const [uid, group] of this.resourceMeshes) { + group.visible = allowed.has(uid); + } + this._rebuildPickableList(); + } + + showAllResources() { + for (const group of this.resourceMeshes.values()) { + group.visible = true; + } + this._rebuildPickableList(); + } + _ensureNamespacePlane(namespace) { if (this.namespacePlanes.has(namespace)) return; @@ -706,6 +726,7 @@ export class ClusterRenderer { group.position.x = start.x + (target.x - start.x) * ease; group.position.y = start.y + (target.y - start.y) * ease; group.position.z = start.z + (target.z - start.z) * ease; + group.userData.baseY = group.position.y; } this.connectionLines.updatePositions(this.resourceMeshes); @@ -719,9 +740,57 @@ export class ClusterRenderer { } resetCamera() { - this.camera.position.set(18, 14, 18); - this.controls.target.set(0, 0, 0); - this.controls.update(); + this.frameAllResources(); + } + + getVisibleBounds() { + const box = new THREE.Box3(); + let hasVisible = false; + + for (const group of this.resourceMeshes.values()) { + if (!group.visible) continue; + box.expandByPoint(group.position); + hasVisible = true; + } + + return hasVisible ? box : null; + } + + frameAllResources(duration = 0) { + const box = this.getVisibleBounds(); + if (!box) { + this.camera.position.set(18, 14, 18); + this.controls.target.set(0, 0, 0); + this.controls.update(); + return; + } + + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const radius = Math.max(size.x, size.y * 2.2, size.z, 10); + const distance = Math.min(Math.max(radius * 1.35, 24), this.controls.maxDistance * 0.9); + const targetPosition = center.clone().add(new THREE.Vector3(distance, distance * 0.65, distance)); + + if (duration <= 0) { + this.camera.position.copy(targetPosition); + this.controls.target.copy(center); + this.controls.update(); + return; + } + + const startTime = performance.now(); + const startCamera = this.camera.position.clone(); + const startTarget = this.controls.target.clone(); + const animate = () => { + const elapsed = performance.now() - startTime; + const t = Math.min(elapsed / duration, 1); + const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + this.camera.position.lerpVectors(startCamera, targetPosition, ease); + this.controls.target.lerpVectors(startTarget, center, ease); + this.controls.update(); + if (t < 1) requestAnimationFrame(animate); + }; + requestAnimationFrame(animate); } dispose() { diff --git a/js/ui/CommandBar.js b/js/ui/CommandBar.js index ce7a78e..829ba1d 100644 --- a/js/ui/CommandBar.js +++ b/js/ui/CommandBar.js @@ -295,6 +295,17 @@ export class CommandBar { const engine = window.game?.engine; if (!cluster || !engine) return { error: true, message: 'Error: Game not initialized' }; + if (cluster.isReadOnly?.() && this._isReadOnlyBlockedCommand(cmd, args)) { + engine.emit('cluster:write-blocked', { + action: cmd, + reason: cluster.readOnlyReason || 'Mirrored cluster is read-only', + }); + return { + error: true, + message: `Error from server (Forbidden): ${cluster.readOnlyReason || 'mirrored cluster is read-only'}`, + }; + } + switch (cmd) { case 'get': return this._cmdGet(args, cluster); case 'describe': return this._cmdDescribe(args, cluster); @@ -316,6 +327,14 @@ export class CommandBar { } } + _isReadOnlyBlockedCommand(cmd, args = []) { + if (cmd === 'rollout') { + const action = args[0]; + return action !== 'status' && action !== 'history'; + } + return ['scale', 'delete', 'apply', 'create', 'run', 'label', 'drain', 'cordon', 'uncordon'].includes(cmd); + } + _cmdGet(args, cluster) { if (args.length === 0) return { error: true, message: 'error: Required resource not specified.\nUse "kubectl get " to see resources.' }; diff --git a/js/ui/ContextMenu.js b/js/ui/ContextMenu.js index cd454f8..703153d 100644 --- a/js/ui/ContextMenu.js +++ b/js/ui/ContextMenu.js @@ -56,6 +56,16 @@ const DEFAULT_ACTIONS = [ ]}, ]; +const MUTATING_EVENTS = new Set([ + 'resource:delete', + 'deployment:scale', + 'deployment:restart', + 'deployment:rollback', + 'node:cordon', + 'node:uncordon', + 'node:drain', +]); + export class ContextMenu { constructor() { this.container = null; @@ -96,13 +106,20 @@ export class ContextMenu { const kind = this.resource.kind || 'Unknown'; const name = this.resource.metadata?.name || 'unnamed'; - const groups = ACTION_MAP[kind] || DEFAULT_ACTIONS; + const readOnly = window.game?.cluster?.isReadOnly?.() || false; + const groups = (ACTION_MAP[kind] || DEFAULT_ACTIONS) + .map((group) => ({ + ...group, + actions: readOnly ? group.actions.filter((action) => !MUTATING_EVENTS.has(action.event)) : group.actions, + })) + .filter((group) => group.actions.length > 0); this.container.innerHTML = `
${this._escapeHTML(name)}
${this._escapeHTML(kind)}
+ ${readOnly ? '
Read-only mirror
' : ''}
${groups.map((group, gi) => ` ${gi > 0 ? '
' : ''} @@ -190,6 +207,14 @@ export class ContextMenu { const name = this.resource.metadata?.name; const namespace = this.resource.metadata?.namespace; + if (cluster?.isReadOnly?.() && MUTATING_EVENTS.has(event)) { + engine.emit('cluster:write-blocked', { + action: event, + reason: cluster.readOnlyReason || 'Mirrored cluster is read-only', + }); + return; + } + switch (event) { case 'resource:delete': if (gameEngine) { diff --git a/js/ui/InspectorPanel.js b/js/ui/InspectorPanel.js index 4a07cc6..979d073 100644 --- a/js/ui/InspectorPanel.js +++ b/js/ui/InspectorPanel.js @@ -124,7 +124,8 @@ export class InspectorPanel { const editBtn = document.getElementById('inspector-edit'); const editableKinds = ['Deployment', 'Service', 'ConfigMap', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Secret', 'Pod', 'Ingress', 'NetworkPolicy', 'HorizontalPodAutoscaler']; - if (editableKinds.includes(kind)) { + const readOnly = window.game?.cluster?.isReadOnly?.() || false; + if (editableKinds.includes(kind) && !readOnly) { editBtn.classList.remove('hidden'); } else { editBtn.classList.add('hidden'); @@ -473,6 +474,13 @@ export class InspectorPanel { _onEdit() { if (!this.resource) return; + if (window.game?.cluster?.isReadOnly?.()) { + window.game?.engine?.emit('cluster:write-blocked', { + action: 'edit', + reason: window.game.cluster.readOnlyReason || 'Mirrored cluster is read-only', + }); + return; + } const kind = this.resource.kind; const name = this.resource.metadata?.name; window.game?.engine.emit('ui:edit-resource', { uid: this.resource.metadata?.uid, kind, name }); diff --git a/style.css b/style.css index 5cc4d6e..23c9468 100644 --- a/style.css +++ b/style.css @@ -362,6 +362,20 @@ html, body { box-shadow: 0 0 16px rgba(50, 108, 229, 0.2), inset 0 0 8px rgba(50, 108, 229, 0.1); } +.palette-item.disabled, +.mirror-readonly .palette-item { + cursor: not-allowed; + opacity: 0.38; +} + +.palette-item.disabled:hover, +.mirror-readonly .palette-item:hover { + transform: none; + box-shadow: none; + background: transparent; + border-color: transparent; +} + .palette-icon { width: 24px; height: 24px;