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:
102
apps/web/src/app/posthog-archive/[type]/[id]/page.tsx
Normal file
102
apps/web/src/app/posthog-archive/[type]/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
apps/web/src/app/posthog-archive/page.tsx
Normal file
106
apps/web/src/app/posthog-archive/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 /> },
|
||||
|
||||
99
apps/web/src/lib/analytics/posthog-archive.ts
Normal file
99
apps/web/src/lib/analytics/posthog-archive.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user