main dashboard
This commit is contained in:
parent
19a6b8fda8
commit
e6c2989c64
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
(selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
|
||||
? forgejoOrganizationId
|
||||
: selectedForgejoRepository
|
||||
),
|
||||
: 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,8 +895,7 @@ export default function DashboardPage() {
|
|||
providerId,
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
(cred): cred is (typeof credentials)[number] =>
|
||||
.filter((cred): cred is (typeof credentials)[number] =>
|
||||
Boolean(cred?.active && cred.has_session_key),
|
||||
);
|
||||
const settled = await Promise.allSettled(
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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="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"]}`}
|
||||
>
|
||||
<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"]}`}>
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))]",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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="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">
|
||||
<span className="block truncate font-medium text-strong">
|
||||
{repo.display_name || `${repo.owner}/${repo.repo}`}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="truncate font-semibold text-strong">
|
||||
{repositoryLabel(repo)}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-xs text-muted">
|
||||
{repo.owner}/{repo.repo} • {repo.connection?.name}
|
||||
{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>
|
||||
{repo.description ? (
|
||||
<span className="mt-1 block max-w-[300px] truncate text-xs text-muted">
|
||||
<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,27 +342,37 @@ 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 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 text-strong">
|
||||
{date.toLocaleDateString()}
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
{date.toLocaleTimeString()}
|
||||
<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>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
Loading…
Reference in New Issue