From 33c6648b84557dc06064650890a193b9febbdf0a Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 16 Jun 2026 08:51:55 -0400 Subject: [PATCH] Initial skeleton: React/TS frontend + Go backend structure --- client/.eslintignore | 1 + client/eslint.config.mjs | 32 +++ client/index.html | 39 ++++ client/package.json | 33 +++ client/postcss.config.js | 6 + client/src/App.tsx | 63 ++++++ client/src/components/canvas/GridOverlay.tsx | 27 +++ client/src/components/canvas/Minimap.tsx | 87 ++++++++ client/src/components/canvas/WorldLayer.tsx | 29 +++ client/src/components/canvas/index.ts | 3 + client/src/components/krate/Krate.tsx | 169 ++++++++++++++ client/src/components/krate/index.ts | 1 + client/src/components/shared/AdminDrawer.tsx | 118 ++++++++++ client/src/components/shared/LogsViewer.tsx | 110 ++++++++++ .../src/components/shared/ShellTerminal.tsx | 118 ++++++++++ client/src/components/shared/Spotlight.tsx | 105 +++++++++ .../components/shared/SpotlightFilters.tsx | 1 + client/src/components/shared/TopBar.tsx | 206 ++++++++++++++++++ client/src/components/shared/index.ts | 5 + client/src/hooks/index.ts | 5 + client/src/hooks/useCanvas.ts | 49 +++++ client/src/hooks/useKeyboardShortcuts.ts | 41 ++++ client/src/hooks/useKubernetes.ts | 1 + client/src/hooks/useSpotlight.ts | 53 +++++ client/src/hooks/useUtils.ts | 50 +++++ client/src/hooks/useWebSocket.ts | 1 + client/src/main.tsx | 9 + client/src/state/canvasStore.ts | 41 ++++ client/src/state/index.ts | 4 + client/src/state/krateStore.ts | 61 ++++++ client/src/state/spotlightStore.ts | 32 +++ client/src/state/userStore.ts | 56 +++++ client/src/utils/crdt.ts | 37 ++++ client/src/utils/fuzzy.ts | 50 +++++ client/src/utils/keyboard.ts | 27 +++ client/src/utils/math.ts | 41 ++++ client/tsconfig.json | 22 ++ client/vite.config.ts | 21 ++ server/cmd/server/main.go | 35 +++ server/go.mod | 10 + 40 files changed, 1799 insertions(+) create mode 100644 client/.eslintignore create mode 100644 client/eslint.config.mjs create mode 100644 client/index.html create mode 100644 client/package.json create mode 100644 client/postcss.config.js create mode 100644 client/src/App.tsx create mode 100644 client/src/components/canvas/GridOverlay.tsx create mode 100644 client/src/components/canvas/Minimap.tsx create mode 100644 client/src/components/canvas/WorldLayer.tsx create mode 100644 client/src/components/canvas/index.ts create mode 100644 client/src/components/krate/Krate.tsx create mode 100644 client/src/components/krate/index.ts create mode 100644 client/src/components/shared/AdminDrawer.tsx create mode 100644 client/src/components/shared/LogsViewer.tsx create mode 100644 client/src/components/shared/ShellTerminal.tsx create mode 100644 client/src/components/shared/Spotlight.tsx create mode 100644 client/src/components/shared/SpotlightFilters.tsx create mode 100644 client/src/components/shared/TopBar.tsx create mode 100644 client/src/components/shared/index.ts create mode 100644 client/src/hooks/index.ts create mode 100644 client/src/hooks/useCanvas.ts create mode 100644 client/src/hooks/useKeyboardShortcuts.ts create mode 100644 client/src/hooks/useKubernetes.ts create mode 100644 client/src/hooks/useSpotlight.ts create mode 100644 client/src/hooks/useUtils.ts create mode 100644 client/src/hooks/useWebSocket.ts create mode 100644 client/src/main.tsx create mode 100644 client/src/state/canvasStore.ts create mode 100644 client/src/state/index.ts create mode 100644 client/src/state/krateStore.ts create mode 100644 client/src/state/spotlightStore.ts create mode 100644 client/src/state/userStore.ts create mode 100644 client/src/utils/crdt.ts create mode 100644 client/src/utils/fuzzy.ts create mode 100644 client/src/utils/keyboard.ts create mode 100644 client/src/utils/math.ts create mode 100644 client/tsconfig.json create mode 100644 client/vite.config.ts create mode 100644 server/cmd/server/main.go create mode 100644 server/go.mod diff --git a/client/.eslintignore b/client/.eslintignore new file mode 100644 index 0000000..9efa7eb --- /dev/null +++ b/client/.eslintignore @@ -0,0 +1 @@ +module: "eslint" \ No newline at end of file diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs new file mode 100644 index 0000000..a89abc0 --- /dev/null +++ b/client/eslint.config.mjs @@ -0,0 +1,32 @@ +import js from "@eslint/js" +import reactHooks from "eslint-plugin-react-hooks" +import reactRefresh from "eslint-plugin-react-refresh" +import tseslint from "typescript-eslint" + +export default tseslint.config( + { ignores: ["dist", "node_modules", "vite.config.ts"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + sourceType: "module", + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..e5c6710 --- /dev/null +++ b/client/index.html @@ -0,0 +1,39 @@ + + + + + + + Krates Yard + + + + + + + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..fc7f08a --- /dev/null +++ b/client/package.json @@ -0,0 +1,33 @@ +{ + "name": "krates-client", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint src --ext ts,tsx --max-warnings 0" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0", + "zustand": "^4.4.7", + "yjs": "^13.6.16", + "y-websocket": "^2.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.10.0", + "@types/react": "^18.3.9", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^9.10.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.12", + "typescript": "^5.6.2", + "typescript-eslint": "^8.4.0", + "vite": "^5.4.3" + } +} diff --git a/client/postcss.config.js b/client/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..1148cfd --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { WorldLayer } from './components/canvas' +import { GridOverlay } from './components/canvas' +import { Minimap } from './components/canvas' +import { TopBar } from './components/shared' +import { Spotlight } from './components/shared' +import { useCanvasStore } from './state' + +export function App() { + const { camX, camY, zoom } = useCanvasStore() + + const canvasStyle: React.CSSProperties = { + position: 'fixed', + inset: 0, + background: '#0b0e13', + overflow: 'hidden', + } + + return ( +
+ + + + + + + {/* Bottom UI */} +
+ + {Math.round(zoom * 100)}% + +
+ +
+ Space + drag to pan + Scroll to pan + Click to spotlight +
+
+ ) +} diff --git a/client/src/components/canvas/GridOverlay.tsx b/client/src/components/canvas/GridOverlay.tsx new file mode 100644 index 0000000..3fef62d --- /dev/null +++ b/client/src/components/canvas/GridOverlay.tsx @@ -0,0 +1,27 @@ +import React, { HTMLAttributes } from 'react' +import { useCanvasStore } from '../state/canvasStore' + +interface GridOverlayProps extends HTMLAttributes {} + +export const GridOverlay: React.FC = ({ ...props }) => { + const { zoom } = useCanvasStore() + + const style: React.CSSProperties = { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + pointerEvents: 'none', + backgroundSize: '34px 34px, 170px 170px', + backgroundPosition: '0 0, 0 0', + backgroundImage: ` + linear-gradient(to right, rgba(125,145,175,.04) 1px, transparent 1px), + linear-gradient(to bottom, rgba(125,145,175,.04) 1px, transparent 1px), + linear-gradient(to right, rgba(125,145,175,.075) 1px, transparent 1px), + linear-gradient(to bottom, rgba(125,145,175,.075) 1px, transparent 1px) + `, + } + + return
+} diff --git a/client/src/components/canvas/Minimap.tsx b/client/src/components/canvas/Minimap.tsx new file mode 100644 index 0000000..e2e913c --- /dev/null +++ b/client/src/components/canvas/Minimap.tsx @@ -0,0 +1,87 @@ +import React, { HTMLAttributes, useEffect, useRef } from 'react' +import { useCanvasStore } from '../state/canvasStore' +import { useKrateStore } from '../state/krateStore' + +interface MinimapProps extends HTMLAttributes {} + +export const Minimap: React.FC = ({ ...props }) => { + const { camX, camY, zoom } = useCanvasStore() + const { krates } = useKrateStore() + const minimapRef = useRef(null) + + const minimapWidth = 180 + const minimapHeight = 120 + const worldWidth = 12000 + const worldHeight = 8000 + const minimapScaleX = minimapWidth / worldWidth + const minimapScaleY = minimapHeight / worldHeight + + const viewportWidthWorld = window.innerWidth / zoom + const viewportHeightWorld = window.innerHeight / zoom + + const viewportX = camX * minimapScaleX + const viewportY = camY * minimapScaleY + const viewportW = viewportWidthWorld * minimapScaleX + const viewportH = viewportHeightWorld * minimapScaleY + + const handleClick = (e: React.MouseEvent) => { + const rect = minimapRef.current?.getBoundingClientRect() + if (!rect) return + + const clickX = e.clientX - rect.left + const clickY = e.clientY - rect.top + + const worldX = (clickX / minimapScaleX) - camX - viewportWidthWorld / 2 + const worldY = (clickY / minimapScaleY) - camY - viewportHeightWorld / 2 + + useCanvasStore.getState().setCam(worldX, worldY) + } + + return ( +
+ {Array.from(krates.values()).map((krate) => ( +
+ ))} + +
+
+ ) +} diff --git a/client/src/components/canvas/WorldLayer.tsx b/client/src/components/canvas/WorldLayer.tsx new file mode 100644 index 0000000..351735a --- /dev/null +++ b/client/src/components/canvas/WorldLayer.tsx @@ -0,0 +1,29 @@ +import React, { HTMLAttributes } from 'react' +import { useCanvasStore } from '../state/canvasStore' +import { useKrateStore } from '../state/krateStore' +import { Krate } from './krate' + +interface WorldLayerProps extends HTMLAttributes {} + +export const WorldLayer: React.FC = ({ ...props }) => { + const { camX, camY, zoom, collapsed } = useCanvasStore() + const { krates } = useKrateStore() + + const style: React.CSSProperties = { + position: 'absolute', + width: '12000px', + height: '8000px', + transform: `translate(${camX}px, ${camY}px) scale(${zoom})`, + transformOrigin: '0 0', + } + + return ( +
+ {Array.from(krates.values()) + .filter((k) => !k.minimized) + .map((krate) => ( + + ))} +
+ ) +} diff --git a/client/src/components/canvas/index.ts b/client/src/components/canvas/index.ts new file mode 100644 index 0000000..5ec3fb0 --- /dev/null +++ b/client/src/components/canvas/index.ts @@ -0,0 +1,3 @@ +export { WorldLayer } from './WorldLayer' +export { GridOverlay } from './GridOverlay' +export { Minimap } from './Minimap' diff --git a/client/src/components/krate/Krate.tsx b/client/src/components/krate/Krate.tsx new file mode 100644 index 0000000..40922dc --- /dev/null +++ b/client/src/components/krate/Krate.tsx @@ -0,0 +1,169 @@ +import React, { useState } from 'react' +import { useKrateStore } from '../../state/krateStore' + +interface KrateProps { + krate: Krate + collapsed: boolean +} + +interface Krate { + id: string + type: string + title: string + x: number + y: number + width: number + height: number + minimized: boolean + windowLayout: { + cols: number + rows: number + cells: Map + } + windows: Map +} + +interface KrateWindow { + id: string + type: string + title: string + state: unknown +} + +export const Krate: React.FC = ({ krate, collapsed }) => { + const { selectKrate } = useKrateStore() + const [isDragging, setIsDragging] = useState(false) + + if (collapsed) { + return ( +
selectKrate(krate.id)} + > +
+ {krate.title} +
+
+ {krate.windows.size > 0 ? '..."' : 'Empty'} +
+
+ ) + } + + return ( +
selectKrate(krate.id)} + > + setIsDragging(true)} /> + +
+ {Array.from(krate.windows.values()).map((window) => ( +
+ {window.title} +
+ ))} +
+
+ ) +} + +const hexToRgba = (hex: string) => { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `${r}, ${g}, ${b}` +} + +const KrateHeader: React.FC void }> = ({ + krate, + onDragStart, +}) => { + return ( +
{ + e.stopPropagation() + onDragStart() + }} + > + {krate.title} + + {krate.windows.size} windows + + + +
+ ) +} diff --git a/client/src/components/krate/index.ts b/client/src/components/krate/index.ts new file mode 100644 index 0000000..14fd8c0 --- /dev/null +++ b/client/src/components/krate/index.ts @@ -0,0 +1 @@ +export { Krate, KrateHeader } from './Krate' diff --git a/client/src/components/shared/AdminDrawer.tsx b/client/src/components/shared/AdminDrawer.tsx new file mode 100644 index 0000000..b7356d8 --- /dev/null +++ b/client/src/components/shared/AdminDrawer.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import { useCanvasStore } from '../../state/canvasStore' + +interface AdminDrawerProps { + open: boolean + onClose: () => void + users: Array<{ + userId: string + name: string + color: string + status: string + }> +} + +export const AdminDrawer: React.FC = ({ open, onClose, users }) => { + const { setCam } = useCanvasStore() + + if (!open) return null + + const handleSpectate = (userId: string) => { + onClose() + // In a real app, this would fly to the user's krate position + console.log(`Spectating user: ${userId}`) + } + + return ( +
+
+

Users

+ +
+ +
+ {users.map((user) => ( +
e.stopPropagation()} + > +
+
+ {user.name.slice(0, 2).toUpperCase()} +
+
+
{user.name}
+
{user.status}
+
+ +
+
+ ))} +
+
+ ) +} diff --git a/client/src/components/shared/LogsViewer.tsx b/client/src/components/shared/LogsViewer.tsx new file mode 100644 index 0000000..f40561b --- /dev/null +++ b/client/src/components/shared/LogsViewer.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useRef, useState } from 'react' + +interface LogLine { + timestamp: string + level: 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' + message: string +} + +interface LogsViewerProps { + pod: string + namespace: string +} + +export const LogsViewer: React.FC = ({ pod, namespace }) => { + const logsRef = useRef(null) + const [logs, setLogs] = useState([]) + const [autoScroll, setAutoScroll] = useState(true) + const [lastScrollTop, setLastScrollTop] = useState(0) + + useEffect(() => { + const ws = new WebSocket( + `/ws/logs?pod=${encodeURIComponent(pod)}&ns=${encodeURIComponent(namespace)}` + ) + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + if (data.type === 'log') { + setLogs((prev) => { + const newLogs = [...prev, data.log] + if (autoScroll && logsRef.current) { + logsRef.current.scrollTop = logsRef.current.scrollHeight + } + return newLogs + }) + } + } catch (err) { + console.error('Failed to parse log message:', err) + } + } + + ws.onclose = () => { + console.log('Logs WebSocket closed') + } + + return () => { + ws.close() + } + }, [pod, namespace, autoScroll]) + + const handleScroll = () => { + if (logsRef.current) { + const isAtBottom = + logsRef.current.scrollTop + logsRef.current.clientHeight >= + logsRef.current.scrollHeight - 10 + setAutoScroll(isAtBottom) + setLastScrollTop(logsRef.current.scrollTop) + } + } + + const formatLevel = (message: string): 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' => { + if (message.includes('ERROR') || message.includes('ERR')) return 'ERROR' + if (message.includes('WARN') || message.includes('WARNING')) return 'WARN' + if (message.includes('DEBUG')) return 'DEBUG' + return 'INFO' + } + + const getColor = (level: string): string => { + switch (level) { + case 'ERROR': + return '#ef6f6f' + case 'WARN': + return '#e8b54a' + case 'DEBUG': + return '#6b7280' + default: + return '#b9c6d8' + } + } + + return ( +
+ {logs.map((line, index) => ( +
+ {line.timestamp} + {line.message} +
+ ))} + {logs.length === 0 && ( +
+ No logs yet... +
+ )} +
+ ) +} diff --git a/client/src/components/shared/ShellTerminal.tsx b/client/src/components/shared/ShellTerminal.tsx new file mode 100644 index 0000000..bbdbaee --- /dev/null +++ b/client/src/components/shared/ShellTerminal.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useRef } from 'react' +import { Terminal } from 'xterm' +import { FitAddon } from 'xterm-addon-fit' + +interface ShellTerminalProps { + pod: string + namespace: string + onConnect?: () => void + onClose?: () => void +} + +export const ShellTerminal: React.FC = ({ + pod, + namespace, + onConnect, + onClose, +}) => { + const termRef = useRef(null) + const terminalRef = useRef(null) + const fitAddonRef = useRef(null) + + useEffect(() => { + if (!termRef.current) return + + const term = new Terminal({ + cursorBlink: true, + fontSize: 12, + fontFamily: 'IBM Plex Mono, monospace', + lineHeight: 1.5, + theme: { + background: '#1a1e26', + foreground: '#b9c6d8', + }, + }) + + const fitAddon = new FitAddon() + term.loadAddon(fitAddon) + + term.open(termRef.current) + fitAddon.fit() + + // Calculate size + const cols = Math.floor(termRef.current.clientWidth / 9) + const rows = Math.floor(termRef.current.clientHeight / 18) + term.resize(cols, rows) + + // Connect to WebSocket + const ws = new WebSocket( + `/ws/shell?pod=${encodeURIComponent(pod)}&ns=${encodeURIComponent(namespace)}` + ) + + ws.onopen = () => { + onConnect?.() + console.log('Shell connected') + } + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + if (data.type === 'shell:output') { + term.write(data.data) + } + } catch (err) { + console.error('Failed to parse shell message:', err) + } + } + + ws.onclose = () => { + console.log('Shell disconnected') + onClose?.() + } + + ws.onerror = (err) => { + console.error('Shell WebSocket error:', err) + } + + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'shell:input', data })) + } + }) + + term.onResize((size) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'shell:resize', cols: size.cols, rows: size.rows })) + } + }) + + terminalRef.current = term + fitAddonRef.current = fitAddon + + const handleResize = () => { + if (termRef.current && fitAddonRef.current) { + fitAddonRef.current.fit() + } + } + + window.addEventListener('resize', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + ws.close() + term.dispose() + } + }, [pod, namespace, onConnect, onClose]) + + return ( +
+ ) +} diff --git a/client/src/components/shared/Spotlight.tsx b/client/src/components/shared/Spotlight.tsx new file mode 100644 index 0000000..0428fcf --- /dev/null +++ b/client/src/components/shared/Spotlight.tsx @@ -0,0 +1,105 @@ +import React, { HTMLAttributes } from 'react' +import { useSpotlightStore } from '../../state/spotlightStore' + +interface SpotlightProps extends HTMLAttributes {} + +export const Spotlight: React.FC = ({ ...props }) => { + const { open, query, filterType, setQuery, setFilter, close, toggle } = useSpotlightStore() + + if (!open) return null + + const filters: { id: string; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'krate', label: 'Krates' }, + { id: 'namespace', label: 'Namespaces' }, + { id: 'pod', label: 'Pods' }, + { id: 'deployment', label: 'Deployments' }, + { id: 'service', label: 'Services' }, + ] + + return ( +
+
+
+ 🔍 + setQuery(e.target.value)} + placeholder="Search..." + autoFocus + style={{ + flex: 1, + background: 'transparent', + border: 'none', + color: '#fff', + fontSize: '16px', + outline: 'none', + }} + /> + +
+ +
+ {filters.map((f) => ( + + ))} +
+ +
+ Select: Enter, Close: Esc +
+
+
+ ) +} diff --git a/client/src/components/shared/SpotlightFilters.tsx b/client/src/components/shared/SpotlightFilters.tsx new file mode 100644 index 0000000..dbf455e --- /dev/null +++ b/client/src/components/shared/SpotlightFilters.tsx @@ -0,0 +1 @@ +export { SpotlightFilter } from './SpotlightFilters' diff --git a/client/src/components/shared/TopBar.tsx b/client/src/components/shared/TopBar.tsx new file mode 100644 index 0000000..628aff7 --- /dev/null +++ b/client/src/components/shared/TopBar.tsx @@ -0,0 +1,206 @@ +import React, { useEffect, useState } from 'react' +import { useCanvasStore } from '../../state/canvasStore' +import { useSpotlightStore } from '../../state/spotlightStore' +import { useUserStore } from '../../state/userStore' +import { AdminDrawer } from './AdminDrawer' + +export const TopBar: React.FC = () => { + const { zoom } = useCanvasStore() + const { open: spotlightOpen, toggle: toggleSpotlight } = useSpotlightStore() + const { currentUser, presence } = useUserStore() + const [adminOpen, setAdminOpen] = useState(false) + + const [synced, setSynced] = useState(true) + + useEffect(() => { + const interval = setInterval(() => { + setSynced((prev) => !prev) + }, 1000) + return () => clearInterval(interval) + }, []) + + const users = Array.from(presence.values()) + + return ( + <> +
+ {/* Left side */} +
+ {/* Logo pill */} +
+
+ + krates / yard + +
+ + {/* Cluster pill */} +
+ local +
+
+ + {/* Krate count pill */} + {users.length > 0 && ( +
+ + {users.length} active + +
+ )} +
+ + {/* Right side */} +
+ {/* Synced pill */} +
+ synced +
+
+ + {/* Admin button */} + + + {/* Roster avatars */} + {users.slice(0, 5).map((user) => ( +
+ {user.userId === currentUser.id ? '★' : user.name.slice(0, 2).toUpperCase()} +
+ ))} + {users.length > 5 && ( +
+ +{users.length - 5} +
+ )} +
+
+ + setAdminOpen(false)} users={users} /> + + ) +} diff --git a/client/src/components/shared/index.ts b/client/src/components/shared/index.ts new file mode 100644 index 0000000..effbbe1 --- /dev/null +++ b/client/src/components/shared/index.ts @@ -0,0 +1,5 @@ +export { Spotlight } from './Spotlight' +export { TopBar } from './TopBar' +export { AdminDrawer } from './AdminDrawer' +export { ShellTerminal } from './ShellTerminal' +export { LogsViewer } from './LogsViewer' diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts new file mode 100644 index 0000000..6cd19c8 --- /dev/null +++ b/client/src/hooks/index.ts @@ -0,0 +1,5 @@ +export { useCanvas } from './useCanvas' +export { useDebounce, useKeyPress, useResizeObserver } from './useUtils' +export { useKeyboardShortcuts } from './useKeyboardShortcuts' +export { fuzzySearch, fuzzyFilter } from '../utils/fuzzy' +export { initCRDT, getCRDTDoc, destroyCRDT } from '../utils/crdt' diff --git a/client/src/hooks/useCanvas.ts b/client/src/hooks/useCanvas.ts new file mode 100644 index 0000000..18d7d5a --- /dev/null +++ b/client/src/hooks/useCanvas.ts @@ -0,0 +1,49 @@ +import { useCanvasStore } from '../state/canvasStore' + +export const useCanvas = () => { + const { camX, camY, zoom, flying, dragging, collapsed, spacePanning } = useCanvasStore() + + const screenToWorld = (screenX: number, screenY: number) => { + return { + x: (screenX - window.innerWidth / 2) / zoom + camX, + y: (screenY - window.innerHeight / 2) / zoom + camY, + } + } + + const worldToScreen = (worldX: number, worldY: number) => { + return { + x: (worldX - camX) * zoom + window.innerWidth / 2, + y: (worldY - camY) * zoom + window.innerHeight / 2, + } + } + + const pan = (dx: number, dy: number) => { + useCanvasStore.getState().setCam(camX - dx / zoom, camY - dy / zoom) + } + + const zoomAt = (factor: number, centerX: number, centerY: number) => { + const state = useCanvasStore.getState() + const newZoom = Math.max(0.1, Math.min(5, state.zoom * factor)) + + const worldCenter = screenToWorld(centerX, centerY) + const newCamX = worldCenter.x - (centerX - window.innerWidth / 2) / newZoom + const newCamY = worldCenter.y - (centerY - window.innerHeight / 2) / newZoom + + useCanvasStore.getState().setZoom(newZoom) + useCanvasStore.getState().setCam(newCamX, newCamY) + } + + return { + camX, + camY, + zoom, + flying, + dragging, + collapsed, + spacePanning, + screenToWorld, + worldToScreen, + pan, + zoomAt, + } +} diff --git a/client/src/hooks/useKeyboardShortcuts.ts b/client/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..2ccf769 --- /dev/null +++ b/client/src/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,41 @@ +import { useEffect } from 'react' + +export const useKeyboardShortcuts = (handlers: { + onOpenSpotlight: () => void + onCloseSpotlight: () => void + onNavigateUp: () => void + onNavigateDown: () => void + onNavigateLeft: () => void + onNavigateRight: () => void + onSelect: () => void +}) => { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey || e.metaKey) return + + switch (e.key) { + case 'Escape': + handlers.onCloseSpotlight() + break + case 'k': + handlers.onNavigateUp() + break + case 'j': + handlers.onNavigateDown() + break + case 'h': + handlers.onNavigateLeft() + break + case 'l': + handlers.onNavigateRight() + break + case 'Enter': + handlers.onSelect() + break + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [handlers]) +} diff --git a/client/src/hooks/useKubernetes.ts b/client/src/hooks/useKubernetes.ts new file mode 100644 index 0000000..a7748be --- /dev/null +++ b/client/src/hooks/useKubernetes.ts @@ -0,0 +1 @@ +export { useKubernetes } from './useKubernetes' diff --git a/client/src/hooks/useSpotlight.ts b/client/src/hooks/useSpotlight.ts new file mode 100644 index 0000000..eea4843 --- /dev/null +++ b/client/src/hooks/useSpotlight.ts @@ -0,0 +1,53 @@ +import { useMemo } from 'react' + +export const useSpotlight = () => { + const fuzzy = (query: string, text: string): number => { + if (!query) return 0 + + const queryLower = query.toLowerCase() + const textLower = text.toLowerCase() + + let score = 0 + let queryIndex = 0 + + for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { + if (textLower[i] === queryLower[queryIndex]) { + score++ + queryIndex++ + } + } + + return score / Math.max(query.length, text.length) + } + + const filterItems = ( + items: T[], + query: string, + filterType: string | null + ): T[] => { + return items.filter((item) => { + const matchesQuery = fuzzy(query, item.name) > 0.3 + const matchesType = !filterType || filterType === 'all' || item.type === filterType + return matchesQuery && matchesType + }) + } + + const debounce = any>(fn: T, delay: number) => { + let timeoutId: NodeJS.Timeout | null = null + return (...args: Parameters): Promise> => { + return new Promise((resolve) => { + if (timeoutId) clearTimeout(timeoutId) + timeoutId = setTimeout(() => resolve(fn(...args)), delay) + }) + } + } + + return useMemo( + () => ({ + fuzzy, + filterItems, + debounce, + }), + [] + ) +} diff --git a/client/src/hooks/useUtils.ts b/client/src/hooks/useUtils.ts new file mode 100644 index 0000000..4ce07e2 --- /dev/null +++ b/client/src/hooks/useUtils.ts @@ -0,0 +1,50 @@ +import { useState, useEffect, useCallback } from 'react' + +export const useDebounce = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + return debouncedValue +} + +export const useKeyPress = (targetKey: string, callback: () => void) => { + useEffect(() => { + const downHandler = ({ key }: KeyboardEvent) => { + if (key.toLowerCase() === targetKey.toLowerCase()) { + callback() + } + } + + window.addEventListener('keydown', downHandler) + return () => window.removeEventListener('keydown', downHandler) + }, [targetKey, callback]) +} + +export const useResizeObserver = ( + ref: React.RefObject, + onResize: (rect: DOMRectReadOnly) => void +) => { + useEffect(() => { + const element = ref.current + if (!element) return + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + onResize(entry.contentRect) + } + }) + + observer.observe(element) + + return () => observer.disconnect() + }, [ref, onResize]) +} diff --git a/client/src/hooks/useWebSocket.ts b/client/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..0864762 --- /dev/null +++ b/client/src/hooks/useWebSocket.ts @@ -0,0 +1 @@ +export { useWebSocket } from './useWebSocket' diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..ee3f745 --- /dev/null +++ b/client/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { App } from './App' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/client/src/state/canvasStore.ts b/client/src/state/canvasStore.ts new file mode 100644 index 0000000..fee5c0f --- /dev/null +++ b/client/src/state/canvasStore.ts @@ -0,0 +1,41 @@ +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +export interface CanvasState { + camX: number + camY: number + zoom: number + flying: boolean + dragging: boolean + collapsed: boolean + spacePanning: boolean +} + +const INITIAL_STATE: CanvasState = { + camX: 0, + camY: 0, + zoom: 1, + flying: false, + dragging: false, + collapsed: false, + spacePanning: false, +} + +export const useCanvasStore = create()( + devtools( + persist( + (set) => ({ + ...INITIAL_STATE, + setCam: (x: number, y: number) => set({ camX: x, camY: y }), + setZoom: (zoom: number) => set({ zoom }), + setFlying: (flying: boolean) => set({ flying }), + setDragging: (dragging: boolean) => set({ dragging }), + setCollapsed: (collapsed: boolean) => set({ collapsed }), + setSpacePanning: (spacePanning: boolean) => set({ spacePanning }), + }), + { + name: 'canvas-state', + } + ) + ) +) diff --git a/client/src/state/index.ts b/client/src/state/index.ts new file mode 100644 index 0000000..80fe0a3 --- /dev/null +++ b/client/src/state/index.ts @@ -0,0 +1,4 @@ +export { useCanvasStore } from './canvasStore' +export { useKrateStore } from './krateStore' +export { useSpotlightStore } from './spotlightStore' +export { useUserStore } from './userStore' diff --git a/client/src/state/krateStore.ts b/client/src/state/krateStore.ts new file mode 100644 index 0000000..562cf4f --- /dev/null +++ b/client/src/state/krateStore.ts @@ -0,0 +1,61 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +export interface Krate { + id: string + type: string + title: string + x: number + y: number + width: number + height: number + minimized: boolean + windowLayout: WindowLayout + windows: Map +} + +export interface WindowLayout { + cols: number + rows: number + cells: Map +} + +export interface KrateWindow { + id: string + type: string + title: string + state: unknown +} + +export interface KrateStore { + krates: Map + selectedKrateId: string | null +} + +const INITIAL_STATE: KrateStore = { + krates: new Map(), + selectedKrateId: null, +} + +export const useKrateStore = create()( + devtools((set) => ({ + ...INITIAL_STATE, + addKrate: (krate: Krate) => + set((state) => ({ krates: new Map(state.krates).set(krate.id, krate) })), + removeKrate: (id: string) => + set((state) => { + const newKrates = new Map(state.krates) + newKrates.delete(id) + return { krates: newKrates } + }), + selectKrate: (id: string | null) => set({ selectedKrateId: id }), + updateKrate: (id: string, updates: Partial) => + set((state) => { + const existing = state.krates.get(id) + if (!existing) return state + return { + krates: new Map(state.krates).set(id, { ...existing, ...updates }), + } + }), + })) +) diff --git a/client/src/state/spotlightStore.ts b/client/src/state/spotlightStore.ts new file mode 100644 index 0000000..ca2835e --- /dev/null +++ b/client/src/state/spotlightStore.ts @@ -0,0 +1,32 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +export interface SpotlightState { + open: boolean + query: string + filterType: string | null + sel: number + navigated: boolean +} + +export type SpotlightFilter = 'all' | 'krate' | 'namespace' | 'pod' | 'deployment' | 'service' + +const INITIAL_STATE: SpotlightState = { + open: false, + query: '', + filterType: null, + sel: 0, + navigated: false, +} + +export const useSpotlightStore = create()( + devtools((set) => ({ + ...INITIAL_STATE, + setOpen: (open: boolean) => set({ open }), + setQuery: (query: string) => set({ query, navigated: false, sel: 0 }), + setFilter: (filterType: SpotlightFilter | null) => set({ filterType }), + setSel: (sel: number) => set({ sel, navigated: true }), + toggle: () => set((state) => ({ open: !state.open })), + close: () => set({ open: false, query: '', filterType: null, sel: 0, navigated: false }), + })) +) diff --git a/client/src/state/userStore.ts b/client/src/state/userStore.ts new file mode 100644 index 0000000..4a4e97d --- /dev/null +++ b/client/src/state/userStore.ts @@ -0,0 +1,56 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +export interface User { + id: string + name: string + avatar?: string +} + +export interface UserPresence { + userId: string + name: string + color: string + cursorPosition?: { x: number; y: number } + activeKrateId?: string + spotlightQuery?: string + timestamp: number +} + +export interface UserStore { + currentUser: User + presence: Map +} + +const INITIAL_STATE: UserStore = { + currentUser: { + id: 'local-user', + name: 'User', + }, + presence: new Map(), +} + +export const useUserStore = create()( + devtools((set) => ({ + ...INITIAL_STATE, + setCurrentUser: (user: User) => set({ currentUser: user }), + addPresence: (presence: UserPresence) => + set((state) => ({ + presence: new Map(state.presence).set(presence.userId, presence), + })), + removePresence: (userId: string) => + set((state) => { + const newPresence = new Map(state.presence) + newPresence.delete(userId) + return { presence: newPresence } + }), + updatePresence: (userId: string, updates: Partial) => + set((state) => { + const existing = state.presence.get(userId) + if (!existing) return state + return { + presence: new Map(state.presence).set(userId, { ...existing, ...updates }), + } + }), + })) +) diff --git a/client/src/utils/crdt.ts b/client/src/utils/crdt.ts new file mode 100644 index 0000000..1901430 --- /dev/null +++ b/client/src/utils/crdt.ts @@ -0,0 +1,37 @@ +import * as Y from 'yjs' + +let doc: Y.Doc | null = null +let provider: any | null = null + +export const initCRDT = (roomName: string, signalingUrl: string) => { + doc = new Y.Doc() + + provider = new (require('y-webrtc').WebrtcProvider)( + roomName, + doc, + { + signaling: [signalingUrl], + } + ) + + provider.on('status', (status: string) => { + console.log('[CRDT] Status:', status) + }) + + return doc +} + +export const getCRDTDoc = (): Y.Doc | null => { + return doc +} + +export const getCRDTProvider = () => { + return provider +} + +export const destroyCRDT = () => { + provider?.destroy() + doc?.destroy() + doc = null + provider = null +} diff --git a/client/src/utils/fuzzy.ts b/client/src/utils/fuzzy.ts new file mode 100644 index 0000000..aaf6912 --- /dev/null +++ b/client/src/utils/fuzzy.ts @@ -0,0 +1,50 @@ +const MAX_DISTANCE = 1000 + +export const fuzzySearch = (query: string, text: string): number => { + if (!query) return 0 + + const queryLower = query.toLowerCase() + const textLower = text.toLowerCase() + + if (textLower.startsWith(queryLower)) { + return 1 - query.length / text.length + } + + let score = 0 + let queryIndex = 0 + + for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { + if (textLower[i] === queryLower[queryIndex]) { + score++ + queryIndex++ + } + } + + if (score === 0) return 0 + + const matchRatio = score / query.length + const positionBonus = queryIndex === query.length ? 0.2 : 0 + const lengthPenalty = Math.min(1, text.length / MAX_DISTANCE) + + return (matchRatio * 0.7 + positionBonus) * lengthPenalty +} + +export const fuzzyFilter = ( + items: T[], + query: string, + typeFilter?: string +): T[] => { + if (!query && !typeFilter) return items + + return items + .map((item) => ({ + item, + score: fuzzySearch(query, item.name), + })) + .filter( + ({ item, score }) => + score > 0.3 && (!typeFilter || typeFilter === 'all' || item.type === typeFilter) + ) + .sort((a, b) => b.score - a.score) + .map(({ item }) => item) +} diff --git a/client/src/utils/keyboard.ts b/client/src/utils/keyboard.ts new file mode 100644 index 0000000..512c55a --- /dev/null +++ b/client/src/utils/keyboard.ts @@ -0,0 +1,27 @@ +export const isKey = (e: KeyboardEvent | React.KeyboardEvent, key: string): boolean => { + return e.key.toLowerCase() === key.toLowerCase() +} + +export const isModifierKey = (e: KeyboardEvent | React.KeyboardEvent): boolean => { + return e.ctrlKey || e.metaKey || e.altKey +} + +export const getMouseClickPosition = (e: React.MouseEvent | MouseEvent) => { + return { + x: e.clientX, + y: e.clientY, + } +} + +export const getKeyboardSelectionDelta = (e: KeyboardEvent | React.KeyboardEvent): { dx: number; dy: number } => { + const step = e.shiftKey ? 10 : 1 + let dx = 0 + let dy = 0 + + if (e.key === 'ArrowUp') dy = -step + else if (e.key === 'ArrowDown') dy = step + else if (e.key === 'ArrowLeft') dx = -step + else if (e.key === 'ArrowRight') dx = step + + return { dx, dy } +} diff --git a/client/src/utils/math.ts b/client/src/utils/math.ts new file mode 100644 index 0000000..6a270a7 --- /dev/null +++ b/client/src/utils/math.ts @@ -0,0 +1,41 @@ +export const degreesToRadians = (degrees: number): number => { + return (degrees * Math.PI) / 180 +} + +export const radiansToDegrees = (radians: number): number => { + return (radians * 180) / Math.PI +} + +export const rotatePoint = ( + x: number, + y: number, + centerX: number, + centerY: number, + angleDegrees: number +): { x: number; y: number } => { + const angle = degreesToRadians(angleDegrees) + const cos = Math.cos(angle) + const sin = Math.sin(angle) + + const dx = x - centerX + const dy = y - centerY + + return { + x: centerX + dx * cos - dy * sin, + y: centerY + dx * sin + dy * cos, + } +} + +export const distance = (x1: number, y1: number, x2: number, y2: number): number => { + const dx = x2 - x1 + const dy = y2 - y1 + return Math.sqrt(dx * dx + dy * dy) +} + +export const clamp = (value: number, min: number, max: number): number => { + return Math.min(Math.max(value, min), max) +} + +export const lerp = (start: number, end: number, t: number): number => { + return start * (1 - t) + end * t +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..69e0a5b --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "noCheckFail": false + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000..2f5a667 --- /dev/null +++ b/client/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + origin: 'http://localhost:3000', + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + '/ws': { + target: 'ws://localhost:8080', + changeOrigin: true, + ws: true, + }, + }, + }, +}) diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go new file mode 100644 index 0000000..6f2ac10 --- /dev/null +++ b/server/cmd/server/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + + "krates/server/internal/api" + "krates/server/internal/crdt" + "krates/server/internal/ws" +) + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + crdtProvider := crdt.NewProvider() + + wsManager := ws.NewManager(crdtProvider) + + mux := http.NewServeMux() + mux.Handle("/ws/shell", wsManager.WithWebSocket(ws.ShellHandler)) + mux.Handle("/ws/logs", wsManager.WithWebSocket(ws.LogsHandler)) + mux.Handle("/ws/watch", wsManager.WithWebSocket(ws.WatchHandler)) + mux.Handle("/ws/sync", wsManager.WithWebSocket(ws.SyncHandler)) + + apiRoutes := api.SetupRoutes(wsManager) + mux.Handle("/api/", http.StripPrefix("/api", apiRoutes)) + + http.ListenAndServe(":"+port, mux) + log.Printf("Server started on port %s", port) +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..4e2c241 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,10 @@ +module krates/server + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.3 + github.com/yjs/y-websocket v1.2.10 + k8s.io/client-go v0.29.0 + github.com/olekukonko/tablewriter v0.0.5 +)