feat(home): founder daily overview

Replaces the previous "active spokes / planned / audit24h / audit7d" home
with a synthesizing daily-overview page. Pulls from every module shipped
to date (VIN observability, user management, BIP, audit log, Coolify
deploys) and answers a single question: "what should I look at today?"

Repo (lib/sase/daily-overview.ts)
- getSaseHealthSnapshot — 24h vs prior-24h success rate + volume; single
  raw query with FILTER aggs on the two windows.
- getInsightSummary — open insights grouped by severity (P0..P3), plus
  recent-7d created and 7d-shipped counts (from panel-pg insights).
- getSaseUserCounts — total/new/active-sub/trial/cancelled, suspended,
  banned, dormant payers (14d), empty-handed payers. Two correlated
  subqueries for dormant/empty since they need NOT EXISTS over
  query_logs.
- getRecentAdminActions — last N audit_log mutations (excluding GET and
  /api/internal/*). Maps each endpoint to a human label
  ("Impersonate", "Refund", …) and a deep-link target where possible.
- getRecentDeployStatuses — last N Sase Coolify deploys with
  regression-flag derived from the ±30min slice analyzer.
- buildActionItems — synthesizes "things you should look at" from all
  the above, sorted critical → low. Surfaces deploy regressions, open
  P0/P1 insights, big 24h success drop, dormant/empty payers,
  suspended users.

UI (app/page.tsx)
- Six-column KPI strip across the top: Sase 24h success (color-coded
  by threshold, with pp delta vs prior 24h), volume delta, active sub,
  new users 7d, open insight total, audit count.
- "Bugün bakmam gerekenler" action-item list, color-coded by severity,
  each clickable.
- Two-column split: open-insights-by-priority (links to /insights with
  the severity filter), Sase user health counts (dormant/empty/
  suspended/banned with links).
- Two-column split: last 5 deploys with Δ-success column + regression
  badge, last 10 admin actions with human label + status + deep link.
- Projects grid moved to the bottom as a compact strip (was the
  primary section before).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 14:04:36 +03:00
parent a10996f6c5
commit c7346fd25e
2 changed files with 740 additions and 57 deletions

View File

@@ -1,97 +1,407 @@
import Link from "next/link";
import { PanelShell } from "@/components/panel-shell";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ProjectBadge } from "@/lib/project-badge";
import { prisma } from "@/lib/db";
import { saseDb } from "@/lib/db-sase";
import {
buildActionItems,
getInsightSummary,
getRecentAdminActions,
getRecentDeployStatuses,
getSaseHealthSnapshot,
getSaseUserCounts,
} from "@/lib/sase/daily-overview";
export const dynamic = "force-dynamic";
async function fetchProjectMetric(key: string): Promise<string | null> {
try {
if (key === "sase") {
const users = await saseDb.user.count();
return `${users} users`;
}
return null;
} catch {
return null;
}
}
async function fetchPanelStats() {
const [audit24h, audit7d] = await Promise.all([
prisma.auditLog.count({
where: { createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
}),
prisma.auditLog.count({
where: { createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } },
}),
]);
return { audit24h, audit7d };
}
export default async function Home() {
const [projects, stats] = await Promise.all([
prisma.project.findMany({ orderBy: [{ status: "desc" }, { name: "asc" }] }),
fetchPanelStats(),
]);
const [projects, health, insights, users, deploys, actions, audit24h] =
await Promise.all([
prisma.project.findMany({ orderBy: [{ status: "desc" }, { name: "asc" }] }),
getSaseHealthSnapshot(),
getInsightSummary(),
getSaseUserCounts(),
getRecentDeployStatuses(5),
getRecentAdminActions(15),
prisma.auditLog.count({
where: { createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
}),
]);
const projectsWithMetric = await Promise.all(
projects.map(async (p) => ({ ...p, metric: await fetchProjectMetric(p.key) })),
);
const active = projectsWithMetric.filter((p) => p.status === "active").length;
const planned = projectsWithMetric.filter((p) => p.status === "planned").length;
const actionItems = buildActionItems({ health, insights, users, deploys });
return (
<PanelShell title="Overview">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<KpiCard label="Active spokes" value={active.toString()} hint={`of ${projects.length} total`} />
<KpiCard label="Planned" value={planned.toString()} hint="awaiting wiring" />
<KpiCard label="Audit (24h)" value={stats.audit24h.toString()} hint="all actions logged" />
<KpiCard label="Audit (7d)" value={stats.audit7d.toString()} hint="rolling window" />
<PanelShell title="Bugün">
{/* At-a-glance health */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
<Kpi
label="Sase 24h success"
value={`${(health.successRate24h * 100).toFixed(1)}%`}
hint={`${health.total24h.toLocaleString("tr-TR")} sorgu`}
delta={health.successDeltaPp}
deltaUnit="pp"
tone={
health.successRate24h >= 0.9
? "ok"
: health.successRate24h >= 0.75
? "warn"
: "bad"
}
/>
<Kpi
label="Sase 24h volume"
value={health.total24h.toLocaleString("tr-TR")}
delta={health.volumeDeltaPct * 100}
deltaUnit="%"
/>
<Kpi
label="Active sub"
value={users.activeSubscriptions.toLocaleString("tr-TR")}
hint={`${users.trialSubscriptions} trial · ${users.cancelledSubscriptions} cancelled`}
/>
<Kpi
label="Yeni user (7g)"
value={users.newUsersThisWeek.toLocaleString("tr-TR")}
hint={`toplam ${users.totalUsers}`}
/>
<Kpi
label="Açık insight"
value={insights.openByPriority
.reduce((a, b) => a + b.count, 0)
.toLocaleString("tr-TR")}
hint={`${insights.recent7d} 7g · ${insights.shipped7d} shipped`}
/>
<Kpi
label="Audit (24h)"
value={audit24h.toLocaleString("tr-TR")}
hint="admin mutations"
/>
</div>
{/* Action items */}
<Card>
<CardHeader>
<CardDescription>Bugün bakmam gerekenler</CardDescription>
</CardHeader>
<CardContent>
{actionItems.length === 0 ? (
<p className="text-sm text-muted-foreground">
Hiçbir aksiyon önerisi yok her şey sakin.
</p>
) : (
<ul className="space-y-1">
{actionItems.map((a, i) => (
<li key={i} className="text-sm">
<Link
href={a.href}
className="flex items-center gap-2 rounded-md p-1.5 hover:bg-muted/40"
>
<SeverityBadge severity={a.severity} />
<span className="flex-1">{a.text}</span>
<span className="text-xs text-muted-foreground"></span>
</Link>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{/* Insight breakdown */}
<Card>
<CardHeader>
<CardDescription>ık insight (öncelik bazında)</CardDescription>
</CardHeader>
<CardContent>
{insights.openByPriority.length === 0 ? (
<p className="text-sm text-muted-foreground">
ık insight yok.{" "}
<Link href="/insights" className="underline">
/insights
</Link>
</p>
) : (
<ul className="space-y-1 text-sm">
{insights.openByPriority.map((b) => (
<li key={b.severity} className="flex items-center justify-between">
<Link
href={`/insights?severity=${b.severity}`}
className="flex items-center gap-2 hover:underline"
>
<Badge
variant={
b.severity === "P0"
? "destructive"
: b.severity === "P1"
? "default"
: "outline"
}
>
{b.severity}
</Badge>
<span>insight</span>
</Link>
<span className="tabular-nums">{b.count}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{/* Dormant/empty */}
<Card>
<CardHeader>
<CardDescription>Sase user health</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-1 text-sm">
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
Dormant payer (14g+ inaktif)
</span>
<Link
href="/projects/sase/vin-decode/business"
className="tabular-nums hover:underline"
>
{users.dormantPayerCount}
</Link>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
Empty-handed (0 decode)
</span>
<Link
href="/projects/sase/vin-decode/business"
className="tabular-nums hover:underline"
>
{users.emptyHandedPayerCount}
</Link>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Suspended user</span>
<span className="tabular-nums">{users.suspendedUsers}</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Banned user</span>
<span className="tabular-nums">{users.bannedUsers}</span>
</li>
</ul>
</CardContent>
</Card>
</div>
{/* Recent deploys + recent admin actions */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Son Sase.tr deploy'ları</CardDescription>
</CardHeader>
<CardContent>
{deploys.length === 0 ? (
<p className="text-sm text-muted-foreground">
Coolify deploy bilgisi yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Tarih</TableHead>
<TableHead>Commit</TableHead>
<TableHead className="text-right">Δ success</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deploys.map((d) => (
<TableRow key={d.deploymentUuid}>
<TableCell className="font-mono text-xs">
{d.startedAt.toISOString().slice(5, 16).replace("T", " ")}
</TableCell>
<TableCell className="font-mono text-xs">
{d.commit?.slice(0, 8) ?? "—"}
{d.regressed && (
<Badge variant="destructive" className="ml-2">
regression
</Badge>
)}
</TableCell>
<TableCell
className={`text-right text-xs tabular-nums ${
d.regressed
? "text-destructive"
: !d.postWindowElapsed
? "text-muted-foreground"
: d.successRateDeltaPp != null && d.successRateDeltaPp > 0
? "text-emerald-600"
: ""
}`}
>
{!d.postWindowElapsed
? "bekleniyor"
: d.successRateDeltaPp != null
? `${d.successRateDeltaPp > 0 ? "+" : ""}${d.successRateDeltaPp.toFixed(1)}pp`
: "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>Son admin aksiyonlar (founder)</CardDescription>
</CardHeader>
<CardContent>
{actions.length === 0 ? (
<p className="text-sm text-muted-foreground">Aksiyon yok.</p>
) : (
<ul className="space-y-1 text-sm">
{actions.slice(0, 10).map((a) => (
<li key={a.id} className="flex items-baseline gap-2 text-xs">
<span className="w-28 font-mono text-muted-foreground">
{a.createdAt.toISOString().slice(5, 16).replace("T", " ")}
</span>
<Badge variant="outline" className="font-mono">
{a.method}
</Badge>
{a.link ? (
<Link
href={a.link}
className="flex-1 truncate hover:underline"
title={a.endpoint}
>
{a.label}
</Link>
) : (
<span className="flex-1 truncate" title={a.endpoint}>
{a.label}
</span>
)}
<span className="tabular-nums text-muted-foreground">
{a.status ?? "—"}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
{/* Projects (compact) */}
<div>
<h2 className="mb-3 text-sm font-medium text-muted-foreground">Projects</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{projectsWithMetric.map((p) => (
<h2 className="mb-2 text-sm font-medium text-muted-foreground">
Projects
</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => (
<Link key={p.id} href={`/projects/${p.key}`} className="block">
<Card className="h-full transition-colors hover:border-foreground/40">
<CardHeader>
<CardHeader className="space-y-1">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{p.name}</CardTitle>
<ProjectBadge status={p.status} />
</div>
<CardDescription>{p.description ?? "—"}</CardDescription>
<CardDescription className="line-clamp-2">
{p.description ?? "—"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-sm tabular-nums">
{p.metric ?? <span className="text-muted-foreground"></span>}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
<p className="mt-4 text-xs text-muted-foreground">
Tip: <kbd className="rounded border px-1.5 py-0.5">K</kbd> for command palette
<p className="mt-2 text-xs text-muted-foreground">
Tip: <kbd className="rounded border px-1.5 py-0.5">K</kbd> for command
palette · <Link href="/audit" className="underline">/audit</Link> full log
</p>
</PanelShell>
);
}
function KpiCard({ label, value, hint }: { label: string; value: string; hint: string }) {
function Kpi({
label,
value,
hint,
delta,
deltaUnit,
tone,
}: {
label: string;
value: string;
hint?: string;
delta?: number;
deltaUnit?: string;
tone?: "ok" | "warn" | "bad";
}) {
const toneColor =
tone === "ok"
? "text-emerald-600"
: tone === "warn"
? "text-yellow-600"
: tone === "bad"
? "text-destructive"
: "";
return (
<Card>
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{value}</CardTitle>
<CardTitle className={`text-2xl font-semibold tabular-nums ${toneColor}`}>
{value}
</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">{hint}</CardContent>
{(hint || delta != null) && (
<CardContent className="text-xs text-muted-foreground">
{delta != null && (
<span
className={
delta > 0.1
? "text-emerald-600"
: delta < -0.1
? "text-destructive"
: "text-muted-foreground"
}
>
{delta > 0 ? "+" : ""}
{delta.toFixed(1)}
{deltaUnit ?? ""}{" "}
</span>
)}
{hint}
</CardContent>
)}
</Card>
);
}
function SeverityBadge({ severity }: { severity: "critical" | "high" | "medium" | "low" }) {
if (severity === "critical") return <Badge variant="destructive">critical</Badge>;
if (severity === "high") return <Badge variant="default">high</Badge>;
if (severity === "medium")
return (
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700">
medium
</Badge>
);
return <Badge variant="outline">low</Badge>;
}