main dashboard

This commit is contained in:
null 2026-05-24 22:38:26 -05:00
parent 19a6b8fda8
commit e6c2989c64
16 changed files with 728 additions and 125 deletions

View File

@ -22,7 +22,7 @@ from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.boards import Board
from app.models.tasks import Task
from app.schemas.activity_events import ActivityEventRead, ActivityTaskCommentFeedItemRead
from app.schemas.activity_events import ActivityEventRead, ActivityTaskCommentFeedItemRead, ActivityTickerItem
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.organizations import (
OrganizationContext,
@ -242,6 +242,63 @@ async def _fetch_task_comment_events(
return _coerce_task_comment_rows(list(await session.exec(statement)))
def _ticker_source(event: ActivityEvent, agent: Agent | None) -> str:
if agent is not None:
return agent.name
parts = event.event_type.replace(".", " ").replace("_", " ").split()
return " ".join(p.capitalize() for p in parts)
@router.get("/ticker", response_model=list[ActivityTickerItem])
async def get_activity_ticker(
limit: int = Query(default=20, ge=1, le=50),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> list[ActivityTickerItem]:
"""Return recent activity items shaped for the navbar ticker."""
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
statement = (
select(ActivityEvent, Agent)
.outerjoin(Agent, col(ActivityEvent.agent_id) == col(Agent.id))
.outerjoin(Task, col(ActivityEvent.task_id) == col(Task.id))
.where(func.length(func.trim(col(ActivityEvent.message))) > 0)
.order_by(desc(col(ActivityEvent.created_at)))
.limit(limit)
)
if board_ids:
statement = statement.where(
or_(
col(ActivityEvent.board_id).in_(board_ids),
and_(
col(ActivityEvent.board_id).is_(None),
col(Task.board_id).in_(board_ids),
),
)
)
else:
statement = statement.where(col(ActivityEvent.id).is_(None))
rows = (await session.exec(statement)).all()
items: list[ActivityTickerItem] = []
for row in rows:
event: ActivityEvent = row[0]
agent: Agent | None = row[1]
msg = (event.message or "").strip()
if not msg:
continue
items.append(
ActivityTickerItem(
id=event.id,
source=_ticker_source(event, agent),
message=msg[:200],
created_at=event.created_at,
)
)
return items
@router.get("", response_model=DefaultLimitOffsetPage[ActivityEventRead])
async def list_activity(
session: AsyncSession = SESSION_DEP,

View File

@ -24,6 +24,15 @@ class ActivityEventRead(SQLModel):
created_at: datetime
class ActivityTickerItem(SQLModel):
"""Single item returned by the activity ticker endpoint."""
id: UUID
source: str
message: str
created_at: datetime
class ActivityTaskCommentFeedItemRead(SQLModel):
"""Denormalized task-comment feed item enriched with task and board fields."""

View File

@ -24,7 +24,6 @@ import { ForgejoHeatmap } from "@/components/git/ForgejoHeatmap";
import {
DashboardMetricCard,
DashboardInfoBlock,
DashboardEmptyState,
PendingApprovalsSection,
SessionsSection,
RecentActivitySection,
@ -435,7 +434,6 @@ const toSessionSummaries = (
});
};
export default function DashboardPage() {
const router = useRouter();
const { isSignedIn } = useAuth();
@ -520,9 +518,9 @@ export default function DashboardPage() {
() =>
selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
? null
: forgejoRepositories.find(
: (forgejoRepositories.find(
(repository) => repository.id === selectedForgejoRepositoryId,
) ?? null,
) ?? null),
[forgejoRepositories, selectedForgejoRepositoryId],
);
const scopedForgejoRepositories = useMemo(
@ -532,7 +530,11 @@ export default function DashboardPage() {
: selectedForgejoRepository
? [selectedForgejoRepository]
: [],
[forgejoRepositories, selectedForgejoRepository, selectedForgejoRepositoryId],
[
forgejoRepositories,
selectedForgejoRepository,
selectedForgejoRepositoryId,
],
);
const forgejoOrganizationId = useMemo(
() =>
@ -565,24 +567,33 @@ export default function DashboardPage() {
isSignedIn &&
!forgejoRepositoriesQuery.isLoading &&
!forgejoRepositoriesQuery.error &&
(
selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
? forgejoOrganizationId
: selectedForgejoRepository
),
(selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
? forgejoOrganizationId
: selectedForgejoRepository),
),
refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
refetchOnMount: "always",
queryFn: () => {
if (selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES) {
return getForgejoMetrics({ repository_id: selectedForgejoRepositoryId });
return getForgejoMetrics({
repository_id: selectedForgejoRepositoryId,
});
}
if (!forgejoOrganizationId) return Promise.resolve(null);
return getForgejoMetrics({ organization_id: forgejoOrganizationId });
},
});
const forgejoHeatmapQuery = useQuery<{ days: ForgejoHeatmapDay[]; max_count: number; total_additions: number; total_deletions: number; has_line_stats: boolean } | 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 &&
@ -591,7 +602,9 @@ export default function DashboardPage() {
!forgejoRepositoriesQuery.error,
),
refetchInterval: (query) =>
query.state.data?.has_line_stats === false ? 3_000 : FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
query.state.data?.has_line_stats === false
? 3_000
: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
refetchOnMount: "always",
queryFn: () => {
if (!forgejoOrganizationId) return Promise.resolve(null);
@ -863,7 +876,8 @@ export default function DashboardPage() {
refetchInterval: 60_000,
refetchOnMount: "always",
queryFn: async () => {
const credentialsRes = await listProviderCredentialsApiV1ProviderCredentialsGet();
const credentialsRes =
await listProviderCredentialsApiV1ProviderCredentialsGet();
if (credentialsRes.status !== 200) return [];
const credentials = credentialsRes.data ?? [];
@ -881,9 +895,8 @@ export default function DashboardPage() {
providerId,
),
)
.filter(
(cred): cred is (typeof credentials)[number] =>
Boolean(cred?.active && cred.has_session_key),
.filter((cred): cred is (typeof credentials)[number] =>
Boolean(cred?.active && cred.has_session_key),
);
const settled = await Promise.allSettled(
selectedCredentials.map((cred) =>
@ -899,7 +912,8 @@ export default function DashboardPage() {
const { cred, res } = item.value;
if (res.status !== 200) continue;
const usage = res.data;
const accountLabel = cred.display_name || cred.account_key || cred.provider;
const accountLabel =
cred.display_name || cred.account_key || cred.provider;
const subscriptionWindows = usage.subscription_windows ?? [];
for (const window of subscriptionWindows) {
windows.push({
@ -925,7 +939,9 @@ export default function DashboardPage() {
confidence: "low",
provider: usage.provider,
gatewayLabel: accountLabel,
note: usage.error ?? "No subscription usage returned for this session key.",
note:
usage.error ??
"No subscription usage returned for this session key.",
});
}
}
@ -935,7 +951,9 @@ export default function DashboardPage() {
const statuslineUsageWindows = providerUsageQuery.data ?? [];
const statuslineProviders = new Set(
statuslineUsageWindows
.filter((window) => window.pctUsed !== null || window.remainingMs !== null)
.filter(
(window) => window.pctUsed !== null || window.remainingMs !== null,
)
.map((window) => window.provider),
);
const providerUsageWindows = [
@ -943,8 +961,8 @@ export default function DashboardPage() {
...(credentialUsageQuery.data ?? []).filter(
(window) =>
!(
window.key.includes("subscription_unavailable")
&& statuslineProviders.has(window.provider)
window.key.includes("subscription_unavailable") &&
statuslineProviders.has(window.provider)
),
),
];
@ -958,8 +976,10 @@ export default function DashboardPage() {
refetchOnMount: "always",
queryFn: () => {
if (!primaryGatewayId) return Promise.resolve(null);
return getGatewayHealthApiV1GatewaysGatewayIdHealthGet(primaryGatewayId).then(
(r) => (r.status === 200 ? (r.data as SystemHealthResponse) : null),
return getGatewayHealthApiV1GatewaysGatewayIdHealthGet(
primaryGatewayId,
).then((r) =>
r.status === 200 ? (r.data as SystemHealthResponse) : null,
);
},
});
@ -979,7 +999,10 @@ export default function DashboardPage() {
// Build a session-id → TopSession lookup for enriching session summaries
const topSessionById = useMemo(() => {
const map = new Map<string, { costUsd: number; totalTokens: number; model: string | null }>();
const map = new Map<
string,
{ costUsd: number; totalTokens: number; model: string | null }
>();
for (const s of runtimeUsage?.topSessions ?? []) {
if (s.session_id) {
map.set(s.session_id, {
@ -999,7 +1022,9 @@ export default function DashboardPage() {
const sourceLabel = snapshot.gatewayUrl || snapshot.boardName;
return toSessionSummaries(snapshot.sessions, snapshot.mainSession).map(
(session) => {
const enrichment = topSessionById.get(session.key) ?? topSessionById.get(`${snapshot.gatewayId}:${session.key}`);
const enrichment =
topSessionById.get(session.key) ??
topSessionById.get(`${snapshot.gatewayId}:${session.key}`);
return {
...session,
key: `${snapshot.gatewayId}:${session.key}`,
@ -1209,7 +1234,6 @@ export default function DashboardPage() {
];
const pendingApprovalItems = metrics?.pending_approvals.items ?? [];
const pendingApprovalsTotal = metrics?.pending_approvals.total ?? 0;
const hasPendingApprovals = pendingApprovalItems.length > 0;
const activityFeedHref = "/activity";
const forgejoIssueMetrics = forgejoMetricsQuery.data ?? null;
const forgejoIssueMetricsError =
@ -1297,8 +1321,12 @@ export default function DashboardPage() {
</SignedOut>
<SignedIn>
<DashboardSidebar />
<main className="flex-1 overflow-y-auto bg-app">
<div className="p-4 md:p-8">
<main className="relative flex-1 overflow-y-auto bg-app">
<div
className="pointer-events-none absolute inset-x-0 top-0 h-72 bg-[linear-gradient(135deg,rgba(96,165,250,0.16),rgba(52,211,153,0.1)_38%,rgba(251,191,36,0.08)_64%,rgba(96,165,250,0)_100%)] blur-2xl"
aria-hidden="true"
/>
<div className="relative p-4 md:p-8">
{metricsQuery.error ? (
<div className="mb-4 rounded-lg border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-sm text-[color:var(--danger)]">
Load failed: {metricsQuery.error.message}
@ -1384,16 +1412,28 @@ export default function DashboardPage() {
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
<DashboardInfoBlock title="Workload" rows={workloadRows} />
<DashboardInfoBlock
title="Workload"
rows={workloadRows}
tone="accent"
/>
<DashboardInfoBlock
title="Throughput"
infoText={`All throughput values are calculated for ${DASHBOARD_RANGE_LABEL}`}
rows={throughputRows}
tone="success"
/>
<DashboardInfoBlock
title="Gateway Health"
badge={{ text: gatewayStatusLabel, tone: gatewayBadgeTone }}
rows={gatewayRows}
tone={
gatewayBadgeTone === "online"
? "success"
: gatewayBadgeTone === "offline"
? "danger"
: "warning"
}
/>
</div>
@ -1413,9 +1453,9 @@ export default function DashboardPage() {
providerUsageWindows={providerUsageWindows}
perGatewayUsage={perGatewayUsage}
isLoading={
runtimeUsageQuery.isLoading
|| providerUsageQuery.isLoading
|| credentialUsageQuery.isLoading
runtimeUsageQuery.isLoading ||
providerUsageQuery.isLoading ||
credentialUsageQuery.isLoading
}
hasGateways={hasConfiguredGateways}
/>

View File

@ -1,6 +1,12 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import Link from "next/link";
import {
AlertCircle,
@ -52,6 +58,39 @@ type RepositoryFilter =
| "webhooks"
| "archived";
type StatTone = "blue" | "green" | "amber" | "rose" | "violet";
const statToneClasses: Record<
StatTone,
{ card: string; icon: string; glow: string }
> = {
blue: {
card: "border-[color:rgba(96,165,250,0.34)] bg-[linear-gradient(145deg,rgba(96,165,250,0.16),rgba(15,23,36,0.94)_42%,var(--surface))]",
icon: "border-[color:rgba(96,165,250,0.3)] bg-[color:rgba(96,165,250,0.14)] text-[color:var(--accent-strong)]",
glow: "bg-[color:rgba(96,165,250,0.22)]",
},
green: {
card: "border-[color:rgba(52,211,153,0.34)] bg-[linear-gradient(145deg,rgba(52,211,153,0.15),rgba(15,23,36,0.94)_42%,var(--surface))]",
icon: "border-[color:rgba(52,211,153,0.3)] bg-[color:rgba(52,211,153,0.14)] text-[color:var(--success)]",
glow: "bg-[color:rgba(52,211,153,0.22)]",
},
amber: {
card: "border-[color:rgba(251,191,36,0.36)] bg-[linear-gradient(145deg,rgba(251,191,36,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]",
icon: "border-[color:rgba(251,191,36,0.32)] bg-[color:rgba(251,191,36,0.13)] text-[color:var(--warning)]",
glow: "bg-[color:rgba(251,191,36,0.2)]",
},
rose: {
card: "border-[color:rgba(248,113,113,0.34)] bg-[linear-gradient(145deg,rgba(248,113,113,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]",
icon: "border-[color:rgba(248,113,113,0.3)] bg-[color:rgba(248,113,113,0.13)] text-[color:var(--danger)]",
glow: "bg-[color:rgba(248,113,113,0.2)]",
},
violet: {
card: "border-[color:rgba(168,85,247,0.32)] bg-[linear-gradient(145deg,rgba(168,85,247,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]",
icon: "border-[color:rgba(168,85,247,0.28)] bg-[color:rgba(168,85,247,0.13)] text-[color:rgb(196,181,253)]",
glow: "bg-[color:rgba(168,85,247,0.2)]",
},
};
const repositoryName = (repository: ForgejoRepository) =>
repository.display_name || `${repository.owner}/${repository.repo}`;
@ -92,14 +131,22 @@ function StatCard({
label,
value,
caption,
tone = "blue",
}: {
icon: React.ReactNode;
icon: ReactNode;
label: string;
value: string;
caption: string;
tone?: StatTone;
}) {
const colors = statToneClasses[tone];
return (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush">
<div
className={`relative overflow-hidden rounded-xl border p-4 shadow-lush ${colors.card}`}
>
<span
className={`pointer-events-none absolute left-4 right-4 top-0 h-px ${colors.glow}`}
/>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-muted">
@ -107,9 +154,7 @@ function StatCard({
</p>
<p className="mt-2 text-2xl font-semibold text-strong">{value}</p>
</div>
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)]">
{icon}
</div>
<div className={`rounded-lg border p-2 ${colors.icon}`}>{icon}</div>
</div>
<p className="mt-3 text-sm text-muted">{caption}</p>
</div>
@ -158,24 +203,28 @@ function RepositoryDetailsDialog({
label="Open Issues"
value={String(repository.open_issues_count)}
caption="Reported upstream."
tone="amber"
/>
<StatCard
icon={<GitBranch className="h-4 w-4" />}
label="Branch"
value={repository.default_branch || "Unknown"}
caption="Default branch."
tone="blue"
/>
<StatCard
icon={<Webhook className="h-4 w-4" />}
label="Webhook"
value={repository.has_webhook_secret ? "Ready" : "Missing"}
caption="Stored secret status."
tone={repository.has_webhook_secret ? "green" : "rose"}
/>
<StatCard
icon={<Clock className="h-4 w-4" />}
label="Synced"
value={formatTimestamp(repository.last_sync_at)}
caption="Last sync timestamp."
tone={repository.last_sync_error ? "rose" : "violet"}
/>
</div>
@ -602,12 +651,13 @@ export default function ForgejoRepositoriesPage() {
<div className="space-y-6">
{notice ? <NoticeBanner notice={notice} /> : null}
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<section className="relative overflow-hidden rounded-xl border border-[color:rgba(96,165,250,0.28)] bg-[linear-gradient(135deg,rgba(96,165,250,0.16),rgba(52,211,153,0.09)_34%,rgba(251,191,36,0.08)_68%,var(--surface)_100%)] p-4 shadow-lush md:p-5">
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,rgba(96,165,250,0),rgba(96,165,250,0.85),rgba(52,211,153,0.85),rgba(251,191,36,0))]" />
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<Badge
variant={attentionRepositories.length ? "warning" : "success"}
className="w-fit"
className="w-fit shadow-[0_0_24px_rgba(96,165,250,0.16)]"
>
{attentionRepositories.length
? `${attentionRepositories.length} need review`
@ -653,29 +703,33 @@ export default function ForgejoRepositoriesPage() {
label="Repositories"
value={`${activeRepositories}/${repositories.length}`}
caption="Active tracked repositories."
tone="blue"
/>
<StatCard
icon={<CircleDot className="h-4 w-4" />}
label="Open Issues"
value={formatCompactNumber(totalOpenIssues)}
caption="Reported by Forgejo."
tone="amber"
/>
<StatCard
icon={<Webhook className="h-4 w-4" />}
label="Webhooks"
value={`${webhookReady}/${activeRepositories}`}
caption="Active repositories with secrets."
tone={webhookReady === activeRepositories ? "green" : "rose"}
/>
<StatCard
icon={<Clock className="h-4 w-4" />}
label="Latest Sync"
value={formatTimestamp(latestSync?.toISOString() ?? null)}
caption={`${syncErrors} errors, ${archivedRepositories} archived, ${linkedBoardCount} board links.`}
tone={syncErrors ? "rose" : "violet"}
/>
</div>
</section>
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<section className="rounded-xl border border-[color:var(--border)] bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0)_72%),var(--surface)] p-4 shadow-lush md:p-5">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted" />
@ -691,20 +745,32 @@ export default function ForgejoRepositoriesPage() {
<Button
key={option.key}
type="button"
variant={filter === option.key ? "secondary" : "outline"}
variant={filter === option.key ? "primary" : "outline"}
size="sm"
onClick={() => setFilter(option.key)}
className="gap-1.5"
className={`gap-1.5 ${
filter === option.key
? "shadow-[0_0_24px_rgba(96,165,250,0.2)]"
: ""
}`}
>
{option.label}
<span className="text-xs text-muted">{option.count}</span>
<span
className={`rounded-full px-1.5 text-xs ${
filter === option.key
? "bg-[color:rgba(7,11,18,0.18)] text-inherit"
: "bg-[color:var(--surface-muted)] text-muted"
}`}
>
{option.count}
</span>
</Button>
))}
</div>
</div>
</section>
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
<div className="overflow-hidden rounded-xl border border-[color:rgba(96,165,250,0.18)] bg-[linear-gradient(180deg,rgba(96,165,250,0.07),rgba(15,23,36,0)_160px),var(--surface)] shadow-lush">
{error ? (
<div className="p-8 text-center">
<p className="text-sm text-[color:var(--danger)]">{error}</p>

View File

@ -210,6 +210,11 @@ textarea::placeholder {
}
}
@keyframes ticker-scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
@keyframes progress-shimmer {
0% {
transform: translateX(-100%);
@ -261,6 +266,12 @@ textarea::placeholder {
.animate-progress-shimmer {
animation: progress-shimmer 1.8s linear infinite;
}
.animate-ticker {
animation: ticker-scroll 40s linear infinite;
}
.animate-ticker:hover {
animation-play-state: paused;
}
.shadow-lush {
box-shadow: var(--shadow-panel);
}

View File

@ -1,6 +1,6 @@
import { toneText, type Tone } from "./tokens";
import { DashboardSection } from "./DashboardSection";
import type { BadgeTone } from "./tokens";
import type { BadgeTone, SectionToneKey } from "./tokens";
export type InfoRow = {
label: string;
@ -13,23 +13,50 @@ interface DashboardInfoBlockProps {
infoText?: string;
badge?: { text: string; tone: BadgeTone };
rows: InfoRow[];
tone?: SectionToneKey;
}
/**
* Labeled key/value block used for Workload, Throughput, Gateway Health.
* Tone color is a lookup, not a ternary chain.
*/
export function DashboardInfoBlock({ title, infoText, badge, rows }: DashboardInfoBlockProps) {
export function DashboardInfoBlock({
title,
infoText,
badge,
rows,
tone = "neutral",
}: DashboardInfoBlockProps) {
return (
<DashboardSection title={title} infoText={infoText} badge={badge}>
<div className="divide-y divide-[color:var(--border)] rounded-lg surface-muted overflow-hidden">
<DashboardSection
title={title}
infoText={infoText}
badge={badge}
tone={tone}
>
<div className="overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)]">
{rows.map((row) => (
<div
key={`${row.label}-${row.value}`}
className="flex items-start justify-between gap-3 px-3 py-2"
className="flex items-start justify-between gap-3 border-b border-[color:var(--border)] px-3 py-2 last:border-b-0"
>
<span className="min-w-0 text-sm text-muted">{row.label}</span>
<span className={`max-w-[65%] break-words text-right text-sm font-medium leading-5 ${toneText[row.tone ?? "default"]}`}>
<span className="flex min-w-0 items-center gap-2 text-sm text-muted">
<span
className={`h-1.5 w-1.5 rounded-full ${
row.tone === "success"
? "bg-[color:var(--success)]"
: row.tone === "warning"
? "bg-[color:var(--warning)]"
: row.tone === "danger"
? "bg-[color:var(--danger)]"
: "bg-[color:var(--border-strong)]"
}`}
/>
{row.label}
</span>
<span
className={`max-w-[65%] break-words text-right text-sm font-medium leading-5 ${toneText[row.tone ?? "default"]}`}
>
{row.value}
</span>
</div>

View File

@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { Info } from "lucide-react";
import { toneIcon, toneCard, type MetricToneKey } from "./tokens";
import { toneCard, toneGlow, toneIcon, type MetricToneKey } from "./tokens";
interface DashboardMetricCardProps {
title: string;
@ -24,7 +24,12 @@ export function DashboardMetricCard({
tone,
}: DashboardMetricCardProps) {
return (
<section className={`rounded-xl p-4 md:p-6 transition hover:-translate-y-0.5 hover:shadow-md ${toneCard[tone]}`}>
<section
className={`relative overflow-hidden rounded-xl p-4 shadow-lush transition hover:-translate-y-0.5 hover:shadow-md md:p-6 ${toneCard[tone]}`}
>
<span
className={`pointer-events-none absolute inset-x-4 top-0 h-px ${toneGlow[tone]}`}
/>
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-1.5">
@ -32,13 +37,19 @@ export function DashboardMetricCard({
{title}
</p>
{infoText && (
<span className="inline-flex text-muted" title={infoText} aria-label={infoText}>
<span
className="inline-flex text-muted"
title={infoText}
aria-label={infoText}
>
<Info className="h-3.5 w-3.5" />
</span>
)}
</div>
<div className="mt-2 flex items-end gap-2">
<p className="font-heading text-4xl font-bold text-strong">{value}</p>
<p className="font-heading text-4xl font-bold text-strong">
{value}
</p>
{secondary && (
<p className="pb-1 text-xs text-muted">{secondary}</p>
)}

View File

@ -2,7 +2,13 @@ import type { ReactNode } from "react";
import Link from "next/link";
import { ArrowUpRight, Info } from "lucide-react";
import { cn } from "@/lib/utils";
import { toneBadge, type BadgeTone } from "./tokens";
import {
sectionRail,
sectionTone,
toneBadge,
type BadgeTone,
type SectionToneKey,
} from "./tokens";
interface DashboardSectionProps {
title: string;
@ -11,6 +17,7 @@ interface DashboardSectionProps {
action?: { label: string; href: string };
children: ReactNode;
className?: string;
tone?: SectionToneKey;
}
/**
@ -25,21 +32,43 @@ export function DashboardSection({
action,
children,
className,
tone = "neutral",
}: DashboardSectionProps) {
return (
<section className={cn("surface-card rounded-xl p-4 md:p-6", className)}>
<section
className={cn(
"relative overflow-hidden rounded-xl border p-4 shadow-lush md:p-6",
sectionTone[tone],
className,
)}
>
<span
className={cn(
"pointer-events-none absolute inset-x-4 top-0 h-px",
sectionRail[tone],
)}
/>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
<h3 className="text-lg font-semibold text-strong">{title}</h3>
{infoText && (
<span className="inline-flex text-muted" title={infoText} aria-label={infoText}>
<span
className="inline-flex text-muted"
title={infoText}
aria-label={infoText}
>
<Info className="h-3.5 w-3.5" />
</span>
)}
</div>
<div className="flex items-center gap-2">
{badge && (
<span className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium", toneBadge[badge.tone])}>
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
toneBadge[badge.tone],
)}
>
{badge.text}
</span>
)}

View File

@ -32,6 +32,7 @@ export function PendingApprovalsSection({
<DashboardSection
title="Pending Approvals"
action={{ label: "Open global approvals", href: "/approvals" }}
tone={items.length > 0 ? "warning" : "success"}
>
{isLoading ? (
<DashboardEmptyState message="Loading pending approvals..." />
@ -42,15 +43,16 @@ export function PendingApprovalsSection({
/>
) : items.length > 0 ? (
<div className="space-y-2">
<div className="divide-y divide-[color:var(--border)] rounded-lg surface-muted overflow-hidden">
<div className="overflow-hidden rounded-lg border border-[color:rgba(251,191,36,0.24)] bg-[color:var(--surface-muted)]">
{items.map((item) => (
<Link
key={item.approval_id}
href={`/boards/${item.board_id}/approvals`}
className="flex items-center justify-between gap-3 px-3 py-2 transition hover:bg-[color:var(--surface-strong)]"
className="flex items-center justify-between gap-3 border-b border-[color:var(--border)] px-3 py-2 transition last:border-b-0 hover:bg-[color:rgba(251,191,36,0.08)]"
>
<span className="min-w-0 text-sm">
<span className="block truncate font-medium text-strong">
<span className="mr-2 inline-block h-1.5 w-1.5 rounded-full bg-[color:var(--warning)]" />
{item.task_title || "Pending approval"}
</span>
<span className="block truncate text-xs text-muted">
@ -65,7 +67,8 @@ export function PendingApprovalsSection({
</div>
{total > items.length && (
<p className="text-xs text-muted">
Showing latest {formatCount(items.length)} of {formatCount(total)} pending approvals.
Showing latest {formatCount(items.length)} of {formatCount(total)}{" "}
pending approvals.
</p>
)}
</div>

View File

@ -3,6 +3,7 @@ import { Shield } from "lucide-react";
import { DashboardSection } from "./DashboardSection";
import { DashboardEmptyState } from "./DashboardEmptyState";
import { Markdown } from "@/components/atoms/Markdown";
import { cn } from "@/lib/utils";
import type { ActivityEventRead } from "@/api/generated/model";
export type ActivityEvent = ActivityEventRead;
@ -17,6 +18,36 @@ interface RecentActivitySectionProps {
formatTimestamp: (ts: string) => string;
}
const eventTone = (eventType: string) => {
const normalized = eventType.toLowerCase();
if (normalized.includes("error") || normalized.includes("fail")) {
return {
rail: "border-l-[color:var(--danger)]",
dot: "bg-[color:var(--danger)]",
row: "hover:border-[color:rgba(248,113,113,0.35)] hover:bg-[color:rgba(248,113,113,0.08)]",
};
}
if (normalized.includes("approval") || normalized.includes("review")) {
return {
rail: "border-l-[color:var(--warning)]",
dot: "bg-[color:var(--warning)]",
row: "hover:border-[color:rgba(251,191,36,0.35)] hover:bg-[color:rgba(251,191,36,0.08)]",
};
}
if (normalized.includes("complete") || normalized.includes("sync")) {
return {
rail: "border-l-[color:var(--success)]",
dot: "bg-[color:var(--success)]",
row: "hover:border-[color:rgba(52,211,153,0.35)] hover:bg-[color:rgba(52,211,153,0.08)]",
};
}
return {
rail: "border-l-[color:var(--accent)]",
dot: "bg-[color:var(--accent)]",
row: "hover:border-[color:rgba(96,165,250,0.35)] hover:bg-[color:rgba(96,165,250,0.08)]",
};
};
export function RecentActivitySection({
events,
feedHref,
@ -30,11 +61,13 @@ export function RecentActivitySection({
<DashboardSection
title="Recent Activity"
action={{ label: "Open feed", href: feedHref }}
tone="accent"
>
<div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1">
{events.length > 0 ? (
events.map((event) => {
const href = buildHref(event);
const tone = eventTone(event.event_type);
return (
<div
key={event.id}
@ -43,7 +76,11 @@ export function RecentActivitySection({
aria-label={`Open related context for ${event.event_type} activity`}
onClick={(e) => onRowClick(e, href)}
onKeyDown={(e) => onRowKeyDown(e, href)}
className="cursor-pointer overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
className={cn(
"cursor-pointer overflow-hidden rounded-lg border border-l-4 border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]",
tone.rail,
tone.row,
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 overflow-hidden">
@ -54,6 +91,12 @@ export function RecentActivitySection({
/>
</div>
<p className="mt-0.5 text-xs uppercase tracking-wider text-muted">
<span
className={cn(
"mr-2 inline-block h-1.5 w-1.5 rounded-full",
tone.dot,
)}
/>
{event.event_type}
</p>
</div>

View File

@ -38,7 +38,11 @@ export function SessionsSection({
dash,
}: SessionsSectionProps) {
return (
<DashboardSection title="Sessions" action={{ label: formatCount(activeSessions), href: "#" }}>
<DashboardSection
title="Sessions"
action={{ label: formatCount(activeSessions), href: "#" }}
tone={gatewayUnavailableCount > 0 ? "warning" : "success"}
>
<div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1">
{!hasConfiguredGateways ? (
<DashboardEmptyState message="No gateways are configured for any board yet." />
@ -55,7 +59,11 @@ export function SessionsSection({
{sessions.map((session) => (
<div
key={session.key}
className="overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2"
className={`overflow-hidden rounded-lg border border-l-4 bg-[color:var(--surface-muted)] px-3 py-2 ${
session.isMain
? "border-[color:rgba(52,211,153,0.28)] border-l-[color:var(--success)]"
: "border-[color:var(--border)] border-l-[color:var(--border-strong)]"
}`}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
@ -69,7 +77,9 @@ export function SessionsSection({
/>
{session.title}
</p>
<p className="mt-0.5 truncate text-xs text-muted">{session.subtitle}</p>
<p className="mt-0.5 truncate text-xs text-muted">
{session.subtitle}
</p>
</div>
<div className="min-w-0 max-w-[45%] text-right">
<p className="truncate text-xs font-medium text-strong">
@ -77,11 +87,11 @@ export function SessionsSection({
? session.costUsd === 0
? "$0.00"
: session.costUsd < 0.01
? `$${session.costUsd.toFixed(4)}`
: `$${session.costUsd.toFixed(2)}`
? `$${session.costUsd.toFixed(4)}`
: `$${session.costUsd.toFixed(2)}`
: session.usage === dash
? "Usage unavailable"
: session.usage}
? "Usage unavailable"
: session.usage}
</p>
<p className="truncate text-[11px] text-muted">
{session.model
@ -90,7 +100,9 @@ export function SessionsSection({
: session.model
: null}
{session.model && session.lastSeenAt ? " · " : null}
{session.lastSeenAt ? formatRelative(session.lastSeenAt) : "Activity unavailable"}
{session.lastSeenAt
? formatRelative(session.lastSeenAt)
: "Activity unavailable"}
</p>
</div>
</div>

View File

@ -3,6 +3,7 @@
export type Tone = "default" | "success" | "warning" | "danger";
export type BadgeTone = "online" | "offline" | "neutral";
export type MetricToneKey = "accent" | "success" | "warning" | "danger";
export type SectionToneKey = "neutral" | MetricToneKey;
/** Inline text color for a data value. */
export const toneText: Record<Tone, string> = {
@ -26,26 +27,64 @@ export const toneBanner: Record<Tone, string> = {
/** Small pill / badge background + text. */
export const toneBadge: Record<BadgeTone, string> = {
online:
"bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]",
offline:
"bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
neutral:
"bg-[color:var(--surface-strong)] text-muted",
online: "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]",
offline: "bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
neutral: "bg-[color:var(--surface-strong)] text-muted",
};
/** Icon container background + icon color for metric cards. */
export const toneIcon: Record<MetricToneKey, string> = {
accent: "bg-[color:var(--accent-soft)] text-[color:var(--accent)]",
success: "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]",
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)]",
accent:
"border border-[color:rgba(96,165,250,0.3)] bg-[color:var(--accent-soft)] text-[color:var(--accent-strong)]",
success:
"border border-[color:rgba(52,211,153,0.3)] bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]",
warning:
"border border-[color:rgba(251,191,36,0.32)] bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]",
danger:
"border border-[color:rgba(248,113,113,0.3)] 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)]",
accent:
"border border-[color:rgba(96,165,250,0.3)] bg-[linear-gradient(145deg,rgba(96,165,250,0.16),var(--surface)_44%)]",
success:
"border border-[color:rgba(52,211,153,0.3)] bg-[linear-gradient(145deg,rgba(52,211,153,0.14),var(--surface)_44%)]",
warning:
"border border-[color:rgba(251,191,36,0.3)] bg-[linear-gradient(145deg,rgba(251,191,36,0.13),var(--surface)_44%)]",
danger:
"border border-[color:rgba(248,113,113,0.3)] bg-[linear-gradient(145deg,rgba(248,113,113,0.13),var(--surface)_44%)]",
};
export const toneGlow: Record<MetricToneKey, string> = {
accent: "bg-[color:rgba(96,165,250,0.34)]",
success: "bg-[color:rgba(52,211,153,0.28)]",
warning: "bg-[color:rgba(251,191,36,0.3)]",
danger: "bg-[color:rgba(248,113,113,0.28)]",
};
export const sectionTone: Record<SectionToneKey, string> = {
neutral:
"border-[color:var(--border)] bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0)_72%),var(--surface)]",
accent:
"border-[color:rgba(96,165,250,0.22)] bg-[linear-gradient(180deg,rgba(96,165,250,0.08),rgba(255,255,255,0)_72%),var(--surface)]",
success:
"border-[color:rgba(52,211,153,0.22)] bg-[linear-gradient(180deg,rgba(52,211,153,0.075),rgba(255,255,255,0)_72%),var(--surface)]",
warning:
"border-[color:rgba(251,191,36,0.24)] bg-[linear-gradient(180deg,rgba(251,191,36,0.075),rgba(255,255,255,0)_72%),var(--surface)]",
danger:
"border-[color:rgba(248,113,113,0.24)] bg-[linear-gradient(180deg,rgba(248,113,113,0.075),rgba(255,255,255,0)_72%),var(--surface)]",
};
export const sectionRail: Record<SectionToneKey, string> = {
neutral:
"bg-[linear-gradient(90deg,rgba(96,165,250,0),rgba(96,165,250,0.28),rgba(52,211,153,0.2),rgba(96,165,250,0))]",
accent:
"bg-[linear-gradient(90deg,rgba(96,165,250,0),rgba(96,165,250,0.82),rgba(96,165,250,0))]",
success:
"bg-[linear-gradient(90deg,rgba(52,211,153,0),rgba(52,211,153,0.78),rgba(52,211,153,0))]",
warning:
"bg-[linear-gradient(90deg,rgba(251,191,36,0),rgba(251,191,36,0.82),rgba(251,191,36,0))]",
danger:
"bg-[linear-gradient(90deg,rgba(248,113,113,0),rgba(248,113,113,0.78),rgba(248,113,113,0))]",
};

View File

@ -98,6 +98,10 @@ function fmtActivityDate(date: string): string {
const d = dateFromIsoDate(date);
return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
}
function fmtCommitTitle(date: string, count: number): string {
if (count <= 0) return `${date}: no commits`;
return `${date}: ${count} commit${count === 1 ? "" : "s"}`;
}
// Catmull-Rom → cubic bezier smooth path
function smoothPath(pts: {x:number;y:number}[]): string {
@ -315,7 +319,7 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
onBlur={() => setHoveredPoint(null)}
style={{fill:"transparent", outline:"none"}}
>
<title>{p.date}{p.count > 0 ? `: ${p.count} commit${p.count!==1?"s":""}` : ": no commits"}</title>
<title>{fmtCommitTitle(p.date, p.count)}</title>
</rect>
))}
</svg>
@ -507,7 +511,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
outline: "none",
}}
>
<title>{day.date}{day.count>0?`: ${day.count} commit${day.count!==1?"s":""}` : ": no commits"}</title>
<title>{fmtCommitTitle(day.date, day.count)}</title>
</rect>
{(range === "7d" || i % 5 === 0 || i === heatmap.daily.length - 1) && (
<text
@ -547,7 +551,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
strokeWidth: 1.5,
outline: "none",
}}>
<title>{cell.date}{cell.count>0?`: ${cell.count} commit${cell.count!==1?"s":""}` : ": no commits"}</title>
<title>{fmtCommitTitle(cell.date, cell.count)}</title>
</rect>
))
)}

View File

@ -12,13 +12,18 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
AlertCircle,
Archive,
CheckCircle2,
CircleDot,
Eye,
GitBranch,
GitCommitHorizontal,
GitFork,
KeyRound,
Loader2,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import { DataTable } from "@/components/tables/DataTable";
import { cn } from "@/lib/utils";
@ -28,6 +33,56 @@ import type {
ForgejoRepositoryValidationResponse,
} from "@/lib/api-forgejo";
const repositoryLabel = (repo: ForgejoRepository) =>
repo.display_name || `${repo.owner}/${repo.repo}`;
const repositoryTone = (repo: ForgejoRepository) => {
if (repo.last_sync_error) return "danger";
if (!repo.active || repo.is_archived) return "muted";
if (!repo.has_webhook_secret || !repo.last_sync_at) return "warning";
return "success";
};
const toneClasses = {
success: {
rail: "border-l-[color:var(--success)]",
row: "bg-[color:rgba(52,211,153,0.025)] hover:bg-[color:rgba(52,211,153,0.07)]",
icon: "border-[color:rgba(52,211,153,0.28)] bg-[color:rgba(52,211,153,0.13)] text-[color:var(--success)]",
dot: "bg-[color:var(--success)]",
},
warning: {
rail: "border-l-[color:var(--warning)]",
row: "bg-[color:rgba(251,191,36,0.025)] hover:bg-[color:rgba(251,191,36,0.075)]",
icon: "border-[color:rgba(251,191,36,0.3)] bg-[color:rgba(251,191,36,0.13)] text-[color:var(--warning)]",
dot: "bg-[color:var(--warning)]",
},
danger: {
rail: "border-l-[color:var(--danger)]",
row: "bg-[color:rgba(248,113,113,0.028)] hover:bg-[color:rgba(248,113,113,0.08)]",
icon: "border-[color:rgba(248,113,113,0.3)] bg-[color:rgba(248,113,113,0.13)] text-[color:var(--danger)]",
dot: "bg-[color:var(--danger)]",
},
muted: {
rail: "border-l-[color:var(--border-strong)]",
row: "hover:bg-[color:var(--surface-muted)]",
icon: "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
dot: "bg-[color:var(--text-quiet)]",
},
} as const;
const formatSyncTime = (value: string | null) => {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return {
date: date.toLocaleDateString(),
time: date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
}),
};
};
type RepositorySyncResult = {
created: number;
updated: number;
@ -80,8 +135,20 @@ export function ForgejoRepositoriesTable({
rowActions={{
getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`,
onDelete: onDelete ?? undefined,
cellClassName: "px-3 py-3 align-middle md:px-5",
}}
tableClassName="min-w-[900px] w-full text-left text-sm"
headerClassName="bg-[linear-gradient(90deg,rgba(96,165,250,0.16),rgba(52,211,153,0.1),rgba(251,191,36,0.08))] text-xs font-semibold uppercase tracking-wider text-[color:var(--text-muted)]"
headerCellClassName="px-3 py-3 md:px-5"
cellClassName="px-3 py-4 align-middle md:px-5"
rowClassName={(row) => {
const tone = repositoryTone(row.original);
return cn(
"border-l-4 transition-colors",
toneClasses[tone].rail,
toneClasses[tone].row,
);
}}
/>
);
}
@ -110,19 +177,67 @@ const columns = (
},
cell: ({ row }) => {
const repo = row.original;
const tone = repositoryTone(repo);
return (
<div className="min-w-0">
<span className="block truncate font-medium text-strong">
{repo.display_name || `${repo.owner}/${repo.repo}`}
</span>
<span className="block truncate font-mono text-xs text-muted">
{repo.owner}/{repo.repo} {repo.connection?.name}
</span>
{repo.description ? (
<span className="mt-1 block max-w-[300px] truncate text-xs text-muted">
{repo.description}
<div className="flex min-w-[280px] items-start gap-3">
<div
className={cn(
"mt-0.5 rounded-xl border p-2 shadow-[0_0_22px_rgba(96,165,250,0.08)]",
toneClasses[tone].icon,
)}
>
<GitBranch className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate font-semibold text-strong">
{repositoryLabel(repo)}
</span>
{repo.default_branch ? (
<span className="inline-flex items-center gap-1 rounded-full border border-[color:rgba(96,165,250,0.26)] bg-[color:rgba(96,165,250,0.1)] px-2 py-0.5 font-mono text-[11px] text-[color:var(--accent-strong)]">
<GitCommitHorizontal className="h-3 w-3" />
{repo.default_branch}
</span>
) : null}
</div>
<span className="mt-1 flex min-w-0 items-center gap-1 truncate font-mono text-xs text-muted">
<GitFork className="h-3 w-3 shrink-0" />
<span className="truncate">
{repo.owner}/{repo.repo}
</span>
{repo.connection?.name ? (
<span className="truncate text-[color:var(--text-quiet)]">
/ {repo.connection.name}
</span>
) : null}
</span>
) : null}
{repo.description ? (
<span className="mt-1 block max-w-[360px] truncate text-xs text-muted">
{repo.description}
</span>
) : null}
{repo.topics.length ? (
<div className="mt-2 flex max-w-[360px] flex-wrap gap-1.5">
{repo.topics.slice(0, 3).map((topic) => (
<Badge
key={topic}
variant="accent"
className="h-5 rounded-full px-2 text-[11px] normal-case"
>
{topic}
</Badge>
))}
{repo.topics.length > 3 ? (
<Badge
variant="outline"
className="h-5 rounded-full px-2 text-[11px]"
>
+{repo.topics.length - 3}
</Badge>
) : null}
</div>
) : null}
</div>
</div>
);
},
@ -133,11 +248,11 @@ const columns = (
cell: ({ row }) => {
const connection = row.original.connection;
return (
<div className="min-w-0">
<span className="block truncate text-sm text-strong">
<div className="min-w-[180px]">
<span className="block truncate font-medium text-sm text-strong">
{connection?.name}
</span>
<span className="block truncate text-xs text-muted">
<span className="mt-1 inline-flex max-w-[240px] items-center rounded-full border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-2 py-0.5 font-mono text-[11px] text-muted">
{connection?.base_url}
</span>
</div>
@ -151,8 +266,16 @@ const columns = (
const repo = row.original;
const isActive = repo.active;
return (
<div className="flex flex-wrap gap-1.5">
<Badge variant={isActive ? "default" : "outline"}>
<div className="flex max-w-[230px] flex-wrap gap-1.5">
<Badge variant={isActive ? "success" : "outline"} className="gap-1">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
isActive
? "bg-[color:var(--success)]"
: "bg-[color:var(--text-quiet)]",
)}
/>
{isActive ? "Active" : "Inactive"}
</Badge>
{repo.is_archived ? (
@ -172,6 +295,22 @@ const columns = (
No secret
</Badge>
)}
{repo.last_sync_error ? (
<Badge variant="danger" className="gap-1">
<AlertCircle className="h-3 w-3" />
Sync error
</Badge>
) : repo.last_sync_at ? (
<Badge variant="accent" className="gap-1">
<ShieldCheck className="h-3 w-3" />
Synced
</Badge>
) : (
<Badge variant="warning" className="gap-1">
<CircleDot className="h-3 w-3" />
New
</Badge>
)}
</div>
);
},
@ -180,11 +319,20 @@ const columns = (
accessorKey: "openIssues",
header: "Issues",
cell: ({ row }) => (
<div className="min-w-[90px]">
<span className="block text-lg font-semibold text-strong">
<div
className={cn(
"inline-flex min-w-[94px] flex-col rounded-xl border px-3 py-2",
row.original.open_issues_count > 0
? "border-[color:rgba(251,191,36,0.28)] bg-[color:rgba(251,191,36,0.1)]"
: "border-[color:rgba(52,211,153,0.24)] bg-[color:rgba(52,211,153,0.08)]",
)}
>
<span className="text-lg font-semibold leading-none text-strong">
{row.original.open_issues_count}
</span>
<span className="text-xs text-muted">open upstream</span>
<span className="mt-1 text-[11px] uppercase tracking-wide text-muted">
open
</span>
</div>
),
},
@ -194,26 +342,36 @@ const columns = (
cell: ({ row }) => {
const lastSyncAt = row.original.last_sync_at;
const lastSyncError = row.original.last_sync_error;
const tone = repositoryTone(row.original);
const syncTime = formatSyncTime(lastSyncAt);
if (!lastSyncAt) {
return <span className="text-sm text-muted">Never</span>;
if (!syncTime) {
return (
<div className="flex items-center gap-2 text-sm text-muted">
<span
className={cn("h-2 w-2 rounded-full", toneClasses[tone].dot)}
/>
Never
</div>
);
}
const date = new Date(lastSyncAt);
return (
<div className="flex flex-col">
<span className="text-sm text-strong">
{date.toLocaleDateString()}
</span>
<span className="text-xs text-muted">
{date.toLocaleTimeString()}
</span>
{lastSyncError && (
<span className="max-w-[220px] truncate text-xs text-[color:var(--danger)]">
Error: {lastSyncError.substring(0, 50)}...
<div className="flex min-w-[150px] items-start gap-2">
<span
className={cn("mt-1.5 h-2 w-2 rounded-full", toneClasses[tone].dot)}
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-strong">
{syncTime.date}
</span>
)}
<span className="text-xs text-muted">{syncTime.time}</span>
{lastSyncError && (
<span className="max-w-[220px] truncate text-xs text-[color:var(--danger)]">
Error: {lastSyncError.substring(0, 50)}...
</span>
)}
</div>
</div>
);
},

View File

@ -0,0 +1,92 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "@/auth/clerk";
import { customFetch } from "@/api/mutator";
interface TickerItem {
id: string;
source: string;
message: string;
created_at: string;
}
function fmtRelative(isoString: string): string {
const diffMs = Date.now() - new Date(isoString).getTime();
const s = Math.round(diffMs / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
async function fetchTickerItems(limit = 20): Promise<TickerItem[]> {
const res = await customFetch<{ data: TickerItem[]; status: number }>(
`/api/v1/activity/ticker?limit=${limit}`,
{ method: "GET" },
);
if (res.status === 200) return res.data;
return [];
}
export function AgentActivityTicker() {
const { isSignedIn } = useAuth();
const [items, setItems] = useState<TickerItem[]>([]);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const load = useCallback(async () => {
try {
const data = await fetchTickerItems(20);
if (data.length > 0) setItems(data);
} catch {
// Silent — ticker is non-critical
}
}, []);
useEffect(() => {
if (!isSignedIn) return;
void load();
intervalRef.current = setInterval(() => void load(), 30_000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isSignedIn, load]);
if (items.length === 0) return null;
// Duplicate items for a seamless loop (animate-ticker moves -50%)
const display = [...items, ...items];
return (
<div className="border-t border-[color:var(--border)] bg-[color:var(--surface-muted)] overflow-hidden h-7 flex items-center">
<span className="shrink-0 px-3 text-[10px] font-semibold uppercase tracking-widest text-[color:var(--text-quiet)] select-none border-r border-[color:var(--border)] h-full flex items-center">
Live
</span>
<div className="flex-1 overflow-hidden h-full flex items-center">
<div className="flex whitespace-nowrap animate-ticker">
{display.map((item, idx) => (
<span
key={`${item.id}-${idx}`}
className="inline-flex items-center gap-1.5 px-4 text-[10px]"
>
<span className="font-semibold text-[color:var(--accent)]">
{item.source}
</span>
<span className="text-[color:var(--text-muted)]">·</span>
<span className="text-[color:var(--text)]">{item.message}</span>
<span className="text-[color:var(--text-quiet)] tabular-nums ml-1">
{fmtRelative(item.created_at)}
</span>
<span className="mx-3 text-[color:var(--border)] select-none">
</span>
</span>
))}
</div>
</div>
</div>
);
}

View File

@ -13,6 +13,7 @@ import {
useGetMeApiV1UsersMeGet,
} from "@/api/generated/users/users";
import { BrandMark } from "@/components/atoms/BrandMark";
import { AgentActivityTicker } from "@/components/organisms/AgentActivityTicker";
import { OrgSwitcher } from "@/components/organisms/OrgSwitcher";
import { ProviderNavbarStatus } from "@/components/organisms/ProviderNavbarStatus";
import { ThemeToggle } from "@/components/organisms/ThemeToggle";
@ -115,6 +116,7 @@ export function DashboardShell({
data-sidebar={sidebarOpen ? "open" : "closed"}
>
<header className="sticky top-0 z-50 border-b border-[color:var(--border)] bg-[color:var(--surface)] shadow-sm">
<AgentActivityTicker />
<div className="flex items-center py-3">
<div className="flex items-center px-4 md:px-6 md:w-[260px]">
{isSignedIn ? (