Skip to content

Commit a7b69b4

Browse files
feat(home): new dashboard landing with KPI tiles (#22)
* feat(home): new dashboard landing — KPI tiles + recent jobs + snapshot Replaces the Health page as the landing at ``/dashboard`` with a higher- level overview that mirrors the design language of attune-ops (KPI grid, sparkline, status chips). Health moves to ``/dashboard/health`` and stays fully functional; a new "Home" entry is the first sidebar item and the default redirect target. What's on the new home: - **4 KPI tiles** — Templates count (manual vs generated), Fresh ratio (with click-through to stale filter), Jobs today (with 7-day inline SVG sparkline that zero-fills missing days), Family (importable count vs total). - **Recent jobs panel** — last 5 jobs with status chip, duration, started-at timestamp. - **Workspace card** — configured workspace, manifest path, feature count. - **Family snapshot** — preserves the per-package card layout from the previous Health landing so users don't lose that surface. Implementation notes: - New ``attune_gui.home_summary`` module composes data from the existing accessors (``cowork_templates``, ``cowork_health``, ``jobs.registry``, ``workspace``). Each accessor is wrapped in try/except so a single failure can't take down the page on a fresh install. - Sparkline is pure inline SVG (no JS deps) — empty input renders the fallback "no jobs in last 7 days" message rather than a broken graph. - CSS additions reuse the existing token system (`--ink`, `--accent`, `--ok-soft`, etc.) so theme changes propagate. New classes: ``kpi-grid``, ``kpi-tile``, ``sparkline``, ``data-table``, ``chip-*``. - 15 new unit tests for the summary builder cover empty data, fail-soft paths, sparkline normalization, KPI math, and a happy-path integration test that mocks all four data sources. - 4 new render tests + 2 updated tests for the page-routing change. Test plan: 34/34 pass on home + page tests; full suite green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(chrome): replace dark sidebar with light topbar — match attune-ops Per user direction during the home refresh review: the sidebar was the biggest visual gap between attune-gui and attune-ops. Replace it with the same horizontal topbar pattern attune-ops uses so the two sibling dashboards feel coherent in adjacent browser tabs. - ``base.html``: ``aside.sidebar`` -> ``header.topbar``. Brand becomes ``attune gui`` with the mark+sub structure attune-ops uses. - ``style.css``: drop the body grid (was a fixed 240px sidebar column), drop sidebar-specific tokens, add topbar selectors. Nav links now go horizontal with the same active-state styling as attune-ops (``--accent-soft`` background, ``--accent`` text). - ``.main`` becomes max-width 1280px centered instead of full-width inside the grid cell — same constraint attune-ops applies. No template changes outside ``base.html``; every page that extended ``base.html`` (Health, Templates, Specs, Summaries, Living Docs, Commands, Jobs, Home) gets the new chrome automatically. Verified via existing render tests + live screenshots. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9c9664b commit a7b69b4

7 files changed

Lines changed: 922 additions & 38 deletions

File tree

sidecar/attune_gui/home_summary.py

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
"""Home page summary: KPI tiles + recent jobs + workspace snapshot.
2+
3+
Composes data from existing accessors so the home route is a thin
4+
orchestrator. All accessors fail soft (return zero/None on missing
5+
data) so the page renders cleanly on a fresh install or when one
6+
data source is unavailable.
7+
8+
Public API: :func:`build_home_summary` — async function the route awaits.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import contextlib
14+
import logging
15+
from dataclasses import dataclass, field
16+
from datetime import date, datetime, timezone
17+
from typing import Any
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
@dataclass(frozen=True)
23+
class TemplateKpi:
24+
"""Templates count + stale-vs-fresh ratio for the home tiles."""
25+
26+
total: int
27+
manual: int
28+
generated: int
29+
fresh: int
30+
stale: int
31+
very_stale: int
32+
33+
@property
34+
def fresh_ratio(self) -> float:
35+
"""Fraction of generated templates that are fresh (0.0 to 1.0)."""
36+
denom = self.fresh + self.stale + self.very_stale
37+
return (self.fresh / denom) if denom else 1.0
38+
39+
40+
@dataclass(frozen=True)
41+
class JobsKpi:
42+
"""Job-activity snapshot."""
43+
44+
today_count: int
45+
week_count: int
46+
last_status: str | None # "completed" | "errored" | "cancelled" | "running" | None
47+
last_finished_at: str | None # ISO 8601 or None
48+
49+
50+
@dataclass(frozen=True)
51+
class DailyJobs:
52+
"""One day's job count for the sparkline."""
53+
54+
day: str # YYYY-MM-DD
55+
count: int
56+
57+
58+
@dataclass(frozen=True)
59+
class FamilyVersion:
60+
"""Installed version of one attune-* package."""
61+
62+
package: str
63+
version: str | None
64+
importable: bool
65+
66+
67+
@dataclass(frozen=True)
68+
class RecentJob:
69+
"""Trimmed Job for the recent-activity panel."""
70+
71+
id: str
72+
name: str
73+
status: str
74+
duration_seconds: float | None
75+
started_at: str | None # ISO 8601 or None
76+
77+
78+
@dataclass(frozen=True)
79+
class HomeSummary:
80+
"""Everything the home page needs in one shape."""
81+
82+
templates: TemplateKpi
83+
jobs: JobsKpi
84+
sparkline: list[DailyJobs] = field(default_factory=list) # 7 entries, oldest first
85+
sparkline_points: str = "" # SVG polyline points string ("" = no data)
86+
recent_jobs: list[RecentJob] = field(default_factory=list)
87+
family: list[FamilyVersion] = field(default_factory=list)
88+
workspace_path: str | None = None
89+
manifest_path: str | None = None
90+
feature_count: int = 0
91+
92+
93+
def sparkline_points(values: list[int], *, width: int = 240, height: int = 40) -> str:
94+
"""Render a list of values as an SVG ``polyline`` ``points`` string.
95+
96+
Returns an empty string when there's nothing to plot. The y-axis is
97+
inverted (SVG origin is top-left) so larger values appear higher.
98+
"""
99+
if not values or all(v == 0 for v in values):
100+
return ""
101+
n = len(values)
102+
span = max(values) or 1
103+
points: list[str] = []
104+
for i, v in enumerate(values):
105+
x = (i / max(n - 1, 1)) * width
106+
y = height - (v / span) * height
107+
points.append(f"{x:.1f},{y:.1f}")
108+
return " ".join(points)
109+
110+
111+
def _to_day(ts: str | None) -> str | None:
112+
if not ts:
113+
return None
114+
with contextlib.suppress(ValueError):
115+
cleaned = ts.rstrip("Z")
116+
dt = datetime.fromisoformat(cleaned)
117+
if dt.tzinfo is None:
118+
dt = dt.replace(tzinfo=timezone.utc)
119+
return dt.date().isoformat()
120+
return None
121+
122+
123+
def _duration(started: str | None, finished: str | None) -> float | None:
124+
if not started or not finished:
125+
return None
126+
with contextlib.suppress(ValueError):
127+
a = datetime.fromisoformat(started.rstrip("Z"))
128+
b = datetime.fromisoformat(finished.rstrip("Z"))
129+
return (b - a).total_seconds()
130+
return None
131+
132+
133+
def _build_template_kpi(items: list[dict[str, Any]]) -> TemplateKpi:
134+
total = len(items)
135+
manual = sum(1 for t in items if t.get("manual"))
136+
generated = total - manual
137+
fresh = sum(1 for t in items if t.get("staleness") == "fresh")
138+
stale = sum(1 for t in items if t.get("staleness") == "stale")
139+
very_stale = sum(1 for t in items if t.get("staleness") == "very-stale")
140+
return TemplateKpi(
141+
total=total,
142+
manual=manual,
143+
generated=generated,
144+
fresh=fresh,
145+
stale=stale,
146+
very_stale=very_stale,
147+
)
148+
149+
150+
def _build_jobs_kpi_and_sparkline(
151+
jobs: list[dict[str, Any]],
152+
*,
153+
today: date | None = None,
154+
) -> tuple[JobsKpi, list[DailyJobs]]:
155+
today = today or date.today()
156+
today_iso = today.isoformat()
157+
158+
by_day: dict[str, int] = {}
159+
today_count = 0
160+
week_count = 0
161+
last_status: str | None = None
162+
last_finished_at: str | None = None
163+
164+
week_start = date.fromordinal(today.toordinal() - 6)
165+
166+
for j in jobs:
167+
finished = j.get("finished_at") or j.get("started_at")
168+
day = _to_day(finished)
169+
if day:
170+
by_day[day] = by_day.get(day, 0) + 1
171+
if day == today_iso:
172+
today_count += 1
173+
if day >= week_start.isoformat():
174+
week_count += 1
175+
if last_finished_at is None and j.get("finished_at"):
176+
last_finished_at = j["finished_at"]
177+
last_status = j.get("status")
178+
179+
spark: list[DailyJobs] = []
180+
for offset in range(6, -1, -1):
181+
d = date.fromordinal(today.toordinal() - offset).isoformat()
182+
spark.append(DailyJobs(day=d, count=by_day.get(d, 0)))
183+
184+
return (
185+
JobsKpi(
186+
today_count=today_count,
187+
week_count=week_count,
188+
last_status=last_status,
189+
last_finished_at=last_finished_at,
190+
),
191+
spark,
192+
)
193+
194+
195+
def _build_recent_jobs(jobs: list[dict[str, Any]], *, limit: int = 5) -> list[RecentJob]:
196+
out: list[RecentJob] = []
197+
for j in jobs[:limit]:
198+
out.append(
199+
RecentJob(
200+
id=str(j.get("id", "")),
201+
name=str(j.get("name", "")),
202+
status=str(j.get("status", "")),
203+
duration_seconds=_duration(j.get("started_at"), j.get("finished_at")),
204+
started_at=j.get("started_at"),
205+
)
206+
)
207+
return out
208+
209+
210+
def _build_family_versions(layers: dict[str, dict[str, Any]]) -> list[FamilyVersion]:
211+
out: list[FamilyVersion] = []
212+
for slug, info in sorted(layers.items()):
213+
out.append(
214+
FamilyVersion(
215+
package=f"attune-{slug}",
216+
version=info.get("version"),
217+
importable=bool(info.get("importable")),
218+
)
219+
)
220+
return out
221+
222+
223+
async def build_home_summary() -> HomeSummary:
224+
"""Build the home-page summary by composing existing accessors.
225+
226+
Each accessor is wrapped so any one failure doesn't take down the page.
227+
"""
228+
from attune_gui.jobs import get_registry # noqa: PLC0415
229+
from attune_gui.routes import cowork_health, cowork_templates # noqa: PLC0415
230+
from attune_gui.workspace import get_workspace # noqa: PLC0415
231+
232+
template_items: list[dict[str, Any]] = []
233+
feature_count = 0
234+
manifest_path: str | None = None
235+
try:
236+
data = await cowork_templates.list_templates()
237+
template_items = list(data.get("templates", []))
238+
except Exception: # noqa: BLE001
239+
logger.debug("home: cowork_templates.list_templates failed; using empty list")
240+
241+
layers: dict[str, dict[str, Any]] = {}
242+
try:
243+
layer_data = await cowork_health.layer_health()
244+
layers = layer_data.get("layers", {})
245+
except Exception: # noqa: BLE001
246+
logger.debug("home: cowork_health.layer_health failed; using empty list")
247+
248+
try:
249+
corpus = await cowork_health.corpus_health()
250+
manifest_path = corpus.get("manifest_path")
251+
feature_count = int(corpus.get("feature_count") or 0)
252+
except Exception: # noqa: BLE001
253+
logger.debug("home: cowork_health.corpus_health failed; treating as missing")
254+
255+
job_dicts: list[dict[str, Any]] = []
256+
try:
257+
registry = get_registry()
258+
job_dicts = [j.to_dict() for j in registry.list_jobs()]
259+
except Exception: # noqa: BLE001
260+
logger.debug("home: jobs.get_registry().list_jobs failed; using empty list")
261+
262+
workspace_path: str | None = None
263+
try:
264+
ws = get_workspace()
265+
if ws is not None:
266+
workspace_path = str(ws)
267+
except Exception: # noqa: BLE001
268+
logger.debug("home: workspace.get_workspace failed; treating as unset")
269+
270+
template_kpi = _build_template_kpi(template_items)
271+
jobs_kpi, spark = _build_jobs_kpi_and_sparkline(job_dicts)
272+
recent = _build_recent_jobs(job_dicts)
273+
family = _build_family_versions(layers)
274+
points = sparkline_points([d.count for d in spark])
275+
276+
return HomeSummary(
277+
templates=template_kpi,
278+
jobs=jobs_kpi,
279+
sparkline=spark,
280+
sparkline_points=points,
281+
recent_jobs=recent,
282+
family=family,
283+
workspace_path=workspace_path,
284+
manifest_path=manifest_path,
285+
feature_count=feature_count,
286+
)

sidecar/attune_gui/routes/cowork_pages.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ def _ctx(request: Request, active: str, **extra: Any) -> dict[str, Any]:
6363
return {
6464
"active": active,
6565
"nav": [
66-
{"slug": "health", "label": "Health", "href": "/dashboard"},
66+
{"slug": "home", "label": "Home", "href": "/dashboard"},
67+
{"slug": "health", "label": "Health", "href": "/dashboard/health"},
6768
{"slug": "templates", "label": "Templates", "href": "/dashboard/templates"},
6869
{"slug": "specs", "label": "Specs", "href": "/dashboard/specs"},
6970
{"slug": "summaries", "label": "Summaries", "href": "/dashboard/summaries"},
@@ -87,11 +88,30 @@ async def root_redirect() -> RedirectResponse:
8788

8889

8990
# ---------------------------------------------------------------------------
90-
# Health (landing page)
91+
# Home (new landing page) — KPI grid + recent jobs + workspace snapshot
9192
# ---------------------------------------------------------------------------
9293

9394

9495
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
96+
async def page_home(request: Request) -> HTMLResponse:
97+
"""Render the Home page — KPI tiles, sparkline, recent jobs, snapshot.
98+
99+
Composes data from existing accessors (cowork_templates, cowork_health,
100+
jobs registry) into a higher-level overview. All accessors fail soft
101+
so the page renders cleanly on a fresh install.
102+
"""
103+
from attune_gui.home_summary import build_home_summary # noqa: PLC0415
104+
105+
summary = await build_home_summary()
106+
return templates.TemplateResponse(request, "home.html", _ctx(request, "home", summary=summary))
107+
108+
109+
# ---------------------------------------------------------------------------
110+
# Health (now under /dashboard/health)
111+
# ---------------------------------------------------------------------------
112+
113+
114+
@router.get("/dashboard/health", response_class=HTMLResponse, include_in_schema=False)
95115
async def page_health(request: Request) -> HTMLResponse:
96116
"""Render the Health page — per-layer version probe + corpus snapshot."""
97117
from attune_gui.routes import cowork_health

0 commit comments

Comments
 (0)