feat(system): add Worker 006 telemetry and network profile
This commit is contained in:
@@ -52,6 +52,7 @@ from k1link.web.plugin_runtime import (
|
||||
)
|
||||
from k1link.web.polygon_api import build_polygon_router, configured_polygon_runs_root
|
||||
from k1link.web.session_api import build_session_router
|
||||
from k1link.web.system_telemetry_api import build_system_telemetry_router
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
INVALID_REQUEST_DETAIL = "Некорректные параметры запроса."
|
||||
@@ -618,6 +619,11 @@ app.include_router(
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_system_telemetry_router(
|
||||
root_provider=lambda: REPOSITORY_ROOT / ".runtime" / "system",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
||||
|
||||
@@ -0,0 +1,834 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ipaddress
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
PROFILE_SCHEMA: Final = "missioncore.worker-connection-profile/v1"
|
||||
TELEMETRY_SCHEMA: Final = "missioncore.worker-telemetry/v1"
|
||||
PROBE_SCHEMA: Final = "missioncore.worker-probe/v1"
|
||||
DEFAULT_PROFILE_ID: Final = "worker-006"
|
||||
DEFAULT_DISPLAY_NAME: Final = "Worker 006"
|
||||
EXPECTED_NODE_ID: Final = "DESKTOP-OPJ8J04"
|
||||
SSH_HOST_ALIAS: Final = "mission-gpu"
|
||||
PROFILE_FILE_NAME: Final = "worker-006.json"
|
||||
CONTAINER_NAMES: Final = (
|
||||
"mission-core-triton",
|
||||
"mission-core-perception-worker",
|
||||
"sentinel-frigate",
|
||||
"sentinel-ollama",
|
||||
)
|
||||
SAFE_HOSTNAME = re.compile(
|
||||
r"^(?=.{1,253}\.?$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*"
|
||||
r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.?$"
|
||||
)
|
||||
PROMETHEUS_SAMPLE = re.compile(
|
||||
r"^(?P<name>nv_inference_(?:request_success|request_failure|count))"
|
||||
r"(?:\{[^}]*\})?\s+(?P<value>[0-9.eE+-]+)$"
|
||||
)
|
||||
|
||||
RootProvider = Callable[[], Path]
|
||||
ProbeRunner = Callable[["WorkerConnectionProfile"], dict[str, Any]]
|
||||
|
||||
|
||||
class WorkerConnectionProfile(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
schema_version: str = PROFILE_SCHEMA
|
||||
profile_id: str = DEFAULT_PROFILE_ID
|
||||
display_name: str = DEFAULT_DISPLAY_NAME
|
||||
expected_node_id: str = EXPECTED_NODE_ID
|
||||
ssh_host_alias: str = SSH_HOST_ALIAS
|
||||
address: str = ""
|
||||
port: int = Field(default=22, ge=1, le=65535)
|
||||
revision: int = Field(default=0, ge=0)
|
||||
updated_at_utc: str | None = None
|
||||
|
||||
@field_validator("address")
|
||||
@classmethod
|
||||
def validate_address(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
if any(character.isspace() or ord(character) < 32 for character in normalized):
|
||||
raise ValueError("worker address contains whitespace or control characters")
|
||||
try:
|
||||
ipaddress.ip_address(normalized)
|
||||
except ValueError:
|
||||
if SAFE_HOSTNAME.fullmatch(normalized) is None:
|
||||
raise ValueError("worker address is not a valid IP address or hostname") from None
|
||||
return normalized.rstrip(".")
|
||||
|
||||
|
||||
class WorkerConnectionProfilePut(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
revision: int = Field(ge=0)
|
||||
address: str = Field(default="", max_length=253)
|
||||
port: int = Field(default=22, ge=1, le=65535)
|
||||
|
||||
@field_validator("address")
|
||||
@classmethod
|
||||
def validate_address(cls, value: str) -> str:
|
||||
return WorkerConnectionProfile.validate_address(value)
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
class WorkerProfileStore:
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = root
|
||||
self.path = root / PROFILE_FILE_NAME
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def read(self) -> WorkerConnectionProfile:
|
||||
with self._lock:
|
||||
if not self.path.is_file():
|
||||
return WorkerConnectionProfile()
|
||||
try:
|
||||
document = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
return WorkerConnectionProfile.model_validate(document)
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
||||
return WorkerConnectionProfile()
|
||||
|
||||
def save(self, request: WorkerConnectionProfilePut) -> WorkerConnectionProfile:
|
||||
with self._lock:
|
||||
current = self._read_unlocked()
|
||||
if current.revision != request.revision:
|
||||
raise RuntimeError("worker profile revision changed")
|
||||
profile = current.model_copy(
|
||||
update={
|
||||
"address": request.address,
|
||||
"port": request.port,
|
||||
"revision": current.revision + 1,
|
||||
"updated_at_utc": _utc_now(),
|
||||
}
|
||||
)
|
||||
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=".worker-006.",
|
||||
suffix=".tmp",
|
||||
dir=self.root,
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
|
||||
json.dump(
|
||||
profile.model_dump(mode="json"),
|
||||
stream,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, self.path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return profile
|
||||
|
||||
def _read_unlocked(self) -> WorkerConnectionProfile:
|
||||
if not self.path.is_file():
|
||||
return WorkerConnectionProfile()
|
||||
try:
|
||||
return WorkerConnectionProfile.model_validate_json(
|
||||
self.path.read_text(encoding="utf-8")
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
return WorkerConnectionProfile()
|
||||
|
||||
|
||||
WORKER_PROBE_POWERSHELL: Final = r"""
|
||||
$ErrorActionPreference = "Stop"
|
||||
$containers = @(
|
||||
"mission-core-triton",
|
||||
"mission-core-perception-worker",
|
||||
"sentinel-frigate",
|
||||
"sentinel-ollama"
|
||||
)
|
||||
$os = Get-CimInstance Win32_OperatingSystem
|
||||
$cpu = Get-CimInstance Win32_Processor
|
||||
$computer = Get-CimInstance Win32_ComputerSystem
|
||||
$dockerStats = @{}
|
||||
try {
|
||||
$statsLines = @(docker stats --no-stream --format "{{json .}}" $containers)
|
||||
foreach ($line in $statsLines) {
|
||||
if ($line) {
|
||||
$row = $line | ConvertFrom-Json
|
||||
$dockerStats[$row.Name] = $row
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
$containerStates = @{}
|
||||
foreach ($name in $containers) {
|
||||
try {
|
||||
$state = (docker inspect --format "{{json .State}}" $name) | ConvertFrom-Json
|
||||
$image = docker inspect --format "{{.Config.Image}}" $name
|
||||
$containerStates[$name] = [ordered]@{state=$state; image=$image}
|
||||
} catch {
|
||||
$containerStates[$name] = $null
|
||||
}
|
||||
}
|
||||
$gpu = $null
|
||||
try {
|
||||
$gpuQuery = "name,utilization.gpu,memory.used,memory.total,power.draw,temperature.gpu"
|
||||
$gpuLine = nvidia-smi "--query-gpu=$gpuQuery" --format=csv,noheader,nounits |
|
||||
Select-Object -First 1
|
||||
$gpuParts = @($gpuLine -split ",\s*")
|
||||
if ($gpuParts.Count -ge 6) {
|
||||
$gpu = [ordered]@{
|
||||
name=$gpuParts[0]
|
||||
utilization_percent=[double]$gpuParts[1]
|
||||
memory_used_mib=[double]$gpuParts[2]
|
||||
memory_total_mib=[double]$gpuParts[3]
|
||||
power_watts=[double]$gpuParts[4]
|
||||
temperature_celsius=[double]$gpuParts[5]
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
$network = @()
|
||||
foreach ($adapter in @(Get-NetAdapter | Where-Object Status -eq "Up")) {
|
||||
try {
|
||||
$statistics = Get-NetAdapterStatistics -Name $adapter.Name
|
||||
$addresses = @(
|
||||
Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||
-ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.IPAddress -notlike "169.254.*" } |
|
||||
Select-Object -ExpandProperty IPAddress
|
||||
)
|
||||
$network += [ordered]@{
|
||||
name=$adapter.Name
|
||||
description=$adapter.InterfaceDescription
|
||||
status=$adapter.Status
|
||||
link_speed_bps=[double]$adapter.Speed
|
||||
mac_address=$adapter.MacAddress
|
||||
addresses=$addresses
|
||||
received_bytes=[double]$statistics.ReceivedBytes
|
||||
sent_bytes=[double]$statistics.SentBytes
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
$tritonReady = $false
|
||||
try {
|
||||
$tritonResponse = Invoke-WebRequest -UseBasicParsing `
|
||||
-Uri "http://127.0.0.1:8000/v2/health/ready" -TimeoutSec 2
|
||||
$tritonReady = $tritonResponse.StatusCode -eq 200
|
||||
} catch {}
|
||||
$tritonMetrics = @()
|
||||
try {
|
||||
$metricsResponse = Invoke-WebRequest -UseBasicParsing `
|
||||
-Uri "http://127.0.0.1:8002/metrics" -TimeoutSec 2
|
||||
$tritonMetrics = @(
|
||||
$metricsResponse.Content -split "`n" |
|
||||
Where-Object { $_ -match "^nv_inference_(request_success|request_failure|count)" }
|
||||
)
|
||||
} catch {}
|
||||
$perceptionHealth = $null
|
||||
try {
|
||||
$healthJson = docker exec mission-core-perception-worker python3 -c "import urllib.request;print(urllib.request.urlopen('http://127.0.0.1:18020/health',timeout=2).read().decode())"
|
||||
$perceptionHealth = $healthJson | ConvertFrom-Json
|
||||
} catch {}
|
||||
$disks = @(
|
||||
Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
|
||||
ForEach-Object {
|
||||
[ordered]@{
|
||||
name=$_.DeviceID
|
||||
size_bytes=[double]$_.Size
|
||||
free_bytes=[double]$_.FreeSpace
|
||||
}
|
||||
}
|
||||
)
|
||||
[ordered]@{
|
||||
schema_version="missioncore.worker-probe-raw/v1"
|
||||
observed_at_utc=[DateTime]::UtcNow.ToString("o")
|
||||
node_id=$env:COMPUTERNAME
|
||||
os=[ordered]@{
|
||||
caption=$os.Caption
|
||||
version=$os.Version
|
||||
uptime_seconds=([DateTime]::UtcNow - $os.LastBootUpTime.ToUniversalTime()).TotalSeconds
|
||||
}
|
||||
cpu=[ordered]@{
|
||||
name=(@($cpu | Select-Object -ExpandProperty Name) -join " + ")
|
||||
logical_processors=[int]$computer.NumberOfLogicalProcessors
|
||||
load_percent=[double](($cpu | Measure-Object LoadPercentage -Average).Average)
|
||||
}
|
||||
memory=[ordered]@{
|
||||
total_bytes=[double]$os.TotalVisibleMemorySize * 1024
|
||||
free_bytes=[double]$os.FreePhysicalMemory * 1024
|
||||
}
|
||||
disks=$disks
|
||||
gpu=$gpu
|
||||
network=$network
|
||||
docker_stats=$dockerStats
|
||||
container_states=$containerStates
|
||||
triton=[ordered]@{ready=$tritonReady; metrics=$tritonMetrics}
|
||||
perception=$perceptionHealth
|
||||
} | ConvertTo-Json -Depth 12 -Compress
|
||||
""".strip()
|
||||
|
||||
|
||||
def _ssh_arguments(profile: WorkerConnectionProfile) -> list[str]:
|
||||
arguments = [
|
||||
"ssh",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=yes",
|
||||
"-o",
|
||||
f"HostKeyAlias={profile.ssh_host_alias}",
|
||||
"-o",
|
||||
"ConnectTimeout=5",
|
||||
]
|
||||
if profile.address:
|
||||
arguments.extend(["-o", f"HostName={profile.address}"])
|
||||
arguments.extend(
|
||||
[
|
||||
"-p",
|
||||
str(profile.port),
|
||||
profile.ssh_host_alias,
|
||||
"powershell.exe",
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
(
|
||||
"$encoded=[Console]::In.ReadToEnd();"
|
||||
"Invoke-Expression "
|
||||
"([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)))"
|
||||
),
|
||||
]
|
||||
)
|
||||
return arguments
|
||||
|
||||
|
||||
def run_worker_probe(profile: WorkerConnectionProfile) -> dict[str, Any]:
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
_ssh_arguments(profile),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
input=base64.b64encode(WORKER_PROBE_POWERSHELL.encode("utf-16le")),
|
||||
timeout=12,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return _failed_probe(profile, "worker-unreachable", started)
|
||||
if completed.returncode != 0:
|
||||
return _failed_probe(profile, "worker-unreachable", started)
|
||||
try:
|
||||
raw = completed.stdout.decode("cp866")
|
||||
document = json.loads(raw.strip())
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, TypeError):
|
||||
return _failed_probe(profile, "invalid-worker-response", started)
|
||||
if not isinstance(document, dict):
|
||||
return _failed_probe(profile, "invalid-worker-response", started)
|
||||
node_id = document.get("node_id")
|
||||
if node_id != profile.expected_node_id:
|
||||
return {
|
||||
"schema_version": PROBE_SCHEMA,
|
||||
"reachable": True,
|
||||
"identity_matches": False,
|
||||
"node_id": node_id if isinstance(node_id, str) else None,
|
||||
"latency_ms": (time.perf_counter() - started) * 1000,
|
||||
"observed_at_utc": _utc_now(),
|
||||
"error_code": "worker-identity-mismatch",
|
||||
"raw": None,
|
||||
}
|
||||
return {
|
||||
"schema_version": PROBE_SCHEMA,
|
||||
"reachable": True,
|
||||
"identity_matches": True,
|
||||
"node_id": node_id,
|
||||
"latency_ms": (time.perf_counter() - started) * 1000,
|
||||
"observed_at_utc": _utc_now(),
|
||||
"error_code": None,
|
||||
"raw": document,
|
||||
}
|
||||
|
||||
|
||||
def _failed_probe(
|
||||
profile: WorkerConnectionProfile,
|
||||
error_code: str,
|
||||
started: float,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": PROBE_SCHEMA,
|
||||
"reachable": False,
|
||||
"identity_matches": False,
|
||||
"node_id": None,
|
||||
"expected_node_id": profile.expected_node_id,
|
||||
"latency_ms": (time.perf_counter() - started) * 1000,
|
||||
"observed_at_utc": _utc_now(),
|
||||
"error_code": error_code,
|
||||
"raw": None,
|
||||
}
|
||||
|
||||
|
||||
def _number(value: object) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
return None
|
||||
result = float(value)
|
||||
return result if math.isfinite(result) else None
|
||||
|
||||
|
||||
def _mapping(value: object) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _items(value: object) -> list[Any]:
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _percent(used: float | None, total: float | None) -> float | None:
|
||||
if used is None or total is None or total <= 0:
|
||||
return None
|
||||
return max(0.0, min(100.0, used / total * 100))
|
||||
|
||||
|
||||
def _container_document(
|
||||
name: str,
|
||||
raw_stats: dict[str, Any],
|
||||
raw_states: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
stats = _mapping(raw_stats.get(name))
|
||||
descriptor = _mapping(raw_states.get(name))
|
||||
state = _mapping(descriptor.get("state"))
|
||||
health = _mapping(state.get("Health"))
|
||||
roles = {
|
||||
"mission-core-triton": ("Inference Runtime", "mission-core", False),
|
||||
"mission-core-perception-worker": ("Perception Pipeline", "mission-core", False),
|
||||
"sentinel-frigate": ("Sentinel Frigate", "external", True),
|
||||
"sentinel-ollama": ("Sentinel Ollama", "external", True),
|
||||
}
|
||||
role, owner, external = roles[name]
|
||||
return {
|
||||
"name": name,
|
||||
"role": role,
|
||||
"owner": owner,
|
||||
"external": external,
|
||||
"image": descriptor.get("image") if isinstance(descriptor.get("image"), str) else None,
|
||||
"state": state.get("Status") if isinstance(state.get("Status"), str) else "unavailable",
|
||||
"health": health.get("Status") if isinstance(health.get("Status"), str) else None,
|
||||
"cpu_percent": _parse_percent(stats.get("CPUPerc")),
|
||||
"memory_percent": _parse_percent(stats.get("MemPerc")),
|
||||
"memory_usage": stats.get("MemUsage") if isinstance(stats.get("MemUsage"), str) else None,
|
||||
"network_io": stats.get("NetIO") if isinstance(stats.get("NetIO"), str) else None,
|
||||
"block_io": stats.get("BlockIO") if isinstance(stats.get("BlockIO"), str) else None,
|
||||
"pids": _parse_integer(stats.get("PIDs")),
|
||||
}
|
||||
|
||||
|
||||
def _parse_percent(value: object) -> float | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
number = float(value.rstrip("%"))
|
||||
except ValueError:
|
||||
return None
|
||||
return number if math.isfinite(number) else None
|
||||
|
||||
|
||||
def _parse_integer(value: object) -> int | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _triton_metrics(lines: object) -> dict[str, float | None]:
|
||||
totals: dict[str, float] = {
|
||||
"nv_inference_request_success": 0,
|
||||
"nv_inference_request_failure": 0,
|
||||
"nv_inference_count": 0,
|
||||
}
|
||||
matched: set[str] = set()
|
||||
for line in _items(lines):
|
||||
if not isinstance(line, str):
|
||||
continue
|
||||
match = PROMETHEUS_SAMPLE.fullmatch(line.strip())
|
||||
if match is None:
|
||||
continue
|
||||
try:
|
||||
value = float(match.group("value"))
|
||||
except ValueError:
|
||||
continue
|
||||
if math.isfinite(value):
|
||||
name = match.group("name")
|
||||
totals[name] += value
|
||||
matched.add(name)
|
||||
return {
|
||||
"requests_succeeded": (
|
||||
totals["nv_inference_request_success"]
|
||||
if "nv_inference_request_success" in matched
|
||||
else None
|
||||
),
|
||||
"requests_failed": (
|
||||
totals["nv_inference_request_failure"]
|
||||
if "nv_inference_request_failure" in matched
|
||||
else None
|
||||
),
|
||||
"inferences": (
|
||||
totals["nv_inference_count"] if "nv_inference_count" in matched else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _pipeline_document(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
perception = _mapping(raw.get("perception"))
|
||||
current_stage = perception.get("current_stage")
|
||||
if not isinstance(current_stage, str):
|
||||
current_stage = None
|
||||
busy = perception.get("state") == "busy"
|
||||
stages = (
|
||||
("source-ingress", "Приём сенсорного потока"),
|
||||
("camera-decode", "Декодирование камеры"),
|
||||
("preprocessing", "Предобработка"),
|
||||
("detector", "Детектор объектов"),
|
||||
("semantic-model", "Семантическая модель"),
|
||||
("sensor-fusion", "Camera ↔ LiDAR fusion"),
|
||||
("tracking", "Трекинг"),
|
||||
("temporal-state", "Временное состояние"),
|
||||
("result-publication", "Публикация результата"),
|
||||
)
|
||||
|
||||
def stage_state(stage_id: str) -> str:
|
||||
if not perception:
|
||||
return "unavailable"
|
||||
if not busy:
|
||||
return "ready"
|
||||
if stage_id == current_stage or stage_id in {
|
||||
"source-ingress",
|
||||
"camera-decode",
|
||||
"semantic-model",
|
||||
}:
|
||||
return "active"
|
||||
return "waiting"
|
||||
|
||||
return {
|
||||
"service_state": (
|
||||
perception.get("state") if isinstance(perception.get("state"), str) else "unavailable"
|
||||
),
|
||||
"current_stage": current_stage,
|
||||
"active_request_id": (
|
||||
perception.get("active_request_id")
|
||||
if isinstance(perception.get("active_request_id"), str)
|
||||
else None
|
||||
),
|
||||
"active_frame_index": (
|
||||
int(perception["active_frame_index"])
|
||||
if isinstance(perception.get("active_frame_index"), int)
|
||||
else None
|
||||
),
|
||||
"completed_runs": (
|
||||
int(perception["completed_runs"])
|
||||
if isinstance(perception.get("completed_runs"), int)
|
||||
else None
|
||||
),
|
||||
"failed_runs": (
|
||||
int(perception["failed_runs"])
|
||||
if isinstance(perception.get("failed_runs"), int)
|
||||
else None
|
||||
),
|
||||
"model_load_seconds": _number(perception.get("model_load_seconds")),
|
||||
"stages": [
|
||||
{
|
||||
"id": stage_id,
|
||||
"label": label,
|
||||
"state": stage_state(stage_id),
|
||||
}
|
||||
for stage_id, label in stages
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class WorkerTelemetryService:
|
||||
def __init__(
|
||||
self,
|
||||
store: WorkerProfileStore,
|
||||
probe_runner: ProbeRunner = run_worker_probe,
|
||||
*,
|
||||
cache_seconds: float = 1.5,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.probe_runner = probe_runner
|
||||
self.cache_seconds = cache_seconds
|
||||
self._lock = threading.Lock()
|
||||
self._cached_at = 0.0
|
||||
self._cached: dict[str, Any] | None = None
|
||||
self._previous_network: tuple[float, float, float] | None = None
|
||||
self._history: deque[dict[str, Any]] = deque(maxlen=300)
|
||||
|
||||
def profile_document(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": PROFILE_SCHEMA,
|
||||
"profile": self.store.read().model_dump(mode="json"),
|
||||
"security": {
|
||||
"transport": "ssh",
|
||||
"host_key_policy": "strict-pinned",
|
||||
"credentials_managed_by_ui": False,
|
||||
"ssh_host_alias": SSH_HOST_ALIAS,
|
||||
},
|
||||
}
|
||||
|
||||
def test_profile(self, request: WorkerConnectionProfilePut) -> dict[str, Any]:
|
||||
current = self.store.read()
|
||||
candidate = current.model_copy(
|
||||
update={"address": request.address, "port": request.port}
|
||||
)
|
||||
return self._public_probe(self.probe_runner(candidate))
|
||||
|
||||
def apply_profile(self, request: WorkerConnectionProfilePut) -> dict[str, Any]:
|
||||
current = self.store.read()
|
||||
if request.revision != current.revision:
|
||||
raise RuntimeError("worker profile revision changed")
|
||||
candidate = current.model_copy(
|
||||
update={"address": request.address, "port": request.port}
|
||||
)
|
||||
probe = self.probe_runner(candidate)
|
||||
if not probe.get("reachable") or not probe.get("identity_matches"):
|
||||
raise ConnectionError(str(probe.get("error_code") or "worker-unreachable"))
|
||||
saved = self.store.save(request)
|
||||
with self._lock:
|
||||
self._cached = None
|
||||
self._cached_at = 0
|
||||
self._previous_network = None
|
||||
self._history.clear()
|
||||
return {
|
||||
**self.profile_document(),
|
||||
"verification": self._public_probe(probe),
|
||||
"profile": saved.model_dump(mode="json"),
|
||||
}
|
||||
|
||||
def snapshot(self, history_limit: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
now = time.monotonic()
|
||||
if self._cached is not None and now - self._cached_at < self.cache_seconds:
|
||||
return {
|
||||
**self._cached,
|
||||
"history": list(self._history)[-history_limit:],
|
||||
}
|
||||
profile = self.store.read()
|
||||
probe = self.probe_runner(profile)
|
||||
document = self._telemetry_document(profile, probe, now)
|
||||
self._cached = document
|
||||
self._cached_at = now
|
||||
return {
|
||||
**document,
|
||||
"history": list(self._history)[-history_limit:],
|
||||
}
|
||||
|
||||
def _telemetry_document(
|
||||
self,
|
||||
profile: WorkerConnectionProfile,
|
||||
probe: dict[str, Any],
|
||||
monotonic_now: float,
|
||||
) -> dict[str, Any]:
|
||||
public_probe = self._public_probe(probe)
|
||||
if not probe.get("reachable") or not probe.get("identity_matches"):
|
||||
return {
|
||||
"schema_version": TELEMETRY_SCHEMA,
|
||||
"profile": profile.model_dump(mode="json"),
|
||||
"connection": public_probe,
|
||||
"node": None,
|
||||
"runtimes": [],
|
||||
"pipeline": _pipeline_document({}),
|
||||
"network": {"interfaces": [], "aggregate": None},
|
||||
}
|
||||
raw = _mapping(probe.get("raw"))
|
||||
memory = _mapping(raw.get("memory"))
|
||||
memory_total = _number(memory.get("total_bytes"))
|
||||
memory_free = _number(memory.get("free_bytes"))
|
||||
memory_used = (
|
||||
memory_total - memory_free
|
||||
if memory_total is not None and memory_free is not None
|
||||
else None
|
||||
)
|
||||
gpu = _mapping(raw.get("gpu"))
|
||||
gpu_used = _number(gpu.get("memory_used_mib"))
|
||||
gpu_total = _number(gpu.get("memory_total_mib"))
|
||||
interfaces = [
|
||||
self._network_interface(item)
|
||||
for item in _items(raw.get("network"))
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
received = sum(
|
||||
value
|
||||
for item in interfaces
|
||||
if (value := _number(item.get("received_bytes"))) is not None
|
||||
)
|
||||
sent = sum(
|
||||
value
|
||||
for item in interfaces
|
||||
if (value := _number(item.get("sent_bytes"))) is not None
|
||||
)
|
||||
receive_rate: float | None = None
|
||||
send_rate: float | None = None
|
||||
if self._previous_network is not None:
|
||||
previous_at, previous_received, previous_sent = self._previous_network
|
||||
elapsed = monotonic_now - previous_at
|
||||
if elapsed > 0 and received >= previous_received and sent >= previous_sent:
|
||||
receive_rate = (received - previous_received) / elapsed
|
||||
send_rate = (sent - previous_sent) / elapsed
|
||||
self._previous_network = (monotonic_now, received, sent)
|
||||
raw_stats = _mapping(raw.get("docker_stats"))
|
||||
raw_states = _mapping(raw.get("container_states"))
|
||||
runtimes = [
|
||||
_container_document(name, raw_stats, raw_states) for name in CONTAINER_NAMES
|
||||
]
|
||||
triton = _mapping(raw.get("triton"))
|
||||
triton_document = {
|
||||
"ready": triton.get("ready") is True,
|
||||
**_triton_metrics(triton.get("metrics")),
|
||||
}
|
||||
memory_used_percent = _percent(memory_used, memory_total)
|
||||
gpu_memory_used_percent = _percent(gpu_used, gpu_total)
|
||||
node: dict[str, Any] = {
|
||||
"node_id": raw.get("node_id"),
|
||||
"observed_at_utc": raw.get("observed_at_utc"),
|
||||
"os": _mapping(raw.get("os")),
|
||||
"cpu": _mapping(raw.get("cpu")),
|
||||
"memory": {
|
||||
"total_bytes": memory_total,
|
||||
"used_bytes": memory_used,
|
||||
"free_bytes": memory_free,
|
||||
"used_percent": memory_used_percent,
|
||||
},
|
||||
"disks": _items(raw.get("disks")),
|
||||
"gpu": {
|
||||
**gpu,
|
||||
"memory_used_percent": gpu_memory_used_percent,
|
||||
}
|
||||
if gpu
|
||||
else None,
|
||||
"triton": triton_document,
|
||||
}
|
||||
network = {
|
||||
"interfaces": interfaces,
|
||||
"aggregate": {
|
||||
"received_bytes": received,
|
||||
"sent_bytes": sent,
|
||||
"receive_bytes_per_second": receive_rate,
|
||||
"send_bytes_per_second": send_rate,
|
||||
},
|
||||
}
|
||||
history_row = {
|
||||
"observed_at_utc": raw.get("observed_at_utc") or _utc_now(),
|
||||
"cpu_percent": _number(_mapping(raw.get("cpu")).get("load_percent")),
|
||||
"memory_percent": memory_used_percent,
|
||||
"gpu_percent": _number(gpu.get("utilization_percent")),
|
||||
"gpu_memory_percent": gpu_memory_used_percent,
|
||||
"network_receive_bytes_per_second": receive_rate,
|
||||
"network_send_bytes_per_second": send_rate,
|
||||
}
|
||||
self._history.append(history_row)
|
||||
return {
|
||||
"schema_version": TELEMETRY_SCHEMA,
|
||||
"profile": profile.model_dump(mode="json"),
|
||||
"connection": public_probe,
|
||||
"node": node,
|
||||
"runtimes": runtimes,
|
||||
"pipeline": _pipeline_document(raw),
|
||||
"network": network,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _network_interface(item: dict[str, Any]) -> dict[str, Any]:
|
||||
addresses = [
|
||||
address for address in _items(item.get("addresses")) if isinstance(address, str)
|
||||
]
|
||||
return {
|
||||
"name": item.get("name") if isinstance(item.get("name"), str) else "unknown",
|
||||
"description": (
|
||||
item.get("description") if isinstance(item.get("description"), str) else None
|
||||
),
|
||||
"status": item.get("status") if isinstance(item.get("status"), str) else None,
|
||||
"link_speed_bps": _number(item.get("link_speed_bps")),
|
||||
"mac_address": (
|
||||
item.get("mac_address") if isinstance(item.get("mac_address"), str) else None
|
||||
),
|
||||
"addresses": addresses,
|
||||
"received_bytes": _number(item.get("received_bytes")),
|
||||
"sent_bytes": _number(item.get("sent_bytes")),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _public_probe(probe: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": PROBE_SCHEMA,
|
||||
"reachable": probe.get("reachable") is True,
|
||||
"identity_matches": probe.get("identity_matches") is True,
|
||||
"node_id": probe.get("node_id") if isinstance(probe.get("node_id"), str) else None,
|
||||
"latency_ms": _number(probe.get("latency_ms")),
|
||||
"observed_at_utc": (
|
||||
probe.get("observed_at_utc")
|
||||
if isinstance(probe.get("observed_at_utc"), str)
|
||||
else _utc_now()
|
||||
),
|
||||
"error_code": (
|
||||
probe.get("error_code")
|
||||
if isinstance(probe.get("error_code"), str)
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_system_telemetry_router(
|
||||
*,
|
||||
root_provider: RootProvider,
|
||||
probe_runner: ProbeRunner = run_worker_probe,
|
||||
) -> APIRouter:
|
||||
store = WorkerProfileStore(root_provider())
|
||||
service = WorkerTelemetryService(store, probe_runner)
|
||||
router = APIRouter(prefix="/api/v1/system", tags=["system"])
|
||||
|
||||
@router.get("/worker-profile")
|
||||
def get_worker_profile() -> dict[str, Any]:
|
||||
return service.profile_document()
|
||||
|
||||
@router.post("/worker-profile/test")
|
||||
def test_worker_profile(request: WorkerConnectionProfilePut) -> dict[str, Any]:
|
||||
return service.test_profile(request)
|
||||
|
||||
@router.put("/worker-profile")
|
||||
def put_worker_profile(request: WorkerConnectionProfilePut) -> dict[str, Any]:
|
||||
try:
|
||||
return service.apply_profile(request)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Профиль Worker 006 был изменён в другой сессии.",
|
||||
) from exc
|
||||
except ConnectionError as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Новый адрес не прошёл проверку узла Worker 006.",
|
||||
) from exc
|
||||
|
||||
@router.get("/worker-telemetry")
|
||||
def get_worker_telemetry(
|
||||
history: int = Query(default=90, ge=1, le=300),
|
||||
) -> dict[str, Any]:
|
||||
return service.snapshot(history)
|
||||
|
||||
return router
|
||||
Reference in New Issue
Block a user