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 Link from "next/link";
import { PanelShell } from "@/components/panel-shell"; 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 { ProjectBadge } from "@/lib/project-badge";
import { prisma } from "@/lib/db"; 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"; 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() { export default async function Home() {
const [projects, stats] = await Promise.all([ const [projects, health, insights, users, deploys, actions, audit24h] =
prisma.project.findMany({ orderBy: [{ status: "desc" }, { name: "asc" }] }), await Promise.all([
fetchPanelStats(), 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( const actionItems = buildActionItems({ health, insights, users, deploys });
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;
return ( return (
<PanelShell title="Overview"> <PanelShell title="Bugün">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4"> {/* At-a-glance health */}
<KpiCard label="Active spokes" value={active.toString()} hint={`of ${projects.length} total`} /> <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
<KpiCard label="Planned" value={planned.toString()} hint="awaiting wiring" /> <Kpi
<KpiCard label="Audit (24h)" value={stats.audit24h.toString()} hint="all actions logged" /> label="Sase 24h success"
<KpiCard label="Audit (7d)" value={stats.audit7d.toString()} hint="rolling window" /> 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> </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> <div>
<h2 className="mb-3 text-sm font-medium text-muted-foreground">Projects</h2> <h2 className="mb-2 text-sm font-medium text-muted-foreground">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> Projects
{projectsWithMetric.map((p) => ( </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"> <Link key={p.id} href={`/projects/${p.key}`} className="block">
<Card className="h-full transition-colors hover:border-foreground/40"> <Card className="h-full transition-colors hover:border-foreground/40">
<CardHeader> <CardHeader className="space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-base">{p.name}</CardTitle> <CardTitle className="text-base">{p.name}</CardTitle>
<ProjectBadge status={p.status} /> <ProjectBadge status={p.status} />
</div> </div>
<CardDescription>{p.description ?? "—"}</CardDescription> <CardDescription className="line-clamp-2">
{p.description ?? "—"}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent>
<div className="text-sm tabular-nums">
{p.metric ?? <span className="text-muted-foreground"></span>}
</div>
</CardContent>
</Card> </Card>
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
<p className="mt-4 text-xs text-muted-foreground"> <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 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> </p>
</PanelShell> </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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardDescription>{label}</CardDescription> <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> </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> </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>;
}

View 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;
}
}