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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time as _time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID
|
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_issues import ForgejoIssue
|
||||||
from app.models.forgejo_repositories import ForgejoRepository
|
from app.models.forgejo_repositories import ForgejoRepository
|
||||||
from app.schemas.metrics import HeatmapDay, HeatmapResponse, MetricsResponse
|
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:
|
if TYPE_CHECKING:
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
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"])
|
router = APIRouter(prefix="/forgejo", tags=["forgejo-metrics"])
|
||||||
SESSION_DEP = Depends(get_session)
|
SESSION_DEP = Depends(get_session)
|
||||||
# Use ORG_MEMBER_DEP directly, not wrapped in Depends again
|
# 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())]
|
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)
|
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()
|
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]:
|
if cache_key not in _line_stats_fetching and (
|
||||||
try:
|
cached is None or now - cached[0] > _LINE_STATS_TTL
|
||||||
async with get_forgejo_client(conn) as client:
|
):
|
||||||
contributors, has_data = await client.get_contributor_stats(repo.owner, repo.repo)
|
# Normalise base_url the same way get_forgejo_client() does, eagerly,
|
||||||
adds = dels = 0
|
# so the background task never touches a potentially-closed session.
|
||||||
for contributor in contributors:
|
import re as _re
|
||||||
for week in contributor.get("weeks", []):
|
repo_tuples: list[tuple[str, str, str, str | None]] = []
|
||||||
if (week.get("w") or 0) >= since_ts:
|
for repo, conn in repos_with_conns:
|
||||||
adds += week.get("a", 0) or 0
|
bu = (conn.base_url or "").rstrip("/")
|
||||||
dels += week.get("d", 0) or 0
|
if "/api/v1" in bu:
|
||||||
return adds, dels, has_data
|
m = _re.match(r"(https?://[^/]+)", bu)
|
||||||
except Exception:
|
bu = m.group(1).rstrip("/") if m else bu
|
||||||
return 0, 0, False
|
repo_tuples.append((repo.owner, repo.repo, bu, getattr(conn, "token", None)))
|
||||||
|
|
||||||
line_results = await asyncio.gather(
|
_line_stats_fetching.add(cache_key)
|
||||||
*[_repo_line_stats(repo, conn) for repo, conn in repos_with_conns]
|
asyncio.create_task(
|
||||||
)
|
_bg_fetch_line_stats(cache_key, repo_tuples, since_ts),
|
||||||
total_additions = sum(a for a, _, _ in line_results)
|
name=f"line-stats-{cache_key}",
|
||||||
total_deletions = sum(d for _, d, _ in line_results)
|
)
|
||||||
has_line_stats = all(ok for _, _, ok in line_results)
|
|
||||||
|
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(
|
return HeatmapResponse(
|
||||||
days=days_list,
|
days=days_list,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Info } from "lucide-react";
|
import { Info } from "lucide-react";
|
||||||
import { toneIcon, type MetricToneKey } from "./tokens";
|
import { toneIcon, toneCard, type MetricToneKey } from "./tokens";
|
||||||
|
|
||||||
interface DashboardMetricCardProps {
|
interface DashboardMetricCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -24,7 +24,7 @@ export function DashboardMetricCard({
|
||||||
tone,
|
tone,
|
||||||
}: DashboardMetricCardProps) {
|
}: DashboardMetricCardProps) {
|
||||||
return (
|
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 className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5">
|
<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)]",
|
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)]",
|
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;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layout constants — 6-month view with larger cells
|
const CELL = 13;
|
||||||
const CELL = 13;
|
const GAP = 3;
|
||||||
const GAP = 3;
|
const STRIDE = CELL + GAP;
|
||||||
const STRIDE = CELL + GAP; // 16px per cell
|
|
||||||
const WEEKS = 27;
|
const WEEKS = 27;
|
||||||
const LEFT = 28; // width reserved for Mon/Wed/Fri labels
|
const LEFT = 28;
|
||||||
const TOP = 18; // height reserved for month labels
|
const TOP = 18;
|
||||||
|
|
||||||
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
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 = [
|
const LEVEL_FILL = [
|
||||||
"rgba(139,92,246,0.07)", // 0 — empty (barely-there grid so cells are visible)
|
"rgba(139,92,246,0.08)", // 0 empty — barely-there grid
|
||||||
"rgba(139,92,246,0.28)", // 1
|
"rgba(139,92,246,0.30)", // 1
|
||||||
"rgba(139,92,246,0.52)", // 2
|
"rgba(139,92,246,0.54)", // 2
|
||||||
"rgba(139,92,246,0.75)", // 3
|
"rgba(139,92,246,0.77)", // 3
|
||||||
"rgba(139,92,246,1.0)", // 4 — full violet-500
|
"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 {
|
function toLevel(count: number): number {
|
||||||
if (count === 0) return 0;
|
if (count === 0) return 0;
|
||||||
if (count <= 2) return 1;
|
if (count <= 2) return 1;
|
||||||
if (count <= 5) return 2;
|
if (count <= 5) return 2;
|
||||||
if (count <= 9) return 3;
|
if (count <= 9) return 3;
|
||||||
return 4;
|
return 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isoDate(d: Date): string {
|
function isoDate(d: Date): string {
|
||||||
const y = d.getFullYear();
|
return [
|
||||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
d.getFullYear(),
|
||||||
const day = String(d.getDate()).padStart(2, "0");
|
String(d.getMonth() + 1).padStart(2, "0"),
|
||||||
return `${y}-${m}-${day}`;
|
String(d.getDate()).padStart(2, "0"),
|
||||||
|
].join("-");
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtLines(n: number): string {
|
function fmtLines(n: number): string {
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
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();
|
return n.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,11 +62,11 @@ export function ForgejoHeatmap({
|
||||||
maxCount: _maxCount,
|
maxCount: _maxCount,
|
||||||
totalAdditions = 0,
|
totalAdditions = 0,
|
||||||
totalDeletions = 0,
|
totalDeletions = 0,
|
||||||
hasLineStats = false,
|
hasLineStats = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: ForgejoHeatmapProps) {
|
}: ForgejoHeatmapProps) {
|
||||||
const { weeks, monthLabels } = useMemo(() => {
|
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();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
@ -79,7 +84,7 @@ export function ForgejoHeatmap({
|
||||||
const week: Cell[] = [];
|
const week: Cell[] = [];
|
||||||
for (let d = 0; d < 7; d++) {
|
for (let d = 0; d < 7; d++) {
|
||||||
const dateStr = isoDate(cur);
|
const dateStr = isoDate(cur);
|
||||||
const month = cur.getMonth();
|
const month = cur.getMonth();
|
||||||
if (d === 0 && month !== lastMonth) {
|
if (d === 0 && month !== lastMonth) {
|
||||||
monthLabelList.push({ weekIdx: w, label: MONTHS[month] });
|
monthLabelList.push({ weekIdx: w, label: MONTHS[month] });
|
||||||
lastMonth = month;
|
lastMonth = month;
|
||||||
|
|
@ -94,16 +99,13 @@ export function ForgejoHeatmap({
|
||||||
}, [days]);
|
}, [days]);
|
||||||
|
|
||||||
const svgW = LEFT + WEEKS * STRIDE;
|
const svgW = LEFT + WEEKS * STRIDE;
|
||||||
const svgH = TOP + 7 * STRIDE - GAP;
|
const svgH = TOP + 7 * STRIDE - GAP;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<div
|
<div className="animate-pulse rounded" style={{ width: svgW, height: svgH, background: "rgba(139,92,246,0.08)" }} />
|
||||||
className="animate-pulse rounded"
|
<div className="mx-auto h-8 w-64 animate-pulse rounded-full" style={{ background: "rgba(139,92,246,0.08)" }} />
|
||||||
style={{ width: svgW, height: svgH, background: "var(--surface-strong)" }}
|
|
||||||
/>
|
|
||||||
<div className="h-10 animate-pulse rounded" style={{ background: "var(--surface-strong)" }} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -111,49 +113,34 @@ export function ForgejoHeatmap({
|
||||||
const totalEvents = days.reduce((sum, d) => sum + d.count, 0);
|
const totalEvents = days.reduce((sum, d) => sum + d.count, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{/* Heatmap grid */}
|
|
||||||
<div style={{ overflowX: "auto" }} className="flex justify-center">
|
{/* ── Heatmap ─────────────────────────────────────────────── */}
|
||||||
<svg
|
<div className="flex justify-center" style={{ overflowX: "auto" }}>
|
||||||
width={svgW}
|
<svg width={svgW} height={svgH} aria-label="Issue activity heatmap" style={{ display: "block" }}>
|
||||||
height={svgH}
|
|
||||||
aria-label="Issue activity heatmap"
|
|
||||||
style={{ display: "block" }}
|
|
||||||
>
|
|
||||||
{/* Month labels */}
|
{/* Month labels */}
|
||||||
{monthLabels.map(({ weekIdx, label }) => (
|
{monthLabels.map(({ weekIdx, label }) => (
|
||||||
<text
|
<text key={`m-${weekIdx}`} x={LEFT + weekIdx * STRIDE} y={11} fontSize={10} style={{ fill: LABEL_CLR }}>
|
||||||
key={`m-${weekIdx}`}
|
|
||||||
x={LEFT + weekIdx * STRIDE}
|
|
||||||
y={11}
|
|
||||||
fontSize={10}
|
|
||||||
style={{ fill: "var(--text-muted, #9ca3af)" }}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Day-of-week labels */}
|
{/* Day labels */}
|
||||||
{(["Mon", "Wed", "Fri"] as const).map((label, i) => (
|
{(["Mon", "Wed", "Fri"] as const).map((label, i) => (
|
||||||
<text
|
<text key={label} x={0} y={TOP + (i * 2 + 1) * STRIDE + CELL - 2} fontSize={10} style={{ fill: LABEL_CLR }}>
|
||||||
key={label}
|
|
||||||
x={0}
|
|
||||||
y={TOP + (i * 2 + 1) * STRIDE + CELL - 2}
|
|
||||||
fontSize={10}
|
|
||||||
style={{ fill: "var(--text-muted, #9ca3af)" }}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</text>
|
</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) =>
|
{weeks.map((week, wi) =>
|
||||||
week.map((cell, di) =>
|
week.map((cell, di) =>
|
||||||
cell.future ? null : (
|
cell.future ? null : (
|
||||||
<rect
|
<rect
|
||||||
key={cell.date}
|
key={cell.date}
|
||||||
x={LEFT + wi * STRIDE}
|
x={LEFT + wi * STRIDE}
|
||||||
y={TOP + di * STRIDE}
|
y={TOP + di * STRIDE}
|
||||||
width={CELL}
|
width={CELL}
|
||||||
height={CELL}
|
height={CELL}
|
||||||
rx={3}
|
rx={3}
|
||||||
|
|
@ -161,70 +148,69 @@ export function ForgejoHeatmap({
|
||||||
>
|
>
|
||||||
<title>
|
<title>
|
||||||
{cell.date}
|
{cell.date}
|
||||||
{cell.count > 0
|
{cell.count > 0 ? `: ${cell.count} event${cell.count !== 1 ? "s" : ""}` : ": no activity"}
|
||||||
? `: ${cell.count} event${cell.count !== 1 ? "s" : ""}`
|
|
||||||
: ": no activity"}
|
|
||||||
</title>
|
</title>
|
||||||
</rect>
|
</rect>
|
||||||
),
|
)
|
||||||
),
|
)
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* ── Legend ──────────────────────────────────────────────── */}
|
||||||
<div className="flex items-center justify-center gap-1.5 text-xs text-muted">
|
<div className="flex items-center justify-center gap-1.5 text-xs" style={{ color: LABEL_CLR }}>
|
||||||
<span>Less</span>
|
<span>Less</span>
|
||||||
{LEVEL_FILL.map((fill, i) => (
|
{LEVEL_FILL.map((fill, i) => (
|
||||||
<div
|
<div key={i} style={{ width: CELL, height: CELL, borderRadius: 3, background: fill, flexShrink: 0 }} />
|
||||||
key={i}
|
|
||||||
style={{ width: CELL, height: CELL, borderRadius: 3, background: fill, flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
<span>More</span>
|
<span>More</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contributions summary — large violet number, readable label */}
|
{/* ── Contributions badge ──────────────────────────────────── */}
|
||||||
<p className="text-center">
|
<div className="flex justify-center">
|
||||||
<span
|
<div
|
||||||
className="text-2xl font-bold tabular-nums"
|
className="inline-flex items-baseline gap-2 rounded-full px-5 py-2"
|
||||||
style={{ color: "rgba(139,92,246,1)" }}
|
style={{
|
||||||
|
background: "rgba(139,92,246,0.10)",
|
||||||
|
border: "1px solid rgba(139,92,246,0.25)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{totalEvents.toLocaleString()}
|
<span className="text-2xl font-bold tabular-nums" style={{ color: VIOLET }}>
|
||||||
</span>
|
{totalEvents.toLocaleString()}
|
||||||
<span className="ml-1.5 text-sm font-medium text-muted">
|
</span>
|
||||||
contributions across all tracked repositories in the last 6 months
|
<span className="text-sm font-medium" style={{ color: LABEL_CLR }}>
|
||||||
</span>
|
contributions across all tracked repositories in the last 6 months
|
||||||
</p>
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Line contribution stats */}
|
{/* ── Line stats ──────────────────────────────────────────── */}
|
||||||
{hasLineStats ? (
|
{hasLineStats ? (
|
||||||
<div className="flex items-center justify-center gap-8 pt-1">
|
<div className="flex items-center justify-center gap-8 pt-1">
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
<span
|
<span className="text-2xl font-bold tabular-nums" style={{ color: GREEN }}>
|
||||||
className="text-2xl font-bold tabular-nums"
|
|
||||||
style={{ color: "rgba(52,211,153,1)" }}
|
|
||||||
>
|
|
||||||
+{fmtLines(totalAdditions)}
|
+{fmtLines(totalAdditions)}
|
||||||
</span>
|
</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>
|
||||||
<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">
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
<span
|
<span className="text-2xl font-bold tabular-nums" style={{ color: RED }}>
|
||||||
className="text-2xl font-bold tabular-nums"
|
|
||||||
style={{ color: "rgba(248,113,113,1)" }}
|
|
||||||
>
|
|
||||||
-{fmtLines(totalDeletions)}
|
-{fmtLines(totalDeletions)}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-xs text-muted opacity-60">
|
<p className="text-center text-xs" style={{ color: LABEL_CLR, opacity: 0.6 }}>
|
||||||
Line stats syncing with Forgejo…
|
Line stats syncing with Forgejo — will appear on next refresh
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
CircleDot,
|
|
||||||
Clock3,
|
Clock3,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import type { ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo";
|
import type { ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo";
|
||||||
|
|
@ -22,7 +24,8 @@ type ForgejoIssueMetricCardsProps = {
|
||||||
error?: string | null;
|
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 = {
|
type MetricCard = {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -45,19 +48,6 @@ const parseDate = (value: string | null | undefined): Date | null => {
|
||||||
return Number.isNaN(date.getTime()) ? null : date;
|
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 => {
|
const newestDate = (dates: Date[]): Date | null => {
|
||||||
if (dates.length === 0) return null;
|
if (dates.length === 0) return null;
|
||||||
return dates.reduce((latest, date) =>
|
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(
|
function buildSyncHealthCard(
|
||||||
metrics: ForgejoIssueMetrics | null,
|
metrics: ForgejoIssueMetrics | null,
|
||||||
repositories: ForgejoRepository[],
|
repositories: ForgejoRepository[],
|
||||||
|
|
@ -73,16 +94,12 @@ function buildSyncHealthCard(
|
||||||
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
|
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
|
||||||
(count) => Number(count) > 0,
|
(count) => Number(count) > 0,
|
||||||
).length;
|
).length;
|
||||||
const metricSyncDates = Object.values(
|
const metricSyncDates = Object.values(metrics?.last_sync_timestamps ?? {}).flatMap(
|
||||||
metrics?.last_sync_timestamps ?? {},
|
(v) => { const d = parseDate(v); return d ? [d] : []; },
|
||||||
).flatMap((value) => {
|
);
|
||||||
const date = parseDate(value);
|
const repositorySyncDates = repositories.flatMap(
|
||||||
return date ? [date] : [];
|
(r) => { const d = parseDate(r.last_sync_at); return d ? [d] : []; },
|
||||||
});
|
);
|
||||||
const repositorySyncDates = repositories.flatMap((repository) => {
|
|
||||||
const date = parseDate(repository.last_sync_at);
|
|
||||||
return date ? [date] : [];
|
|
||||||
});
|
|
||||||
const latestSync = newestDate(
|
const latestSync = newestDate(
|
||||||
metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates,
|
metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates,
|
||||||
);
|
);
|
||||||
|
|
@ -100,7 +117,6 @@ function buildSyncHealthCard(
|
||||||
icon: RefreshCw,
|
icon: RefreshCw,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (syncErrorCount > 0) {
|
if (syncErrorCount > 0) {
|
||||||
return {
|
return {
|
||||||
title: "Last Sync Health",
|
title: "Last Sync Health",
|
||||||
|
|
@ -108,42 +124,40 @@ function buildSyncHealthCard(
|
||||||
caption: "Repository sync needs attention.",
|
caption: "Repository sync needs attention.",
|
||||||
href: "/git-projects/repositories",
|
href: "/git-projects/repositories",
|
||||||
tone: "danger",
|
tone: "danger",
|
||||||
icon: AlertTriangle,
|
icon: ShieldAlert,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!latestSync) {
|
if (!latestSync) {
|
||||||
return {
|
return {
|
||||||
title: "Last Sync Health",
|
title: "Last Sync Health",
|
||||||
value: "Waiting",
|
value: "Waiting",
|
||||||
caption: "Repositories have not synced yet.",
|
caption: "Repositories have not synced yet.",
|
||||||
href: "/git-projects/repositories",
|
href: "/git-projects/repositories",
|
||||||
tone: "warning",
|
tone: "slate",
|
||||||
icon: Clock3,
|
icon: Clock3,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (latestSyncAge > STALE_SYNC_THRESHOLD_MS) {
|
if (latestSyncAge > STALE_SYNC_THRESHOLD_MS) {
|
||||||
return {
|
return {
|
||||||
title: "Last Sync Health",
|
title: "Last Sync Health",
|
||||||
value: "Stale",
|
value: "Stale",
|
||||||
caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`,
|
caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`,
|
||||||
href: "/git-projects/repositories",
|
href: "/git-projects/repositories",
|
||||||
tone: "warning",
|
tone: "amber",
|
||||||
icon: Clock3,
|
icon: Clock3,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: "Last Sync Health",
|
title: "Last Sync Health",
|
||||||
value: "Healthy",
|
value: "Healthy",
|
||||||
caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`,
|
caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`,
|
||||||
href: "/git-projects/repositories",
|
href: "/git-projects/repositories",
|
||||||
tone: "success",
|
tone: "cyan",
|
||||||
icon: CheckCircle2,
|
icon: ShieldCheck,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Skeleton ───────────────────────────────────────────────────────────────
|
||||||
function MetricSkeleton() {
|
function MetricSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4">
|
<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 }) {
|
function MetricCardLink({ card }: { card: MetricCard }) {
|
||||||
const Icon = card.icon;
|
const Icon = card.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={card.href}
|
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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
|
||||||
{card.title}
|
{card.title}
|
||||||
</p>
|
</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}
|
{card.value}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 rounded-lg border p-2 transition group-hover:scale-105",
|
"shrink-0 rounded-lg border p-2 transition-transform duration-200 group-hover:scale-110",
|
||||||
toneClasses[card.tone],
|
toneIconClasses[card.tone],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
|
|
@ -188,17 +206,24 @@ function MetricCardLink({ card }: { card: MetricCard }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Main export ────────────────────────────────────────────────────────────
|
||||||
export function ForgejoIssueMetricCards({
|
export function ForgejoIssueMetricCards({
|
||||||
metrics,
|
metrics,
|
||||||
repositories,
|
repositories,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error,
|
error,
|
||||||
}: ForgejoIssueMetricCardsProps) {
|
}: ForgejoIssueMetricCardsProps) {
|
||||||
const openIssues = metrics?.open_issues ?? 0;
|
const openIssues = metrics?.open_issues ?? 0;
|
||||||
const recentlyClosed = metrics?.closed_last_7_days ?? 0;
|
const recentlyClosed = metrics?.closed_last_7_days ?? 0;
|
||||||
const staleOpen = metrics?.stale_open_issues ?? 0;
|
const staleOpen = metrics?.stale_open_issues ?? 0;
|
||||||
const repositoriesSynced =
|
const repositoriesSynced = metrics?.repositories_synced ?? repositories.length;
|
||||||
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[] = [
|
const cards: MetricCard[] = [
|
||||||
{
|
{
|
||||||
title: "Open Issues",
|
title: "Open Issues",
|
||||||
|
|
@ -208,8 +233,8 @@ export function ForgejoIssueMetricCards({
|
||||||
? "No open Git Project issues."
|
? "No open Git Project issues."
|
||||||
: "Review open issues across Git Projects.",
|
: "Review open issues across Git Projects.",
|
||||||
href: "/git-projects/issues?state=open",
|
href: "/git-projects/issues?state=open",
|
||||||
tone: openIssues > 0 ? "accent" : "muted",
|
tone: openIssues > 0 ? "amber" : "muted",
|
||||||
icon: CircleDot,
|
icon: openIssues > 0 ? AlertCircle : CheckCircle2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Recently Closed",
|
title: "Recently Closed",
|
||||||
|
|
@ -224,11 +249,11 @@ export function ForgejoIssueMetricCards({
|
||||||
value: formatCount(staleOpen),
|
value: formatCount(staleOpen),
|
||||||
caption:
|
caption:
|
||||||
staleOpen === 0
|
staleOpen === 0
|
||||||
? "No stale open issues detected."
|
? "Nothing abandoned right now."
|
||||||
: "Open issues without recent movement.",
|
: "Open issues without recent movement.",
|
||||||
href: "/git-projects/issues?state=open&stale=1",
|
href: "/git-projects/issues?state=open&stale=1",
|
||||||
tone: staleOpen > 0 ? "warning" : "success",
|
tone: staleTone,
|
||||||
icon: Clock3,
|
icon: staleOpen === 0 ? CheckCircle2 : Clock3,
|
||||||
},
|
},
|
||||||
buildSyncHealthCard(metrics, repositories),
|
buildSyncHealthCard(metrics, repositories),
|
||||||
];
|
];
|
||||||
|
|
@ -241,8 +266,7 @@ export function ForgejoIssueMetricCards({
|
||||||
Git Project Issue Tracking
|
Git Project Issue Tracking
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-muted">
|
<p className="mt-1 text-sm text-muted">
|
||||||
High-level Forgejo issue health across repositories synced into
|
High-level Forgejo issue health across repositories synced into Pipeline.
|
||||||
Pipeline.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -254,35 +278,29 @@ export function ForgejoIssueMetricCards({
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Warning banner — left accent border, brighter amber */}
|
||||||
{error ? (
|
{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)]">
|
<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" />
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-[color:#F59E0B]" />
|
||||||
<span>{error}</span>
|
<span className="font-medium">{error}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
{isLoading
|
{isLoading
|
||||||
? Array.from({ length: 4 }).map((_, index) => (
|
? Array.from({ length: 4 }).map((_, i) => <MetricSkeleton key={i} />)
|
||||||
<MetricSkeleton key={index} />
|
: cards.map((card) => <MetricCardLink key={card.title} card={card} />)}
|
||||||
))
|
|
||||||
: cards.map((card) => (
|
|
||||||
<MetricCardLink key={card.title} card={card} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLoading && !error && repositories.length === 0 ? (
|
{!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">
|
<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
|
No Git Project repositories are configured yet. Metrics will populate after repositories are added and synced.
|
||||||
after repositories are added and synced.
|
|
||||||
</div>
|
</div>
|
||||||
) : !isLoading && !error && repositoriesSynced === 0 ? (
|
) : !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">
|
<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
|
Git Project repositories are configured, but Pipeline has not synced issue metrics yet.
|
||||||
issue metrics yet.
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue