Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/torq/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,18 @@ def metrics():
return models.metrics()


@router.get("/metrics/trend")
def metrics_trend():
"""Per-day diagnosis latency + MTTR for the trend chart."""
return models.trend()


@router.get("/metrics/faults-per-machine")
def metrics_faults_per_machine():
"""Work-order count per machine for the bar chart."""
return models.faults_per_machine()


@router.get("/events/stream")
async def event_stream(request: Request):
"""SSE endpoint: streams incoming MachineFaultEvent in real-time."""
Expand Down
45 changes: 44 additions & 1 deletion src/torq/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ def metrics() -> dict:
for w in wos:
by_status[w.status] = by_status.get(w.status, 0) + 1

ttd = [s for w in wos if (s := _secs(w.fault_arrived_at, w.dispatched_at)) is not None]
# Diagnosis latency = fault arrival -> work order created (the AI step).
# Not -> dispatched_at, which also includes the human approval wait.
ttd = [s for w in wos if (s := _secs(w.fault_arrived_at, w.created_at)) is not None]
resolved = [w for w in wos if w.status == "resolved"]
ttf = [
float(w.outcome["time_to_fix_min"])
Expand Down Expand Up @@ -194,3 +196,44 @@ def metrics() -> dict:
"resolution_rate": round(len(resolved) / len(wos), 2) if wos else None,
"machine_downtime": machine_downtime,
}


def trend(days: int = 7) -> list[dict]:
"""Per-day avg diagnosis latency (min) and MTTR (min) for the recent window."""
buckets: dict[str, dict[str, list[float]]] = {}
for w in list_all():
if not w.created_at:
continue
day = w.created_at[:10]
b = buckets.setdefault(day, {"diag": [], "mttr": []})
d = _secs(w.fault_arrived_at, w.created_at)
if d is not None:
b["diag"].append(d / 60)
if w.status == "resolved":
fix = _downtime_min(w)
if fix is not None:
b["mttr"].append(fix)

rows = []
for day in sorted(buckets)[-days:]:
b = buckets[day]
rows.append(
{
"label": day[5:], # MM-DD
"diagnosis": round(sum(b["diag"]) / len(b["diag"]), 1) if b["diag"] else 0,
"mttr": round(sum(b["mttr"]) / len(b["mttr"]), 1) if b["mttr"] else 0,
}
)
return rows


def faults_per_machine() -> list[dict]:
"""Count of work orders per machine, most faults first."""
counts: dict[str, int] = {}
for w in list_all():
if w.machine:
counts[w.machine] = counts.get(w.machine, 0) + 1
return [
{"machine": m, "count": c}
for m, c in sorted(counts.items(), key=lambda kv: -kv[1])
]
41 changes: 41 additions & 0 deletions tests/test_metrics_charts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Trend + faults-per-machine aggregation, and diagnosis latency semantics."""

from unittest.mock import patch

from torq.agent.schemas import WorkOrder
from torq.db import models


def _wo(**kw):
base = dict(id=kw.pop("id", "x"), fault_code="E-201", machine="CM-350", root_cause="rc")
base.update(kw)
return WorkOrder(**base)


SAMPLE = [
_wo(id="a", machine="CM-350", fault_arrived_at="2026-07-18T10:00:00+00:00",
created_at="2026-07-18T10:00:20+00:00", status="resolved",
outcome={"time_to_fix_min": 30}),
_wo(id="b", machine="Pump 3", fault_arrived_at="2026-07-18T11:00:00+00:00",
created_at="2026-07-18T11:00:40+00:00", status="pending"),
_wo(id="c", machine="CM-350", fault_arrived_at="2026-07-19T09:00:00+00:00",
created_at="2026-07-19T09:00:10+00:00", status="resolved",
outcome={"time_to_fix_min": 50}),
]


@patch("torq.db.models.list_all", return_value=SAMPLE)
def test_faults_per_machine_counts_and_orders(_):
fpm = models.faults_per_machine()
assert fpm[0] == {"machine": "CM-350", "count": 2} # most first
assert {"machine": "Pump 3", "count": 1} in fpm


@patch("torq.db.models.list_all", return_value=SAMPLE)
def test_trend_buckets_by_day(_):
rows = models.trend()
assert [r["label"] for r in rows] == ["07-18", "07-19"]
# diagnosis is fault->created latency in minutes (20s/40s avg = 0.5 on day 1)
assert rows[0]["diagnosis"] == 0.5
assert rows[0]["mttr"] == 30.0 # only the resolved one counts
assert rows[1]["mttr"] == 50.0
18 changes: 13 additions & 5 deletions web/src/pages/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ const FIX = {
time_to_fix_min: 30,
};

// Varied demo faults across skills (E=electromechanical, J=packaging, C/P/A=general)
// so repeated clicks exercise different machines and route to different techs.
const SIM_FAULTS = [
{ fault_code: "E-471", machine: "CM-350 Line 2", context: "Motor tripped after hours running." },
{ fault_code: "E-201", machine: "CM-350 Line 1", context: "Overcurrent on start, grinding noise." },
{ fault_code: "J-108", machine: "PK-9 Line 3", context: "Film feed jammed at the roller nip." },
{ fault_code: "J-233", machine: "PK-9 Line 3", context: "Seal temperature low, weak seals." },
{ fault_code: "C-207", machine: "Chiller 6", context: "Condenser water flow dropping, efficiency down." },
{ fault_code: "P-410", machine: "Pump 3", context: "High vibration and bearing noise." },
{ fault_code: "A-120", machine: "AHU 2", context: "Filter differential pressure over setpoint." },
];

function Tile({ label, value }) {
return (
<div className={styles.tile}>
Expand Down Expand Up @@ -600,11 +612,7 @@ export default function Dashboard() {

const simulate = () =>
act(
() => api.reportFault({
fault_code: "E-471",
machine: "CM-350 Line 2",
context: "Motor tripped after hours running.",
}),
() => api.reportFault(SIM_FAULTS[Math.floor(Math.random() * SIM_FAULTS.length)]),
t("dashboard.toast_simulated")
);

Expand Down
Loading