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:
@@ -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>Açık insight (öncelik bazında)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{insights.openByPriority.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Açı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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user