mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-08-17 05:50:35 +00:00
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:
parent
4cad34faf4
commit
d86625e220
14 changed files with 3651 additions and 0 deletions
624
4-Infrastructure/shim/vcn_compute_substrate.py
Normal file
624
4-Infrastructure/shim/vcn_compute_substrate.py
Normal 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()
|
||||||
4
5-Applications/cluster-dashboard/.gitignore
vendored
Normal file
4
5-Applications/cluster-dashboard/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
45
5-Applications/cluster-dashboard/Containerfile
Normal file
45
5-Applications/cluster-dashboard/Containerfile
Normal 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"]
|
||||||
7
5-Applications/cluster-dashboard/backend/entrypoint.sh
Executable file
7
5-Applications/cluster-dashboard/backend/entrypoint.sh
Executable 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
|
||||||
448
5-Applications/cluster-dashboard/backend/main.py
Normal file
448
5-Applications/cluster-dashboard/backend/main.py
Normal 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")
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.34
|
||||||
|
websockets>=15.0
|
||||||
23
5-Applications/cluster-dashboard/frontend/index.html
Normal file
23
5-Applications/cluster-dashboard/frontend/index.html
Normal 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>
|
||||||
1910
5-Applications/cluster-dashboard/frontend/package-lock.json
generated
Normal file
1910
5-Applications/cluster-dashboard/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
5-Applications/cluster-dashboard/frontend/package.json
Normal file
23
5-Applications/cluster-dashboard/frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
410
5-Applications/cluster-dashboard/frontend/src/App.tsx
Normal file
410
5-Applications/cluster-dashboard/frontend/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
5-Applications/cluster-dashboard/frontend/src/main.tsx
Normal file
9
5-Applications/cluster-dashboard/frontend/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
20
5-Applications/cluster-dashboard/frontend/tsconfig.json
Normal file
20
5-Applications/cluster-dashboard/frontend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
18
5-Applications/cluster-dashboard/frontend/vite.config.ts
Normal file
18
5-Applications/cluster-dashboard/frontend/vite.config.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
107
5-Applications/cluster-dashboard/k8s/dashboard.yaml
Normal file
107
5-Applications/cluster-dashboard/k8s/dashboard.yaml
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue