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.agents import Agent
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
from app.models.tasks import Task
|
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.schemas.pagination import DefaultLimitOffsetPage
|
||||||
from app.services.organizations import (
|
from app.services.organizations import (
|
||||||
OrganizationContext,
|
OrganizationContext,
|
||||||
|
|
@ -242,6 +242,63 @@ async def _fetch_task_comment_events(
|
||||||
return _coerce_task_comment_rows(list(await session.exec(statement)))
|
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])
|
@router.get("", response_model=DefaultLimitOffsetPage[ActivityEventRead])
|
||||||
async def list_activity(
|
async def list_activity(
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,15 @@ class ActivityEventRead(SQLModel):
|
||||||
created_at: datetime
|
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):
|
class ActivityTaskCommentFeedItemRead(SQLModel):
|
||||||
"""Denormalized task-comment feed item enriched with task and board fields."""
|
"""Denormalized task-comment feed item enriched with task and board fields."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import { ForgejoHeatmap } from "@/components/git/ForgejoHeatmap";
|
||||||
import {
|
import {
|
||||||
DashboardMetricCard,
|
DashboardMetricCard,
|
||||||
DashboardInfoBlock,
|
DashboardInfoBlock,
|
||||||
DashboardEmptyState,
|
|
||||||
PendingApprovalsSection,
|
PendingApprovalsSection,
|
||||||
SessionsSection,
|
SessionsSection,
|
||||||
RecentActivitySection,
|
RecentActivitySection,
|
||||||
|
|
@ -435,7 +434,6 @@ const toSessionSummaries = (
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
|
|
@ -520,9 +518,9 @@ export default function DashboardPage() {
|
||||||
() =>
|
() =>
|
||||||
selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
|
selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
|
||||||
? null
|
? null
|
||||||
: forgejoRepositories.find(
|
: (forgejoRepositories.find(
|
||||||
(repository) => repository.id === selectedForgejoRepositoryId,
|
(repository) => repository.id === selectedForgejoRepositoryId,
|
||||||
) ?? null,
|
) ?? null),
|
||||||
[forgejoRepositories, selectedForgejoRepositoryId],
|
[forgejoRepositories, selectedForgejoRepositoryId],
|
||||||
);
|
);
|
||||||
const scopedForgejoRepositories = useMemo(
|
const scopedForgejoRepositories = useMemo(
|
||||||
|
|
@ -532,7 +530,11 @@ export default function DashboardPage() {
|
||||||
: selectedForgejoRepository
|
: selectedForgejoRepository
|
||||||
? [selectedForgejoRepository]
|
? [selectedForgejoRepository]
|
||||||
: [],
|
: [],
|
||||||
[forgejoRepositories, selectedForgejoRepository, selectedForgejoRepositoryId],
|
[
|
||||||
|
forgejoRepositories,
|
||||||
|
selectedForgejoRepository,
|
||||||
|
selectedForgejoRepositoryId,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const forgejoOrganizationId = useMemo(
|
const forgejoOrganizationId = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -565,24 +567,33 @@ export default function DashboardPage() {
|
||||||
isSignedIn &&
|
isSignedIn &&
|
||||||
!forgejoRepositoriesQuery.isLoading &&
|
!forgejoRepositoriesQuery.isLoading &&
|
||||||
!forgejoRepositoriesQuery.error &&
|
!forgejoRepositoriesQuery.error &&
|
||||||
(
|
(selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
|
||||||
selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
|
? forgejoOrganizationId
|
||||||
? forgejoOrganizationId
|
: selectedForgejoRepository),
|
||||||
: selectedForgejoRepository
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
|
refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
if (selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES) {
|
if (selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES) {
|
||||||
return getForgejoMetrics({ repository_id: selectedForgejoRepositoryId });
|
return getForgejoMetrics({
|
||||||
|
repository_id: selectedForgejoRepositoryId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (!forgejoOrganizationId) return Promise.resolve(null);
|
if (!forgejoOrganizationId) return Promise.resolve(null);
|
||||||
return getForgejoMetrics({ organization_id: forgejoOrganizationId });
|
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],
|
queryKey: ["dashboard", "forgejo", "heatmap", forgejoOrganizationId],
|
||||||
enabled: Boolean(
|
enabled: Boolean(
|
||||||
isSignedIn &&
|
isSignedIn &&
|
||||||
|
|
@ -591,7 +602,9 @@ export default function DashboardPage() {
|
||||||
!forgejoRepositoriesQuery.error,
|
!forgejoRepositoriesQuery.error,
|
||||||
),
|
),
|
||||||
refetchInterval: (query) =>
|
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",
|
refetchOnMount: "always",
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
if (!forgejoOrganizationId) return Promise.resolve(null);
|
if (!forgejoOrganizationId) return Promise.resolve(null);
|
||||||
|
|
@ -863,7 +876,8 @@ export default function DashboardPage() {
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const credentialsRes = await listProviderCredentialsApiV1ProviderCredentialsGet();
|
const credentialsRes =
|
||||||
|
await listProviderCredentialsApiV1ProviderCredentialsGet();
|
||||||
if (credentialsRes.status !== 200) return [];
|
if (credentialsRes.status !== 200) return [];
|
||||||
|
|
||||||
const credentials = credentialsRes.data ?? [];
|
const credentials = credentialsRes.data ?? [];
|
||||||
|
|
@ -881,9 +895,8 @@ export default function DashboardPage() {
|
||||||
providerId,
|
providerId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.filter(
|
.filter((cred): cred is (typeof credentials)[number] =>
|
||||||
(cred): cred is (typeof credentials)[number] =>
|
Boolean(cred?.active && cred.has_session_key),
|
||||||
Boolean(cred?.active && cred.has_session_key),
|
|
||||||
);
|
);
|
||||||
const settled = await Promise.allSettled(
|
const settled = await Promise.allSettled(
|
||||||
selectedCredentials.map((cred) =>
|
selectedCredentials.map((cred) =>
|
||||||
|
|
@ -899,7 +912,8 @@ export default function DashboardPage() {
|
||||||
const { cred, res } = item.value;
|
const { cred, res } = item.value;
|
||||||
if (res.status !== 200) continue;
|
if (res.status !== 200) continue;
|
||||||
const usage = res.data;
|
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 ?? [];
|
const subscriptionWindows = usage.subscription_windows ?? [];
|
||||||
for (const window of subscriptionWindows) {
|
for (const window of subscriptionWindows) {
|
||||||
windows.push({
|
windows.push({
|
||||||
|
|
@ -925,7 +939,9 @@ export default function DashboardPage() {
|
||||||
confidence: "low",
|
confidence: "low",
|
||||||
provider: usage.provider,
|
provider: usage.provider,
|
||||||
gatewayLabel: accountLabel,
|
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 statuslineUsageWindows = providerUsageQuery.data ?? [];
|
||||||
const statuslineProviders = new Set(
|
const statuslineProviders = new Set(
|
||||||
statuslineUsageWindows
|
statuslineUsageWindows
|
||||||
.filter((window) => window.pctUsed !== null || window.remainingMs !== null)
|
.filter(
|
||||||
|
(window) => window.pctUsed !== null || window.remainingMs !== null,
|
||||||
|
)
|
||||||
.map((window) => window.provider),
|
.map((window) => window.provider),
|
||||||
);
|
);
|
||||||
const providerUsageWindows = [
|
const providerUsageWindows = [
|
||||||
|
|
@ -943,8 +961,8 @@ export default function DashboardPage() {
|
||||||
...(credentialUsageQuery.data ?? []).filter(
|
...(credentialUsageQuery.data ?? []).filter(
|
||||||
(window) =>
|
(window) =>
|
||||||
!(
|
!(
|
||||||
window.key.includes("subscription_unavailable")
|
window.key.includes("subscription_unavailable") &&
|
||||||
&& statuslineProviders.has(window.provider)
|
statuslineProviders.has(window.provider)
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
@ -958,8 +976,10 @@ export default function DashboardPage() {
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
if (!primaryGatewayId) return Promise.resolve(null);
|
if (!primaryGatewayId) return Promise.resolve(null);
|
||||||
return getGatewayHealthApiV1GatewaysGatewayIdHealthGet(primaryGatewayId).then(
|
return getGatewayHealthApiV1GatewaysGatewayIdHealthGet(
|
||||||
(r) => (r.status === 200 ? (r.data as SystemHealthResponse) : null),
|
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
|
// Build a session-id → TopSession lookup for enriching session summaries
|
||||||
const topSessionById = useMemo(() => {
|
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 ?? []) {
|
for (const s of runtimeUsage?.topSessions ?? []) {
|
||||||
if (s.session_id) {
|
if (s.session_id) {
|
||||||
map.set(s.session_id, {
|
map.set(s.session_id, {
|
||||||
|
|
@ -999,7 +1022,9 @@ export default function DashboardPage() {
|
||||||
const sourceLabel = snapshot.gatewayUrl || snapshot.boardName;
|
const sourceLabel = snapshot.gatewayUrl || snapshot.boardName;
|
||||||
return toSessionSummaries(snapshot.sessions, snapshot.mainSession).map(
|
return toSessionSummaries(snapshot.sessions, snapshot.mainSession).map(
|
||||||
(session) => {
|
(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 {
|
return {
|
||||||
...session,
|
...session,
|
||||||
key: `${snapshot.gatewayId}:${session.key}`,
|
key: `${snapshot.gatewayId}:${session.key}`,
|
||||||
|
|
@ -1209,7 +1234,6 @@ export default function DashboardPage() {
|
||||||
];
|
];
|
||||||
const pendingApprovalItems = metrics?.pending_approvals.items ?? [];
|
const pendingApprovalItems = metrics?.pending_approvals.items ?? [];
|
||||||
const pendingApprovalsTotal = metrics?.pending_approvals.total ?? 0;
|
const pendingApprovalsTotal = metrics?.pending_approvals.total ?? 0;
|
||||||
const hasPendingApprovals = pendingApprovalItems.length > 0;
|
|
||||||
const activityFeedHref = "/activity";
|
const activityFeedHref = "/activity";
|
||||||
const forgejoIssueMetrics = forgejoMetricsQuery.data ?? null;
|
const forgejoIssueMetrics = forgejoMetricsQuery.data ?? null;
|
||||||
const forgejoIssueMetricsError =
|
const forgejoIssueMetricsError =
|
||||||
|
|
@ -1297,8 +1321,12 @@ export default function DashboardPage() {
|
||||||
</SignedOut>
|
</SignedOut>
|
||||||
<SignedIn>
|
<SignedIn>
|
||||||
<DashboardSidebar />
|
<DashboardSidebar />
|
||||||
<main className="flex-1 overflow-y-auto bg-app">
|
<main className="relative flex-1 overflow-y-auto bg-app">
|
||||||
<div className="p-4 md:p-8">
|
<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 ? (
|
{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)]">
|
<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}
|
Load failed: {metricsQuery.error.message}
|
||||||
|
|
@ -1384,16 +1412,28 @@ export default function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
<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
|
<DashboardInfoBlock
|
||||||
title="Throughput"
|
title="Throughput"
|
||||||
infoText={`All throughput values are calculated for ${DASHBOARD_RANGE_LABEL}`}
|
infoText={`All throughput values are calculated for ${DASHBOARD_RANGE_LABEL}`}
|
||||||
rows={throughputRows}
|
rows={throughputRows}
|
||||||
|
tone="success"
|
||||||
/>
|
/>
|
||||||
<DashboardInfoBlock
|
<DashboardInfoBlock
|
||||||
title="Gateway Health"
|
title="Gateway Health"
|
||||||
badge={{ text: gatewayStatusLabel, tone: gatewayBadgeTone }}
|
badge={{ text: gatewayStatusLabel, tone: gatewayBadgeTone }}
|
||||||
rows={gatewayRows}
|
rows={gatewayRows}
|
||||||
|
tone={
|
||||||
|
gatewayBadgeTone === "online"
|
||||||
|
? "success"
|
||||||
|
: gatewayBadgeTone === "offline"
|
||||||
|
? "danger"
|
||||||
|
: "warning"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1413,9 +1453,9 @@ export default function DashboardPage() {
|
||||||
providerUsageWindows={providerUsageWindows}
|
providerUsageWindows={providerUsageWindows}
|
||||||
perGatewayUsage={perGatewayUsage}
|
perGatewayUsage={perGatewayUsage}
|
||||||
isLoading={
|
isLoading={
|
||||||
runtimeUsageQuery.isLoading
|
runtimeUsageQuery.isLoading ||
|
||||||
|| providerUsageQuery.isLoading
|
providerUsageQuery.isLoading ||
|
||||||
|| credentialUsageQuery.isLoading
|
credentialUsageQuery.isLoading
|
||||||
}
|
}
|
||||||
hasGateways={hasConfiguredGateways}
|
hasGateways={hasConfiguredGateways}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
|
@ -52,6 +58,39 @@ type RepositoryFilter =
|
||||||
| "webhooks"
|
| "webhooks"
|
||||||
| "archived";
|
| "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) =>
|
const repositoryName = (repository: ForgejoRepository) =>
|
||||||
repository.display_name || `${repository.owner}/${repository.repo}`;
|
repository.display_name || `${repository.owner}/${repository.repo}`;
|
||||||
|
|
||||||
|
|
@ -92,14 +131,22 @@ function StatCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
caption,
|
caption,
|
||||||
|
tone = "blue",
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
caption: string;
|
caption: string;
|
||||||
|
tone?: StatTone;
|
||||||
}) {
|
}) {
|
||||||
|
const colors = statToneClasses[tone];
|
||||||
return (
|
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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted">
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||||
|
|
@ -107,9 +154,7 @@ function StatCard({
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-2xl font-semibold text-strong">{value}</p>
|
<p className="mt-2 text-2xl font-semibold text-strong">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)]">
|
<div className={`rounded-lg border p-2 ${colors.icon}`}>{icon}</div>
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm text-muted">{caption}</p>
|
<p className="mt-3 text-sm text-muted">{caption}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -158,24 +203,28 @@ function RepositoryDetailsDialog({
|
||||||
label="Open Issues"
|
label="Open Issues"
|
||||||
value={String(repository.open_issues_count)}
|
value={String(repository.open_issues_count)}
|
||||||
caption="Reported upstream."
|
caption="Reported upstream."
|
||||||
|
tone="amber"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<GitBranch className="h-4 w-4" />}
|
icon={<GitBranch className="h-4 w-4" />}
|
||||||
label="Branch"
|
label="Branch"
|
||||||
value={repository.default_branch || "Unknown"}
|
value={repository.default_branch || "Unknown"}
|
||||||
caption="Default branch."
|
caption="Default branch."
|
||||||
|
tone="blue"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<Webhook className="h-4 w-4" />}
|
icon={<Webhook className="h-4 w-4" />}
|
||||||
label="Webhook"
|
label="Webhook"
|
||||||
value={repository.has_webhook_secret ? "Ready" : "Missing"}
|
value={repository.has_webhook_secret ? "Ready" : "Missing"}
|
||||||
caption="Stored secret status."
|
caption="Stored secret status."
|
||||||
|
tone={repository.has_webhook_secret ? "green" : "rose"}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<Clock className="h-4 w-4" />}
|
icon={<Clock className="h-4 w-4" />}
|
||||||
label="Synced"
|
label="Synced"
|
||||||
value={formatTimestamp(repository.last_sync_at)}
|
value={formatTimestamp(repository.last_sync_at)}
|
||||||
caption="Last sync timestamp."
|
caption="Last sync timestamp."
|
||||||
|
tone={repository.last_sync_error ? "rose" : "violet"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -602,12 +651,13 @@ export default function ForgejoRepositoriesPage() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{notice ? <NoticeBanner notice={notice} /> : null}
|
{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="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<Badge
|
<Badge
|
||||||
variant={attentionRepositories.length ? "warning" : "success"}
|
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
|
||||||
? `${attentionRepositories.length} need review`
|
? `${attentionRepositories.length} need review`
|
||||||
|
|
@ -653,29 +703,33 @@ export default function ForgejoRepositoriesPage() {
|
||||||
label="Repositories"
|
label="Repositories"
|
||||||
value={`${activeRepositories}/${repositories.length}`}
|
value={`${activeRepositories}/${repositories.length}`}
|
||||||
caption="Active tracked repositories."
|
caption="Active tracked repositories."
|
||||||
|
tone="blue"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<CircleDot className="h-4 w-4" />}
|
icon={<CircleDot className="h-4 w-4" />}
|
||||||
label="Open Issues"
|
label="Open Issues"
|
||||||
value={formatCompactNumber(totalOpenIssues)}
|
value={formatCompactNumber(totalOpenIssues)}
|
||||||
caption="Reported by Forgejo."
|
caption="Reported by Forgejo."
|
||||||
|
tone="amber"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<Webhook className="h-4 w-4" />}
|
icon={<Webhook className="h-4 w-4" />}
|
||||||
label="Webhooks"
|
label="Webhooks"
|
||||||
value={`${webhookReady}/${activeRepositories}`}
|
value={`${webhookReady}/${activeRepositories}`}
|
||||||
caption="Active repositories with secrets."
|
caption="Active repositories with secrets."
|
||||||
|
tone={webhookReady === activeRepositories ? "green" : "rose"}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<Clock className="h-4 w-4" />}
|
icon={<Clock className="h-4 w-4" />}
|
||||||
label="Latest Sync"
|
label="Latest Sync"
|
||||||
value={formatTimestamp(latestSync?.toISOString() ?? null)}
|
value={formatTimestamp(latestSync?.toISOString() ?? null)}
|
||||||
caption={`${syncErrors} errors, ${archivedRepositories} archived, ${linkedBoardCount} board links.`}
|
caption={`${syncErrors} errors, ${archivedRepositories} archived, ${linkedBoardCount} board links.`}
|
||||||
|
tone={syncErrors ? "rose" : "violet"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<div className="relative min-w-0 flex-1">
|
<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" />
|
<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
|
<Button
|
||||||
key={option.key}
|
key={option.key}
|
||||||
type="button"
|
type="button"
|
||||||
variant={filter === option.key ? "secondary" : "outline"}
|
variant={filter === option.key ? "primary" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setFilter(option.key)}
|
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}
|
{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>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 ? (
|
{error ? (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<p className="text-sm text-[color:var(--danger)]">{error}</p>
|
<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 {
|
@keyframes progress-shimmer {
|
||||||
0% {
|
0% {
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
|
|
@ -261,6 +266,12 @@ textarea::placeholder {
|
||||||
.animate-progress-shimmer {
|
.animate-progress-shimmer {
|
||||||
animation: progress-shimmer 1.8s linear infinite;
|
animation: progress-shimmer 1.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
.animate-ticker {
|
||||||
|
animation: ticker-scroll 40s linear infinite;
|
||||||
|
}
|
||||||
|
.animate-ticker:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
.shadow-lush {
|
.shadow-lush {
|
||||||
box-shadow: var(--shadow-panel);
|
box-shadow: var(--shadow-panel);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { toneText, type Tone } from "./tokens";
|
import { toneText, type Tone } from "./tokens";
|
||||||
import { DashboardSection } from "./DashboardSection";
|
import { DashboardSection } from "./DashboardSection";
|
||||||
import type { BadgeTone } from "./tokens";
|
import type { BadgeTone, SectionToneKey } from "./tokens";
|
||||||
|
|
||||||
export type InfoRow = {
|
export type InfoRow = {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -13,23 +13,50 @@ interface DashboardInfoBlockProps {
|
||||||
infoText?: string;
|
infoText?: string;
|
||||||
badge?: { text: string; tone: BadgeTone };
|
badge?: { text: string; tone: BadgeTone };
|
||||||
rows: InfoRow[];
|
rows: InfoRow[];
|
||||||
|
tone?: SectionToneKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Labeled key/value block used for Workload, Throughput, Gateway Health.
|
* Labeled key/value block used for Workload, Throughput, Gateway Health.
|
||||||
* Tone → color is a lookup, not a ternary chain.
|
* 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 (
|
return (
|
||||||
<DashboardSection title={title} infoText={infoText} badge={badge}>
|
<DashboardSection
|
||||||
<div className="divide-y divide-[color:var(--border)] rounded-lg surface-muted overflow-hidden">
|
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) => (
|
{rows.map((row) => (
|
||||||
<div
|
<div
|
||||||
key={`${row.label}-${row.value}`}
|
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="flex min-w-0 items-center gap-2 text-sm text-muted">
|
||||||
<span className={`max-w-[65%] break-words text-right text-sm font-medium leading-5 ${toneText[row.tone ?? "default"]}`}>
|
<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}
|
{row.value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Info } from "lucide-react";
|
import { Info } from "lucide-react";
|
||||||
import { toneIcon, toneCard, type MetricToneKey } from "./tokens";
|
import { toneCard, toneGlow, toneIcon, type MetricToneKey } from "./tokens";
|
||||||
|
|
||||||
interface DashboardMetricCardProps {
|
interface DashboardMetricCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -24,7 +24,12 @@ export function DashboardMetricCard({
|
||||||
tone,
|
tone,
|
||||||
}: DashboardMetricCardProps) {
|
}: DashboardMetricCardProps) {
|
||||||
return (
|
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 className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|
@ -32,13 +37,19 @@ export function DashboardMetricCard({
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
{infoText && (
|
{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" />
|
<Info className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-end gap-2">
|
<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 && (
|
{secondary && (
|
||||||
<p className="pb-1 text-xs text-muted">{secondary}</p>
|
<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 Link from "next/link";
|
||||||
import { ArrowUpRight, Info } from "lucide-react";
|
import { ArrowUpRight, Info } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { toneBadge, type BadgeTone } from "./tokens";
|
import {
|
||||||
|
sectionRail,
|
||||||
|
sectionTone,
|
||||||
|
toneBadge,
|
||||||
|
type BadgeTone,
|
||||||
|
type SectionToneKey,
|
||||||
|
} from "./tokens";
|
||||||
|
|
||||||
interface DashboardSectionProps {
|
interface DashboardSectionProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -11,6 +17,7 @@ interface DashboardSectionProps {
|
||||||
action?: { label: string; href: string };
|
action?: { label: string; href: string };
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
tone?: SectionToneKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,21 +32,43 @@ export function DashboardSection({
|
||||||
action,
|
action,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
tone = "neutral",
|
||||||
}: DashboardSectionProps) {
|
}: DashboardSectionProps) {
|
||||||
return (
|
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="mb-4 flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<h3 className="text-lg font-semibold text-strong">{title}</h3>
|
<h3 className="text-lg font-semibold text-strong">{title}</h3>
|
||||||
{infoText && (
|
{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" />
|
<Info className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{badge && (
|
{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}
|
{badge.text}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export function PendingApprovalsSection({
|
||||||
<DashboardSection
|
<DashboardSection
|
||||||
title="Pending Approvals"
|
title="Pending Approvals"
|
||||||
action={{ label: "Open global approvals", href: "/approvals" }}
|
action={{ label: "Open global approvals", href: "/approvals" }}
|
||||||
|
tone={items.length > 0 ? "warning" : "success"}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<DashboardEmptyState message="Loading pending approvals..." />
|
<DashboardEmptyState message="Loading pending approvals..." />
|
||||||
|
|
@ -42,15 +43,16 @@ export function PendingApprovalsSection({
|
||||||
/>
|
/>
|
||||||
) : items.length > 0 ? (
|
) : items.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<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) => (
|
{items.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.approval_id}
|
key={item.approval_id}
|
||||||
href={`/boards/${item.board_id}/approvals`}
|
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="min-w-0 text-sm">
|
||||||
<span className="block truncate font-medium text-strong">
|
<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"}
|
{item.task_title || "Pending approval"}
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate text-xs text-muted">
|
<span className="block truncate text-xs text-muted">
|
||||||
|
|
@ -65,7 +67,8 @@ export function PendingApprovalsSection({
|
||||||
</div>
|
</div>
|
||||||
{total > items.length && (
|
{total > items.length && (
|
||||||
<p className="text-xs text-muted">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Shield } from "lucide-react";
|
||||||
import { DashboardSection } from "./DashboardSection";
|
import { DashboardSection } from "./DashboardSection";
|
||||||
import { DashboardEmptyState } from "./DashboardEmptyState";
|
import { DashboardEmptyState } from "./DashboardEmptyState";
|
||||||
import { Markdown } from "@/components/atoms/Markdown";
|
import { Markdown } from "@/components/atoms/Markdown";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import type { ActivityEventRead } from "@/api/generated/model";
|
import type { ActivityEventRead } from "@/api/generated/model";
|
||||||
|
|
||||||
export type ActivityEvent = ActivityEventRead;
|
export type ActivityEvent = ActivityEventRead;
|
||||||
|
|
@ -17,6 +18,36 @@ interface RecentActivitySectionProps {
|
||||||
formatTimestamp: (ts: string) => string;
|
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({
|
export function RecentActivitySection({
|
||||||
events,
|
events,
|
||||||
feedHref,
|
feedHref,
|
||||||
|
|
@ -30,11 +61,13 @@ export function RecentActivitySection({
|
||||||
<DashboardSection
|
<DashboardSection
|
||||||
title="Recent Activity"
|
title="Recent Activity"
|
||||||
action={{ label: "Open feed", href: feedHref }}
|
action={{ label: "Open feed", href: feedHref }}
|
||||||
|
tone="accent"
|
||||||
>
|
>
|
||||||
<div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1">
|
<div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1">
|
||||||
{events.length > 0 ? (
|
{events.length > 0 ? (
|
||||||
events.map((event) => {
|
events.map((event) => {
|
||||||
const href = buildHref(event);
|
const href = buildHref(event);
|
||||||
|
const tone = eventTone(event.event_type);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
|
|
@ -43,7 +76,11 @@ export function RecentActivitySection({
|
||||||
aria-label={`Open related context for ${event.event_type} activity`}
|
aria-label={`Open related context for ${event.event_type} activity`}
|
||||||
onClick={(e) => onRowClick(e, href)}
|
onClick={(e) => onRowClick(e, href)}
|
||||||
onKeyDown={(e) => onRowKeyDown(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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
|
|
@ -54,6 +91,12 @@ export function RecentActivitySection({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 text-xs uppercase tracking-wider text-muted">
|
<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}
|
{event.event_type}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,11 @@ export function SessionsSection({
|
||||||
dash,
|
dash,
|
||||||
}: SessionsSectionProps) {
|
}: SessionsSectionProps) {
|
||||||
return (
|
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">
|
<div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1">
|
||||||
{!hasConfiguredGateways ? (
|
{!hasConfiguredGateways ? (
|
||||||
<DashboardEmptyState message="No gateways are configured for any board yet." />
|
<DashboardEmptyState message="No gateways are configured for any board yet." />
|
||||||
|
|
@ -55,7 +59,11 @@ export function SessionsSection({
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<div
|
<div
|
||||||
key={session.key}
|
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="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|
@ -69,7 +77,9 @@ export function SessionsSection({
|
||||||
/>
|
/>
|
||||||
{session.title}
|
{session.title}
|
||||||
</p>
|
</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>
|
||||||
<div className="min-w-0 max-w-[45%] text-right">
|
<div className="min-w-0 max-w-[45%] text-right">
|
||||||
<p className="truncate text-xs font-medium text-strong">
|
<p className="truncate text-xs font-medium text-strong">
|
||||||
|
|
@ -77,11 +87,11 @@ export function SessionsSection({
|
||||||
? session.costUsd === 0
|
? session.costUsd === 0
|
||||||
? "$0.00"
|
? "$0.00"
|
||||||
: session.costUsd < 0.01
|
: session.costUsd < 0.01
|
||||||
? `$${session.costUsd.toFixed(4)}`
|
? `$${session.costUsd.toFixed(4)}`
|
||||||
: `$${session.costUsd.toFixed(2)}`
|
: `$${session.costUsd.toFixed(2)}`
|
||||||
: session.usage === dash
|
: session.usage === dash
|
||||||
? "Usage unavailable"
|
? "Usage unavailable"
|
||||||
: session.usage}
|
: session.usage}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-[11px] text-muted">
|
<p className="truncate text-[11px] text-muted">
|
||||||
{session.model
|
{session.model
|
||||||
|
|
@ -90,7 +100,9 @@ export function SessionsSection({
|
||||||
: session.model
|
: session.model
|
||||||
: null}
|
: null}
|
||||||
{session.model && session.lastSeenAt ? " · " : null}
|
{session.model && session.lastSeenAt ? " · " : null}
|
||||||
{session.lastSeenAt ? formatRelative(session.lastSeenAt) : "Activity unavailable"}
|
{session.lastSeenAt
|
||||||
|
? formatRelative(session.lastSeenAt)
|
||||||
|
: "Activity unavailable"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
export type Tone = "default" | "success" | "warning" | "danger";
|
export type Tone = "default" | "success" | "warning" | "danger";
|
||||||
export type BadgeTone = "online" | "offline" | "neutral";
|
export type BadgeTone = "online" | "offline" | "neutral";
|
||||||
export type MetricToneKey = "accent" | "success" | "warning" | "danger";
|
export type MetricToneKey = "accent" | "success" | "warning" | "danger";
|
||||||
|
export type SectionToneKey = "neutral" | MetricToneKey;
|
||||||
|
|
||||||
/** Inline text color for a data value. */
|
/** Inline text color for a data value. */
|
||||||
export const toneText: Record<Tone, string> = {
|
export const toneText: Record<Tone, string> = {
|
||||||
|
|
@ -26,26 +27,64 @@ export const toneBanner: Record<Tone, string> = {
|
||||||
|
|
||||||
/** Small pill / badge background + text. */
|
/** Small pill / badge background + text. */
|
||||||
export const toneBadge: Record<BadgeTone, string> = {
|
export const toneBadge: Record<BadgeTone, string> = {
|
||||||
online:
|
online: "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]",
|
||||||
"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)]",
|
||||||
offline:
|
neutral: "bg-[color:var(--surface-strong)] text-muted",
|
||||||
"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. */
|
/** Icon container background + icon color for metric cards. */
|
||||||
export const toneIcon: Record<MetricToneKey, string> = {
|
export const toneIcon: Record<MetricToneKey, string> = {
|
||||||
accent: "bg-[color:var(--accent-soft)] text-[color:var(--accent)]",
|
accent:
|
||||||
success: "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]",
|
"border border-[color:rgba(96,165,250,0.3)] bg-[color:var(--accent-soft)] text-[color:var(--accent-strong)]",
|
||||||
warning: "bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]",
|
success:
|
||||||
danger: "bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
|
"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. */
|
/** Card-level background + border tint for metric cards. */
|
||||||
export const toneCard: Record<MetricToneKey, string> = {
|
export const toneCard: Record<MetricToneKey, string> = {
|
||||||
accent: "border border-[color:rgba(139,92,246,0.28)] bg-[color:rgba(139,92,246,0.08)]",
|
accent:
|
||||||
success: "border border-[color:rgba(52,211,153,0.28)] bg-[color:rgba(52,211,153,0.07)]",
|
"border border-[color:rgba(96,165,250,0.3)] bg-[linear-gradient(145deg,rgba(96,165,250,0.16),var(--surface)_44%)]",
|
||||||
warning: "border border-[color:rgba(251,191,36,0.28)] bg-[color:rgba(251,191,36,0.07)]",
|
success:
|
||||||
danger: "border border-[color:rgba(248,113,113,0.28)] bg-[color:rgba(248,113,113,0.07)]",
|
"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);
|
const d = dateFromIsoDate(date);
|
||||||
return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
|
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
|
// Catmull-Rom → cubic bezier smooth path
|
||||||
function smoothPath(pts: {x:number;y:number}[]): string {
|
function smoothPath(pts: {x:number;y:number}[]): string {
|
||||||
|
|
@ -315,7 +319,7 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
|
||||||
onBlur={() => setHoveredPoint(null)}
|
onBlur={() => setHoveredPoint(null)}
|
||||||
style={{fill:"transparent", outline:"none"}}
|
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>
|
</rect>
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -507,7 +511,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
|
||||||
outline: "none",
|
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>
|
</rect>
|
||||||
{(range === "7d" || i % 5 === 0 || i === heatmap.daily.length - 1) && (
|
{(range === "7d" || i % 5 === 0 || i === heatmap.daily.length - 1) && (
|
||||||
<text
|
<text
|
||||||
|
|
@ -547,7 +551,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
|
||||||
strokeWidth: 1.5,
|
strokeWidth: 1.5,
|
||||||
outline: "none",
|
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>
|
</rect>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,18 @@ import {
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
AlertCircle,
|
||||||
Archive,
|
Archive,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
CircleDot,
|
||||||
Eye,
|
Eye,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
GitCommitHorizontal,
|
||||||
|
GitFork,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DataTable } from "@/components/tables/DataTable";
|
import { DataTable } from "@/components/tables/DataTable";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -28,6 +33,56 @@ import type {
|
||||||
ForgejoRepositoryValidationResponse,
|
ForgejoRepositoryValidationResponse,
|
||||||
} from "@/lib/api-forgejo";
|
} 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 = {
|
type RepositorySyncResult = {
|
||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
|
|
@ -80,8 +135,20 @@ export function ForgejoRepositoriesTable({
|
||||||
rowActions={{
|
rowActions={{
|
||||||
getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`,
|
getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`,
|
||||||
onDelete: onDelete ?? undefined,
|
onDelete: onDelete ?? undefined,
|
||||||
|
cellClassName: "px-3 py-3 align-middle md:px-5",
|
||||||
}}
|
}}
|
||||||
tableClassName="min-w-[900px] w-full text-left text-sm"
|
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 }) => {
|
cell: ({ row }) => {
|
||||||
const repo = row.original;
|
const repo = row.original;
|
||||||
|
const tone = repositoryTone(repo);
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="flex min-w-[280px] items-start gap-3">
|
||||||
<span className="block truncate font-medium text-strong">
|
<div
|
||||||
{repo.display_name || `${repo.owner}/${repo.repo}`}
|
className={cn(
|
||||||
</span>
|
"mt-0.5 rounded-xl border p-2 shadow-[0_0_22px_rgba(96,165,250,0.08)]",
|
||||||
<span className="block truncate font-mono text-xs text-muted">
|
toneClasses[tone].icon,
|
||||||
{repo.owner}/{repo.repo} • {repo.connection?.name}
|
)}
|
||||||
</span>
|
>
|
||||||
{repo.description ? (
|
<GitBranch className="h-4 w-4" />
|
||||||
<span className="mt-1 block max-w-[300px] truncate text-xs text-muted">
|
</div>
|
||||||
{repo.description}
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -133,11 +248,11 @@ const columns = (
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const connection = row.original.connection;
|
const connection = row.original.connection;
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-[180px]">
|
||||||
<span className="block truncate text-sm text-strong">
|
<span className="block truncate font-medium text-sm text-strong">
|
||||||
{connection?.name}
|
{connection?.name}
|
||||||
</span>
|
</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}
|
{connection?.base_url}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -151,8 +266,16 @@ const columns = (
|
||||||
const repo = row.original;
|
const repo = row.original;
|
||||||
const isActive = repo.active;
|
const isActive = repo.active;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex max-w-[230px] flex-wrap gap-1.5">
|
||||||
<Badge variant={isActive ? "default" : "outline"}>
|
<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"}
|
{isActive ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{repo.is_archived ? (
|
{repo.is_archived ? (
|
||||||
|
|
@ -172,6 +295,22 @@ const columns = (
|
||||||
No secret
|
No secret
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -180,11 +319,20 @@ const columns = (
|
||||||
accessorKey: "openIssues",
|
accessorKey: "openIssues",
|
||||||
header: "Issues",
|
header: "Issues",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="min-w-[90px]">
|
<div
|
||||||
<span className="block text-lg font-semibold text-strong">
|
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}
|
{row.original.open_issues_count}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted">open upstream</span>
|
<span className="mt-1 text-[11px] uppercase tracking-wide text-muted">
|
||||||
|
open
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -194,26 +342,36 @@ const columns = (
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const lastSyncAt = row.original.last_sync_at;
|
const lastSyncAt = row.original.last_sync_at;
|
||||||
const lastSyncError = row.original.last_sync_error;
|
const lastSyncError = row.original.last_sync_error;
|
||||||
|
const tone = repositoryTone(row.original);
|
||||||
|
const syncTime = formatSyncTime(lastSyncAt);
|
||||||
|
|
||||||
if (!lastSyncAt) {
|
if (!syncTime) {
|
||||||
return <span className="text-sm text-muted">Never</span>;
|
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 (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex min-w-[150px] items-start gap-2">
|
||||||
<span className="text-sm text-strong">
|
<span
|
||||||
{date.toLocaleDateString()}
|
className={cn("mt-1.5 h-2 w-2 rounded-full", toneClasses[tone].dot)}
|
||||||
</span>
|
/>
|
||||||
<span className="text-xs text-muted">
|
<div className="flex flex-col">
|
||||||
{date.toLocaleTimeString()}
|
<span className="text-sm font-medium text-strong">
|
||||||
</span>
|
{syncTime.date}
|
||||||
{lastSyncError && (
|
|
||||||
<span className="max-w-[220px] truncate text-xs text-[color:var(--danger)]">
|
|
||||||
Error: {lastSyncError.substring(0, 50)}...
|
|
||||||
</span>
|
</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>
|
</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,
|
useGetMeApiV1UsersMeGet,
|
||||||
} from "@/api/generated/users/users";
|
} from "@/api/generated/users/users";
|
||||||
import { BrandMark } from "@/components/atoms/BrandMark";
|
import { BrandMark } from "@/components/atoms/BrandMark";
|
||||||
|
import { AgentActivityTicker } from "@/components/organisms/AgentActivityTicker";
|
||||||
import { OrgSwitcher } from "@/components/organisms/OrgSwitcher";
|
import { OrgSwitcher } from "@/components/organisms/OrgSwitcher";
|
||||||
import { ProviderNavbarStatus } from "@/components/organisms/ProviderNavbarStatus";
|
import { ProviderNavbarStatus } from "@/components/organisms/ProviderNavbarStatus";
|
||||||
import { ThemeToggle } from "@/components/organisms/ThemeToggle";
|
import { ThemeToggle } from "@/components/organisms/ThemeToggle";
|
||||||
|
|
@ -115,6 +116,7 @@ export function DashboardShell({
|
||||||
data-sidebar={sidebarOpen ? "open" : "closed"}
|
data-sidebar={sidebarOpen ? "open" : "closed"}
|
||||||
>
|
>
|
||||||
<header className="sticky top-0 z-50 border-b border-[color:var(--border)] bg-[color:var(--surface)] shadow-sm">
|
<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 py-3">
|
||||||
<div className="flex items-center px-4 md:px-6 md:w-[260px]">
|
<div className="flex items-center px-4 md:px-6 md:w-[260px]">
|
||||||
{isSignedIn ? (
|
{isSignedIn ? (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue