acdc25c797
Split out of the single Ampelos repository. Next.js app, Drizzle schema and migrations, brand art, planning notes. What left: scripts/, which was the agent's job library misfiled under web/ and imported nothing from src/; and deploy/truenas, whose broadcast posts to the scan listener on :3427 -- an agent script -- so it belongs beside the thing it talks to. This repository keeps the schema. The agent speaks raw SQL against the same tables and holds no copy of it, so a rename here can break it silently where it used to be one commit. The README says so, and the agent carries a snapshot to check against. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
9.5 KiB
TypeScript
182 lines
9.5 KiB
TypeScript
import { db } from "@/db/client";
|
|
import { storageAvailability, storageFiles, storageTiers } from "@/db/schema";
|
|
import { AGENT_TIMEOUT_SECONDS, formatSince, getAgentStatuses } from "@/lib/agents";
|
|
import { desc, sql } from "drizzle-orm";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
function formatBytes(value: number | null) {
|
|
if (!value || value <= 0) return "0 B";
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
let size = value;
|
|
let unit = 0;
|
|
while (size >= 1024 && unit < units.length - 1) {
|
|
size /= 1024;
|
|
unit += 1;
|
|
}
|
|
return size.toFixed(unit === 0 ? 0 : 1) + " " + units[unit];
|
|
}
|
|
|
|
type AgentPath = { path: string; exists: boolean; mounted: boolean; readable: boolean; freeBytes: number };
|
|
|
|
function agentPaths(details: unknown): AgentPath[] {
|
|
const paths = (details as { paths?: unknown } | null)?.paths;
|
|
return Array.isArray(paths) ? (paths as AgentPath[]) : [];
|
|
}
|
|
|
|
function formatDate(value: Date | null) {
|
|
return value ? value.toISOString().slice(0, 16).replace("T", " ") : "Open";
|
|
}
|
|
|
|
export default async function AdminStoragePage() {
|
|
const agents = await getAgentStatuses();
|
|
const agentByName = new Map(agents.map((agent) => [agent.agent, agent]));
|
|
|
|
const [tiers, availabilityRows, inventoryRows] = await Promise.all([
|
|
db.select().from(storageTiers).orderBy(storageTiers.tier),
|
|
db.select().from(storageAvailability).orderBy(desc(storageAvailability.detectedAt)).limit(100),
|
|
db
|
|
.select({
|
|
tierId: storageFiles.tierId,
|
|
fileCount: sql<string>`count(*)::text`,
|
|
movieFileCount: sql<string>`count(*) filter (where ${storageFiles.relativePath} like 'Movies/%' or ${storageFiles.relativePath} like 'Movies-EN/%')::text`,
|
|
tvFileCount: sql<string>`count(*) filter (where ${storageFiles.relativePath} like 'Television/%')::text`,
|
|
totalSize: sql<string>`pg_size_pretty(coalesce(sum(${storageFiles.sizeBytes}), 0))`,
|
|
movieSize: sql<string>`pg_size_pretty(coalesce(sum(${storageFiles.sizeBytes}) filter (where ${storageFiles.relativePath} like 'Movies/%' or ${storageFiles.relativePath} like 'Movies-EN/%'), 0))`,
|
|
tvSize: sql<string>`pg_size_pretty(coalesce(sum(${storageFiles.sizeBytes}) filter (where ${storageFiles.relativePath} like 'Television/%'), 0))`,
|
|
})
|
|
.from(storageFiles)
|
|
.groupBy(storageFiles.tierId),
|
|
]);
|
|
|
|
const availabilityByTier = new Map<string, typeof availabilityRows>();
|
|
for (const row of availabilityRows) {
|
|
availabilityByTier.set(row.tierId, [...(availabilityByTier.get(row.tierId) ?? []), row]);
|
|
}
|
|
const inventoryByTier = new Map(inventoryRows.map((row) => [row.tierId, row]));
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Storage</p>
|
|
<h1 className="mt-2 font-serif text-3xl font-semibold">Storage Tiers</h1>
|
|
</div>
|
|
<section className="admin-panel p-5">
|
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
<h2 className="font-serif text-xl font-semibold">Agents</h2>
|
|
<p className="text-xs text-admin-muted">Considered offline after {AGENT_TIMEOUT_SECONDS}s of silence</p>
|
|
</div>
|
|
{agents.length ? (
|
|
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
|
{agents.map((agent) => {
|
|
const paths = agentPaths(agent.details);
|
|
return (
|
|
<div key={agent.agent} className="rounded-md border border-admin-line bg-admin-subpanel p-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="font-semibold">{agent.agent}</p>
|
|
<p className="text-xs text-admin-muted">{agent.hostname ?? "unknown host"}</p>
|
|
</div>
|
|
<span className={agent.online ? "text-sm font-semibold text-admin-good" : "text-sm font-semibold text-admin-warn"}>
|
|
{agent.online ? "Online" : "Offline"}
|
|
</span>
|
|
</div>
|
|
<p className="mt-2 text-xs text-admin-muted">Last beat {formatSince(agent.secondsSinceSeen)}</p>
|
|
{paths.length ? (
|
|
<ul className="mt-3 space-y-1 text-xs">
|
|
{paths.map((path) => (
|
|
<li key={path.path} className="flex items-center justify-between gap-2">
|
|
<span className="break-all text-admin-muted">{path.path}</span>
|
|
<span className={path.readable ? "shrink-0 text-admin-good" : "shrink-0 text-admin-warn"}>
|
|
{path.readable ? formatBytes(path.freeBytes) + " free" : "unreachable"}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="mt-3 text-sm text-admin-muted">
|
|
No agents have reported yet. See <span className="font-mono">agent/README.md</span> to install the herald on silenus or edda.
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
<div className="grid gap-5 xl:grid-cols-3">
|
|
{tiers.length ? tiers.map((tier) => {
|
|
const events = availabilityByTier.get(tier.id) ?? [];
|
|
const latest = events[0];
|
|
// When a tier names an agent, that agent's heartbeat is authoritative:
|
|
// storage on a powered-off host is not reachable no matter what the
|
|
// last scan observed.
|
|
const agent = tier.agentName ? agentByName.get(tier.agentName) : undefined;
|
|
const online = tier.agentName
|
|
? Boolean(agent?.online)
|
|
: tier.alwaysOnline || latest?.online;
|
|
const inventory = inventoryByTier.get(tier.id);
|
|
return (
|
|
<section key={tier.id} className="admin-panel p-5">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-admin-muted">{tier.tier}</p>
|
|
<h2 className="mt-2 font-serif text-2xl font-semibold">{tier.label}</h2>
|
|
</div>
|
|
<span className={online ? "text-sm font-semibold text-admin-good" : "text-sm font-semibold text-admin-muted"}>{online ? "Online" : "Offline"}</span>
|
|
</div>
|
|
{tier.agentName ? (
|
|
<p className="mt-2 text-xs text-admin-muted">
|
|
Reachability from agent <span className="font-semibold">{tier.agentName}</span>
|
|
{agent ? ` — last beat ${formatSince(agent.secondsSinceSeen)}` : " — never reported"}
|
|
</p>
|
|
) : null}
|
|
<p className="mt-4 break-all text-sm text-admin-muted">{tier.basePath}</p>
|
|
{tier.notes ? <p className="mt-3 text-sm text-admin-muted">{tier.notes}</p> : null}
|
|
<div className="mt-5 grid grid-cols-2 gap-3 text-sm">
|
|
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
|
|
<p className="text-admin-muted">Movie files</p>
|
|
<p className="mt-1 text-xl font-semibold">{inventory?.movieFileCount ?? "0"}</p>
|
|
</div>
|
|
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
|
|
<p className="text-admin-muted">Movie size</p>
|
|
<p className="mt-1 text-xl font-semibold">{inventory?.movieSize ?? "0 bytes"}</p>
|
|
</div>
|
|
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
|
|
<p className="text-admin-muted">TV files</p>
|
|
<p className="mt-1 text-xl font-semibold">{inventory?.tvFileCount ?? "0"}</p>
|
|
</div>
|
|
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
|
|
<p className="text-admin-muted">TV size</p>
|
|
<p className="mt-1 text-xl font-semibold">{inventory?.tvSize ?? "0 bytes"}</p>
|
|
</div>
|
|
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
|
|
<p className="text-admin-muted">All files</p>
|
|
<p className="mt-1 text-xl font-semibold">{inventory?.fileCount ?? "0"}</p>
|
|
</div>
|
|
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
|
|
<p className="text-admin-muted">All size</p>
|
|
<p className="mt-1 text-xl font-semibold">{inventory?.totalSize ?? "0 bytes"}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-5 space-y-2">
|
|
<h3 className="text-sm font-semibold">Recent availability</h3>
|
|
{events.length ? events.slice(0, 5).map((event) => (
|
|
<div key={event.id} className="rounded-md border border-admin-line bg-admin-subpanel p-3 text-sm">
|
|
<div className="flex justify-between gap-3">
|
|
<span>{event.online ? "Online" : "Offline"}</span>
|
|
<span className="text-admin-muted">{formatDate(event.detectedAt)}</span>
|
|
</div>
|
|
<p className="mt-1 text-admin-muted">Resolved: {formatDate(event.resolvedAt)}</p>
|
|
</div>
|
|
)) : <p className="text-sm text-admin-muted">No availability observations yet.</p>}
|
|
</div>
|
|
</section>
|
|
);
|
|
}) : <section className="admin-panel p-5 text-sm text-admin-muted">No storage tiers configured yet.</section>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|