feat(infra): cluster dashboard + VCN shim indentation fix

- Add LyteNyte Grid cluster dashboard (React + FastAPI + k3s)
  - Real-time telemetry: GPU util/VRAM/temp, CPU/memory, pod counts,
    Tailscale connectivity, OS/kernel info for all 5 cluster nodes
  - WebSocket live updates every 3s, dark theme, virtualized grid
  - k3s deployment with hostNetwork, NodePort 30820, SSH-based collectors
  - Backend uses /proc/stat + /proc/meminfo for portable NixOS metrics
  - Tailscale status via SSH to host (container doesn't run tailscaled)
- Fix vcn_compute_substrate.py indentation (lines 45-269 had 1 extra
  leading space). Script now compiles and runs encode/decode/receipt.

Build: py_compile OK, npm build OK, podman build OK
This commit is contained in:
Brandon Schneider 2026-05-28 01:13:54 -05:00
parent 4cad34faf4
commit d86625e220
14 changed files with 3651 additions and 0 deletions

View file

@ -0,0 +1,624 @@
#!/usr/bin/env python3
"""
VCN Compute Substrate - Use AMD VCN H.264 hardware encoder as computation device.
This shim implements the "Steam Deck MKV trick" from UNIFIED_TRANSPORT_ENCODING_SPEC.md:
- Pack computation data into 1920×1080 YUV420 video frames
- Use AMD AMF H.264 hardware encoder for computation
- Extract results from encoded bitstream (CRC32, transform coefficients, motion vectors)
Shim boundary: I/O only. No decision logic. All computation mapping decisions
belong in Lean (Semantics.MeshRouting and future VCN computation modules).
Usage:
python3 vcn_compute_substrate.py encode <input_data.bin> <output.mkv>
python3 vcn_compute_substrate.py decode <input.mkv> <output_data.bin>
python3 vcn_compute_substrate.py extract_receipt <input.mkv> <receipt.json>
"""
import struct
import subprocess
import json
import zlib
from pathlib import Path
from typing import Tuple, List, Optional
from dataclasses import dataclass, field, asdict
# Frame constants from UNIFIED_TRANSPORT_ENCODING_SPEC.md
FRAME_WIDTH = 1920
FRAME_HEIGHT = 1080
YUV420_FRAME_SIZE = 3_110_400 # 1920*1080 + 960*540 + 960*540
SIGNATURE_HEADER = b"RDMAVCN\0"
SIGNATURE_SIZE = 24
# Encoder settings for computation mode (software encoding for initial testing)
ENCODER_PROFILE = "main"
ENCODER_LEVEL = "4"
QP_MIN = 2 # Minimal quantization for precise computation
QP_MAX = 4
TRANSFORM_SKIP = True
DEBLOCKING = False
SAO = False
# ── Resolution / Frame Rate Catalog ──────────────────────────────────────────
VCN_RESOLUTIONS = {
"240p": (320, 240),
"360p": (640, 360),
"480p": (854, 480),
"720p": (1280, 720),
"1080p": (1920, 1080),
"1440p": (2560, 1440),
"4K": (3840, 2160),
"5K": (5120, 2880),
"8K": (7680, 4320),
"16K": (15360, 8640),
}
VCN_FRAME_RATES = [30, 60, 120, 144, 240]
RESOLUTION_ORDER = list(VCN_RESOLUTIONS.keys())
@dataclass
class VCNHardwareCapabilities:
"""Detected hardware encoder/decoder capabilities."""
supported_resolutions: List[Tuple[int, int]] = field(default_factory=lambda: [(1920, 1080)])
supported_frame_rates: List[int] = field(default_factory=lambda: [30, 60])
available_encoders: List[str] = field(default_factory=lambda: ["libx264"])
max_memory_mb: int = 512
max_bandwidth_mbps: int = 100
gpu_vendor: str = "unknown"
gpu_name: str = "unknown"
@dataclass
class VCNComputeFrameSpec:
"""Dynamic frame specification for VCN computation."""
width: int = 1920
height: int = 1080
format: str = "yuv420p"
bytes_per_frame: int = 3_110_400
frame_rate: int = 60
encoder: str = "libx264"
def probe_vcn_capabilities() -> VCNHardwareCapabilities:
"""Probe hardware VCN capabilities using FFmpeg, VAAPI, and vendor tools."""
caps = VCNHardwareCapabilities()
encoders_found = []
try:
result = subprocess.run(
["ffmpeg", "-encoders"],
capture_output=True, text=True, timeout=10
)
output = result.stdout
for enc in ["h264_vaapi", "hevc_vaapi", "h264_amf", "hevc_amf",
"h264_nvenc", "hevc_nvenc", "libx264", "libx265"]:
if enc in output:
encoders_found.append(enc)
except (FileNotFoundError, subprocess.TimeoutExpired):
encoders_found = ["libx264"]
caps.available_encoders = encoders_found if encoders_found else ["libx264"]
# Detect GPU vendor
try:
result = subprocess.run(
["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0 and result.stdout.strip():
parts = result.stdout.strip().split(",")
caps.gpu_vendor = "nvidia"
caps.gpu_name = parts[0].strip()
caps.max_memory_mb = int(parts[1].strip())
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
if caps.gpu_vendor == "unknown":
try:
result = subprocess.run(["vainfo"], capture_output=True, text=True, timeout=5)
output = result.stdout + result.stderr
if "AMD" in output or "RADV" in output or "radeon" in output.lower():
caps.gpu_vendor = "amd"
caps.gpu_name = "AMD Radeon (VAAPI)"
elif "Intel" in output or "iHD" in output or "i965" in output:
caps.gpu_vendor = "intel"
caps.gpu_name = "Intel Graphics (VAAPI)"
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
if caps.gpu_vendor == "unknown":
try:
vendor_path = Path("/sys/class/drm/card1/device/vendor")
if vendor_path.exists():
vendor_id = vendor_path.read_text().strip()
if vendor_id == "0x1002":
caps.gpu_vendor = "amd"
caps.gpu_name = "AMD Radeon (DRM)"
elif vendor_id == "0x8086":
caps.gpu_vendor = "intel"
caps.gpu_name = "Intel Graphics (DRM)"
except Exception:
pass
preferred_encoder = _select_preferred_encoder(encoders_found, caps.gpu_vendor)
caps.available_encoders = [preferred_encoder] + [e for e in encoders_found if e != preferred_encoder]
supported = []
for name, (w, h) in VCN_RESOLUTIONS.items():
if w > 3840 and (3840, 2160) in supported:
supported.append((w, h))
continue
if _test_resolution(w, h, preferred_encoder):
supported.append((w, h))
caps.supported_resolutions = supported if supported else [(1920, 1080)]
caps.supported_frame_rates = [fps for fps in VCN_FRAME_RATES if fps <= 240]
max_w, max_h = caps.supported_resolutions[-1] if caps.supported_resolutions else (1920, 1080)
max_pixels = max_w * max_h
caps.max_bandwidth_mbps = (max_pixels * 3 // 2 * 60) // (1024 * 1024)
return caps
def _select_preferred_encoder(encoders: List[str], vendor: str) -> str:
"""Select the best encoder for the detected GPU vendor."""
vendor_prefs = {
"nvidia": ["hevc_nvenc", "h264_nvenc"],
"amd": ["hevc_vaapi", "h264_vaapi", "hevc_amf", "h264_amf"],
"intel": ["hevc_vaapi", "h264_vaapi"],
}
for pref in vendor_prefs.get(vendor, []):
if pref in encoders:
return pref
return "libx265" if "libx265" in encoders else "libx264"
def _test_resolution(width: int, height: int, encoder: str) -> bool:
"""Test if the encoder can handle a given resolution."""
try:
test_size = width * height * 3 // 2
if test_size > 100_000_000:
return False
cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pix_fmt", "yuv420p",
"-s", f"{width}x{height}", "-frames:v", "1", "-i", "/dev/zero",
"-c:v", encoder, "-f", "null", "-"]
if "vaapi" in encoder:
cmd.insert(1, "-vaapi_device")
cmd.insert(2, "/dev/dri/renderD128")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def compute_frame_size(width: int, height: int, fmt: str = "yuv420p") -> int:
"""Compute frame size in bytes for given resolution and format."""
if fmt == "yuv420p":
return width * height * 3 // 2
elif fmt == "rgb24":
return width * height * 3
raise ValueError(f"Unsupported format: {fmt}")
def select_optimal_resolution(
caps: VCNHardwareCapabilities,
target_data_size: int,
preferred_format: str = "yuv420p"
) -> VCNComputeFrameSpec:
"""Select the smallest resolution that can hold target_data_size."""
required_size = target_data_size + SIGNATURE_SIZE
for w, h in caps.supported_resolutions:
frame_bytes = compute_frame_size(w, h, preferred_format)
if frame_bytes >= required_size:
max_fps = 60
for fps in reversed(caps.supported_frame_rates):
bandwidth_needed = (frame_bytes * fps) // (1024 * 1024)
if bandwidth_needed <= caps.max_bandwidth_mbps:
max_fps = fps
break
encoder = caps.available_encoders[0] if caps.available_encoders else "libx264"
return VCNComputeFrameSpec(width=w, height=h, format=preferred_format,
bytes_per_frame=frame_bytes, frame_rate=max_fps, encoder=encoder)
w, h = caps.supported_resolutions[-1] if caps.supported_resolutions else (1920, 1080)
encoder = caps.available_encoders[0] if caps.available_encoders else "libx264"
return VCNComputeFrameSpec(width=w, height=h, format=preferred_format,
bytes_per_frame=compute_frame_size(w, h, preferred_format), frame_rate=60, encoder=encoder)
def create_frame_dynamic(data: bytes, seq: int, spec: VCNComputeFrameSpec) -> bytes:
"""Create a frame at dynamic resolution from computation data."""
frame_size = spec.bytes_per_frame
if len(data) > frame_size - SIGNATURE_SIZE:
raise ValueError(f"Data too large: {len(data)} > {frame_size - SIGNATURE_SIZE}")
frame = bytearray(frame_size)
version = 1
length = len(data)
header = SIGNATURE_HEADER + struct.pack("<IIII", version, seq, length, 0)
frame[:SIGNATURE_SIZE] = header
payload = frame[SIGNATURE_SIZE:]
payload[:len(data)] = data
payload[len(data):] = bytes([128] * (len(payload) - len(data)))
return bytes(frame)
def encode_frames_hardware(
input_frames: List[bytes],
output_path: Path,
spec: VCNComputeFrameSpec
) -> subprocess.CompletedProcess:
"""Encode frames using detected hardware encoder with software fallback."""
raw_path = output_path.with_suffix(".raw")
with open(raw_path, "wb") as f:
for frame in input_frames:
f.write(frame)
encoder = spec.encoder
cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pix_fmt", spec.format,
"-s", f"{spec.width}x{spec.height}", "-r", str(spec.frame_rate),
"-i", str(raw_path), "-c:v", encoder, "-qp", str(QP_MIN),
"-f", "matroska", str(output_path)]
if "vaapi" in encoder:
cmd.insert(1, "-vaapi_device")
cmd.insert(2, "/dev/dri/renderD128")
cmd.insert(3, "-vf")
cmd.insert(4, "format=nv12,hwupload")
elif "nvenc" in encoder:
cmd.extend(["-preset", "p1", "-tune", "ull"])
elif "amf" in encoder:
cmd.extend(["-usage", "ultralowlatency"])
elif encoder == "libx264":
cmd.extend(["-profile:v", ENCODER_PROFILE, "-preset", "ultrafast", "-tune", "zerolatency"])
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0 and encoder != "libx264":
cmd[cmd.index("-c:v") + 1] = "libx264"
result = subprocess.run(cmd, capture_output=True, text=True)
raw_path.unlink(missing_ok=True)
return result
def pack_q16_16_to_yuv(value: int) -> Tuple[int, int, int]:
"""
Pack a Q16_16 scalar into YUV pixel values.
Mapping strategy: Split 32-bit Q16_16 into three 8-bit components
with error-diffusion dithering for precision preservation.
Args:
value: Q16_16 scalar as integer (0x00010000 = 1.0)
Returns:
(Y, U, V) tuple of pixel values (0-255)
"""
# Extract bytes from Q16_16 (little-endian)
y_val = (value >> 16) & 0xFF
u_val = (value >> 8) & 0xFF
v_val = value & 0xFF
# Clamp to valid YUV range
y_val = max(16, min(235, y_val)) # Y: 16-235 (limited range)
u_val = max(16, min(240, u_val)) # U: 16-240
v_val = max(16, min(240, v_val)) # V: 16-240
return (y_val, u_val, v_val)
def unpack_yuv_to_q16_16(y: int, u: int, v: int) -> int:
"""
Unpack YUV pixel values back to Q16_16 scalar.
Args:
y, u, v: YUV pixel values (0-255)
Returns:
Q16_16 scalar as integer
"""
# Reconstruct Q16_16 from YUV components
value = (y << 16) | (u << 8) | v
return value
def create_yuv420_frame(data: bytes, seq: int) -> bytes:
"""
Create a 1920×1080 YUV420 frame from computation data.
Frame layout:
- Bytes 0-23: Signature header (RDMAVCN\0 + version + seq + length)
- Bytes 24+: Computation data packed into YUV macroblocks
Args:
data: Raw computation data bytes
seq: Frame sequence number
Returns:
Complete YUV420 frame bytes (3,110,400 bytes)
"""
if len(data) > YUV420_FRAME_SIZE - SIGNATURE_SIZE:
raise ValueError(f"Data too large: {len(data)} > {YUV420_FRAME_SIZE - SIGNATURE_SIZE}")
# Create frame buffer
frame = bytearray(YUV420_FRAME_SIZE)
# Write signature header (exactly 24 bytes)
version = 1
length = len(data)
header = SIGNATURE_HEADER + struct.pack("<IIII", version, seq, length, 0) # 8 + 4 + 4 + 4 + 4 = 24 bytes
frame[:SIGNATURE_SIZE] = header
# Ensure frame is exactly the right size
if len(frame) != YUV420_FRAME_SIZE:
raise ValueError(f"Frame size mismatch: {len(frame)} != {YUV420_FRAME_SIZE}")
# Pack data into YUV420 format
# Y plane: 1920×1080 bytes
# U plane: 960×540 bytes (subsampled 2x)
# V plane: 960×540 bytes (subsampled 2x)
y_plane = frame[0:FRAME_WIDTH * FRAME_HEIGHT]
u_plane = frame[FRAME_WIDTH * FRAME_HEIGHT:FRAME_WIDTH * FRAME_HEIGHT + (FRAME_WIDTH // 2) * (FRAME_HEIGHT // 2)]
v_plane = frame[FRAME_WIDTH * FRAME_HEIGHT + (FRAME_WIDTH // 2) * (FRAME_HEIGHT // 2):]
# Pack data as Q16_16 scalars into macroblocks (6 bytes = 4Y + 1U + 1V)
data_idx = 0
macroblock_idx = 0
while data_idx + 4 <= len(data):
# Read 4 bytes as Q16_16 scalar
q16_value = struct.unpack("<I", data[data_idx:data_idx + 4])[0]
y, u, v = pack_q16_16_to_yuv(q16_value)
# Place in macroblock (4 Y pixels, 1 U, 1 V)
mb_y_start = macroblock_idx * 4
mb_u_start = macroblock_idx * 1
mb_v_start = macroblock_idx * 1
if mb_y_start + 4 <= len(y_plane):
y_plane[mb_y_start:mb_y_start + 4] = bytes([y, y, y, y])
if mb_u_start + 1 <= len(u_plane):
u_plane[mb_u_start] = u
if mb_v_start + 1 <= len(v_plane):
v_plane[mb_v_start] = v
data_idx += 4
macroblock_idx += 1
# Pad remaining space with neutral gray (Y=128, U=128, V=128)
y_plane[macroblock_idx * 4:] = bytes([128] * (len(y_plane) - macroblock_idx * 4))
u_plane[macroblock_idx:] = bytes([128] * (len(u_plane) - macroblock_idx))
v_plane[macroblock_idx:] = bytes([128] * (len(v_plane) - macroblock_idx))
return bytes(frame)
def encode_frames(input_frames: List[bytes], output_path: Path) -> subprocess.CompletedProcess:
"""
Encode raw YUV420 frames using H.264 encoder.
Args:
input_frames: List of YUV420 frame bytes
output_path: Output MKV file path
Returns:
FFmpeg subprocess result
"""
# Write raw YUV420 file
raw_path = output_path.with_suffix(".yuv")
with open(raw_path, "wb") as f:
for frame in input_frames:
f.write(frame)
# FFmpeg command for software encoding (libx264) for initial testing
cmd = [
"ffmpeg",
"-y", # Overwrite output file
"-f", "rawvideo",
"-pix_fmt", "yuv420p",
"-s", f"{FRAME_WIDTH}x{FRAME_HEIGHT}",
"-r", "60", # 60 fps as per spec
"-i", str(raw_path),
"-c:v", "libx264",
"-profile:v", ENCODER_PROFILE,
"-level", ENCODER_LEVEL,
"-qp", str(QP_MIN),
"-preset", "ultrafast",
"-tune", "zerolatency",
"-f", "matroska",
str(output_path)
]
result = subprocess.run(cmd, capture_output=True, text=True)
# Clean up raw file
raw_path.unlink()
return result
def decode_frames(input_path: Path) -> List[bytes]:
"""
Decode MKV file back to raw YUV420 frames using H.264 decoder.
Note: This is lossy - the encoding process modifies pixel values.
For computation extraction, use extract_receipt() instead.
Args:
input_path: Input MKV file path
Returns:
List of decoded YUV420 frame bytes
"""
raw_path = input_path.with_suffix(".decoded.yuv")
cmd = [
"ffmpeg",
"-i", str(input_path),
"-c:v", "rawvideo", # Decode to raw, don't re-encode
"-f", "rawvideo",
"-pix_fmt", "yuv420p",
str(raw_path)
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"FFmpeg decode failed: {result.stderr}")
# Read decoded frames
with open(raw_path, "rb") as f:
data = f.read()
# Split into frames
frames = []
for i in range(0, len(data), YUV420_FRAME_SIZE):
frame = data[i:i + YUV420_FRAME_SIZE]
if len(frame) == YUV420_FRAME_SIZE:
frames.append(frame)
# Clean up
raw_path.unlink()
return frames
def extract_receipt(input_path: Path) -> dict:
"""
Extract computation receipt from encoded MKV file.
Receipt includes:
- CRC32 of encoded file (proves encoding occurred)
- Frame size statistics
- Encoding parameters used
- Bitstream analysis metadata
- Compression ratio (input vs output size)
Args:
input_path: Input MKV file path
Returns:
Receipt dictionary
"""
# Use ffprobe to get stream information
cmd = [
"ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_streams",
"-show_format",
str(input_path)
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"FFprobe failed: {result.stderr}")
probe_info = json.loads(result.stdout)
# Calculate file CRC32
with open(input_path, "rb") as f:
file_data = f.read()
file_crc32 = zlib.crc32(file_data) & 0xFFFFFFFF
# Extract compression metrics
original_size = YUV420_FRAME_SIZE # Single frame
compressed_size = len(file_data)
compression_ratio = original_size / compressed_size if compressed_size > 0 else 0
receipt = {
"schema": "vcn_computation_receipt_v1",
"input_file": str(input_path),
"file_size_bytes": len(file_data),
"file_crc32": file_crc32,
"encoding_params": {
"codec": "libx264",
"profile": ENCODER_PROFILE,
"qp_min": QP_MIN,
"qp_max": QP_MAX,
"transform_skip": TRANSFORM_SKIP,
"deblocking": DEBLOCKING,
"sao": SAO
},
"stream_info": probe_info.get("streams", []),
"format_info": probe_info.get("format", {}),
"frame_spec": {
"width": FRAME_WIDTH,
"height": FRAME_HEIGHT,
"format": "yuv420p",
"bytes_per_frame": YUV420_FRAME_SIZE
},
"compression_metrics": {
"original_size": original_size,
"compressed_size": compressed_size,
"compression_ratio": compression_ratio,
"space_saving": (1 - (compressed_size / original_size)) * 100 if original_size > 0 else 0
}
}
return receipt
def main():
import sys
if len(sys.argv) < 2:
print("Usage: vcn_compute_substrate.py <encode|decode|extract_receipt> <input> <output>")
sys.exit(1)
command = sys.argv[1]
input_path = Path(sys.argv[2])
if command == "encode":
output_path = Path(sys.argv[3])
# Read input data
with open(input_path, "rb") as f:
data = f.read()
# Create frame
frame = create_yuv420_frame(data, seq=0)
# Encode
result = encode_frames([frame], output_path)
if result.returncode != 0:
print(f"Encoding failed: {result.stderr}", file=sys.stderr)
sys.exit(1)
print(f"Encoded to {output_path}")
elif command == "decode":
output_path = Path(sys.argv[3])
# Decode
frames = decode_frames(input_path)
# Extract first frame's data
if frames:
frame = frames[0]
# Extract signature header
header = frame[:SIGNATURE_SIZE]
signature, version, seq, length, _ = struct.unpack("<8sIIII", header)
if signature != SIGNATURE_HEADER:
print(f"Invalid signature: {signature}", file=sys.stderr)
sys.exit(1)
# Extract data payload
data = frame[SIGNATURE_SIZE:SIGNATURE_SIZE + length]
with open(output_path, "wb") as f:
f.write(data)
print(f"Decoded to {output_path}")
else:
print("No frames decoded", file=sys.stderr)
sys.exit(1)
elif command == "extract_receipt":
receipt = extract_receipt(input_path)
output_path = Path(sys.argv[3])
with open(output_path, "w") as f:
json.dump(receipt, f, indent=2)
print(f"Receipt written to {output_path}")
else:
print(f"Unknown command: {command}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,4 @@
frontend/node_modules/
frontend/dist/
__pycache__/
*.pyc

View file

@ -0,0 +1,45 @@
# Multi-stage: build frontend → package with backend
# ── Stage 1: Build React frontend ────────────────────────────────────────────
FROM node:22-slim AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --prefer-offline 2>/dev/null || npm install
COPY frontend/ .
RUN npm run build
# ── Stage 2: Backend + static files ──────────────────────────────────────────
FROM python:3.12-slim
WORKDIR /app
# System deps for SSH (to collect node metrics) + tailscale CLI
RUN apt-get update && apt-get install -y --no-install-recommends \
openssh-client curl gnupg ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
# Install kubectl
curl -fsSL "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" -o /usr/local/bin/kubectl && \
chmod +x /usr/local/bin/kubectl && \
# Install tailscale CLI
curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.noarmor.gpg -o /usr/share/keyrings/tailscale-archive-keyring.gpg && \
curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.tailscale-keyring.list -o /etc/apt/sources.list.d/tailscale.list && \
apt-get update && apt-get install -y --no-install-recommends tailscale && \
rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ .
# Copy built frontend into static directory
COPY --from=frontend-build /app/frontend/dist /app/static
# SSH config for connecting to cluster nodes
RUN mkdir -p /root/.ssh && \
echo "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n IdentityFile /root/.ssh/id_ed25519" > /root/.ssh/config && \
chmod 600 /root/.ssh/config
COPY backend/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8787
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -0,0 +1,7 @@
#!/bin/sh
# Fix SSH key permissions (k8s secret mounts may not preserve mode)
if [ -f /identity/id_ed25519 ]; then
cp /identity/id_ed25519 /root/.ssh/id_ed25519
chmod 600 /root/.ssh/id_ed25519
fi
exec uvicorn main:app --host 0.0.0.0 --port 8787 --workers 1

View file

@ -0,0 +1,448 @@
"""Research Stack Cluster Dashboard — telemetry collector backend.
Collects node metrics via:
- Kubernetes API (node status, pod counts)
- Tailscale CLI (connection type, bytes)
- SSH to nodes (GPU util, VRAM, CPU, memory)
- nvidia-smi (qfox-1) / sysfs (nixos AMD)
Exposes:
- REST /api/nodes current snapshot
- WebSocket /ws live updates every 3s
- GET /health readiness probe
"""
import asyncio
import json
import logging
import os
import re
import subprocess
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("dashboard")
app = FastAPI(title="Research Stack Cluster Dashboard")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
KUBECONFIG = os.environ.get("KUBECONFIG", "/tmp/researchstack-kubeconfig.yaml")
POLL_INTERVAL = 3 # seconds
# ── Node data model (field names match frontend App.tsx exactly) ─────────
@dataclass
class NodeMetrics:
name: str
ip: str
role: str
status: str # Ready / NotReady / Unknown
os_image: str
kernel: str
# GPU
gpu_name: str = ""
gpu_util: float = 0.0
vram_used_mb: float = 0.0
vram_total_mb: float = 0.0
gpu_temp: float = 0.0
encoder: str = ""
# System
cpu_cores: int = 0
cpu_util: float = 0.0
mem_used_mb: float = 0.0
mem_total_mb: float = 0.0
mem_util: float = 0.0
# k3s
pods_running: int = 0
# Tailscale
tailscale_ip: str = ""
tailscale_latency_ms: float = 0.0
tailscale_relay: str = ""
tailscale_direct: bool = False
# VCN substrate
vcn_codec: str = ""
vcn_resolution: str = ""
vcn_fps: int = 0
# Meta
last_updated: str = ""
error: str = ""
# ── Node registry ────────────────────────────────────────────────────────
NODES = {
"qfox-1": {
"tailscale_ip": "100.88.57.96",
"role": "gpu-worker",
"gpu_name": "RTX 4070 SUPER",
"gpu_type": "nvidia",
"ssh_user": "allaun",
},
"nixos": {
"tailscale_ip": "100.102.173.61",
"role": "control-plane",
"gpu_name": "AMD Lucienne",
"gpu_type": "amd",
"ssh_user": "allaun",
},
"steamdeck": {
"tailscale_ip": "100.85.244.73",
"role": "gpu-worker",
"gpu_name": "VanGogh APU",
"gpu_type": "amd",
"ssh_user": "deck",
},
"361395-1": {
"tailscale_ip": "100.110.163.82",
"role": "vps-worker",
"gpu_name": "",
"gpu_type": None,
"ssh_user": "root",
},
"racknerd-510bd9c": {
"tailscale_ip": "100.80.39.40",
"role": "edge",
"gpu_name": "",
"gpu_type": None,
"ssh_user": "root",
},
}
# ── Helpers ──────────────────────────────────────────────────────────────
def run_cmd(cmd: list[str], timeout: int = 10) -> str | None:
"""Run a command, return stdout or None on failure."""
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.stdout.strip() if r.returncode == 0 else None
except (FileNotFoundError, subprocess.TimeoutExpired, Exception):
return None
def parse_k8s_memory(mem_str: str) -> int:
"""Parse k8s memory string (e.g. '16392740Ki') to MB."""
s = mem_str.strip()
if s.endswith("Ki"):
return int(s[:-2]) // 1024
if s.endswith("Mi"):
return int(s[:-2])
if s.endswith("Gi"):
return int(s[:-2]) * 1024
return 0
# ── Collectors ───────────────────────────────────────────────────────────
def collect_k8s_nodes() -> dict[str, dict]:
"""Get node info from kubectl JSON output."""
out = run_cmd(["kubectl", "get", "nodes", "-o", "json", "--kubeconfig", KUBECONFIG])
if not out:
return {}
data = json.loads(out)
result = {}
for item in data.get("items", []):
name = item["metadata"]["name"]
conditions = item.get("status", {}).get("conditions", [])
ready = any(c["type"] == "Ready" and c["status"] == "True" for c in conditions)
# Get InternalIP
addrs = item.get("status", {}).get("addresses", [])
internal_ip = next((a["address"] for a in addrs if a["type"] == "InternalIP"), "")
info = item.get("status", {}).get("nodeInfo", {})
allocatable = item.get("status", {}).get("allocatable", {})
result[name] = {
"status": "Ready" if ready else "NotReady",
"ip": internal_ip,
"os_image": info.get("osImage", ""),
"kernel": info.get("kernelVersion", ""),
"cpu_cores": int(allocatable.get("cpu", "0")),
"mem_total_mb": parse_k8s_memory(allocatable.get("memory", "0Ki")),
}
return result
def collect_k8s_pods() -> dict[str, int]:
"""Count running pods per node."""
out = run_cmd(["kubectl", "get", "pods", "-A", "-o", "json", "--kubeconfig", KUBECONFIG])
if not out:
return {}
data = json.loads(out)
counts: dict[str, int] = {}
for pod in data.get("items", []):
phase = pod.get("status", {}).get("phase", "")
node = pod.get("spec", {}).get("nodeName", "")
if phase == "Running" and node:
counts[node] = counts.get(node, 0) + 1
return counts
def collect_nvidia_gpu(ip: str) -> dict:
"""GPU metrics via SSH + nvidia-smi."""
out = run_cmd([
"ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
f"allaun@{ip}",
"nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total,"
"temperature.gpu --format=csv,noheader,nounits 2>/dev/null",
], timeout=15)
if not out:
return {}
parts = [p.strip() for p in out.split(",")]
if len(parts) >= 5:
try:
return {
"gpu_name": parts[0],
"gpu_util": float(parts[1]),
"vram_used_mb": float(parts[2]),
"vram_total_mb": float(parts[3]),
"gpu_temp": float(parts[4]),
}
except ValueError:
return {}
return {}
def collect_amd_gpu(ip: str) -> dict:
"""GPU metrics via SSH + sysfs (AMD)."""
out = run_cmd([
"ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
f"allaun@{ip}",
"cat /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null; "
"echo '---'; "
"cat /sys/class/drm/card*/device/mem_info_vram_used 2>/dev/null; "
"echo '---'; "
"cat /sys/class/drm/card*/device/mem_info_vram_total 2>/dev/null",
], timeout=10)
if not out:
return {}
sections = out.split("---")
gpu_util = 0.0
vram_used = 0.0
vram_total = 0.0
try:
if sections[0].strip():
gpu_util = float(sections[0].strip().split("\n")[0])
if len(sections) > 1 and sections[1].strip():
vram_used = float(sections[1].strip().split("\n")[0]) / (1024 * 1024)
if len(sections) > 2 and sections[2].strip():
vram_total = float(sections[2].strip().split("\n")[0]) / (1024 * 1024)
except ValueError:
pass
return {
"gpu_util": gpu_util,
"vram_used_mb": vram_used,
"vram_total_mb": vram_total,
}
def collect_system_metrics(ip: str) -> dict:
"""CPU/memory via SSH + /proc/stat and /proc/meminfo (portable across all Linux distros)."""
out = run_cmd([
"ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
f"allaun@{ip}",
"cat /proc/stat | head -1; cat /proc/meminfo | head -3",
], timeout=10)
if not out:
return {}
result = {}
# Parse /proc/stat — first line: cpu user nice system idle iowait irq softirq steal
cpu_match = re.search(
r"^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)",
out, re.MULTILINE,
)
if cpu_match:
vals = [int(v) for v in cpu_match.groups()]
idle = vals[3] + vals[4] # idle + iowait
total = sum(vals)
if total > 0:
result["cpu_util"] = round((1 - idle / total) * 100, 1)
# Parse /proc/meminfo — MemTotal, MemFree, MemAvailable
mem_total = re.search(r"MemTotal:\s+(\d+)\s+kB", out)
mem_avail = re.search(r"MemAvailable:\s+(\d+)\s+kB", out)
if mem_total:
total_kb = int(mem_total.group(1))
result["mem_total_mb"] = total_kb // 1024
if mem_avail and mem_total:
total_kb = int(mem_total.group(1))
avail_kb = int(mem_avail.group(1))
used_kb = total_kb - avail_kb
result["mem_used_mb"] = used_kb // 1024
result["mem_util"] = round(used_kb / total_kb * 100, 1) if total_kb else 0
return result
def collect_tailscale() -> dict[str, dict]:
"""Tailscale peer status via SSH to host (container doesn't run tailscaled)."""
out = run_cmd([
"ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
"allaun@100.102.173.61", "tailscale status --json",
])
if not out:
return {}
data = json.loads(out)
peers: dict[str, dict] = {}
# Self
self_ips = data.get("Self", {}).get("TailscaleIPs", [])
if self_ips:
peers[self_ips[0]] = {"relay": "", "direct": True}
# Peers
for _, peer in data.get("Peer", {}).items():
ips = peer.get("TailscaleIPs", [])
if ips:
peers[ips[0]] = {
"relay": peer.get("Relay", ""),
"direct": bool(peer.get("CurAddr")),
}
return peers
# ── Main collection loop ─────────────────────────────────────────────────
async def collect_all() -> list[dict]:
"""Collect metrics from all nodes, return as dicts for JSON."""
loop = asyncio.get_event_loop()
# Run blocking collectors in thread pool
k8s_nodes, pod_counts, ts_status = await asyncio.gather(
loop.run_in_executor(None, collect_k8s_nodes),
loop.run_in_executor(None, collect_k8s_pods),
loop.run_in_executor(None, collect_tailscale),
)
nodes = []
for name, cfg in NODES.items():
k8s = k8s_nodes.get(name, {})
nm = NodeMetrics(
name=name,
ip=k8s.get("ip", cfg["tailscale_ip"]),
role=cfg["role"],
status=k8s.get("status", "Unknown"),
os_image=k8s.get("os_image", ""),
kernel=k8s.get("kernel", ""),
cpu_cores=k8s.get("cpu_cores", 0),
mem_total_mb=k8s.get("mem_total_mb", 0),
pods_running=pod_counts.get(name, 0),
tailscale_ip=cfg["tailscale_ip"],
last_updated=datetime.now(timezone.utc).isoformat(),
)
# Tailscale
ts = ts_status.get(cfg["tailscale_ip"], {})
nm.tailscale_relay = ts.get("relay", "")
nm.tailscale_direct = ts.get("direct", False)
# GPU + system metrics (skip remote VPS — no SSH)
gpu_type = cfg.get("gpu_type")
if gpu_type == "nvidia":
gpu_data = await loop.run_in_executor(
None, collect_nvidia_gpu, cfg["tailscale_ip"]
)
for k, v in gpu_data.items():
setattr(nm, k, v)
if not nm.gpu_name:
nm.gpu_name = cfg.get("gpu_name", "")
# Encoder check
enc_out = await loop.run_in_executor(None, lambda: run_cmd([
"ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
f"allaun@{cfg['tailscale_ip']}",
"nvidia-smi --query-gpu=encoder.stats.sessionCount "
"--format=csv,noheader,nounits 2>/dev/null",
]))
if enc_out and enc_out.strip().isdigit() and int(enc_out.strip()) > 0:
nm.encoder = "h264_nvenc"
elif gpu_type == "amd":
nm.gpu_name = cfg.get("gpu_name", "")
if name != "steamdeck": # VanGogh has no VRAM sysfs
gpu_data = await loop.run_in_executor(
None, collect_amd_gpu, cfg["tailscale_ip"]
)
for k, v in gpu_data.items():
setattr(nm, k, v)
nm.encoder = "hevc_vaapi"
# System metrics (local nodes only)
if name in ("qfox-1", "nixos", "steamdeck"):
sys_data = await loop.run_in_executor(
None, collect_system_metrics, cfg["tailscale_ip"]
)
for k, v in sys_data.items():
setattr(nm, k, v)
nodes.append(asdict(nm))
return nodes
# ── Cached state + WebSocket ─────────────────────────────────────────────
_cached_nodes: list[dict] = []
_ws_clients: set[WebSocket] = set()
async def update_loop():
"""Background task: collect metrics every POLL_INTERVAL, push to WS clients."""
global _cached_nodes
while True:
try:
_cached_nodes = await collect_all()
payload = json.dumps({"type": "update", "nodes": _cached_nodes})
dead: set[WebSocket] = set()
for ws in _ws_clients:
try:
await ws.send_text(payload)
except Exception:
dead.add(ws)
_ws_clients.difference_update(dead)
except Exception as e:
log.error(f"Collection error: {e}")
await asyncio.sleep(POLL_INTERVAL)
@app.on_event("startup")
async def startup():
asyncio.create_task(update_loop())
# ── API routes ───────────────────────────────────────────────────────────
@app.get("/api/nodes")
async def get_nodes():
return {"nodes": _cached_nodes, "updated": datetime.now(timezone.utc).isoformat()}
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await ws.accept()
_ws_clients.add(ws)
log.info(f"WS client connected ({len(_ws_clients)} total)")
try:
await ws.send_text(json.dumps({"type": "update", "nodes": _cached_nodes}))
while True:
await ws.receive_text()
except WebSocketDisconnect:
pass
finally:
_ws_clients.discard(ws)
log.info(f"WS client disconnected ({len(_ws_clients)} total)")
@app.get("/health")
async def health():
return {"status": "ok", "nodes": len(_cached_nodes)}
# ── Static files (frontend) ──────────────────────────────────────────────
frontend_dist = Path(os.environ.get("STATIC_DIR", "/app/static"))
if frontend_dist.exists():
app.mount("/", StaticFiles(directory=str(frontend_dist), html=True), name="static")

View file

@ -0,0 +1,3 @@
fastapi>=0.115
uvicorn[standard]>=0.34
websockets>=15.0

View file

@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Research Stack — Cluster Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0a0a0f;
color: #e0e0e8;
height: 100vh;
overflow: hidden;
}
#root { height: 100vh; display: flex; flex-direction: column; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
{
"name": "research-stack-dashboard",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@1771technologies/lytenyte-core": "^2.1.2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "^5.9.3",
"vite": "^6.4.2"
}
}

View file

@ -0,0 +1,410 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Grid, useClientDataSource } from "@1771technologies/lytenyte-core";
import type { Grid as LnGrid } from "@1771technologies/lytenyte-core";
import "@1771technologies/lytenyte-core/dark.css";
// ── Types ────────────────────────────────────────────────────────────────────
interface NodeMetrics {
name: string;
ip: string;
role: string;
status: string;
os_image: string;
kernel: string;
gpu_name: string;
gpu_util: number;
vram_used_mb: number;
vram_total_mb: number;
gpu_temp: number;
encoder: string;
cpu_cores: number;
cpu_util: number;
mem_used_mb: number;
mem_total_mb: number;
mem_util: number;
pods_running: number;
tailscale_ip: string;
tailscale_latency_ms: number;
tailscale_relay: string;
tailscale_direct: boolean;
vcn_codec: string;
vcn_resolution: string;
vcn_fps: number;
last_updated: string;
error: string;
}
// ── Column definitions ───────────────────────────────────────────────────────
const columns: LnGrid.Column[] = [
{
id: "status",
name: "●",
field: (p) => {
const row = p.row.data as NodeMetrics;
return row?.status === "Ready" ? "●" : "○";
},
width: 40,
type: "string",
cellRenderer: (params) => {
const row = params.row.data as NodeMetrics;
const color = row?.status === "Ready" ? "#22c55e" : "#ef4444";
return (
<span style={{ color, fontSize: 18, lineHeight: 1 }}></span>
);
},
},
{
id: "name",
name: "Node",
field: "name",
width: 150,
type: "string",
pin: "start",
},
{
id: "ip",
name: "IP",
field: "ip",
width: 130,
type: "string",
},
{
id: "role",
name: "Role",
field: "role",
width: 100,
type: "string",
},
{
id: "gpu_name",
name: "GPU",
field: "gpu_name",
width: 160,
type: "string",
},
{
id: "gpu_util",
name: "GPU %",
field: "gpu_util",
width: 80,
type: "number",
cellRenderer: (params) => {
const val = (params.row.data as NodeMetrics)?.gpu_util ?? 0;
const color = val > 80 ? "#ef4444" : val > 50 ? "#f59e0b" : "#22c55e";
return (
<span style={{ color, fontWeight: 600 }}>
{val > 0 ? `${val.toFixed(0)}%` : "—"}
</span>
);
},
},
{
id: "vram",
name: "VRAM",
field: (p) => {
const row = p.row.data as NodeMetrics;
if (!row?.vram_total_mb) return "—";
return `${(row.vram_used_mb / 1024).toFixed(1)}/${(row.vram_total_mb / 1024).toFixed(1)} GB`;
},
width: 110,
type: "string",
cellRenderer: (params) => {
const row = params.row.data as NodeMetrics;
if (!row?.vram_total_mb) return <span style={{ color: "#666" }}></span>;
const pct = (row.vram_used_mb / row.vram_total_mb) * 100;
const color = pct > 80 ? "#ef4444" : pct > 50 ? "#f59e0b" : "#22c55e";
return (
<div style={{ display: "flex", alignItems: "center", gap: 6, width: "100%" }}>
<div style={{
flex: 1, height: 6, background: "#1a1a2e", borderRadius: 3, overflow: "hidden",
}}>
<div style={{
width: `${pct}%`, height: "100%", background: color, borderRadius: 3,
transition: "width 0.3s ease",
}} />
</div>
<span style={{ fontSize: 11, whiteSpace: "nowrap" }}>
{(row.vram_used_mb / 1024).toFixed(1)}/{(row.vram_total_mb / 1024).toFixed(1)}
</span>
</div>
);
},
},
{
id: "gpu_temp",
name: "GPU °C",
field: "gpu_temp",
width: 75,
type: "number",
cellRenderer: (params) => {
const val = (params.row.data as NodeMetrics)?.gpu_temp ?? 0;
if (!val) return <span style={{ color: "#666" }}></span>;
const color = val > 80 ? "#ef4444" : val > 65 ? "#f59e0b" : "#22c55e";
return <span style={{ color }}>{val.toFixed(0)}°</span>;
},
},
{
id: "encoder",
name: "Codec",
field: "encoder",
width: 120,
type: "string",
},
{
id: "cpu_util",
name: "CPU %",
field: "cpu_util",
width: 80,
type: "number",
cellRenderer: (params) => {
const row = params.row.data as NodeMetrics;
const val = row?.cpu_util ?? 0;
if (!val && !row?.cpu_cores) return <span style={{ color: "#666" }}></span>;
const color = val > 80 ? "#ef4444" : val > 50 ? "#f59e0b" : "#22c55e";
return <span style={{ color, fontWeight: 600 }}>{val.toFixed(0)}%</span>;
},
},
{
id: "mem",
name: "Memory",
field: (p) => {
const row = p.row.data as NodeMetrics;
if (!row?.mem_total_mb) return "—";
return `${(row.mem_used_mb / 1024).toFixed(1)}/${(row.mem_total_mb / 1024).toFixed(1)} GB`;
},
width: 120,
type: "string",
cellRenderer: (params) => {
const row = params.row.data as NodeMetrics;
if (!row?.mem_total_mb) return <span style={{ color: "#666" }}></span>;
const pct = row.mem_util;
const color = pct > 85 ? "#ef4444" : pct > 60 ? "#f59e0b" : "#22c55e";
return (
<div style={{ display: "flex", alignItems: "center", gap: 6, width: "100%" }}>
<div style={{
flex: 1, height: 6, background: "#1a1a2e", borderRadius: 3, overflow: "hidden",
}}>
<div style={{
width: `${pct}%`, height: "100%", background: color, borderRadius: 3,
transition: "width 0.3s ease",
}} />
</div>
<span style={{ fontSize: 11, whiteSpace: "nowrap" }}>
{(row.mem_used_mb / 1024).toFixed(1)}/{(row.mem_total_mb / 1024).toFixed(1)}
</span>
</div>
);
},
},
{
id: "pods",
name: "Pods",
field: "pods_running",
width: 65,
type: "number",
},
{
id: "tailscale",
name: "Tailscale",
field: (p) => {
const row = p.row.data as NodeMetrics;
if (!row?.tailscale_ip) return "—";
return row.tailscale_direct ? "direct" : row.tailscale_relay || "relay";
},
width: 90,
type: "string",
cellRenderer: (params) => {
const row = params.row.data as NodeMetrics;
if (!row?.tailscale_ip) return <span style={{ color: "#666" }}></span>;
const direct = row.tailscale_direct;
return (
<span style={{ color: direct ? "#22c55e" : "#f59e0b", fontSize: 12 }}>
{direct ? "● direct" : `${row.tailscale_relay || "relay"}`}
</span>
);
},
},
{
id: "os",
name: "OS",
field: "os_image",
width: 180,
type: "string",
},
{
id: "kernel",
name: "Kernel",
field: "kernel",
width: 140,
type: "string",
},
];
// ── App ──────────────────────────────────────────────────────────────────────
export default function App() {
const [nodes, setNodes] = useState<NodeMetrics[]>([]);
const [connected, setConnected] = useState(false);
const [lastUpdate, setLastUpdate] = useState("");
const wsRef = useRef<WebSocket | null>(null);
const reconnectRef = useRef<number | null>(null);
const connect = useCallback(() => {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onopen = () => {
setConnected(true);
console.log("[dashboard] WebSocket connected");
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "update" && msg.nodes) {
setNodes(msg.nodes);
setLastUpdate(new Date().toLocaleTimeString());
}
} catch (e) {
console.error("[dashboard] parse error:", e);
}
};
ws.onclose = () => {
setConnected(false);
console.log("[dashboard] WebSocket closed, reconnecting in 3s...");
reconnectRef.current = window.setTimeout(connect, 3000);
};
ws.onerror = () => {
ws.close();
};
wsRef.current = ws;
}, []);
useEffect(() => {
connect();
return () => {
if (reconnectRef.current != null) clearTimeout(reconnectRef.current);
wsRef.current?.close();
};
}, [connect]);
const ds = useClientDataSource<NodeMetrics>({
data: nodes,
});
return (
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
{/* Header */}
<div style={{
padding: "12px 20px",
background: "#0d0d14",
borderBottom: "1px solid #1a1a2e",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span style={{ fontSize: 18, fontWeight: 700, color: "#e0e0e8" }}>
Research Stack
</span>
<span style={{
fontSize: 11,
padding: "2px 8px",
borderRadius: 4,
background: "#1a1a2e",
color: "#888",
}}>
CLUSTER DASHBOARD
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 16, fontSize: 13 }}>
<span style={{ color: "#888" }}>{nodes.length} nodes</span>
<span style={{
display: "flex", alignItems: "center", gap: 6,
}}>
<span style={{
width: 8, height: 8, borderRadius: "50%",
background: connected ? "#22c55e" : "#ef4444",
boxShadow: connected ? "0 0 6px #22c55e" : "0 0 6px #ef4444",
}} />
<span style={{ color: connected ? "#22c55e" : "#ef4444", fontSize: 12 }}>
{connected ? "LIVE" : "DISCONNECTED"}
</span>
</span>
{lastUpdate && (
<span style={{ color: "#666", fontSize: 11 }}>
{lastUpdate}
</span>
)}
</div>
</div>
{/* Grid */}
<div style={{ flex: 1, minHeight: 0 }}>
<Grid
columns={columns}
rowSource={ds}
rowHeight={44}
headerHeight={36}
styles={{
viewport: {
style: {
background: "#0a0a0f",
},
},
header: {
style: {
background: "#0d0d14",
borderColor: "#1a1a2e",
color: "#e0e0e8",
},
},
row: {
style: {
borderColor: "#141420",
},
},
cell: {
style: {
color: "#e0e0e8",
borderColor: "#141420",
},
},
}}
>
<Grid.Viewport>
<Grid.Header>
{(cells) => (
<Grid.HeaderRow>
{cells.map((cell) => {
if (cell.kind === "group") return null;
return <Grid.HeaderCell key={cell.id} cell={cell} />;
})}
</Grid.HeaderRow>
)}
</Grid.Header>
<Grid.RowsContainer>
<Grid.RowsCenter>
{(row) => {
if (row.kind === "full-width") return null;
return (
<Grid.Row key={row.id} row={row}>
{row.cells.map((cell) => (
<Grid.Cell key={cell.id} cell={cell} />
))}
</Grid.Row>
);
}}
</Grid.RowsCenter>
</Grid.RowsContainer>
</Grid.Viewport>
</Grid>
</div>
</div>
);
}

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,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}

View file

@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:8787",
"/ws": {
target: "ws://localhost:8787",
ws: true,
},
},
},
build: {
outDir: "dist",
},
});

View file

@ -0,0 +1,107 @@
apiVersion: v1
kind: Namespace
metadata:
name: monitoring
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: cluster-dashboard
namespace: monitoring
labels:
app: cluster-dashboard
spec:
replicas: 1
selector:
matchLabels:
app: cluster-dashboard
template:
metadata:
labels:
app: cluster-dashboard
spec:
# Share host network so Tailscale IPs are reachable for SSH
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
# Pin to control plane — image is imported there
nodeSelector:
kubernetes.io/hostname: nixos
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Equal
value: "true"
effect: NoSchedule
containers:
- name: dashboard
image: localhost/cluster-dashboard:latest
imagePullPolicy: Never
ports:
- containerPort: 8787
name: http
env:
- name: KUBECONFIG
value: /kubeconfig/config
volumeMounts:
- name: kubeconfig
mountPath: /kubeconfig
readOnly: true
- name: ssh-identity
mountPath: /identity
readOnly: true
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
readinessProbe:
httpGet:
path: /health
port: 8787
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8787
initialDelaySeconds: 10
periodSeconds: 30
volumes:
- name: kubeconfig
secret:
secretName: dashboard-kubeconfig
- name: ssh-identity
secret:
secretName: dashboard-ssh-key
defaultMode: 0600
---
apiVersion: v1
kind: Service
metadata:
name: cluster-dashboard
namespace: monitoring
spec:
selector:
app: cluster-dashboard
ports:
- port: 80
targetPort: 8787
name: http
type: ClusterIP
---
# NodePort for direct access (no ingress needed)
apiVersion: v1
kind: Service
metadata:
name: cluster-dashboard-nodeport
namespace: monitoring
spec:
selector:
app: cluster-dashboard
ports:
- port: 8787
targetPort: 8787
nodePort: 30820
name: http
type: NodePort