feat: add monitoring trends
This commit is contained in:
1 parent
19fc29c72c
commit
4d360fac42
18 files changed
+1683
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Historical monitoring read models."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Dependency construction for the Monitoring domain."""
|
||||
|
||||
from app.monitoring.repository import MonitoringRepository
|
||||
from app.monitoring.service import MonitoringService
|
||||
|
||||
|
||||
def get_monitoring_service() -> MonitoringService:
|
||||
return MonitoringService(MonitoringRepository())
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Monitoring domain exceptions."""
|
||||
|
||||
|
||||
class MonitoringError(Exception):
|
||||
"""Base monitoring-domain error."""
|
||||
|
||||
|
||||
class MonitoringValidationError(MonitoringError):
|
||||
"""The requested trend scope or range is invalid."""
|
||||
|
||||
|
||||
class MonitoringScopeNotFound(MonitoringError):
|
||||
"""The requested durable scope does not exist."""
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Authenticated frontend routes for Monitoring Trends."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from app.frontend.views import render_app_page
|
||||
from app.identity import require_permission, user_has_permission
|
||||
from app.monitoring.models import (
|
||||
MonitoringTrendQuery,
|
||||
TrendRange,
|
||||
TrendScopeType,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(tags=["frontend"])
|
||||
STATIC_ROOT = Path(__file__).parents[1] / "frontend" / "static"
|
||||
MonitoringUser = Annotated[dict, Depends(require_permission("miners.view"))]
|
||||
|
||||
|
||||
@router.get("/assets/minerops-monitoring-trends.css", include_in_schema=False)
|
||||
def monitoring_trends_styles() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "monitoring-trends.css").read_text(encoding="utf-8"),
|
||||
media_type="text/css",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-monitoring-trends.js", include_in_schema=False)
|
||||
def monitoring_trends_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "monitoring-trends.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/monitoring/analytics",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
def monitoring_analytics_page(
|
||||
current_user: MonitoringUser,
|
||||
scope: Annotated[TrendScopeType, Query()] = "fleet",
|
||||
scope_id: Annotated[UUID | None, Query()] = None,
|
||||
time_range: Annotated[TrendRange, Query(alias="range")] = "24h",
|
||||
) -> str:
|
||||
try:
|
||||
query = MonitoringTrendQuery(
|
||||
scope=scope, scope_id=scope_id, range=time_range
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
if query.scope in {"site", "room"} and not user_has_permission(
|
||||
current_user, "locations.view"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Location viewing permission is required for this scope",
|
||||
)
|
||||
return render_app_page(
|
||||
"analytics",
|
||||
current_user,
|
||||
lambda permission: user_has_permission(current_user, permission),
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Typed contracts for bounded historical telemetry trends."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
TrendScopeType = Literal["fleet", "site", "room", "miner"]
|
||||
TrendRange = Literal["6h", "24h", "7d"]
|
||||
TrendMetric = Literal["hashrate", "power"]
|
||||
TrendAttribution = Literal["current_placement_cohort", "miner_identity", "fleet"]
|
||||
|
||||
|
||||
class MonitoringTrendQuery(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
scope: TrendScopeType = "fleet"
|
||||
scope_id: UUID | None = None
|
||||
time_range: TrendRange = Field(default="24h", alias="range")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_scope_identity(self) -> "MonitoringTrendQuery":
|
||||
if self.scope == "fleet" and self.scope_id is not None:
|
||||
raise ValueError("scope_id is not allowed for fleet scope")
|
||||
if self.scope != "fleet" and self.scope_id is None:
|
||||
raise ValueError("scope_id is required for this scope")
|
||||
return self
|
||||
|
||||
|
||||
class MonitoringScope(BaseModel):
|
||||
type: TrendScopeType
|
||||
id: UUID | None = None
|
||||
label: str
|
||||
|
||||
|
||||
class MonitoringSiteOption(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class MonitoringRoomOption(BaseModel):
|
||||
id: UUID
|
||||
site_id: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class MonitoringAvailableScopes(BaseModel):
|
||||
fleet: bool = True
|
||||
sites: list[MonitoringSiteOption] = Field(default_factory=list)
|
||||
rooms: list[MonitoringRoomOption] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MonitoringTrendPoint(BaseModel):
|
||||
bucket_start: datetime
|
||||
bucket_end: datetime
|
||||
value: float | None = None
|
||||
observation_count: int
|
||||
metric_sample_count: int
|
||||
observed_miner_count: int
|
||||
contributing_miner_count: int
|
||||
scoped_miner_count: int
|
||||
incomplete: bool = False
|
||||
|
||||
|
||||
class MonitoringTrendSeries(BaseModel):
|
||||
metric: TrendMetric
|
||||
unit: Literal["TH/s", "W"]
|
||||
label: str
|
||||
points: list[MonitoringTrendPoint]
|
||||
|
||||
|
||||
class MonitoringTrendData(BaseModel):
|
||||
scope: MonitoringScope
|
||||
series: list[MonitoringTrendSeries]
|
||||
available_scopes: MonitoringAvailableScopes
|
||||
|
||||
|
||||
class MonitoringTrendMeta(BaseModel):
|
||||
generated_at: datetime
|
||||
latest_observed_at: datetime | None = None
|
||||
source: Literal["raw_snapshots"] = "raw_snapshots"
|
||||
attribution: TrendAttribution
|
||||
time_range: TrendRange = Field(alias="range")
|
||||
bucket_seconds: int
|
||||
maximum_points: int = 200
|
||||
completeness: Literal["not_guaranteed"] = "not_guaranteed"
|
||||
current_bucket_incomplete: bool
|
||||
database_query_count: int = Field(ge=1, le=2)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MonitoringTrendResponse(BaseModel):
|
||||
data: MonitoringTrendData
|
||||
meta: MonitoringTrendMeta
|
||||
error: None = None
|
||||
@@ -0,0 +1,237 @@
|
||||
"""PostgreSQL persistence for bounded monitoring trends."""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
|
||||
|
||||
def default_connection_factory() -> psycopg.Connection:
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
TREND_SQL = """
|
||||
WITH requested AS (
|
||||
SELECT %s::text AS scope_type, %s::uuid AS scope_id,
|
||||
%s::timestamptz AS window_start, %s::timestamptz AS window_end,
|
||||
%s::interval AS bucket_width
|
||||
),
|
||||
current_locations AS (
|
||||
SELECT miners.id AS miner_id,
|
||||
COALESCE(position.site_id, miners.site_id) AS site_id,
|
||||
COALESCE(position.room_id, miners.room_id) AS room_id
|
||||
FROM miners
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT rooms.site_id, location_positions.room_id
|
||||
FROM location_positions
|
||||
LEFT JOIN rooms ON rooms.id = location_positions.room_id
|
||||
WHERE location_positions.miner_id = miners.id
|
||||
ORDER BY location_positions.id
|
||||
LIMIT 1
|
||||
) AS position ON true
|
||||
),
|
||||
scope_meta AS (
|
||||
SELECT requested.scope_type, requested.scope_id,
|
||||
CASE requested.scope_type
|
||||
WHEN 'fleet' THEN true
|
||||
WHEN 'miner' THEN EXISTS (
|
||||
SELECT 1 FROM miners WHERE miners.id = requested.scope_id
|
||||
)
|
||||
WHEN 'site' THEN EXISTS (
|
||||
SELECT 1 FROM sites WHERE sites.id = requested.scope_id
|
||||
)
|
||||
WHEN 'room' THEN EXISTS (
|
||||
SELECT 1 FROM rooms WHERE rooms.id = requested.scope_id
|
||||
)
|
||||
ELSE false
|
||||
END AS scope_exists,
|
||||
CASE requested.scope_type
|
||||
WHEN 'fleet' THEN 'Fleet'
|
||||
WHEN 'miner' THEN (
|
||||
SELECT COALESCE(NULLIF(trim(miners.asset_tag), ''), miners.id::text)
|
||||
FROM miners WHERE miners.id = requested.scope_id
|
||||
)
|
||||
WHEN 'site' THEN (
|
||||
SELECT sites.name FROM sites WHERE sites.id = requested.scope_id
|
||||
)
|
||||
WHEN 'room' THEN (
|
||||
SELECT rooms.name FROM rooms WHERE rooms.id = requested.scope_id
|
||||
)
|
||||
END AS scope_label
|
||||
FROM requested
|
||||
),
|
||||
scoped_miners AS (
|
||||
SELECT miners.id
|
||||
FROM miners
|
||||
JOIN current_locations ON current_locations.miner_id = miners.id
|
||||
CROSS JOIN requested
|
||||
WHERE requested.scope_type = 'fleet'
|
||||
OR (requested.scope_type = 'miner' AND miners.id = requested.scope_id)
|
||||
OR (requested.scope_type = 'site'
|
||||
AND current_locations.site_id = requested.scope_id)
|
||||
OR (requested.scope_type = 'room'
|
||||
AND current_locations.room_id = requested.scope_id)
|
||||
),
|
||||
scope_count AS (
|
||||
SELECT count(*)::integer AS scoped_miner_count FROM scoped_miners
|
||||
),
|
||||
buckets AS (
|
||||
SELECT bucket_start,
|
||||
bucket_start + requested.bucket_width AS bucket_end
|
||||
FROM requested
|
||||
CROSS JOIN LATERAL generate_series(
|
||||
requested.window_start,
|
||||
requested.window_end - requested.bucket_width,
|
||||
requested.bucket_width
|
||||
) AS bucket_start
|
||||
),
|
||||
per_miner_bucket AS (
|
||||
SELECT date_bin(
|
||||
requested.bucket_width,
|
||||
snapshots.observed_at,
|
||||
'1970-01-01 00:00:00+00'::timestamptz
|
||||
) AS bucket_start,
|
||||
snapshots.miner_id,
|
||||
count(*)::integer AS observation_count,
|
||||
count(snapshots.mhs_av)::integer AS hashrate_sample_count,
|
||||
count(snapshots.power_w)::integer AS power_sample_count,
|
||||
avg(snapshots.mhs_av) AS average_mhs_av,
|
||||
avg(snapshots.power_w) AS average_power_w
|
||||
FROM miner_status_snapshots AS snapshots
|
||||
JOIN scoped_miners ON scoped_miners.id = snapshots.miner_id
|
||||
CROSS JOIN requested
|
||||
WHERE snapshots.observed_at >= requested.window_start
|
||||
AND snapshots.observed_at < requested.window_end
|
||||
GROUP BY bucket_start, snapshots.miner_id
|
||||
),
|
||||
bucket_totals AS (
|
||||
SELECT per_miner_bucket.bucket_start,
|
||||
sum(per_miner_bucket.observation_count)::integer AS observation_count,
|
||||
sum(per_miner_bucket.hashrate_sample_count)::integer
|
||||
AS hashrate_sample_count,
|
||||
sum(per_miner_bucket.power_sample_count)::integer AS power_sample_count,
|
||||
count(*)::integer AS observed_miner_count,
|
||||
count(*) FILTER (
|
||||
WHERE per_miner_bucket.hashrate_sample_count > 0
|
||||
)::integer AS hashrate_contributing_miner_count,
|
||||
count(*) FILTER (
|
||||
WHERE per_miner_bucket.power_sample_count > 0
|
||||
)::integer AS power_contributing_miner_count,
|
||||
CASE WHEN sum(per_miner_bucket.hashrate_sample_count) > 0
|
||||
THEN sum(per_miner_bucket.average_mhs_av) FILTER (
|
||||
WHERE per_miner_bucket.hashrate_sample_count > 0
|
||||
) / 1000000.0
|
||||
END AS hashrate_ths,
|
||||
CASE WHEN sum(per_miner_bucket.power_sample_count) > 0
|
||||
THEN sum(per_miner_bucket.average_power_w) FILTER (
|
||||
WHERE per_miner_bucket.power_sample_count > 0
|
||||
)
|
||||
END AS power_w
|
||||
FROM per_miner_bucket
|
||||
GROUP BY per_miner_bucket.bucket_start
|
||||
),
|
||||
latest AS (
|
||||
SELECT max(snapshots.observed_at) AS latest_observed_at
|
||||
FROM miner_status_snapshots AS snapshots
|
||||
JOIN scoped_miners ON scoped_miners.id = snapshots.miner_id
|
||||
CROSS JOIN requested
|
||||
WHERE snapshots.observed_at >= requested.window_start
|
||||
AND snapshots.observed_at < requested.window_end
|
||||
)
|
||||
SELECT scope_meta.scope_exists, scope_meta.scope_label,
|
||||
buckets.bucket_start, buckets.bucket_end,
|
||||
bucket_totals.hashrate_ths, bucket_totals.power_w,
|
||||
COALESCE(bucket_totals.observation_count, 0) AS observation_count,
|
||||
COALESCE(bucket_totals.hashrate_sample_count, 0) AS hashrate_sample_count,
|
||||
COALESCE(bucket_totals.power_sample_count, 0) AS power_sample_count,
|
||||
COALESCE(bucket_totals.observed_miner_count, 0) AS observed_miner_count,
|
||||
COALESCE(bucket_totals.hashrate_contributing_miner_count, 0)
|
||||
AS hashrate_contributing_miner_count,
|
||||
COALESCE(bucket_totals.power_contributing_miner_count, 0)
|
||||
AS power_contributing_miner_count,
|
||||
scope_count.scoped_miner_count, latest.latest_observed_at
|
||||
FROM buckets
|
||||
CROSS JOIN scope_meta
|
||||
CROSS JOIN scope_count
|
||||
CROSS JOIN latest
|
||||
LEFT JOIN bucket_totals ON bucket_totals.bucket_start = buckets.bucket_start
|
||||
ORDER BY buckets.bucket_start
|
||||
"""
|
||||
|
||||
|
||||
SCOPE_OPTIONS_SQL = """
|
||||
SELECT option_type, id, site_id, name
|
||||
FROM (
|
||||
SELECT 'site'::text AS option_type, sites.id,
|
||||
NULL::uuid AS site_id, sites.name
|
||||
FROM sites
|
||||
UNION ALL
|
||||
SELECT 'room'::text AS option_type, rooms.id,
|
||||
rooms.site_id, rooms.name
|
||||
FROM rooms
|
||||
) AS options
|
||||
ORDER BY option_type DESC, lower(name), id
|
||||
"""
|
||||
|
||||
|
||||
class MonitoringRepository:
|
||||
"""Own the set-based raw-snapshot monitoring read path."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection_factory: ConnectionFactory | None = None,
|
||||
*,
|
||||
connection: psycopg.Connection | None = None,
|
||||
):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
self._connection = connection
|
||||
|
||||
@contextmanager
|
||||
def _connection_scope(self) -> Iterator[psycopg.Connection]:
|
||||
if self._connection is not None:
|
||||
yield self._connection
|
||||
return
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def read_trends(
|
||||
self,
|
||||
*,
|
||||
scope: str,
|
||||
scope_id: UUID | None,
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
bucket_width: timedelta,
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
TREND_SQL,
|
||||
(scope, scope_id, window_start, window_end, bucket_width),
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
def list_location_scope_options(self) -> list[dict[str, Any]]:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(SCOPE_OPTIONS_SQL)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""HTTP boundary for bounded historical monitoring trends."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
|
||||
from app.identity import require_permission, user_has_permission
|
||||
from app.monitoring.dependencies import get_monitoring_service
|
||||
from app.monitoring.exceptions import MonitoringScopeNotFound
|
||||
from app.monitoring.models import (
|
||||
MonitoringTrendQuery,
|
||||
MonitoringTrendResponse,
|
||||
TrendRange,
|
||||
TrendScopeType,
|
||||
)
|
||||
from app.monitoring.service import MonitoringService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
||||
MonitoringUser = Annotated[dict, Depends(require_permission("miners.view"))]
|
||||
MonitoringServiceDependency = Annotated[
|
||||
MonitoringService,
|
||||
Depends(get_monitoring_service),
|
||||
]
|
||||
|
||||
|
||||
@router.get("/trends", response_model=MonitoringTrendResponse)
|
||||
def get_monitoring_trends(
|
||||
current_user: MonitoringUser,
|
||||
service: MonitoringServiceDependency,
|
||||
response: Response,
|
||||
scope: Annotated[TrendScopeType, Query()] = "fleet",
|
||||
scope_id: Annotated[UUID | None, Query()] = None,
|
||||
time_range: Annotated[TrendRange, Query(alias="range")] = "24h",
|
||||
) -> MonitoringTrendResponse:
|
||||
try:
|
||||
query = MonitoringTrendQuery(
|
||||
scope=scope, scope_id=scope_id, range=time_range
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
|
||||
) from exc
|
||||
can_view_locations = user_has_permission(current_user, "locations.view")
|
||||
if query.scope in {"site", "room"} and not can_view_locations:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Location viewing permission is required for this scope",
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
try:
|
||||
return service.get_trends(
|
||||
query,
|
||||
include_location_scopes=can_view_locations,
|
||||
)
|
||||
except MonitoringScopeNotFound as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Business semantics for bounded historical monitoring trends."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from app.monitoring.exceptions import MonitoringScopeNotFound
|
||||
from app.monitoring.models import (
|
||||
MonitoringAvailableScopes,
|
||||
MonitoringRoomOption,
|
||||
MonitoringScope,
|
||||
MonitoringSiteOption,
|
||||
MonitoringTrendData,
|
||||
MonitoringTrendMeta,
|
||||
MonitoringTrendPoint,
|
||||
MonitoringTrendQuery,
|
||||
MonitoringTrendResponse,
|
||||
MonitoringTrendSeries,
|
||||
)
|
||||
from app.monitoring.repository import MonitoringRepository
|
||||
|
||||
|
||||
RANGE_CONFIGURATION = {
|
||||
"6h": (timedelta(hours=6), timedelta(minutes=5)),
|
||||
"24h": (timedelta(hours=24), timedelta(minutes=15)),
|
||||
"7d": (timedelta(days=7), timedelta(hours=1)),
|
||||
}
|
||||
MAXIMUM_POINTS = 200
|
||||
|
||||
|
||||
class MonitoringService:
|
||||
"""Coordinate factual telemetry aggregation without HTTP dependencies."""
|
||||
|
||||
def __init__(self, repository: MonitoringRepository):
|
||||
self.repository = repository
|
||||
|
||||
@staticmethod
|
||||
def _window(
|
||||
query: MonitoringTrendQuery,
|
||||
generated_at: datetime,
|
||||
) -> tuple[datetime, datetime, timedelta]:
|
||||
duration, bucket_width = RANGE_CONFIGURATION[query.time_range]
|
||||
generated_at = generated_at.astimezone(timezone.utc)
|
||||
bucket_seconds = int(bucket_width.total_seconds())
|
||||
current_bucket_epoch = (
|
||||
int(generated_at.timestamp()) // bucket_seconds * bucket_seconds
|
||||
)
|
||||
current_bucket_start = datetime.fromtimestamp(
|
||||
current_bucket_epoch, tz=timezone.utc
|
||||
)
|
||||
window_end = current_bucket_start + bucket_width
|
||||
return window_end - duration, window_end, bucket_width
|
||||
|
||||
def get_trends(
|
||||
self,
|
||||
query: MonitoringTrendQuery,
|
||||
*,
|
||||
include_location_scopes: bool,
|
||||
generated_at: datetime | None = None,
|
||||
) -> MonitoringTrendResponse:
|
||||
generated_at = generated_at or datetime.now(timezone.utc)
|
||||
window_start, window_end, bucket_width = self._window(query, generated_at)
|
||||
rows = self.repository.read_trends(
|
||||
scope=query.scope,
|
||||
scope_id=query.scope_id,
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
bucket_width=bucket_width,
|
||||
)
|
||||
if not rows or not rows[0]["scope_exists"]:
|
||||
raise MonitoringScopeNotFound(f"{query.scope.title()} not found")
|
||||
|
||||
options = MonitoringAvailableScopes()
|
||||
query_count = 1
|
||||
if include_location_scopes:
|
||||
option_rows = self.repository.list_location_scope_options()
|
||||
query_count += 1
|
||||
options = MonitoringAvailableScopes(
|
||||
sites=[
|
||||
MonitoringSiteOption.model_validate(row)
|
||||
for row in option_rows
|
||||
if row["option_type"] == "site"
|
||||
],
|
||||
rooms=[
|
||||
MonitoringRoomOption.model_validate(row)
|
||||
for row in option_rows
|
||||
if row["option_type"] == "room"
|
||||
],
|
||||
)
|
||||
|
||||
generated_at = generated_at.astimezone(timezone.utc)
|
||||
hashrate_points = []
|
||||
power_points = []
|
||||
for row in rows:
|
||||
shared = {
|
||||
"bucket_start": row["bucket_start"],
|
||||
"bucket_end": row["bucket_end"],
|
||||
"observation_count": row["observation_count"],
|
||||
"observed_miner_count": row["observed_miner_count"],
|
||||
"scoped_miner_count": row["scoped_miner_count"],
|
||||
"incomplete": row["bucket_end"] > generated_at,
|
||||
}
|
||||
hashrate_points.append(
|
||||
MonitoringTrendPoint(
|
||||
**shared,
|
||||
value=(
|
||||
float(row["hashrate_ths"])
|
||||
if row["hashrate_ths"] is not None
|
||||
else None
|
||||
),
|
||||
metric_sample_count=row["hashrate_sample_count"],
|
||||
contributing_miner_count=(
|
||||
row["hashrate_contributing_miner_count"]
|
||||
),
|
||||
)
|
||||
)
|
||||
power_points.append(
|
||||
MonitoringTrendPoint(
|
||||
**shared,
|
||||
value=(
|
||||
float(row["power_w"])
|
||||
if row["power_w"] is not None
|
||||
else None
|
||||
),
|
||||
metric_sample_count=row["power_sample_count"],
|
||||
contributing_miner_count=(
|
||||
row["power_contributing_miner_count"]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if len(rows) > MAXIMUM_POINTS:
|
||||
raise RuntimeError("Monitoring query exceeded the fixed point bound")
|
||||
attribution = (
|
||||
"current_placement_cohort"
|
||||
if query.scope in {"site", "room"}
|
||||
else "miner_identity"
|
||||
if query.scope == "miner"
|
||||
else "fleet"
|
||||
)
|
||||
latest_observed_at = rows[0]["latest_observed_at"]
|
||||
return MonitoringTrendResponse(
|
||||
data=MonitoringTrendData(
|
||||
scope=MonitoringScope(
|
||||
type=query.scope,
|
||||
id=query.scope_id,
|
||||
label=rows[0]["scope_label"],
|
||||
),
|
||||
series=[
|
||||
MonitoringTrendSeries(
|
||||
metric="hashrate",
|
||||
unit="TH/s",
|
||||
label="Average recorded hashrate",
|
||||
points=hashrate_points,
|
||||
),
|
||||
MonitoringTrendSeries(
|
||||
metric="power",
|
||||
unit="W",
|
||||
label="Average observed power",
|
||||
points=power_points,
|
||||
),
|
||||
],
|
||||
available_scopes=options,
|
||||
),
|
||||
meta=MonitoringTrendMeta(
|
||||
generated_at=generated_at,
|
||||
latest_observed_at=latest_observed_at,
|
||||
attribution=attribution,
|
||||
range=query.time_range,
|
||||
bucket_seconds=int(bucket_width.total_seconds()),
|
||||
current_bucket_incomplete=window_end > generated_at,
|
||||
database_query_count=query_count,
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user