From c04ab6ac8b78cf4285111fa74c1f7f184699b387 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 22 May 2026 16:17:39 -0500 Subject: [PATCH] feat(dashboard): enhance metric cards with new tone classes and improve styling --- backend/app/api/forgejo_metrics.py | 95 +++++++--- .../dashboard/DashboardMetricCard.tsx | 4 +- frontend/src/components/dashboard/tokens.ts | 8 + .../src/components/git/ForgejoHeatmap.tsx | 172 ++++++++---------- .../git/ForgejoIssueMetricCards.tsx | 144 ++++++++------- 5 files changed, 244 insertions(+), 179 deletions(-) diff --git a/backend/app/api/forgejo_metrics.py b/backend/app/api/forgejo_metrics.py index bf6987a..e2e65a2 100644 --- a/backend/app/api/forgejo_metrics.py +++ b/backend/app/api/forgejo_metrics.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import time as _time from datetime import timedelta from typing import TYPE_CHECKING from uuid import UUID @@ -21,11 +22,54 @@ from app.models.forgejo_connections import ForgejoConnection from app.models.forgejo_issues import ForgejoIssue from app.models.forgejo_repositories import ForgejoRepository from app.schemas.metrics import HeatmapDay, HeatmapResponse, MetricsResponse -from app.services.forgejo_client import get_forgejo_client +from app.services.forgejo_client import ForgejoAPIClient, get_forgejo_client if TYPE_CHECKING: from sqlmodel.ext.asyncio.session import AsyncSession +# --------------------------------------------------------------------------- +# Line-stats background cache +# --------------------------------------------------------------------------- +# Key: org_id string → (fetched_at, total_additions, total_deletions, has_data) +# Populated by a fire-and-forget asyncio task so the heatmap endpoint never +# blocks waiting for Forgejo's 202 "still computing" response. +# --------------------------------------------------------------------------- +_line_stats_cache: dict[str, tuple[float, int, int, bool]] = {} +_line_stats_fetching: set[str] = set() +_LINE_STATS_TTL = 300 # seconds before a re-fetch is triggered + + +async def _bg_fetch_line_stats( + cache_key: str, + # Plain tuples so this task never touches a closed DB session + repos: list[tuple[str, str, str, str | None]], # (owner, repo, base_url, token) + since_ts: float, +) -> None: + """Background task: fetch per-repo contributor stats and cache the totals.""" + + async def _one(owner: str, repo: str, base_url: str, token: str | None) -> tuple[int, int, bool]: + try: + async with ForgejoAPIClient(base_url=base_url, token=token) as client: + contributors, has_data = await client.get_contributor_stats(owner, repo) + adds = dels = 0 + for contributor in contributors: + for week in contributor.get("weeks", []): + if (week.get("w") or 0) >= since_ts: + adds += week.get("a", 0) or 0 + dels += week.get("d", 0) or 0 + return adds, dels, has_data + except Exception: + return 0, 0, False + + try: + results = await asyncio.gather(*[_one(o, r, bu, tok) for o, r, bu, tok in repos]) + total_adds = sum(a for a, _, _ in results) + total_dels = sum(d for _, d, _ in results) + has_data = bool(results) and all(ok for _, _, ok in results) + _line_stats_cache[cache_key] = (_time.monotonic(), total_adds, total_dels, has_data) + finally: + _line_stats_fetching.discard(cache_key) + router = APIRouter(prefix="/forgejo", tags=["forgejo-metrics"]) SESSION_DEP = Depends(get_session) # Use ORG_MEMBER_DEP directly, not wrapped in Depends again @@ -332,29 +376,38 @@ async def get_forgejo_heatmap( days_list = [HeatmapDay(date=k, count=v) for k, v in sorted(counts.items())] max_count = max((d.count for d in days_list), default=0) - # Fetch contributor line stats concurrently from Forgejo API + # Line stats — served from cache; background task refreshes when stale. + # Extract plain values NOW while the DB session is still open. since_ts = since.timestamp() + cache_key = str(ctx.organization.id) + cached = _line_stats_cache.get(cache_key) + now = _time.monotonic() - async def _repo_line_stats(repo: ForgejoRepository, conn: ForgejoConnection) -> tuple[int, int, bool]: - try: - async with get_forgejo_client(conn) as client: - contributors, has_data = await client.get_contributor_stats(repo.owner, repo.repo) - adds = dels = 0 - for contributor in contributors: - for week in contributor.get("weeks", []): - if (week.get("w") or 0) >= since_ts: - adds += week.get("a", 0) or 0 - dels += week.get("d", 0) or 0 - return adds, dels, has_data - except Exception: - return 0, 0, False + if cache_key not in _line_stats_fetching and ( + cached is None or now - cached[0] > _LINE_STATS_TTL + ): + # Normalise base_url the same way get_forgejo_client() does, eagerly, + # so the background task never touches a potentially-closed session. + import re as _re + repo_tuples: list[tuple[str, str, str, str | None]] = [] + for repo, conn in repos_with_conns: + bu = (conn.base_url or "").rstrip("/") + if "/api/v1" in bu: + m = _re.match(r"(https?://[^/]+)", bu) + bu = m.group(1).rstrip("/") if m else bu + repo_tuples.append((repo.owner, repo.repo, bu, getattr(conn, "token", None))) - line_results = await asyncio.gather( - *[_repo_line_stats(repo, conn) for repo, conn in repos_with_conns] - ) - total_additions = sum(a for a, _, _ in line_results) - total_deletions = sum(d for _, d, _ in line_results) - has_line_stats = all(ok for _, _, ok in line_results) + _line_stats_fetching.add(cache_key) + asyncio.create_task( + _bg_fetch_line_stats(cache_key, repo_tuples, since_ts), + name=f"line-stats-{cache_key}", + ) + + if cached is not None: + _, total_additions, total_deletions, has_line_stats = cached + else: + total_additions = total_deletions = 0 + has_line_stats = False return HeatmapResponse( days=days_list, diff --git a/frontend/src/components/dashboard/DashboardMetricCard.tsx b/frontend/src/components/dashboard/DashboardMetricCard.tsx index 9f7adfc..0cb006c 100644 --- a/frontend/src/components/dashboard/DashboardMetricCard.tsx +++ b/frontend/src/components/dashboard/DashboardMetricCard.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; import { Info } from "lucide-react"; -import { toneIcon, type MetricToneKey } from "./tokens"; +import { toneIcon, toneCard, type MetricToneKey } from "./tokens"; interface DashboardMetricCardProps { title: string; @@ -24,7 +24,7 @@ export function DashboardMetricCard({ tone, }: DashboardMetricCardProps) { return ( -
+
diff --git a/frontend/src/components/dashboard/tokens.ts b/frontend/src/components/dashboard/tokens.ts index 9cca506..edba1fa 100644 --- a/frontend/src/components/dashboard/tokens.ts +++ b/frontend/src/components/dashboard/tokens.ts @@ -41,3 +41,11 @@ export const toneIcon: Record = { warning: "bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]", danger: "bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]", }; + +/** Card-level background + border tint for metric cards. */ +export const toneCard: Record = { + accent: "border border-[color:rgba(139,92,246,0.28)] bg-[color:rgba(139,92,246,0.08)]", + success: "border border-[color:rgba(52,211,153,0.28)] bg-[color:rgba(52,211,153,0.07)]", + warning: "border border-[color:rgba(251,191,36,0.28)] bg-[color:rgba(251,191,36,0.07)]", + danger: "border border-[color:rgba(248,113,113,0.28)] bg-[color:rgba(248,113,113,0.07)]", +}; diff --git a/frontend/src/components/git/ForgejoHeatmap.tsx b/frontend/src/components/git/ForgejoHeatmap.tsx index ebc363b..7c01ef9 100644 --- a/frontend/src/components/git/ForgejoHeatmap.tsx +++ b/frontend/src/components/git/ForgejoHeatmap.tsx @@ -12,43 +12,48 @@ interface ForgejoHeatmapProps { isLoading?: boolean; } -// Layout constants — 6-month view with larger cells -const CELL = 13; -const GAP = 3; -const STRIDE = CELL + GAP; // 16px per cell +const CELL = 13; +const GAP = 3; +const STRIDE = CELL + GAP; const WEEKS = 27; -const LEFT = 28; // width reserved for Mon/Wed/Fri labels -const TOP = 18; // height reserved for month labels +const LEFT = 28; +const TOP = 18; const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; -// Violet/purple palette — fixed rgba so they render in SVG regardless of CSS var support +// All fixed rgba — no CSS vars inside SVG fill so every browser renders them const LEVEL_FILL = [ - "rgba(139,92,246,0.07)", // 0 — empty (barely-there grid so cells are visible) - "rgba(139,92,246,0.28)", // 1 - "rgba(139,92,246,0.52)", // 2 - "rgba(139,92,246,0.75)", // 3 - "rgba(139,92,246,1.0)", // 4 — full violet-500 + "rgba(139,92,246,0.08)", // 0 empty — barely-there grid + "rgba(139,92,246,0.30)", // 1 + "rgba(139,92,246,0.54)", // 2 + "rgba(139,92,246,0.77)", // 3 + "rgba(139,92,246,1.00)", // 4 full violet-500 ]; +const VIOLET = "rgba(139,92,246,1)"; +const GREEN = "rgba(52,211,153,1)"; +const RED = "rgba(248,113,113,1)"; +const LABEL_CLR = "rgba(139,92,246,0.65)"; + function toLevel(count: number): number { if (count === 0) return 0; - if (count <= 2) return 1; - if (count <= 5) return 2; - if (count <= 9) return 3; + if (count <= 2) return 1; + if (count <= 5) return 2; + if (count <= 9) return 3; return 4; } function isoDate(d: Date): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; + return [ + d.getFullYear(), + String(d.getMonth() + 1).padStart(2, "0"), + String(d.getDate()).padStart(2, "0"), + ].join("-"); } function fmtLines(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; return n.toLocaleString(); } @@ -57,11 +62,11 @@ export function ForgejoHeatmap({ maxCount: _maxCount, totalAdditions = 0, totalDeletions = 0, - hasLineStats = false, - isLoading = false, + hasLineStats = false, + isLoading = false, }: ForgejoHeatmapProps) { const { weeks, monthLabels } = useMemo(() => { - const data = new Map(days.map((d) => [d.date, d.count])); + const data = new Map(days.map((d) => [d.date, d.count])); const today = new Date(); today.setHours(0, 0, 0, 0); @@ -79,7 +84,7 @@ export function ForgejoHeatmap({ const week: Cell[] = []; for (let d = 0; d < 7; d++) { const dateStr = isoDate(cur); - const month = cur.getMonth(); + const month = cur.getMonth(); if (d === 0 && month !== lastMonth) { monthLabelList.push({ weekIdx: w, label: MONTHS[month] }); lastMonth = month; @@ -94,16 +99,13 @@ export function ForgejoHeatmap({ }, [days]); const svgW = LEFT + WEEKS * STRIDE; - const svgH = TOP + 7 * STRIDE - GAP; + const svgH = TOP + 7 * STRIDE - GAP; if (isLoading) { return ( -
-
-
+
+
+
); } @@ -111,49 +113,34 @@ export function ForgejoHeatmap({ const totalEvents = days.reduce((sum, d) => sum + d.count, 0); return ( -
- {/* Heatmap grid */} -
- +
+ + {/* ── Heatmap ─────────────────────────────────────────────── */} +
+ + {/* Month labels */} {monthLabels.map(({ weekIdx, label }) => ( - + {label} ))} - {/* Day-of-week labels */} + {/* Day labels */} {(["Mon", "Wed", "Fri"] as const).map((label, i) => ( - + {label} ))} - {/* Cells — use style.fill so rgba + CSS vars both resolve correctly */} + {/* Cells — style.fill so rgba resolves correctly in SVG */} {weeks.map((week, wi) => week.map((cell, di) => cell.future ? null : ( {cell.date} - {cell.count > 0 - ? `: ${cell.count} event${cell.count !== 1 ? "s" : ""}` - : ": no activity"} + {cell.count > 0 ? `: ${cell.count} event${cell.count !== 1 ? "s" : ""}` : ": no activity"} - ), - ), + ) + ) )}
- {/* Legend */} -
+ {/* ── Legend ──────────────────────────────────────────────── */} +
Less {LEVEL_FILL.map((fill, i) => ( -
+
))} More
- {/* Contributions summary — large violet number, readable label */} -

- +

- {totalEvents.toLocaleString()} - - - contributions across all tracked repositories in the last 6 months - -

+ + {totalEvents.toLocaleString()} + + + contributions across all tracked repositories in the last 6 months + +
+
- {/* Line contribution stats */} + {/* ── Line stats ──────────────────────────────────────────── */} {hasLineStats ? (
- + +{fmtLines(totalAdditions)} - lines added + + lines added +
-
+
- + -{fmtLines(totalDeletions)} - lines removed + + lines removed +
) : ( -

- Line stats syncing with Forgejo… +

+ Line stats syncing with Forgejo — will appear on next refresh

)} +
); } diff --git a/frontend/src/components/git/ForgejoIssueMetricCards.tsx b/frontend/src/components/git/ForgejoIssueMetricCards.tsx index 2adbae7..d341794 100644 --- a/frontend/src/components/git/ForgejoIssueMetricCards.tsx +++ b/frontend/src/components/git/ForgejoIssueMetricCards.tsx @@ -3,12 +3,14 @@ import type { ComponentType } from "react"; import Link from "next/link"; import { + AlertCircle, AlertTriangle, ArrowUpRight, CheckCircle2, - CircleDot, Clock3, RefreshCw, + ShieldAlert, + ShieldCheck, } from "lucide-react"; import type { ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo"; @@ -22,7 +24,8 @@ type ForgejoIssueMetricCardsProps = { error?: string | null; }; -type MetricTone = "accent" | "success" | "warning" | "danger" | "muted"; +// Extended tone set — covers every semantic state +type MetricTone = "amber" | "success" | "danger" | "cyan" | "slate" | "muted"; type MetricCard = { title: string; @@ -45,19 +48,6 @@ const parseDate = (value: string | null | undefined): Date | null => { return Number.isNaN(date.getTime()) ? null : date; }; -const toneClasses: Record = { - accent: - "border-[color:var(--accent)]/30 bg-[color:var(--accent-soft)] text-[color:var(--accent)]", - success: - "border-[color:var(--success)]/30 bg-[color:rgba(52,211,153,0.12)] text-[color:var(--success)]", - warning: - "border-[color:var(--warning)]/30 bg-[color:rgba(251,191,36,0.12)] text-[color:var(--warning)]", - danger: - "border-[color:var(--danger)]/30 bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]", - muted: - "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted", -}; - const newestDate = (dates: Date[]): Date | null => { if (dates.length === 0) return null; return dates.reduce((latest, date) => @@ -65,6 +55,37 @@ const newestDate = (dates: Date[]): Date | null => { ); }; +// ── Tone → icon badge (background + text color) ──────────────────────────── +const toneIconClasses: Record = { + amber: "border-[color:rgba(245,158,11,0.35)] bg-[color:rgba(245,158,11,0.14)] text-[color:#F59E0B]", + success: "border-[color:rgba(16,185,129,0.35)] bg-[color:rgba(16,185,129,0.14)] text-[color:#10B981]", + danger: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.14)] text-[color:var(--danger)]", + cyan: "border-[color:rgba(6,182,212,0.35)] bg-[color:rgba(6,182,212,0.14)] text-[color:#06B6D4]", + slate: "border-[color:rgba(100,116,139,0.35)] bg-[color:rgba(100,116,139,0.12)] text-[color:#64748B]", + muted: "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted", +}; + +// ── Tone → card (full background + border + hover glow) ─────────────────── +const toneCardClasses: Record = { + amber: "border-[color:rgba(245,158,11,0.28)] bg-[color:rgba(245,158,11,0.06)] hover:border-[color:rgba(245,158,11,0.55)] hover:shadow-[0_4px_24px_rgba(245,158,11,0.18)]", + success: "border-[color:rgba(16,185,129,0.28)] bg-[color:rgba(16,185,129,0.06)] hover:border-[color:rgba(16,185,129,0.55)] hover:shadow-[0_4px_24px_rgba(16,185,129,0.18)]", + danger: "border-[color:rgba(248,113,113,0.28)] bg-[color:rgba(248,113,113,0.06)] hover:border-[color:rgba(248,113,113,0.55)] hover:shadow-[0_4px_24px_rgba(248,113,113,0.18)]", + cyan: "border-[color:rgba(6,182,212,0.28)] bg-[color:rgba(6,182,212,0.06)] hover:border-[color:rgba(6,182,212,0.55)] hover:shadow-[0_4px_24px_rgba(6,182,212,0.18)]", + slate: "border-[color:rgba(100,116,139,0.22)] bg-[color:rgba(100,116,139,0.05)] hover:border-[color:rgba(100,116,139,0.40)] hover:shadow-[0_4px_16px_rgba(100,116,139,0.12)]", + muted: "border-[color:var(--border)] bg-[color:var(--surface-muted)] hover:border-[color:var(--border-strong)] hover:shadow-sm", +}; + +// ── Tone → value text color ──────────────────────────────────────────────── +const toneValueClasses: Record = { + amber: "text-[color:#F59E0B]", + success: "text-[color:#10B981]", + danger: "text-[color:var(--danger)]", + cyan: "text-[color:#06B6D4]", + slate: "text-[color:#64748B]", + muted: "text-strong", +}; + +// ── Card builder: Last Sync Health ───────────────────────────────────────── function buildSyncHealthCard( metrics: ForgejoIssueMetrics | null, repositories: ForgejoRepository[], @@ -73,16 +94,12 @@ function buildSyncHealthCard( const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter( (count) => Number(count) > 0, ).length; - const metricSyncDates = Object.values( - metrics?.last_sync_timestamps ?? {}, - ).flatMap((value) => { - const date = parseDate(value); - return date ? [date] : []; - }); - const repositorySyncDates = repositories.flatMap((repository) => { - const date = parseDate(repository.last_sync_at); - return date ? [date] : []; - }); + const metricSyncDates = Object.values(metrics?.last_sync_timestamps ?? {}).flatMap( + (v) => { const d = parseDate(v); return d ? [d] : []; }, + ); + const repositorySyncDates = repositories.flatMap( + (r) => { const d = parseDate(r.last_sync_at); return d ? [d] : []; }, + ); const latestSync = newestDate( metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates, ); @@ -100,7 +117,6 @@ function buildSyncHealthCard( icon: RefreshCw, }; } - if (syncErrorCount > 0) { return { title: "Last Sync Health", @@ -108,42 +124,40 @@ function buildSyncHealthCard( caption: "Repository sync needs attention.", href: "/git-projects/repositories", tone: "danger", - icon: AlertTriangle, + icon: ShieldAlert, }; } - if (!latestSync) { return { title: "Last Sync Health", value: "Waiting", caption: "Repositories have not synced yet.", href: "/git-projects/repositories", - tone: "warning", + tone: "slate", icon: Clock3, }; } - if (latestSyncAge > STALE_SYNC_THRESHOLD_MS) { return { title: "Last Sync Health", value: "Stale", caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`, href: "/git-projects/repositories", - tone: "warning", + tone: "amber", icon: Clock3, }; } - return { title: "Last Sync Health", value: "Healthy", caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`, href: "/git-projects/repositories", - tone: "success", - icon: CheckCircle2, + tone: "cyan", + icon: ShieldCheck, }; } +// ── Skeleton ─────────────────────────────────────────────────────────────── function MetricSkeleton() { return (
@@ -154,27 +168,31 @@ function MetricSkeleton() { ); } +// ── Card renderer ────────────────────────────────────────────────────────── function MetricCardLink({ card }: { card: MetricCard }) { const Icon = card.icon; return (

{card.title}

-

+

{card.value}

@@ -188,17 +206,24 @@ function MetricCardLink({ card }: { card: MetricCard }) { ); } +// ── Main export ──────────────────────────────────────────────────────────── export function ForgejoIssueMetricCards({ metrics, repositories, isLoading = false, error, }: ForgejoIssueMetricCardsProps) { - const openIssues = metrics?.open_issues ?? 0; + const openIssues = metrics?.open_issues ?? 0; const recentlyClosed = metrics?.closed_last_7_days ?? 0; - const staleOpen = metrics?.stale_open_issues ?? 0; - const repositoriesSynced = - metrics?.repositories_synced ?? repositories.length; + const staleOpen = metrics?.stale_open_issues ?? 0; + const repositoriesSynced = metrics?.repositories_synced ?? repositories.length; + + // Stale: 0 → slate, 1–5 → amber, >5 → danger + const staleTone: MetricTone = + staleOpen === 0 ? "slate" : + staleOpen <= 5 ? "amber" : + "danger"; + const cards: MetricCard[] = [ { title: "Open Issues", @@ -208,8 +233,8 @@ export function ForgejoIssueMetricCards({ ? "No open Git Project issues." : "Review open issues across Git Projects.", href: "/git-projects/issues?state=open", - tone: openIssues > 0 ? "accent" : "muted", - icon: CircleDot, + tone: openIssues > 0 ? "amber" : "muted", + icon: openIssues > 0 ? AlertCircle : CheckCircle2, }, { title: "Recently Closed", @@ -224,11 +249,11 @@ export function ForgejoIssueMetricCards({ value: formatCount(staleOpen), caption: staleOpen === 0 - ? "No stale open issues detected." + ? "Nothing abandoned right now." : "Open issues without recent movement.", href: "/git-projects/issues?state=open&stale=1", - tone: staleOpen > 0 ? "warning" : "success", - icon: Clock3, + tone: staleTone, + icon: staleOpen === 0 ? CheckCircle2 : Clock3, }, buildSyncHealthCard(metrics, repositories), ]; @@ -241,8 +266,7 @@ export function ForgejoIssueMetricCards({ Git Project Issue Tracking

- High-level Forgejo issue health across repositories synced into - Pipeline. + High-level Forgejo issue health across repositories synced into Pipeline.

+ {/* Warning banner — left accent border, brighter amber */} {error ? ( -
- - {error} +
+ + {error}
) : null}
{isLoading - ? Array.from({ length: 4 }).map((_, index) => ( - - )) - : cards.map((card) => ( - - ))} + ? Array.from({ length: 4 }).map((_, i) => ) + : cards.map((card) => )}
{!isLoading && !error && repositories.length === 0 ? (
- No Git Project repositories are configured yet. Metrics will populate - after repositories are added and synced. + No Git Project repositories are configured yet. Metrics will populate after repositories are added and synced.
) : !isLoading && !error && repositoriesSynced === 0 ? (
- Git Project repositories are configured, but Pipeline has not synced - issue metrics yet. + Git Project repositories are configured, but Pipeline has not synced issue metrics yet.
) : null} -
); }