mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +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