feat(forgejo-metrics): enhance heatmap metrics to include line contributions and adjust time frame to last 6 months
This commit is contained in:
parent
99965330b5
commit
bbfde53fe9
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
|
@ -16,9 +17,11 @@ from app.api.deps import ORG_MEMBER_DEP, OrganizationContext
|
|||
from app.core.time import utcnow
|
||||
from app.db.session import get_session
|
||||
from app.models.board_repository_links import BoardRepositoryLink
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
|
@ -261,30 +264,31 @@ async def get_forgejo_metrics(
|
|||
"/heatmap",
|
||||
response_model=HeatmapResponse,
|
||||
summary="Forgejo issue activity heatmap",
|
||||
description="Daily issue open+close event counts for the last 365 days, scoped to the caller's organisation.",
|
||||
description="Daily issue open+close event counts for the last 6 months, scoped to the caller's organisation.",
|
||||
)
|
||||
async def get_forgejo_heatmap(
|
||||
organization_id: UUID | None = Query(None, description="Filter by organisation ID"),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> HeatmapResponse:
|
||||
"""Return per-day issue event counts (created + closed) for the last 365 days."""
|
||||
"""Return per-day issue event counts and total line contributions for the last 6 months."""
|
||||
if organization_id and organization_id != ctx.organization.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
since = utcnow() - timedelta(days=365)
|
||||
since = utcnow() - timedelta(days=183)
|
||||
|
||||
repo_ids_result = (
|
||||
# Fetch repos with their connections in one query
|
||||
repos_with_conns = (
|
||||
await session.exec(
|
||||
select(ForgejoRepository.id).where(
|
||||
ForgejoRepository.organization_id == ctx.organization.id,
|
||||
)
|
||||
select(ForgejoRepository, ForgejoConnection)
|
||||
.join(ForgejoConnection, ForgejoRepository.connection_id == ForgejoConnection.id)
|
||||
.where(ForgejoRepository.organization_id == ctx.organization.id)
|
||||
)
|
||||
).all()
|
||||
if not repo_ids_result:
|
||||
if not repos_with_conns:
|
||||
return HeatmapResponse(days=[], max_count=0)
|
||||
|
||||
repo_ids = list(repo_ids_result)
|
||||
repo_ids = [repo.id for repo, _ in repos_with_conns]
|
||||
counts: dict[str, int] = {}
|
||||
|
||||
# Issues created per day
|
||||
|
|
@ -325,9 +329,40 @@ async def get_forgejo_heatmap(
|
|||
key = str(day)
|
||||
counts[key] = counts.get(key, 0) + int(cnt)
|
||||
|
||||
days = [HeatmapDay(date=k, count=v) for k, v in sorted(counts.items())]
|
||||
max_count = max((d.count for d in days), default=0)
|
||||
return HeatmapResponse(days=days, max_count=max_count)
|
||||
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
|
||||
since_ts = since.timestamp()
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
return HeatmapResponse(
|
||||
days=days_list,
|
||||
max_count=max_count,
|
||||
total_additions=total_additions,
|
||||
total_deletions=total_deletions,
|
||||
has_line_stats=has_line_stats,
|
||||
)
|
||||
|
||||
|
||||
def _zeroed_metrics() -> MetricsResponse:
|
||||
|
|
|
|||
|
|
@ -129,10 +129,13 @@ class HeatmapDay(SQLModel):
|
|||
|
||||
|
||||
class HeatmapResponse(SQLModel):
|
||||
"""Issue activity heatmap for the last 365 days."""
|
||||
"""Issue activity heatmap for the last 6 months."""
|
||||
|
||||
days: list[HeatmapDay]
|
||||
max_count: int
|
||||
total_additions: int = 0
|
||||
total_deletions: int = 0
|
||||
has_line_stats: bool = False # False when Forgejo is still computing stats (HTTP 202)
|
||||
|
||||
|
||||
class MetricsResponse(SQLModel):
|
||||
|
|
|
|||
|
|
@ -350,6 +350,36 @@ class ForgejoAPIClient:
|
|||
return list(data)
|
||||
|
||||
|
||||
async def get_contributor_stats(self, owner: str, repo: str) -> tuple[list[dict], bool]:
|
||||
"""Fetch per-contributor weekly stats for a repository.
|
||||
|
||||
Returns (contributors, has_data). On the first call Forgejo may return
|
||||
HTTP 202 ("computing") — we wait 2 s and retry once so the stats are
|
||||
available on the next dashboard load even if not this one.
|
||||
|
||||
Each contributor has a ``weeks`` array with ``w`` (Unix timestamp of
|
||||
week start), ``a`` (additions), and ``d`` (deletions).
|
||||
"""
|
||||
import asyncio as _asyncio
|
||||
|
||||
client = await self._get_client()
|
||||
url = f"/api/v1/repos/{owner}/{repo}/stats/contributors"
|
||||
response = await client.get(url)
|
||||
|
||||
if response.status_code == 202:
|
||||
# Forgejo is computing — wait briefly then try once more
|
||||
await _asyncio.sleep(2)
|
||||
response = await client.get(url)
|
||||
|
||||
if response.status_code == 202:
|
||||
return [], False # still computing after retry
|
||||
if response.status_code == 404:
|
||||
return [], True # no data, but not a 202 — treat as "has_data"
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return (data if isinstance(data, list) else []), True
|
||||
|
||||
|
||||
def get_forgejo_client(
|
||||
connection: object,
|
||||
) -> ForgejoAPIClient:
|
||||
|
|
|
|||
|
|
@ -522,7 +522,7 @@ export default function DashboardPage() {
|
|||
},
|
||||
});
|
||||
|
||||
const forgejoHeatmapQuery = useQuery<{ days: ForgejoHeatmapDay[]; max_count: number } | null, Error>({
|
||||
const forgejoHeatmapQuery = useQuery<{ days: ForgejoHeatmapDay[]; max_count: number; total_additions: number; total_deletions: number; has_line_stats: boolean } | null, Error>({
|
||||
queryKey: ["dashboard", "forgejo", "heatmap", forgejoOrganizationId],
|
||||
enabled: Boolean(
|
||||
isSignedIn &&
|
||||
|
|
@ -1240,6 +1240,9 @@ export default function DashboardPage() {
|
|||
<ForgejoHeatmap
|
||||
days={forgejoHeatmapQuery.data?.days ?? []}
|
||||
maxCount={forgejoHeatmapQuery.data?.max_count ?? 0}
|
||||
totalAdditions={forgejoHeatmapQuery.data?.total_additions ?? 0}
|
||||
totalDeletions={forgejoHeatmapQuery.data?.total_deletions ?? 0}
|
||||
hasLineStats={forgejoHeatmapQuery.data?.has_line_stats ?? false}
|
||||
isLoading={forgejoHeatmapQuery.isLoading}
|
||||
/>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -592,11 +592,23 @@ function UsageWindowBar({ label, pct, resetInMs, badge }: UsageWindowBarProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function fmtElapsed(since: Date, now: number): string {
|
||||
const s = Math.round((now - since.getTime()) / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
return `${Math.floor(s / 60)}m ago`;
|
||||
}
|
||||
|
||||
function UsageStrip({ credentialId, provider }: { credentialId: string; provider: string }) {
|
||||
const [usage, setUsage] = useState<ProviderUsageLiveRead | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastFetched, setLastFetched] = useState<Date | null>(null);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const fetchUsage = useCallback(
|
||||
async (refresh = false) => {
|
||||
|
|
@ -777,7 +789,7 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
|
|||
)}
|
||||
<div className="flex items-center justify-between text-[11px] text-muted">
|
||||
{lastFetched && (
|
||||
<span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span>
|
||||
<span>Updated {fmtElapsed(lastFetched, now)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -828,7 +840,7 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
|
|||
|
||||
<div className="flex items-center justify-between text-[11px] text-muted">
|
||||
{lastFetched && (
|
||||
<span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span>
|
||||
<span>Updated {fmtElapsed(lastFetched, now)}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -6,26 +6,29 @@ import type { ForgejoHeatmapDay } from "@/lib/api-forgejo";
|
|||
interface ForgejoHeatmapProps {
|
||||
days: ForgejoHeatmapDay[];
|
||||
maxCount: number;
|
||||
totalAdditions?: number;
|
||||
totalDeletions?: number;
|
||||
hasLineStats?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
// Layout constants — match Forgejo's contribution graph
|
||||
const CELL = 10;
|
||||
// Layout constants — 6-month view with larger cells
|
||||
const CELL = 13;
|
||||
const GAP = 3;
|
||||
const STRIDE = CELL + GAP; // 13px per cell
|
||||
const WEEKS = 53;
|
||||
const STRIDE = CELL + GAP; // 16px per cell
|
||||
const WEEKS = 27;
|
||||
const LEFT = 28; // width reserved for Mon/Wed/Fri labels
|
||||
const TOP = 18; // height reserved for month labels
|
||||
|
||||
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
|
||||
// Forgejo dark-mode green palette (levels 0–4)
|
||||
// Violet/purple palette — fixed rgba so they render in SVG regardless of CSS var support
|
||||
const LEVEL_FILL = [
|
||||
"var(--surface-strong)", // 0 — no activity
|
||||
"rgba(52,211,153,0.22)", // 1
|
||||
"rgba(52,211,153,0.46)", // 2
|
||||
"rgba(52,211,153,0.70)", // 3
|
||||
"var(--success)", // 4
|
||||
"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
|
||||
];
|
||||
|
||||
function toLevel(count: number): number {
|
||||
|
|
@ -43,21 +46,28 @@ function isoDate(d: Date): string {
|
|||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
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`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function ForgejoHeatmap({
|
||||
days,
|
||||
maxCount: _maxCount,
|
||||
totalAdditions = 0,
|
||||
totalDeletions = 0,
|
||||
hasLineStats = false,
|
||||
isLoading = false,
|
||||
}: ForgejoHeatmapProps) {
|
||||
const { weeks, monthLabels } = useMemo(() => {
|
||||
const data = new Map<string, number>(days.map((d) => [d.date, d.count]));
|
||||
|
||||
// Start on the Sunday 52 weeks before the current week's Sunday,
|
||||
// so the last column always contains today and future cells are clipped.
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const start = new Date(today);
|
||||
start.setDate(start.getDate() - start.getDay()); // rewind to this week's Sunday
|
||||
start.setDate(start.getDate() - (WEEKS - 1) * 7); // go back 52 more weeks
|
||||
start.setDate(start.getDate() - start.getDay());
|
||||
start.setDate(start.getDate() - (WEEKS - 1) * 7);
|
||||
|
||||
type Cell = { date: string; count: number; future: boolean };
|
||||
const builtWeeks: Cell[][] = [];
|
||||
|
|
@ -88,15 +98,21 @@ export function ForgejoHeatmap({
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="animate-pulse rounded"
|
||||
style={{ width: svgW, height: svgH, background: "var(--surface-strong)" }}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const totalEvents = days.reduce((sum, d) => sum + d.count, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
{/* Heatmap grid */}
|
||||
<div style={{ overflowX: "auto" }} className="flex justify-center">
|
||||
<svg
|
||||
width={svgW}
|
||||
|
|
@ -111,30 +127,26 @@ export function ForgejoHeatmap({
|
|||
x={LEFT + weekIdx * STRIDE}
|
||||
y={11}
|
||||
fontSize={10}
|
||||
fill="currentColor"
|
||||
className="text-muted"
|
||||
style={{ fill: "var(--text-muted, #9ca3af)" }}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Day-of-week labels: Mon=row1, Wed=row3, Fri=row5
|
||||
y = cell top + CELL - 2 puts the baseline near the bottom of the
|
||||
10px cell, which reads as vertically centred for a 10px font. */}
|
||||
{/* Day-of-week labels */}
|
||||
{(["Mon", "Wed", "Fri"] as const).map((label, i) => (
|
||||
<text
|
||||
key={label}
|
||||
x={0}
|
||||
y={TOP + (i * 2 + 1) * STRIDE + CELL - 2}
|
||||
fontSize={10}
|
||||
fill="currentColor"
|
||||
className="text-muted"
|
||||
style={{ fill: "var(--text-muted, #9ca3af)" }}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Cells */}
|
||||
{/* Cells — use style.fill so rgba + CSS vars both resolve correctly */}
|
||||
{weeks.map((week, wi) =>
|
||||
week.map((cell, di) =>
|
||||
cell.future ? null : (
|
||||
|
|
@ -144,8 +156,8 @@ export function ForgejoHeatmap({
|
|||
y={TOP + di * STRIDE}
|
||||
width={CELL}
|
||||
height={CELL}
|
||||
rx={2}
|
||||
fill={LEVEL_FILL[toLevel(cell.count)]}
|
||||
rx={3}
|
||||
style={{ fill: LEVEL_FILL[toLevel(cell.count)] }}
|
||||
>
|
||||
<title>
|
||||
{cell.date}
|
||||
|
|
@ -161,19 +173,58 @@ export function ForgejoHeatmap({
|
|||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-2 flex items-center justify-center gap-1.5 text-xs text-muted">
|
||||
<div className="flex items-center justify-center gap-1.5 text-xs text-muted">
|
||||
<span>Less</span>
|
||||
{LEVEL_FILL.map((fill, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ width: CELL, height: CELL, borderRadius: 2, background: fill, flexShrink: 0 }}
|
||||
style={{ width: CELL, height: CELL, borderRadius: 3, background: fill, flexShrink: 0 }}
|
||||
/>
|
||||
))}
|
||||
<span>More</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-center text-[10px] text-muted opacity-70">
|
||||
{days.reduce((sum, d) => sum + d.count, 0).toLocaleString()} contributions across all tracked repositories in the last 12 months.
|
||||
|
||||
{/* 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)" }}
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* Line contribution 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)" }}
|
||||
>
|
||||
+{fmtLines(totalAdditions)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-muted">lines added</span>
|
||||
</div>
|
||||
<div className="h-10 w-px" style={{ background: "var(--border)" }} />
|
||||
<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)" }}
|
||||
>
|
||||
-{fmtLines(totalDeletions)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-muted">lines removed</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-xs text-muted opacity-60">
|
||||
Line stats syncing with Forgejo…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,6 +242,12 @@ function ProviderInlineStatus({
|
|||
);
|
||||
}
|
||||
|
||||
function fmtElapsed(since: Date, now: number): string {
|
||||
const s = Math.round((now - since.getTime()) / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
return `${Math.floor(s / 60)}m ago`;
|
||||
}
|
||||
|
||||
export function ProviderNavbarStatus() {
|
||||
const { isSignedIn } = useAuth();
|
||||
const [credentials, setCredentials] = useState<ProviderCredentialRead[]>([]);
|
||||
|
|
@ -249,6 +255,13 @@ export function ProviderNavbarStatus() {
|
|||
Record<string, ProviderUsageLiveRead | null>
|
||||
>({});
|
||||
const [isUsageLoading, setIsUsageLoading] = useState(false);
|
||||
const [lastFetchedAt, setLastFetchedAt] = useState<Date | null>(null);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const usageCredentials = useMemo(() => {
|
||||
return credentials
|
||||
|
|
@ -321,6 +334,7 @@ export function ProviderNavbarStatus() {
|
|||
);
|
||||
if (!cancelled) {
|
||||
setUsageByCredentialId(Object.fromEntries(pairs));
|
||||
setLastFetchedAt(new Date());
|
||||
setIsUsageLoading(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -364,6 +378,14 @@ export function ProviderNavbarStatus() {
|
|||
<ProviderInlineStatus item={item} />
|
||||
</div>
|
||||
))}
|
||||
{lastFetchedAt && (
|
||||
<>
|
||||
<span className="h-3.5 w-px shrink-0 bg-[color:var(--border)]" />
|
||||
<span className="tabular-nums text-[color:var(--text-quiet)]">
|
||||
Updated {fmtElapsed(lastFetchedAt, now)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -611,6 +611,9 @@ export interface ForgejoHeatmapDay {
|
|||
export interface ForgejoHeatmapResponse {
|
||||
days: ForgejoHeatmapDay[];
|
||||
max_count: number;
|
||||
total_additions: number;
|
||||
total_deletions: number;
|
||||
has_line_stats: boolean;
|
||||
}
|
||||
|
||||
export async function getForgejoHeatmap(params?: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue