feat(analytics): browsable PostHog Archive (/posthog-archive)

Read view over posthog_resource_snapshots: type tabs (feature flags,
experiments, surveys, insights, dashboards, annotations, actions) with
resource counts, latest-per-resource table, and a detail page showing the
full lossless payload + change history (version timeline). Nav entry added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Semih
2026-06-10 21:05:41 +03:00
parent dd54f4983b
commit e953f5fc34
4 changed files with 309 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { PanelShell } from "@/components/panel-shell";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { getResourceHistory, TYPE_LABEL } from "@/lib/analytics/posthog-archive";
export const dynamic = "force-dynamic";
const PROJECT = "sase";
export default async function ResourceDetailPage({
params,
}: {
params: Promise<{ type: string; id: string }>;
}) {
const { type, id } = await params;
const resourceId = decodeURIComponent(id);
const history = await getResourceHistory(PROJECT, type, resourceId);
if (history.length === 0) notFound();
const latest = history[0];
const data = (latest.data ?? {}) as Record<string, unknown>;
const fields: Array<[string, string]> = [];
for (const [k, label] of [
["key", "key"],
["active", "active"],
["type", "type"],
["start_date", "start_date"],
["end_date", "end_date"],
["created_at", "created_at"],
["deleted", "deleted"],
["description", "description"],
] as const) {
const v = data[k];
if (v !== undefined && v !== null && typeof v !== "object") fields.push([label, String(v)]);
}
return (
<PanelShell title={latest.name || resourceId}>
<Link href={`/posthog-archive?type=${type}`} className="text-xs underline text-muted-foreground">
{TYPE_LABEL[type] ?? type}
</Link>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{TYPE_LABEL[type] ?? type}</Badge>
<Badge variant="secondary">{history.length} versiyon</Badge>
<span className="font-mono text-xs text-muted-foreground">id={resourceId}</span>
<span className="text-xs text-muted-foreground">
son: {latest.capturedAt.toISOString().slice(0, 16).replace("T", " ")}
</span>
</div>
{fields.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Özellikler</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-2 text-sm md:grid-cols-2 lg:grid-cols-3">
{fields.map(([l, v]) => (
<div key={l}>
<span className="text-muted-foreground">{l}:</span> {v.slice(0, 140)}
</div>
))}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardDescription>Tam içerik en güncel snapshot (lossless)</CardDescription>
</CardHeader>
<CardContent>
<pre className="max-h-[480px] overflow-auto whitespace-pre-wrap rounded-md bg-muted/30 p-3 font-mono text-xs">
{JSON.stringify(data, null, 2)}
</pre>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Değişiklik geçmişi ({history.length})</CardTitle>
<CardDescription>Yalnızca config değiştiğinde yeni snapshot yazılır</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-1 text-sm">
{history.map((h, i) => (
<li key={h.id} className="flex items-center gap-2">
<Badge variant={i === 0 ? "default" : "outline"}>v{history.length - i}</Badge>
<span className="font-mono text-xs text-muted-foreground">
{h.capturedAt.toISOString().slice(0, 19).replace("T", " ")}
</span>
{i === 0 && <span className="text-xs text-muted-foreground">(güncel)</span>}
</li>
))}
</ul>
</CardContent>
</Card>
</PanelShell>
);
}

View File

@@ -0,0 +1,106 @@
import Link from "next/link";
import { PanelShell } from "@/components/panel-shell";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
getArchiveCounts,
getLatestResources,
RESOURCE_TYPES,
TYPE_LABEL,
type ResourceType,
} from "@/lib/analytics/posthog-archive";
export const dynamic = "force-dynamic";
const PROJECT = "sase";
function isType(v: string | undefined): v is ResourceType {
return !!v && (RESOURCE_TYPES as readonly string[]).includes(v);
}
export default async function PosthogArchivePage({
searchParams,
}: {
searchParams: Promise<{ type?: string }>;
}) {
const sp = await searchParams;
const type: ResourceType = isType(sp.type) ? sp.type : "feature_flag";
const [counts, resources] = await Promise.all([
getArchiveCounts(PROJECT),
getLatestResources(PROJECT, type),
]);
return (
<PanelShell title="PostHog Arşiv">
<p className="text-sm text-muted-foreground">
PostHog&apos;daki tüm kaynak modülleri (survey, feature flag, experiment, dashboard, insight,
annotation, action) değişiklik geçmişiyle birlikte arşivleniyor (~6 saat tazelik, DB + MinIO).
</p>
<div className="flex flex-wrap gap-1.5">
{counts.map((c) => (
<Link
key={c.type}
href={`/posthog-archive?type=${c.type}`}
className={buttonVariants({ variant: c.type === type ? "default" : "outline", size: "sm" })}
>
{TYPE_LABEL[c.type] ?? c.type}
<Badge variant="secondary" className="ml-1.5">
{c.resources}
</Badge>
</Link>
))}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Ad</TableHead>
<TableHead className="w-28">ID</TableHead>
<TableHead className="w-24 text-right">Versiyon</TableHead>
<TableHead className="w-36 text-right">Son yakalama</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resources.map((r) => (
<TableRow key={r.resourceId}>
<TableCell className="max-w-[460px] truncate font-medium">
<Link
href={`/posthog-archive/${type}/${encodeURIComponent(r.resourceId)}`}
className="underline"
>
{r.name || `(adsız ${type})`}
</Link>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{r.resourceId}</TableCell>
<TableCell className="text-right tabular-nums">
{r.versions > 1 ? <Badge variant="outline">{r.versions}</Badge> : r.versions}
</TableCell>
<TableCell className="text-right font-mono text-xs text-muted-foreground">
{r.capturedAt.toISOString().slice(0, 16).replace("T", " ")}
</TableCell>
</TableRow>
))}
{resources.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-sm text-muted-foreground">
Bu tip için henüz arşiv yok.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</PanelShell>
);
}

View File

@@ -5,6 +5,7 @@ import {
ActivityIcon,
BarChart3Icon,
CommandIcon,
DatabaseIcon,
FolderIcon,
LayoutDashboardIcon,
LightbulbIcon,
@@ -33,6 +34,7 @@ const navMain = [
{ title: "Operations", url: "/operations", icon: <TerminalIcon /> },
{ title: "Insights", url: "/insights", icon: <LightbulbIcon /> },
{ title: "Analytics", url: "/analytics", icon: <BarChart3Icon /> },
{ title: "PH Archive", url: "/posthog-archive", icon: <DatabaseIcon /> },
{ title: "Content", url: "/content", icon: <PenLineIcon /> },
{ title: "Events", url: "/events", icon: <ActivityIcon /> },
{ title: "Audit", url: "/audit", icon: <ScrollTextIcon /> },

View File

@@ -0,0 +1,99 @@
import { prisma } from "@/lib/db";
export const RESOURCE_TYPES = [
"feature_flag",
"experiment",
"survey",
"insight",
"dashboard",
"annotation",
"action",
] as const;
export type ResourceType = (typeof RESOURCE_TYPES)[number];
export const TYPE_LABEL: Record<string, string> = {
feature_flag: "Feature Flags",
experiment: "Experiments",
survey: "Surveys",
insight: "Insights",
dashboard: "Dashboards",
annotation: "Annotations",
action: "Actions",
};
export type ArchiveCount = { type: string; resources: number; snapshots: number; lastCaptured: Date | null };
export async function getArchiveCounts(projectKey: string): Promise<ArchiveCount[]> {
const rows = await prisma.$queryRaw<
Array<{ resourceType: string; resources: bigint; snapshots: bigint; lastCaptured: Date }>
>`
SELECT "resourceType",
count(DISTINCT "resourceId")::bigint AS resources,
count(*)::bigint AS snapshots,
max("capturedAt") AS "lastCaptured"
FROM posthog_resource_snapshots
WHERE "projectKey" = ${projectKey}
GROUP BY "resourceType"
`;
const byType = new Map(rows.map((r) => [r.resourceType, r]));
return RESOURCE_TYPES.map((t) => {
const r = byType.get(t);
return {
type: t,
resources: r ? Number(r.resources) : 0,
snapshots: r ? Number(r.snapshots) : 0,
lastCaptured: r?.lastCaptured ?? null,
};
});
}
export type LatestResource = {
resourceId: string;
name: string | null;
capturedAt: Date;
versions: number;
};
export async function getLatestResources(projectKey: string, type: string): Promise<LatestResource[]> {
const rows = await prisma.$queryRaw<
Array<{ resourceId: string; name: string | null; capturedAt: Date; versions: bigint }>
>`
WITH ranked AS (
SELECT "resourceId", name, "capturedAt",
row_number() OVER (PARTITION BY "resourceId" ORDER BY "capturedAt" DESC) AS rn,
count(*) OVER (PARTITION BY "resourceId") AS versions
FROM posthog_resource_snapshots
WHERE "projectKey" = ${projectKey} AND "resourceType" = ${type}
)
SELECT "resourceId", name, "capturedAt", versions::bigint AS versions
FROM ranked WHERE rn = 1
ORDER BY "capturedAt" DESC
`;
return rows.map((r) => ({
resourceId: r.resourceId,
name: r.name,
capturedAt: r.capturedAt,
versions: Number(r.versions),
}));
}
export type ResourceSnapshot = {
id: string;
capturedAt: Date;
name: string | null;
data: unknown;
};
export async function getResourceHistory(
projectKey: string,
type: string,
resourceId: string,
): Promise<ResourceSnapshot[]> {
const rows = await prisma.posthogResourceSnapshot.findMany({
where: { projectKey, resourceType: type, resourceId },
orderBy: { capturedAt: "desc" },
take: 50,
select: { id: true, capturedAt: true, name: true, data: true },
});
return rows;
}