Ampelos dashboard: the web face, and the owner of the schema

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>
This commit is contained in:
Ryan
2026-08-15 12:12:08 +02:00
commit acdc25c797
138 changed files with 70946 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
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>
);
}