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>;
|
||||
}
|
||||
|
||||
373
apps/web/src/lib/sase/daily-overview.ts
Normal file
373
apps/web/src/lib/sase/daily-overview.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { saseDb } from "@/lib/db-sase";
|
||||
import { listSaseDeploys, analyzeDeployRegressions } from "./deploy-timeline";
|
||||
|
||||
// ─── Sase 24h snapshot (current vs prior 24h) ────────────────────────────
|
||||
export type SaseHealthSnapshot = {
|
||||
total24h: number;
|
||||
successRate24h: number;
|
||||
totalPrior24h: number;
|
||||
successRatePrior24h: number;
|
||||
volumeDeltaPct: number; // null-safe: 0 if prior is 0
|
||||
successDeltaPp: number;
|
||||
};
|
||||
|
||||
export async function getSaseHealthSnapshot(): Promise<SaseHealthSnapshot> {
|
||||
const now = new Date();
|
||||
const start24 = new Date(now.getTime() - 24 * 60 * 60_000);
|
||||
const start48 = new Date(now.getTime() - 48 * 60 * 60_000);
|
||||
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
cur_total: bigint;
|
||||
cur_succ: bigint;
|
||||
prior_total: bigint;
|
||||
prior_succ: bigint;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
count(*) FILTER (WHERE created_at >= ${start24}) AS cur_total,
|
||||
count(*) FILTER (WHERE created_at >= ${start24} AND success = true) AS cur_succ,
|
||||
count(*) FILTER (WHERE created_at >= ${start48} AND created_at < ${start24}) AS prior_total,
|
||||
count(*) FILTER (WHERE created_at >= ${start48} AND created_at < ${start24} AND success = true) AS prior_succ
|
||||
FROM query_logs
|
||||
WHERE created_at >= ${start48}
|
||||
`;
|
||||
|
||||
const r = rows[0] ?? {
|
||||
cur_total: 0n,
|
||||
cur_succ: 0n,
|
||||
prior_total: 0n,
|
||||
prior_succ: 0n,
|
||||
};
|
||||
const curTotal = Number(r.cur_total);
|
||||
const curSucc = Number(r.cur_succ);
|
||||
const priorTotal = Number(r.prior_total);
|
||||
const priorSucc = Number(r.prior_succ);
|
||||
|
||||
const curRate = curTotal > 0 ? curSucc / curTotal : 0;
|
||||
const priorRate = priorTotal > 0 ? priorSucc / priorTotal : 0;
|
||||
|
||||
return {
|
||||
total24h: curTotal,
|
||||
successRate24h: curRate,
|
||||
totalPrior24h: priorTotal,
|
||||
successRatePrior24h: priorRate,
|
||||
volumeDeltaPct:
|
||||
priorTotal > 0 ? (curTotal - priorTotal) / priorTotal : 0,
|
||||
successDeltaPp: (curRate - priorRate) * 100,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Insight pipeline summary ────────────────────────────────────────────
|
||||
export type InsightSummary = {
|
||||
openByPriority: Array<{ severity: string; count: number }>;
|
||||
recent7d: number;
|
||||
shipped7d: number;
|
||||
};
|
||||
|
||||
export async function getInsightSummary(): Promise<InsightSummary> {
|
||||
const since7 = new Date(Date.now() - 7 * 24 * 60 * 60_000);
|
||||
|
||||
const [byPriority, recent7d, shipped7d] = await Promise.all([
|
||||
prisma.insight.groupBy({
|
||||
by: ["severity"],
|
||||
where: {
|
||||
status: { in: ["new", "in_backlog", "investigating"] },
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.insight.count({
|
||||
where: { createdAt: { gte: since7 } },
|
||||
}),
|
||||
prisma.insight.count({
|
||||
where: { status: "shipped", shippedAt: { gte: since7 } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const order = ["P0", "P1", "P2", "P3"];
|
||||
const openByPriority = order
|
||||
.map((sev) => ({
|
||||
severity: sev,
|
||||
count: byPriority.find((b) => b.severity === sev)?._count._all ?? 0,
|
||||
}))
|
||||
.filter((b) => b.count > 0);
|
||||
|
||||
return { openByPriority, recent7d, shipped7d };
|
||||
}
|
||||
|
||||
// ─── Sase user-mgmt counts ────────────────────────────────────────────────
|
||||
export type SaseUserCounts = {
|
||||
totalUsers: number;
|
||||
newUsersThisWeek: number;
|
||||
activeSubscriptions: number;
|
||||
trialSubscriptions: number;
|
||||
cancelledSubscriptions: number;
|
||||
suspendedUsers: number;
|
||||
bannedUsers: number;
|
||||
dormantPayerCount: number;
|
||||
emptyHandedPayerCount: number;
|
||||
};
|
||||
|
||||
export async function getSaseUserCounts(): Promise<SaseUserCounts> {
|
||||
const since7 = new Date(Date.now() - 7 * 24 * 60 * 60_000);
|
||||
const dormantCutoff = new Date(Date.now() - 14 * 24 * 60 * 60_000);
|
||||
|
||||
const [
|
||||
totalUsers,
|
||||
newUsersThisWeek,
|
||||
activeSubs,
|
||||
trialSubs,
|
||||
cancelledSubs,
|
||||
suspendedUsers,
|
||||
bannedUsers,
|
||||
] = await Promise.all([
|
||||
saseDb.user.count(),
|
||||
saseDb.user.count({ where: { createdAt: { gte: since7 } } }),
|
||||
saseDb.userSubscription.count({ where: { status: "active" } }),
|
||||
saseDb.userSubscription.count({ where: { status: "trial" } }),
|
||||
saseDb.userSubscription.count({ where: { status: "cancelled" } }),
|
||||
saseDb.user.count({ where: { status: "suspended" } }),
|
||||
saseDb.user.count({ where: { status: "banned" } }),
|
||||
]);
|
||||
|
||||
const [dormantRows, emptyRows] = await Promise.all([
|
||||
saseDb.$queryRaw<Array<{ cnt: bigint }>>`
|
||||
SELECT count(*) AS cnt
|
||||
FROM users u
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM user_subscriptions us
|
||||
WHERE us.user_id = u.id AND us.status = 'active'
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM query_logs q WHERE q.user_id = u.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM query_logs q
|
||||
WHERE q.user_id = u.id AND q.created_at >= ${dormantCutoff}
|
||||
)
|
||||
`,
|
||||
saseDb.$queryRaw<Array<{ cnt: bigint }>>`
|
||||
SELECT count(*) AS cnt
|
||||
FROM users u
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM user_subscriptions us
|
||||
WHERE us.user_id = u.id AND us.status = 'active'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM query_logs q WHERE q.user_id = u.id
|
||||
)
|
||||
`,
|
||||
]);
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
newUsersThisWeek,
|
||||
activeSubscriptions: activeSubs,
|
||||
trialSubscriptions: trialSubs,
|
||||
cancelledSubscriptions: cancelledSubs,
|
||||
suspendedUsers,
|
||||
bannedUsers,
|
||||
dormantPayerCount: Number(dormantRows[0]?.cnt ?? 0n),
|
||||
emptyHandedPayerCount: Number(emptyRows[0]?.cnt ?? 0n),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Recent admin actions (human-readable) ────────────────────────────────
|
||||
export type AdminAction = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
actor: string | null;
|
||||
projectKey: string | null;
|
||||
label: string;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
status: number | null;
|
||||
// Deep-link target if we can parse a user/sub/payment id out of the endpoint.
|
||||
link: string | null;
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Array<{ pattern: RegExp; label: string }> = [
|
||||
{ pattern: /\/users\/[^/]+\/lifecycle/, label: "User lifecycle" },
|
||||
{ pattern: /\/users\/[^/]+\/impersonate-readonly/, label: "Impersonate (read-only)" },
|
||||
{ pattern: /\/users\/[^/]+\/notes/, label: "Founder note" },
|
||||
{ pattern: /\/notes\/[^/]+/, label: "Note edit/delete" },
|
||||
{ pattern: /\/subscriptions\/[^/]+\/brands/, label: "Brand reassignment" },
|
||||
{ pattern: /\/subscriptions\/[^/]+\/trial-extend/, label: "Trial extend / bonus" },
|
||||
{ pattern: /\/subscriptions\/[^/]+\/activate/, label: "Subscription activate" },
|
||||
{ pattern: /\/subscriptions\/[^/]+\/change-plan/, label: "Plan change" },
|
||||
{ pattern: /\/subscriptions\/[^/]+\/cancel/, label: "Subscription cancel" },
|
||||
{ pattern: /\/subscriptions\/[^/]+\/resume/, label: "Subscription resume" },
|
||||
{ pattern: /\/payments\/[^/]+\/refund/, label: "Refund" },
|
||||
];
|
||||
|
||||
function labelForEndpoint(endpoint: string): string {
|
||||
for (const { pattern, label } of ACTION_LABELS) {
|
||||
if (pattern.test(endpoint)) return label;
|
||||
}
|
||||
return endpoint.split("/").pop() ?? endpoint;
|
||||
}
|
||||
|
||||
function linkForEndpoint(endpoint: string): string | null {
|
||||
const userMatch = endpoint.match(/\/users\/([0-9a-f-]{36})/i);
|
||||
if (userMatch) return `/projects/sase/users/${userMatch[1]}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getRecentAdminActions(limit = 15): Promise<AdminAction[]> {
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
method: { not: "GET" },
|
||||
// Filter out background panel calls so the feed shows founder activity.
|
||||
endpoint: { not: { contains: "/api/internal/" } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
createdAt: r.createdAt,
|
||||
actor: r.actorUserId,
|
||||
projectKey: r.projectKey,
|
||||
label: labelForEndpoint(r.endpoint),
|
||||
endpoint: r.endpoint,
|
||||
method: r.method,
|
||||
status: r.responseStatus,
|
||||
link: linkForEndpoint(r.endpoint),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Recent deploys + regression flag ─────────────────────────────────────
|
||||
export type DeployStatus = {
|
||||
deploymentUuid: string;
|
||||
commit: string | null;
|
||||
startedAt: Date;
|
||||
finishedAt: Date | null;
|
||||
regressed: boolean;
|
||||
successRateDeltaPp: number | null;
|
||||
beforeSuccessRate: number | null;
|
||||
afterSuccessRate: number | null;
|
||||
postWindowElapsed: boolean;
|
||||
};
|
||||
|
||||
export async function getRecentDeployStatuses(limit = 5): Promise<DeployStatus[]> {
|
||||
const deploys = await listSaseDeploys(limit);
|
||||
if (deploys.length === 0) return [];
|
||||
const analyses = await analyzeDeployRegressions(deploys);
|
||||
return deploys.map((d) => {
|
||||
const a = analyses.find((x) => x.deploy.deploymentUuid === d.deploymentUuid);
|
||||
return {
|
||||
deploymentUuid: d.deploymentUuid,
|
||||
commit: d.commit,
|
||||
startedAt: d.startedAt,
|
||||
finishedAt: d.finishedAt,
|
||||
regressed: a?.regressed ?? false,
|
||||
successRateDeltaPp: a?.successRateDeltaPp ?? null,
|
||||
beforeSuccessRate: a?.before.successRate ?? null,
|
||||
afterSuccessRate: a?.after.successRate ?? null,
|
||||
postWindowElapsed: a != null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Synthesized action items ─────────────────────────────────────────────
|
||||
export type ActionItem = {
|
||||
severity: "critical" | "high" | "medium" | "low";
|
||||
text: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export function buildActionItems(input: {
|
||||
health: SaseHealthSnapshot;
|
||||
insights: InsightSummary;
|
||||
users: SaseUserCounts;
|
||||
deploys: DeployStatus[];
|
||||
}): ActionItem[] {
|
||||
const items: ActionItem[] = [];
|
||||
|
||||
// Unresolved regressions
|
||||
const recentRegressions = input.deploys.filter((d) => d.regressed);
|
||||
for (const d of recentRegressions) {
|
||||
items.push({
|
||||
severity: "critical",
|
||||
text: `Deploy regresyonu: ${d.commit?.slice(0, 8) ?? "?"} (${d.successRateDeltaPp?.toFixed(1)}pp düşüş)`,
|
||||
href: "/projects/sase/vin-decode",
|
||||
});
|
||||
}
|
||||
|
||||
// Open P0/P1 insights
|
||||
const p0 = input.insights.openByPriority.find((b) => b.severity === "P0")?.count ?? 0;
|
||||
const p1 = input.insights.openByPriority.find((b) => b.severity === "P1")?.count ?? 0;
|
||||
if (p0 > 0) {
|
||||
items.push({
|
||||
severity: "critical",
|
||||
text: `${p0} P0 insight triage bekliyor`,
|
||||
href: "/insights?severity=P0",
|
||||
});
|
||||
}
|
||||
if (p1 > 0) {
|
||||
items.push({
|
||||
severity: "high",
|
||||
text: `${p1} P1 insight triage bekliyor`,
|
||||
href: "/insights?severity=P1",
|
||||
});
|
||||
}
|
||||
|
||||
// Big success rate drop (24h vs prior)
|
||||
if (
|
||||
input.health.successDeltaPp <= -3 &&
|
||||
input.health.total24h >= 20 &&
|
||||
input.health.totalPrior24h >= 20
|
||||
) {
|
||||
items.push({
|
||||
severity:
|
||||
input.health.successDeltaPp <= -10 ? "critical" : "high",
|
||||
text: `Sase success rate düşüşü: ${(input.health.successRatePrior24h * 100).toFixed(1)}% → ${(input.health.successRate24h * 100).toFixed(1)}% (${input.health.successDeltaPp.toFixed(1)}pp)`,
|
||||
href: "/projects/sase/vin-decode",
|
||||
});
|
||||
}
|
||||
|
||||
// Dormant payers
|
||||
if (input.users.dormantPayerCount > 0) {
|
||||
items.push({
|
||||
severity:
|
||||
input.users.dormantPayerCount >= 10 ? "high" : "medium",
|
||||
text: `${input.users.dormantPayerCount} dormant payer (active sub, 14g+ kullanmıyor)`,
|
||||
href: "/projects/sase/vin-decode/business",
|
||||
});
|
||||
}
|
||||
|
||||
// Empty-handed payers
|
||||
if (input.users.emptyHandedPayerCount > 0) {
|
||||
items.push({
|
||||
severity: "high",
|
||||
text: `${input.users.emptyHandedPayerCount} empty-handed payer (sub var, 0 decode — onboarding kırık)`,
|
||||
href: "/projects/sase/vin-decode/business",
|
||||
});
|
||||
}
|
||||
|
||||
// Suspended/banned users count
|
||||
if (input.users.suspendedUsers > 0) {
|
||||
items.push({
|
||||
severity: "low",
|
||||
text: `${input.users.suspendedUsers} suspended user var`,
|
||||
href: "/projects/sase/users?status=suspended",
|
||||
});
|
||||
}
|
||||
|
||||
return items.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
|
||||
}
|
||||
|
||||
function severityRank(s: ActionItem["severity"]): number {
|
||||
switch (s) {
|
||||
case "critical":
|
||||
return 0;
|
||||
case "high":
|
||||
return 1;
|
||||
case "medium":
|
||||
return 2;
|
||||
case "low":
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user