feat(dashboard): enhance metric cards with new tone classes and improve styling
This commit is contained in:
parent
bbfde53fe9
commit
c04ab6ac8b
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<section className="surface-card rounded-xl p-4 md:p-6 transition hover:-translate-y-0.5 hover:shadow-md">
|
||||
<section className={`rounded-xl p-4 md:p-6 transition hover:-translate-y-0.5 hover:shadow-md ${toneCard[tone]}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
|
|
|||
|
|
@ -41,3 +41,11 @@ export const toneIcon: Record<MetricToneKey, string> = {
|
|||
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<MetricToneKey, string> = {
|
||||
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)]",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string, number>(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 (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className="animate-pulse rounded"
|
||||
style={{ width: svgW, height: svgH, background: "var(--surface-strong)" }}
|
||||
/>
|
||||
<div className="h-10 animate-pulse rounded" style={{ background: "var(--surface-strong)" }} />
|
||||
<div className="space-y-4">
|
||||
<div className="animate-pulse rounded" style={{ width: svgW, height: svgH, background: "rgba(139,92,246,0.08)" }} />
|
||||
<div className="mx-auto h-8 w-64 animate-pulse rounded-full" style={{ background: "rgba(139,92,246,0.08)" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -111,49 +113,34 @@ export function ForgejoHeatmap({
|
|||
const totalEvents = days.reduce((sum, d) => sum + d.count, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Heatmap grid */}
|
||||
<div style={{ overflowX: "auto" }} className="flex justify-center">
|
||||
<svg
|
||||
width={svgW}
|
||||
height={svgH}
|
||||
aria-label="Issue activity heatmap"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* ── Heatmap ─────────────────────────────────────────────── */}
|
||||
<div className="flex justify-center" style={{ overflowX: "auto" }}>
|
||||
<svg width={svgW} height={svgH} aria-label="Issue activity heatmap" style={{ display: "block" }}>
|
||||
|
||||
{/* Month labels */}
|
||||
{monthLabels.map(({ weekIdx, label }) => (
|
||||
<text
|
||||
key={`m-${weekIdx}`}
|
||||
x={LEFT + weekIdx * STRIDE}
|
||||
y={11}
|
||||
fontSize={10}
|
||||
style={{ fill: "var(--text-muted, #9ca3af)" }}
|
||||
>
|
||||
<text key={`m-${weekIdx}`} x={LEFT + weekIdx * STRIDE} y={11} fontSize={10} style={{ fill: LABEL_CLR }}>
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Day-of-week labels */}
|
||||
{/* Day labels */}
|
||||
{(["Mon", "Wed", "Fri"] as const).map((label, i) => (
|
||||
<text
|
||||
key={label}
|
||||
x={0}
|
||||
y={TOP + (i * 2 + 1) * STRIDE + CELL - 2}
|
||||
fontSize={10}
|
||||
style={{ fill: "var(--text-muted, #9ca3af)" }}
|
||||
>
|
||||
<text key={label} x={0} y={TOP + (i * 2 + 1) * STRIDE + CELL - 2} fontSize={10} style={{ fill: LABEL_CLR }}>
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* 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 : (
|
||||
<rect
|
||||
key={cell.date}
|
||||
x={LEFT + wi * STRIDE}
|
||||
y={TOP + di * STRIDE}
|
||||
y={TOP + di * STRIDE}
|
||||
width={CELL}
|
||||
height={CELL}
|
||||
rx={3}
|
||||
|
|
@ -161,70 +148,69 @@ export function ForgejoHeatmap({
|
|||
>
|
||||
<title>
|
||||
{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"}
|
||||
</title>
|
||||
</rect>
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-center gap-1.5 text-xs text-muted">
|
||||
{/* ── Legend ──────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-center gap-1.5 text-xs" style={{ color: LABEL_CLR }}>
|
||||
<span>Less</span>
|
||||
{LEVEL_FILL.map((fill, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ width: CELL, height: CELL, borderRadius: 3, background: fill, flexShrink: 0 }}
|
||||
/>
|
||||
<div key={i} style={{ width: CELL, height: CELL, borderRadius: 3, background: fill, flexShrink: 0 }} />
|
||||
))}
|
||||
<span>More</span>
|
||||
</div>
|
||||
|
||||
{/* Contributions summary — large violet number, readable label */}
|
||||
<p className="text-center">
|
||||
<span
|
||||
className="text-2xl font-bold tabular-nums"
|
||||
style={{ color: "rgba(139,92,246,1)" }}
|
||||
{/* ── Contributions badge ──────────────────────────────────── */}
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="inline-flex items-baseline gap-2 rounded-full px-5 py-2"
|
||||
style={{
|
||||
background: "rgba(139,92,246,0.10)",
|
||||
border: "1px solid rgba(139,92,246,0.25)",
|
||||
}}
|
||||
>
|
||||
{totalEvents.toLocaleString()}
|
||||
</span>
|
||||
<span className="ml-1.5 text-sm font-medium text-muted">
|
||||
contributions across all tracked repositories in the last 6 months
|
||||
</span>
|
||||
</p>
|
||||
<span className="text-2xl font-bold tabular-nums" style={{ color: VIOLET }}>
|
||||
{totalEvents.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-sm font-medium" style={{ color: LABEL_CLR }}>
|
||||
contributions across all tracked repositories in the last 6 months
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line contribution stats */}
|
||||
{/* ── Line stats ──────────────────────────────────────────── */}
|
||||
{hasLineStats ? (
|
||||
<div className="flex items-center justify-center gap-8 pt-1">
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span
|
||||
className="text-2xl font-bold tabular-nums"
|
||||
style={{ color: "rgba(52,211,153,1)" }}
|
||||
>
|
||||
<span className="text-2xl font-bold tabular-nums" style={{ color: GREEN }}>
|
||||
+{fmtLines(totalAdditions)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-muted">lines added</span>
|
||||
<span className="text-xs font-medium" style={{ color: "rgba(52,211,153,0.70)" }}>
|
||||
lines added
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-10 w-px" style={{ background: "var(--border)" }} />
|
||||
<div style={{ width: 1, height: 40, background: "rgba(139,92,246,0.20)" }} />
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span
|
||||
className="text-2xl font-bold tabular-nums"
|
||||
style={{ color: "rgba(248,113,113,1)" }}
|
||||
>
|
||||
<span className="text-2xl font-bold tabular-nums" style={{ color: RED }}>
|
||||
-{fmtLines(totalDeletions)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-muted">lines removed</span>
|
||||
<span className="text-xs font-medium" style={{ color: "rgba(248,113,113,0.70)" }}>
|
||||
lines removed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-xs text-muted opacity-60">
|
||||
Line stats syncing with Forgejo…
|
||||
<p className="text-center text-xs" style={{ color: LABEL_CLR, opacity: 0.6 }}>
|
||||
Line stats syncing with Forgejo — will appear on next refresh
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MetricTone, string> = {
|
||||
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<MetricTone, string> = {
|
||||
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<MetricTone, string> = {
|
||||
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<MetricTone, string> = {
|
||||
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 (
|
||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4">
|
||||
|
|
@ -154,27 +168,31 @@ function MetricSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Card renderer ──────────────────────────────────────────────────────────
|
||||
function MetricCardLink({ card }: { card: MetricCard }) {
|
||||
const Icon = card.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={card.href}
|
||||
className="group flex min-w-0 flex-col justify-between rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 transition hover:-translate-y-0.5 hover:border-[color:var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
|
||||
className={cn(
|
||||
"group flex min-w-0 flex-col justify-between rounded-xl border p-4 transition-all duration-200 hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]",
|
||||
toneCardClasses[card.tone],
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
|
||||
{card.title}
|
||||
</p>
|
||||
<p className="mt-3 break-words font-heading text-3xl font-semibold text-strong">
|
||||
<p className={cn("mt-3 break-words font-heading text-3xl font-semibold", toneValueClasses[card.tone])}>
|
||||
{card.value}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-lg border p-2 transition group-hover:scale-105",
|
||||
toneClasses[card.tone],
|
||||
"shrink-0 rounded-lg border p-2 transition-transform duration-200 group-hover:scale-110",
|
||||
toneIconClasses[card.tone],
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
|
|
@ -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
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
High-level Forgejo issue health across repositories synced into
|
||||
Pipeline.
|
||||
High-level Forgejo issue health across repositories synced into Pipeline.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
|
|
@ -254,35 +278,29 @@ export function ForgejoIssueMetricCards({
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Warning banner — left accent border, brighter amber */}
|
||||
{error ? (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-lg border border-[color:var(--warning)]/35 bg-[color:rgba(251,191,36,0.12)] p-3 text-sm text-[color:var(--warning)]">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
<div className="mb-4 flex items-start gap-3 rounded-lg border border-[color:rgba(245,158,11,0.45)] bg-[color:rgba(245,158,11,0.10)] p-3 text-sm text-[color:#F59E0B] [border-left:3px_solid_#F59E0B]">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-[color:#F59E0B]" />
|
||||
<span className="font-medium">{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{isLoading
|
||||
? Array.from({ length: 4 }).map((_, index) => (
|
||||
<MetricSkeleton key={index} />
|
||||
))
|
||||
: cards.map((card) => (
|
||||
<MetricCardLink key={card.title} card={card} />
|
||||
))}
|
||||
? Array.from({ length: 4 }).map((_, i) => <MetricSkeleton key={i} />)
|
||||
: cards.map((card) => <MetricCardLink key={card.title} card={card} />)}
|
||||
</div>
|
||||
|
||||
{!isLoading && !error && repositories.length === 0 ? (
|
||||
<div className="mt-4 rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
|
||||
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.
|
||||
</div>
|
||||
) : !isLoading && !error && repositoriesSynced === 0 ? (
|
||||
<div className="mt-4 rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
|
||||
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.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue