Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions plugin/dashboard/app/api/archive-status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { readArchiveStatus } from "@/lib/archive-status";

export const dynamic = "force-dynamic";

export async function GET() {
return NextResponse.json(await readArchiveStatus());
}
76 changes: 53 additions & 23 deletions plugin/dashboard/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import {
BookOpen,
Archive,
MessageSquare,
Sparkles,
Activity,
Expand All @@ -16,7 +17,7 @@ import { StatCard } from "@/components/common/stat-card";
import { EmptyState } from "@/components/common/empty-state";
import { Badge } from "@/components/ui/badge";
import { reflexio } from "@/lib/reflexio-client";
import { formatRelative, truncate, truncateId } from "@/lib/format";
import { formatBytes, formatRelative, truncate, truncateId } from "@/lib/format";
import { agentPlaybookStatusLabel } from "@/lib/status";
import type {
AgentPlaybook,
Expand All @@ -37,6 +38,14 @@ interface RecentLearning {
statKey: string;
}

interface ArchiveStatus {
enabled: boolean;
entryCount: number;
sizeBytes: number;
warningBytes: number;
exceeded: boolean;
}

export default function DashboardPage() {
const [sessions, setSessions] = useState<SessionSummary[] | null>(null);
const [projectSkills, setProjectSkills] = useState<UserPlaybook[] | null>(null);
Expand All @@ -45,39 +54,45 @@ export default function DashboardPage() {
const [topApplied, setTopApplied] = useState<PlaybookApplicationStat[] | null>(
null,
);
const [archiveStatus, setArchiveStatus] = useState<ArchiveStatus | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
async function load() {
setError(null);
try {
const [sRes, projectRes, sharedRes, prefRes, statsRes] = await Promise.all([
fetch("/api/sessions", { cache: "no-store" }).then((r) => r.json()),
reflexio
.getUserPlaybooks({})
.catch(() => ({ user_playbooks: [] as UserPlaybook[] })),
reflexio
.getAgentPlaybooks({})
.catch(() => ({ agent_playbooks: [] as AgentPlaybook[] })),
reflexio
.getAllProfiles({ limit: 100 })
.catch(() => ({ user_profiles: [] as UserProfile[] })),
fetch("/api/rules/applied?daysBack=30&limit=200", {
cache: "no-store",
})
.then((r) => r.json())
.catch(() => ({
success: false,
stats: [] as PlaybookApplicationStat[],
})),
]);
const [sRes, projectRes, sharedRes, prefRes, statsRes, archiveRes] =
await Promise.all([
fetch("/api/sessions", { cache: "no-store" }).then((r) => r.json()),
reflexio
.getUserPlaybooks({})
.catch(() => ({ user_playbooks: [] as UserPlaybook[] })),
reflexio
.getAgentPlaybooks({})
.catch(() => ({ agent_playbooks: [] as AgentPlaybook[] })),
reflexio
.getAllProfiles({ limit: 100 })
.catch(() => ({ user_profiles: [] as UserProfile[] })),
fetch("/api/rules/applied?daysBack=30&limit=200", {
cache: "no-store",
})
.then((r) => r.json())
.catch(() => ({
success: false,
stats: [] as PlaybookApplicationStat[],
})),
fetch("/api/archive-status", { cache: "no-store" })
.then((r) => r.json())
.catch(() => null),
]);
if (cancelled) return;
setSessions(sRes.sessions ?? []);
setProjectSkills(projectRes.user_playbooks ?? []);
setSharedSkills(sharedRes.agent_playbooks ?? []);
setPreferences(prefRes.user_profiles ?? []);
setTopApplied(statsRes.stats ?? []);
setArchiveStatus(archiveRes);
} catch (e) {
if (!cancelled)
setError(e instanceof Error ? e.message : "failed to load");
Expand All @@ -95,7 +110,7 @@ export default function DashboardPage() {
const approvedSharedSkills = (sharedSkills ?? []).filter(
(p) => agentPlaybookStatusLabel(p) === "APPROVED",
);
const currentPreferences = (preferences ?? []).filter((p) => p.status == null);
const currentPreferences = (preferences ?? []).filter((p) => p.status == null);
const statsByRule = useMemo(() => {
const map = new Map<string, PlaybookApplicationStat>();
for (const s of topApplied ?? []) {
Expand Down Expand Up @@ -153,7 +168,11 @@ export default function DashboardPage() {
/>

<div className="p-6 space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div
className={`grid grid-cols-1 gap-3 sm:grid-cols-2 ${
archiveStatus?.enabled ? "lg:grid-cols-5" : "lg:grid-cols-4"
}`}
>
<StatCard
label="Sessions recorded"
value={sessions?.length ?? "—"}
Expand All @@ -178,6 +197,17 @@ export default function DashboardPage() {
hint="turns where a skill or preference was cited"
icon={Sparkles}
/>
{archiveStatus?.enabled && (
<StatCard
label="Archived JSONL entries"
value={archiveStatus.entryCount}
hint={`${archiveStatus.exceeded ? "Warning threshold exceeded · " : ""}${formatBytes(
archiveStatus.sizeBytes,
)} of ${formatBytes(archiveStatus.warningBytes)}`}
icon={Archive}
tone={archiveStatus.exceeded ? "danger" : "default"}
/>
)}
</div>

{error && (
Expand Down
34 changes: 30 additions & 4 deletions plugin/dashboard/components/common/stat-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,59 @@ export function StatCard({
hint,
icon: Icon,
className,
tone = "default",
}: {
label: string;
value: string | number;
hint?: string;
icon?: LucideIcon;
className?: string;
tone?: "default" | "danger";
}) {
return (
<div
className={cn(
"rounded-lg border border-border bg-card/92 px-5 py-4 flex items-start justify-between gap-4 shadow-sm",
tone === "danger" && "border-destructive/70 bg-destructive/10",
className,
)}
>
<div className="min-w-0">
<div className="text-xs uppercase text-muted-foreground font-semibold">
<div
className={cn(
"text-xs uppercase text-muted-foreground font-semibold",
tone === "danger" && "text-destructive",
)}
>
{label}
</div>
<div className="mt-2 text-3xl font-semibold tabular-nums text-foreground">
<div
className={cn(
"mt-2 text-3xl font-semibold tabular-nums text-foreground",
tone === "danger" && "text-destructive",
)}
>
{value}
</div>
{hint && (
<div className="text-xs text-muted-foreground mt-1.5">{hint}</div>
<div
className={cn(
"text-xs text-muted-foreground mt-1.5",
tone === "danger" && "text-destructive/80",
)}
>
{hint}
</div>
)}
</div>
{Icon && (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary",
tone === "danger" &&
"border-destructive/25 bg-destructive/15 text-destructive",
)}
>
<Icon className="h-4 w-4" />
</div>
)}
Expand Down
136 changes: 136 additions & 0 deletions plugin/dashboard/lib/archive-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { createReadStream } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";

const DEFAULT_WARNING_BYTES = 10 * 1024 ** 3;
const ENV_KEYS = [
"REFLEXIO_RETENTION_ARCHIVE",
"REFLEXIO_RETENTION_ARCHIVE_DIR",
"REFLEXIO_RETENTION_ARCHIVE_WARN_BYTES",
"LOCAL_STORAGE_PATH",
] as const;

export interface ArchiveStatus {
enabled: boolean;
entryCount: number;
sizeBytes: number;
warningBytes: number;
exceeded: boolean;
}

function parseEnv(text: string): Record<string, string> {
const values: Record<string, string> = {};
for (const rawLine of text.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const separator = line.indexOf("=");
if (separator < 0) continue;
const key = line.slice(0, separator).trim();
let value = line.slice(separator + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
values[key] = value;
}
return values;
}

async function readEnv(file: string): Promise<Record<string, string>> {
try {
return parseEnv(await fs.readFile(file, "utf-8"));
} catch {
return {};
}
}

function isTruthy(value: string | undefined): boolean {
return value === "1" || value?.toLowerCase() === "true";
}

function warningBytes(value: string | undefined): number {
if (!value) return DEFAULT_WARNING_BYTES;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0
? parsed
: DEFAULT_WARNING_BYTES;
}

function countEntries(file: string): Promise<number> {
return new Promise((resolve, reject) => {
let lines = 0;
let sawBytes = false;
let lastByte = 10;
const stream = createReadStream(file);
stream.on("data", (chunk) => {
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
if (bytes.length === 0) return;
sawBytes = true;
lastByte = bytes[bytes.length - 1];
for (const byte of bytes) {
if (byte === 10) lines += 1;
}
});
stream.on("error", reject);
stream.on("end", () => resolve(lines + (sawBytes && lastByte !== 10 ? 1 : 0)));
});
}

export async function readArchiveStatus(): Promise<ArchiveStatus> {
const home = os.homedir();
const reflexioEnv = await readEnv(path.join(home, ".reflexio", ".env"));
const claudeSmartEnv = await readEnv(path.join(home, ".claude-smart", ".env"));
const values = { ...reflexioEnv, ...claudeSmartEnv };
for (const key of ENV_KEYS) {
if (process.env[key] !== undefined) values[key] = process.env[key];
}

const enabled = isTruthy(values.REFLEXIO_RETENTION_ARCHIVE);
const threshold = warningBytes(values.REFLEXIO_RETENTION_ARCHIVE_WARN_BYTES);
if (!enabled) {
return {
enabled: false,
entryCount: 0,
sizeBytes: 0,
warningBytes: threshold,
exceeded: false,
};
}

const archiveDir =
values.REFLEXIO_RETENTION_ARCHIVE_DIR ||
path.join(
values.LOCAL_STORAGE_PATH || path.join(home, ".reflexio", "data"),
"archive",
);
let files: string[] = [];
try {
files = (await fs.readdir(archiveDir))
.filter((name) => name.endsWith(".jsonl"))
.map((name) => path.join(archiveDir, name));
} catch {
files = [];
}

let entryCount = 0;
let sizeBytes = 0;
for (const file of files) {
try {
const [entries, stat] = await Promise.all([countEntries(file), fs.stat(file)]);
entryCount += entries;
sizeBytes += stat.size;
} catch {
// Rotation can remove a file between readdir and read; skip that file.
}
}
return {
enabled: true,
entryCount,
sizeBytes,
warningBytes: threshold,
exceeded: sizeBytes > threshold,
};
}
12 changes: 12 additions & 0 deletions plugin/dashboard/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ export function truncateId(id: string, prefix = 8, suffix = 4): string {
return `${id.slice(0, prefix)}…${id.slice(-suffix)}`;
}

export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let value = bytes;
let unit = -1;
do {
value /= 1024;
unit += 1;
} while (value >= 1024 && unit < units.length - 1);
return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
}

export function dayBucket(ts: number | null | undefined): string {
if (!ts) return "Unknown";
const ms = ts < 1e12 ? ts * 1000 : ts;
Expand Down
Loading
Loading