Initial skeleton: React/TS frontend + Go backend structure

This commit is contained in:
Hermes Agent
2026-06-16 08:51:55 -04:00
parent 78f19cde7d
commit 33c6648b84
40 changed files with 1799 additions and 0 deletions

1
client/.eslintignore Normal file
View File

@@ -0,0 +1 @@
module: "eslint"

32
client/eslint.config.mjs Normal file
View File

@@ -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 },
],
},
},
)

39
client/index.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Krates Yard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'IBM Plex Sans', sans-serif;
background: #0b0e13;
color: #fff;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

33
client/package.json Normal file
View File

@@ -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"
}
}

6
client/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

63
client/src/App.tsx Normal file
View File

@@ -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 (
<div style={canvasStyle}>
<TopBar />
<Spotlight />
<GridOverlay />
<WorldLayer />
<Minimap />
{/* Bottom UI */}
<div
style={{
position: 'absolute',
left: '18px',
bottom: '18px',
background: 'rgba(14,18,25,.82)',
border: '1px solid rgba(140,165,200,.18)',
borderRadius: '9px',
padding: '6px 12px',
backdropFilter: 'blur(6px)',
}}
>
<span style={{ color: '#aaa', fontSize: '12px' }}>
{Math.round(zoom * 100)}%
</span>
</div>
<div
style={{
position: 'absolute',
right: '18px',
bottom: '18px',
color: '#888',
fontSize: '12px',
background: 'rgba(14,18,25,.82)',
padding: '8px 12px',
borderRadius: '6px',
}}
>
<span>Space + drag to pan</span>
<span style={{ marginLeft: '12px' }}>Scroll to pan</span>
<span style={{ marginLeft: '12px' }}>Click to spotlight</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import React, { HTMLAttributes } from 'react'
import { useCanvasStore } from '../state/canvasStore'
interface GridOverlayProps extends HTMLAttributes<HTMLDivElement> {}
export const GridOverlay: React.FC<GridOverlayProps> = ({ ...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 <div style={style} {...props} />
}

View File

@@ -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<HTMLDivElement> {}
export const Minimap: React.FC<MinimapProps> = ({ ...props }) => {
const { camX, camY, zoom } = useCanvasStore()
const { krates } = useKrateStore()
const minimapRef = useRef<HTMLDivElement>(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 (
<div
ref={minimapRef}
onClick={handleClick}
style={{
position: 'absolute',
right: '18px',
bottom: '64px',
width: minimapWidth,
height: minimapHeight,
background: 'rgba(16,20,28,.97)',
border: '1px solid rgba(140,165,200,.2)',
borderRadius: '4px',
overflow: 'hidden',
zIndex: 30,
}}
{...props}
>
{Array.from(krates.values()).map((krate) => (
<div
key={krate.id}
style={{
position: 'absolute',
left: krate.x * minimapScaleX,
top: krate.y * minimapScaleY,
width: krate.width * minimapScaleX,
height: krate.height * minimapScaleY,
backgroundColor: krate.color || '#6fb1ff',
opacity: 0.7,
}}
/>
))}
<div
style={{
position: 'absolute',
left: viewportX,
top: viewportY,
width: viewportW,
height: viewportH,
border: '1px solid #4dd6e8',
borderColor: 'rgba(77, 214, 232, 0.4)',
boxSizing: 'border-box',
}}
/>
</div>
)
}

View File

@@ -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<HTMLDivElement> {}
export const WorldLayer: React.FC<WorldLayerProps> = ({ ...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 (
<div style={style} {...props}>
{Array.from(krates.values())
.filter((k) => !k.minimized)
.map((krate) => (
<Krate key={krate.id} krate={krate} collapsed={collapsed} />
))}
</div>
)
}

View File

@@ -0,0 +1,3 @@
export { WorldLayer } from './WorldLayer'
export { GridOverlay } from './GridOverlay'
export { Minimap } from './Minimap'

View File

@@ -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<string, { col: number; row: number; colSpan: number; rowSpan: number }>
}
windows: Map<string, KrateWindow>
}
interface KrateWindow {
id: string
type: string
title: string
state: unknown
}
export const Krate: React.FC<KrateProps> = ({ krate, collapsed }) => {
const { selectKrate } = useKrateStore()
const [isDragging, setIsDragging] = useState(false)
if (collapsed) {
return (
<div
style={{
position: 'absolute',
left: krate.x,
top: krate.y,
width: '230px',
height: 'auto',
padding: '12px',
background: krate.color ? `rgba(${hexToRgba(krate.color)}, 0.04)` : 'rgba(255,255,255,0.04)',
border: `1px dashed ${krate.color || '#6fb1ff'}4D`,
borderRadius: '18px',
cursor: 'pointer',
}}
onClick={() => selectKrate(krate.id)}
>
<div style={{ fontWeight: '600', color: krate.color || '#6fb1ff' }}>
{krate.title}
</div>
<div style={{ fontSize: '12px', color: '#888' }}>
{krate.windows.size > 0 ? '..."' : 'Empty'}
</div>
</div>
)
}
return (
<div
style={{
position: 'absolute',
left: krate.x,
top: krate.y,
width: krate.width,
height: krate.height,
background: krate.color ? `rgba(${hexToRgba(krate.color)}, 0.04)` : 'rgba(255,255,255,0.04)',
border: `1px dashed ${krate.color || '#6fb1ff'}4D`,
borderRadius: '18px',
}}
onMouseDown={() => selectKrate(krate.id)}
>
<KrateHeader krate={krate} onDragStart={() => setIsDragging(true)} />
<div
style={{
padding: '30px',
display: 'grid',
gridTemplateColumns: `repeat(${krate.windowLayout.cols}, 1fr)`,
gap: '16px',
}}
>
{Array.from(krate.windows.values()).map((window) => (
<div
key={window.id}
style={{
backgroundColor: '#1a1e26',
border: '1px solid rgba(140,165,200,0.18)',
borderRadius: '8px',
padding: '12px',
minHeight: '100px',
}}
>
{window.title}
</div>
))}
</div>
</div>
)
}
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<KrateProps & { onDragStart: () => void }> = ({
krate,
onDragStart,
}) => {
return (
<div
style={{
padding: '8px 12px',
borderBottom: `1px solid ${krate.color || '#6fb1ff'}33`,
cursor: 'grab',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseDown={(e) => {
e.stopPropagation()
onDragStart()
}}
>
<span style={{ fontWeight: '600', flex: 1 }}>{krate.title}</span>
<span style={{ fontSize: '12px', color: '#888' }}>
{krate.windows.size} windows
</span>
<button
style={{
background: 'none',
border: 'none',
color: '#888',
cursor: 'pointer',
padding: '4px',
}}
onClick={(e) => {
e.stopPropagation()
alert('Minimize todo')
}}
>
</button>
<button
style={{
background: 'none',
border: 'none',
color: '#888',
cursor: 'pointer',
padding: '4px',
}}
onClick={(e) => {
e.stopPropagation()
alert('Delete todo')
}}
>
×
</button>
</div>
)
}

View File

@@ -0,0 +1 @@
export { Krate, KrateHeader } from './Krate'

View File

@@ -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<AdminDrawerProps> = ({ 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 (
<div
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '380px',
zIndex: 56,
background: 'rgba(13,17,24,.98)',
borderLeft: '1px solid rgba(140,165,200,.2)',
overflowY: 'auto',
}}
onClick={onClose}
>
<div
style={{
padding: '16px',
borderBottom: '1px solid rgba(140,165,200,0.18)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h2 style={{ margin: 0, color: '#fff', fontSize: '16px' }}>Users</h2>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
color: '#888',
cursor: 'pointer',
fontSize: '20px',
}}
>
×
</button>
</div>
<div style={{ padding: '16px' }}>
{users.map((user) => (
<div
key={user.userId}
style={{
marginBottom: '16px',
padding: '12px',
background: 'rgba(255,255,255,0.02)',
borderRadius: '8px',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: user.color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontWeight: '600',
}}
>
{user.name.slice(0, 2).toUpperCase()}
</div>
<div style={{ flex: 1 }}>
<div style={{ color: '#fff', fontWeight: '600' }}>{user.name}</div>
<div style={{ color: '#888', fontSize: '12px' }}>{user.status}</div>
</div>
<button
onClick={() => handleSpectate(user.userId)}
style={{
background: '#4dd6e8',
border: 'none',
borderRadius: '4px',
padding: '6px 10px',
color: '#000',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
}}
>
Spectate
</button>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -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<LogsViewerProps> = ({ pod, namespace }) => {
const logsRef = useRef<HTMLDivElement>(null)
const [logs, setLogs] = useState<LogLine[]>([])
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 (
<div
ref={logsRef}
onScroll={handleScroll}
style={{
height: '286px',
overflow: 'auto',
fontFamily: 'IBM Plex Mono, monospace',
fontSize: '12px',
lineHeight: '1.65',
WebkitOverflowScrolling: 'touch',
}}
>
{logs.map((line, index) => (
<div
key={index}
style={{ color: getColor(line.level), whiteSpace: 'pre-wrap' }}
>
<span style={{ color: '#6b7280' }}>{line.timestamp} </span>
{line.message}
</div>
))}
{logs.length === 0 && (
<div style={{ color: '#888', textAlign: 'center', padding: '40px' }}>
No logs yet...
</div>
)}
</div>
)
}

View File

@@ -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<ShellTerminalProps> = ({
pod,
namespace,
onConnect,
onClose,
}) => {
const termRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(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 (
<div
ref={termRef}
style={{
overflow: 'hidden',
fontFamily: 'IBM Plex Mono, monospace',
fontSize: '12px',
lineHeight: '1.65',
}}
/>
)
}

View File

@@ -0,0 +1,105 @@
import React, { HTMLAttributes } from 'react'
import { useSpotlightStore } from '../../state/spotlightStore'
interface SpotlightProps extends HTMLAttributes<HTMLDivElement> {}
export const Spotlight: React.FC<SpotlightProps> = ({ ...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 (
<div
style={{
position: 'absolute',
top: '100px',
left: '50%',
transform: 'translateX(-50%)',
width: '600px',
zIndex: 100,
}}
{...props}
>
<div
style={{
background: '#1a1e26',
border: '1px solid rgba(140,165,200,0.18)',
borderRadius: '12px',
overflow: 'hidden',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
borderBottom: '1px solid rgba(140,165,200,0.18)',
}}
>
<span style={{ color: '#888' }}>🔍</span>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
autoFocus
style={{
flex: 1,
background: 'transparent',
border: 'none',
color: '#fff',
fontSize: '16px',
outline: 'none',
}}
/>
<button
onClick={close}
style={{
background: 'none',
border: 'none',
color: '#888',
cursor: 'pointer',
padding: '4px',
}}
>
Esc
</button>
</div>
<div style={{ padding: '8px 16px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{filters.map((f) => (
<button
key={f.id}
style={{
padding: '4px 12px',
borderRadius: '12px',
background: filterType === f.id ? '#4dd6e8' : '#2a3040',
color: filterType === f.id ? '#000' : '#aaa',
border: 'none',
cursor: 'pointer',
fontSize: '12px',
}}
onClick={() => setFilter(f.id as string)}
>
{f.label}
</button>
))}
</div>
<div style={{ padding: '8px 16px', color: '#888', fontSize: '12px' }}>
Select: Enter, Close: Esc
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { SpotlightFilter } from './SpotlightFilters'

View File

@@ -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 (
<>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
height: '56px',
display: 'flex',
alignItems: 'center',
padding: '13px 18px',
}}
>
{/* Left side */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1 }}>
{/* Logo pill */}
<div
style={{
background: 'rgba(14,18,25,.82)',
border: '1px solid rgba(140,165,200,.18)',
borderRadius: '9px',
padding: '7px 12px',
backdropFilter: 'blur(6px)',
display: 'flex',
alignItems: 'center',
gap: '6px',
cursor: 'pointer',
}}
onClick={toggleSpotlight}
>
<div
style={{
width: '12px',
height: '12px',
background: '#4dd6e8',
clipPath: 'polygon(50% 0,100% 50%,50% 100%,0 50%)',
}}
/>
<span style={{ color: '#fff', fontSize: '14px', fontWeight: '600' }}>
krates / yard
</span>
</div>
{/* Cluster pill */}
<div
style={{
background: 'rgba(14,18,25,.82)',
border: '1px solid rgba(140,165,200,.18)',
borderRadius: '9px',
padding: '7px 12px',
display: 'flex',
alignItems: 'center',
gap: '8px',
backdropFilter: 'blur(6px)',
}}
>
<span style={{ color: '#fff', fontSize: '14px' }}>local</span>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: '#4ad07a',
boxShadow: '0 0 8px #4ad07a',
}}
/>
</div>
{/* Krate count pill */}
{users.length > 0 && (
<div
style={{
background: 'rgba(14,18,25,.82)',
border: '1px solid rgba(140,165,200,.18)',
borderRadius: '9px',
padding: '7px 12px',
backdropFilter: 'blur(6px)',
}}
>
<span style={{ color: '#4dd6e8', fontSize: '14px' }}>
{users.length} active
</span>
</div>
)}
</div>
{/* Right side */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{/* Synced pill */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px 10px',
borderRadius: '9px',
background: 'rgba(14,18,25,.82)',
border: '1px solid rgba(140,165,200,.18)',
backdropFilter: 'blur(6px)',
}}
>
<span style={{ color: '#aaa', fontSize: '12px' }}>synced</span>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: '#4dd6e8',
opacity: synced ? 1 : 0.5,
animation: synced ? 'pulse 1.6s ease-in-out infinite' : 'none',
}}
/>
</div>
{/* Admin button */}
<button
onClick={() => setAdminOpen(!adminOpen)}
style={{
background: adminOpen ? 'rgba(77,214,232,0.1)' : 'rgba(14,18,25,.82)',
border: adminOpen
? '1px solid #4dd6e8'
: '1px solid rgba(140,165,200,.18)',
borderRadius: '9px',
padding: '6px 12px',
backdropFilter: 'blur(6px)',
color: adminOpen ? '#4dd6e8' : '#aaa',
fontSize: '14px',
cursor: 'pointer',
}}
>
admin
</button>
{/* Roster avatars */}
{users.slice(0, 5).map((user) => (
<div
key={user.userId}
style={{
width: '30px',
height: '30px',
borderRadius: '50%',
background: user.color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
color: '#fff',
fontWeight: '600',
cursor: 'pointer',
border: user.userId === currentUser.id ? '2px solid #4dd6e8' : 'none',
}}
title={user.name}
>
{user.userId === currentUser.id ? '★' : user.name.slice(0, 2).toUpperCase()}
</div>
))}
{users.length > 5 && (
<div
style={{
width: '30px',
height: '30px',
borderRadius: '50%',
background: '#2a3040',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
color: '#aaa',
cursor: 'pointer',
}}
>
+{users.length - 5}
</div>
)}
</div>
</div>
<AdminDrawer open={adminOpen} onClose={() => setAdminOpen(false)} users={users} />
</>
)
}

View File

@@ -0,0 +1,5 @@
export { Spotlight } from './Spotlight'
export { TopBar } from './TopBar'
export { AdminDrawer } from './AdminDrawer'
export { ShellTerminal } from './ShellTerminal'
export { LogsViewer } from './LogsViewer'

View File

@@ -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'

View File

@@ -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,
}
}

View File

@@ -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])
}

View File

@@ -0,0 +1 @@
export { useKubernetes } from './useKubernetes'

View File

@@ -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 = <T extends { name: string; type: string }>(
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 = <T extends (...args: any[]) => any>(fn: T, delay: number) => {
let timeoutId: NodeJS.Timeout | null = null
return (...args: Parameters<T>): Promise<ReturnType<T>> => {
return new Promise((resolve) => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => resolve(fn(...args)), delay)
})
}
}
return useMemo(
() => ({
fuzzy,
filterItems,
debounce,
}),
[]
)
}

View File

@@ -0,0 +1,50 @@
import { useState, useEffect, useCallback } from 'react'
export const useDebounce = <T>(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<HTMLElement>,
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])
}

View File

@@ -0,0 +1 @@
export { useWebSocket } from './useWebSocket'

9
client/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -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<CanvasState>()(
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',
}
)
)
)

View File

@@ -0,0 +1,4 @@
export { useCanvasStore } from './canvasStore'
export { useKrateStore } from './krateStore'
export { useSpotlightStore } from './spotlightStore'
export { useUserStore } from './userStore'

View File

@@ -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<string, KrateWindow>
}
export interface WindowLayout {
cols: number
rows: number
cells: Map<string, { col: number; row: number; colSpan: number; rowSpan: number }>
}
export interface KrateWindow {
id: string
type: string
title: string
state: unknown
}
export interface KrateStore {
krates: Map<string, Krate>
selectedKrateId: string | null
}
const INITIAL_STATE: KrateStore = {
krates: new Map(),
selectedKrateId: null,
}
export const useKrateStore = create<KrateStore>()(
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<Krate>) =>
set((state) => {
const existing = state.krates.get(id)
if (!existing) return state
return {
krates: new Map(state.krates).set(id, { ...existing, ...updates }),
}
}),
}))
)

View File

@@ -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<SpotlightState>()(
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 }),
}))
)

View File

@@ -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<string, UserPresence>
}
const INITIAL_STATE: UserStore = {
currentUser: {
id: 'local-user',
name: 'User',
},
presence: new Map(),
}
export const useUserStore = create<UserStore>()(
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<UserPresence>) =>
set((state) => {
const existing = state.presence.get(userId)
if (!existing) return state
return {
presence: new Map(state.presence).set(userId, { ...existing, ...updates }),
}
}),
}))
)

37
client/src/utils/crdt.ts Normal file
View File

@@ -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
}

50
client/src/utils/fuzzy.ts Normal file
View File

@@ -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 = <T extends { name: string; type?: string }>(
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)
}

View File

@@ -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 }
}

41
client/src/utils/math.ts Normal file
View File

@@ -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
}

22
client/tsconfig.json Normal file
View File

@@ -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"]
}

21
client/vite.config.ts Normal file
View File

@@ -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,
},
},
},
})

35
server/cmd/server/main.go Normal file
View File

@@ -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)
}

10
server/go.mod Normal file
View File

@@ -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
)