diff --git a/backend/app/api/metrics.py b/backend/app/api/metrics.py new file mode 100644 index 00000000..921b0bf1 --- /dev/null +++ b/backend/app/api/metrics.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Literal + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import case, func +from sqlmodel import Session, col, select + +from app.api.deps import require_admin_auth +from app.core.auth import AuthContext +from app.db.session import get_session +from app.models.activity_events import ActivityEvent +from app.models.agents import Agent +from app.models.tasks import Task +from app.schemas.metrics import ( + DashboardKpis, + DashboardMetrics, + DashboardRangeSeries, + DashboardSeriesPoint, + DashboardSeriesSet, + DashboardWipPoint, + DashboardWipRangeSeries, + DashboardWipSeriesSet, +) + +router = APIRouter(prefix="/metrics", tags=["metrics"]) + +OFFLINE_AFTER = timedelta(minutes=10) +ERROR_EVENT_PATTERN = "%failed" + + +@dataclass(frozen=True) +class RangeSpec: + key: Literal["24h", "7d"] + start: datetime + end: datetime + bucket: Literal["hour", "day"] + + +def _resolve_range(range_key: Literal["24h", "7d"]) -> RangeSpec: + now = datetime.utcnow() + if range_key == "7d": + return RangeSpec( + key="7d", + start=now - timedelta(days=7), + end=now, + bucket="day", + ) + return RangeSpec( + key="24h", + start=now - timedelta(hours=24), + end=now, + bucket="hour", + ) + + +def _comparison_range(range_key: Literal["24h", "7d"]) -> RangeSpec: + return _resolve_range("7d" if range_key == "24h" else "24h") + + +def _bucket_start(value: datetime, bucket: Literal["hour", "day"]) -> datetime: + if bucket == "day": + return value.replace(hour=0, minute=0, second=0, microsecond=0) + return value.replace(minute=0, second=0, microsecond=0) + + +def _build_buckets(range_spec: RangeSpec) -> list[datetime]: + cursor = _bucket_start(range_spec.start, range_spec.bucket) + step = timedelta(days=1) if range_spec.bucket == "day" else timedelta(hours=1) + buckets: list[datetime] = [] + while cursor <= range_spec.end: + buckets.append(cursor) + cursor += step + return buckets + + +def _series_from_mapping( + range_spec: RangeSpec, mapping: dict[datetime, float] +) -> DashboardRangeSeries: + points = [ + DashboardSeriesPoint(period=bucket, value=float(mapping.get(bucket, 0))) + for bucket in _build_buckets(range_spec) + ] + return DashboardRangeSeries( + range=range_spec.key, + bucket=range_spec.bucket, + points=points, + ) + + +def _wip_series_from_mapping( + range_spec: RangeSpec, mapping: dict[datetime, dict[str, int]] +) -> DashboardWipRangeSeries: + points: list[DashboardWipPoint] = [] + for bucket in _build_buckets(range_spec): + values = mapping.get(bucket, {}) + points.append( + DashboardWipPoint( + period=bucket, + inbox=values.get("inbox", 0), + in_progress=values.get("in_progress", 0), + review=values.get("review", 0), + ) + ) + return DashboardWipRangeSeries( + range=range_spec.key, + bucket=range_spec.bucket, + points=points, + ) + + +def _query_throughput(session: Session, range_spec: RangeSpec) -> DashboardRangeSeries: + bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket") + statement = ( + select(bucket_col, func.count(Task.id)) + .where(col(Task.status) == "review") + .where(col(Task.updated_at) >= range_spec.start) + .where(col(Task.updated_at) <= range_spec.end) + .group_by(bucket_col) + .order_by(bucket_col) + ) + results = session.exec(statement).all() + mapping = {row[0]: float(row[1]) for row in results} + return _series_from_mapping(range_spec, mapping) + + +def _query_cycle_time(session: Session, range_spec: RangeSpec) -> DashboardRangeSeries: + bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket") + duration_hours = func.extract( + "epoch", Task.updated_at - Task.in_progress_at + ) / 3600.0 + statement = ( + select(bucket_col, func.avg(duration_hours)) + .where(col(Task.status) == "review") + .where(col(Task.in_progress_at).is_not(None)) + .where(col(Task.updated_at) >= range_spec.start) + .where(col(Task.updated_at) <= range_spec.end) + .group_by(bucket_col) + .order_by(bucket_col) + ) + results = session.exec(statement).all() + mapping = {row[0]: float(row[1] or 0) for row in results} + return _series_from_mapping(range_spec, mapping) + + +def _query_error_rate(session: Session, range_spec: RangeSpec) -> DashboardRangeSeries: + bucket_col = func.date_trunc(range_spec.bucket, ActivityEvent.created_at).label( + "bucket" + ) + error_case = case( + ( + col(ActivityEvent.event_type).like(ERROR_EVENT_PATTERN), + 1, + ), + else_=0, + ) + statement = ( + select(bucket_col, func.sum(error_case), func.count(ActivityEvent.id)) + .where(col(ActivityEvent.created_at) >= range_spec.start) + .where(col(ActivityEvent.created_at) <= range_spec.end) + .group_by(bucket_col) + .order_by(bucket_col) + ) + results = session.exec(statement).all() + mapping: dict[datetime, float] = {} + for bucket, errors, total in results: + total_count = float(total or 0) + error_count = float(errors or 0) + rate = (error_count / total_count) * 100 if total_count > 0 else 0.0 + mapping[bucket] = rate + return _series_from_mapping(range_spec, mapping) + + +def _query_wip(session: Session, range_spec: RangeSpec) -> DashboardWipRangeSeries: + bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket") + inbox_case = case((col(Task.status) == "inbox", 1), else_=0) + progress_case = case((col(Task.status) == "in_progress", 1), else_=0) + review_case = case((col(Task.status) == "review", 1), else_=0) + statement = ( + select( + bucket_col, + func.sum(inbox_case), + func.sum(progress_case), + func.sum(review_case), + ) + .where(col(Task.updated_at) >= range_spec.start) + .where(col(Task.updated_at) <= range_spec.end) + .group_by(bucket_col) + .order_by(bucket_col) + ) + results = session.exec(statement).all() + mapping: dict[datetime, dict[str, int]] = {} + for bucket, inbox, in_progress, review in results: + mapping[bucket] = { + "inbox": int(inbox or 0), + "in_progress": int(in_progress or 0), + "review": int(review or 0), + } + return _wip_series_from_mapping(range_spec, mapping) + + +def _median_cycle_time_7d(session: Session) -> float | None: + now = datetime.utcnow() + start = now - timedelta(days=7) + duration_hours = func.extract( + "epoch", Task.updated_at - Task.in_progress_at + ) / 3600.0 + statement = ( + select(func.percentile_cont(0.5).within_group(duration_hours)) + .where(col(Task.status) == "review") + .where(col(Task.in_progress_at).is_not(None)) + .where(col(Task.updated_at) >= start) + .where(col(Task.updated_at) <= now) + ) + value = session.exec(statement).one_or_none() + if value is None: + return None + if isinstance(value, tuple): + value = value[0] + if value is None: + return None + return float(value) + + +def _error_rate_kpi(session: Session, range_spec: RangeSpec) -> float: + error_case = case( + ( + col(ActivityEvent.event_type).like(ERROR_EVENT_PATTERN), + 1, + ), + else_=0, + ) + statement = ( + select(func.sum(error_case), func.count(ActivityEvent.id)) + .where(col(ActivityEvent.created_at) >= range_spec.start) + .where(col(ActivityEvent.created_at) <= range_spec.end) + ) + result = session.exec(statement).one_or_none() + if result is None: + return 0.0 + errors, total = result + total_count = float(total or 0) + error_count = float(errors or 0) + return (error_count / total_count) * 100 if total_count > 0 else 0.0 + + +def _active_agents(session: Session) -> int: + threshold = datetime.utcnow() - OFFLINE_AFTER + statement = select(func.count(Agent.id)).where( + col(Agent.last_seen_at).is_not(None), + col(Agent.last_seen_at) >= threshold, + ) + result = session.exec(statement).one() + return int(result) + + +def _tasks_in_progress(session: Session) -> int: + statement = select(func.count(Task.id)).where(col(Task.status) == "in_progress") + result = session.exec(statement).one() + return int(result) + + +@router.get("/dashboard", response_model=DashboardMetrics) +def dashboard_metrics( + range: Literal["24h", "7d"] = Query(default="24h"), + session: Session = Depends(get_session), + auth: AuthContext = Depends(require_admin_auth), +) -> DashboardMetrics: + primary = _resolve_range(range) + comparison = _comparison_range(range) + + throughput = DashboardSeriesSet( + primary=_query_throughput(session, primary), + comparison=_query_throughput(session, comparison), + ) + cycle_time = DashboardSeriesSet( + primary=_query_cycle_time(session, primary), + comparison=_query_cycle_time(session, comparison), + ) + error_rate = DashboardSeriesSet( + primary=_query_error_rate(session, primary), + comparison=_query_error_rate(session, comparison), + ) + wip = DashboardWipSeriesSet( + primary=_query_wip(session, primary), + comparison=_query_wip(session, comparison), + ) + + kpis = DashboardKpis( + active_agents=_active_agents(session), + tasks_in_progress=_tasks_in_progress(session), + error_rate_pct=_error_rate_kpi(session, primary), + median_cycle_time_hours_7d=_median_cycle_time_7d(session), + ) + + return DashboardMetrics( + range=primary.key, + generated_at=datetime.utcnow(), + kpis=kpis, + throughput=throughput, + cycle_time=cycle_time, + error_rate=error_rate, + wip=wip, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 26368542..52eb1642 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,7 @@ from app.api.agents import router as agents_router from app.api.auth import router as auth_router from app.api.boards import router as boards_router from app.api.gateway import router as gateway_router +from app.api.metrics import router as metrics_router from app.api.tasks import router as tasks_router from app.api.users import router as users_router from app.core.config import settings @@ -54,6 +55,7 @@ api_v1.include_router(auth_router) api_v1.include_router(agents_router) api_v1.include_router(activity_router) api_v1.include_router(gateway_router) +api_v1.include_router(metrics_router) api_v1.include_router(boards_router) api_v1.include_router(tasks_router) api_v1.include_router(users_router) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 9a7a3ffe..41dab5f0 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,6 +1,7 @@ from app.schemas.activity_events import ActivityEventRead from app.schemas.agents import AgentCreate, AgentRead, AgentUpdate from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate +from app.schemas.metrics import DashboardMetrics from app.schemas.tasks import TaskCreate, TaskRead, TaskUpdate from app.schemas.users import UserCreate, UserRead, UserUpdate @@ -12,6 +13,7 @@ __all__ = [ "BoardCreate", "BoardRead", "BoardUpdate", + "DashboardMetrics", "TaskCreate", "TaskRead", "TaskUpdate", diff --git a/backend/app/schemas/metrics.py b/backend/app/schemas/metrics.py new file mode 100644 index 00000000..04a79d1f --- /dev/null +++ b/backend/app/schemas/metrics.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from sqlmodel import SQLModel + + +class DashboardSeriesPoint(SQLModel): + period: datetime + value: float + + +class DashboardWipPoint(SQLModel): + period: datetime + inbox: int + in_progress: int + review: int + + +class DashboardRangeSeries(SQLModel): + range: Literal["24h", "7d"] + bucket: Literal["hour", "day"] + points: list[DashboardSeriesPoint] + + +class DashboardWipRangeSeries(SQLModel): + range: Literal["24h", "7d"] + bucket: Literal["hour", "day"] + points: list[DashboardWipPoint] + + +class DashboardSeriesSet(SQLModel): + primary: DashboardRangeSeries + comparison: DashboardRangeSeries + + +class DashboardWipSeriesSet(SQLModel): + primary: DashboardWipRangeSeries + comparison: DashboardWipRangeSeries + + +class DashboardKpis(SQLModel): + active_agents: int + tasks_in_progress: int + error_rate_pct: float + median_cycle_time_hours_7d: float | None + + +class DashboardMetrics(SQLModel): + range: Literal["24h", "7d"] + generated_at: datetime + kpis: DashboardKpis + throughput: DashboardSeriesSet + cycle_time: DashboardSeriesSet + error_rate: DashboardSeriesSet + wip: DashboardWipSeriesSet diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1d939865..e50fa237 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,7 +19,8 @@ "cmdk": "^1.1.1", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "recharts": "^3.7.0" }, "devDependencies": { "@types/node": "^20", @@ -2684,6 +2685,42 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2900,6 +2937,18 @@ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2979,6 +3028,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3047,6 +3159,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -4265,7 +4383,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4373,6 +4490,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -4452,6 +4690,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4780,6 +5024,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -5269,6 +5523,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/execa": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", @@ -5938,6 +6198,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5980,6 +6250,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7792,9 +8071,31 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -7887,6 +8188,51 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7951,6 +8297,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8797,6 +9149,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -9327,6 +9685,28 @@ "dev": true, "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 97dad9cc..3427a034 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,8 +12,8 @@ }, "dependencies": { "@clerk/nextjs": "^6.37.1", - "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.2", @@ -22,7 +22,8 @@ "cmdk": "^1.1.1", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "recharts": "^3.7.0" }, "devDependencies": { "@types/node": "^20", diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 314de1dc..cdebe48d 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -1,22 +1,319 @@ "use client"; -import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; -import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs"; +import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; +import MetricSparkline from "@/components/charts/metric-sparkline"; +import { getApiBaseUrl } from "@/lib/api-base"; + +type RangeKey = "24h" | "7d"; +type BucketKey = "hour" | "day"; + +type SeriesPoint = { + period: string; + value: number; +}; + +type WipPoint = { + period: string; + inbox: number; + in_progress: number; + review: number; +}; + +type RangeSeries = { + range: RangeKey; + bucket: BucketKey; + points: SeriesPoint[]; +}; + +type WipRangeSeries = { + range: RangeKey; + bucket: BucketKey; + points: WipPoint[]; +}; + +type SeriesSet = { + primary: RangeSeries; + comparison: RangeSeries; +}; + +type WipSeriesSet = { + primary: WipRangeSeries; + comparison: WipRangeSeries; +}; + +type DashboardMetrics = { + range: RangeKey; + generated_at: string; + kpis: { + active_agents: number; + tasks_in_progress: number; + error_rate_pct: number; + median_cycle_time_hours_7d: number | null; + }; + throughput: SeriesSet; + cycle_time: SeriesSet; + error_rate: SeriesSet; + wip: WipSeriesSet; +}; + +const apiBase = getApiBaseUrl(); + +const hourFormatter = new Intl.DateTimeFormat("en-US", { hour: "numeric" }); +const dayFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", +}); +const updatedFormatter = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", +}); + +const formatPeriod = (value: string, bucket: BucketKey) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return bucket === "hour" ? hourFormatter.format(date) : dayFormatter.format(date); +}; + +const formatNumber = (value: number) => value.toLocaleString("en-US"); +const formatPercent = (value: number) => `${value.toFixed(1)}%`; +const formatHours = (value: number | null) => + value === null || !Number.isFinite(value) ? "--" : `${value.toFixed(1)}h`; + +function buildSeries(series: RangeSeries) { + return series.points.map((point) => ({ + period: formatPeriod(point.period, series.bucket), + value: Number(point.value ?? 0), + })); +} + +function buildWipSeries(series: WipRangeSeries) { + return series.points.map((point) => ({ + period: formatPeriod(point.period, series.bucket), + inbox: Number(point.inbox ?? 0), + in_progress: Number(point.in_progress ?? 0), + review: Number(point.review ?? 0), + })); +} + +function buildSparkline(series: RangeSeries) { + return { + values: series.points.map((point) => Number(point.value ?? 0)), + labels: series.points.map((point) => formatPeriod(point.period, series.bucket)), + }; +} + +function buildWipSparkline(series: WipRangeSeries, key: keyof WipPoint) { + return { + values: series.points.map((point) => Number(point[key] ?? 0)), + labels: series.points.map((point) => formatPeriod(point.period, series.bucket)), + }; +} + +type TooltipProps = { + active?: boolean; + payload?: Array<{ value?: number; name?: string; color?: string }>; + label?: string; + formatter?: (value: number, name?: string) => string; +}; + +function TooltipCard({ active, payload, label, formatter }: TooltipProps) { + if (!active || !payload?.length) return null; + return ( +
- Sign in to access your dashboard. + Sign in to access the dashboard.
- Your work lives in boards. Jump in to manage tasks. -
- +