diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs
index 910cbc9..5bd1122 100644
--- a/crates/archivr-server/src/routes.rs
+++ b/crates/archivr-server/src/routes.rs
@@ -8,6 +8,7 @@ use axum::{
response::{IntoResponse, Response},
routing::get,
};
+use tower_http::services::{ServeDir, ServeFile};
use crate::registry::{MountedArchive, ServerRegistry};
@@ -20,6 +21,7 @@ pub fn app(registry: ServerRegistry) -> Router {
let state = AppState {
registry: Arc::new(registry),
};
+ let static_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static");
Router::new()
.route("/health", get(|| async { "ok" }))
@@ -30,6 +32,8 @@ pub fn app(registry: ServerRegistry) -> Router {
get(entry_detail),
)
.route("/api/archives/:archive_id/runs", get(list_runs))
+ .nest_service("/assets", ServeDir::new(&static_dir))
+ .fallback_service(ServeFile::new(static_dir.join("index.html")))
.with_state(state)
}
diff --git a/crates/archivr-server/static/app.js b/crates/archivr-server/static/app.js
new file mode 100644
index 0000000..68b1616
--- /dev/null
+++ b/crates/archivr-server/static/app.js
@@ -0,0 +1,218 @@
+const state = {
+ archives: [],
+ archiveId: null,
+ entries: [],
+ filteredEntries: [],
+ selectedEntryUid: null,
+};
+
+const archiveSwitcher = document.querySelector("#archive-switcher");
+const entriesBody = document.querySelector("#entries-body");
+const runsBody = document.querySelector("#runs-body");
+const contextBody = document.querySelector("#context-body");
+const navButtons = document.querySelectorAll(".nav-link");
+const searchInput = document.querySelector("#search");
+const resultCount = document.querySelector("#result-count");
+const adminArchives = document.querySelector("#admin-archives");
+
+function formatBytes(bytes) {
+ if (!bytes) return "0 B";
+ const units = ["B", "KB", "MB", "GB"];
+ let size = bytes;
+ let unit = 0;
+ while (size >= 1024 && unit < units.length - 1) {
+ size /= 1024;
+ unit += 1;
+ }
+ return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
+}
+
+function valueText(value) {
+ return value ?? "";
+}
+
+async function getJson(url) {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`${response.status} ${response.statusText}`);
+ }
+ return response.json();
+}
+
+function appendCell(row, text, className) {
+ const cell = document.createElement("td");
+ if (className) cell.className = className;
+ cell.textContent = text;
+ row.append(cell);
+ return cell;
+}
+
+function renderArchives() {
+ archiveSwitcher.innerHTML = "";
+ for (const archive of state.archives) {
+ const option = document.createElement("option");
+ option.value = archive.id;
+ option.textContent = archive.label;
+ archiveSwitcher.append(option);
+ }
+ archiveSwitcher.value = state.archiveId ?? "";
+
+ adminArchives.innerHTML = "";
+ for (const archive of state.archives) {
+ const item = document.createElement("div");
+ item.className = "admin-archive";
+ const label = document.createElement("strong");
+ label.textContent = archive.label;
+ const path = document.createElement("div");
+ path.className = "muted";
+ path.textContent = archive.archive_path;
+ item.append(label, path);
+ adminArchives.append(item);
+ }
+}
+
+function applyEntryFilter() {
+ const query = searchInput.value.trim().toLowerCase();
+ if (!query) {
+ state.filteredEntries = state.entries;
+ return;
+ }
+ state.filteredEntries = state.entries.filter((entry) => {
+ const haystack = [
+ entry.title,
+ entry.original_url,
+ entry.entry_uid,
+ entry.source_kind,
+ entry.entity_kind,
+ entry.visibility,
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ return haystack.includes(query);
+ });
+}
+
+function renderEntries() {
+ entriesBody.innerHTML = "";
+ resultCount.textContent = `${state.filteredEntries.length} entries`;
+
+ for (const entry of state.filteredEntries) {
+ const row = document.createElement("tr");
+ row.tabIndex = 0;
+ row.dataset.entryUid = entry.entry_uid;
+ if (entry.entry_uid === state.selectedEntryUid) {
+ row.classList.add("is-selected");
+ }
+
+ appendCell(row, valueText(entry.archived_at));
+
+ const titleCell = appendCell(row, "");
+ const title = document.createElement("span");
+ title.className = "entry-title";
+ title.textContent = valueText(entry.title) || valueText(entry.entry_uid);
+ titleCell.append(title);
+
+ const typeCell = appendCell(row, "");
+ const type = document.createElement("span");
+ type.className = "type-pill";
+ type.textContent = valueText(entry.entity_kind);
+ typeCell.append(type);
+
+ appendCell(row, formatBytes(entry.total_artifact_bytes));
+ appendCell(row, valueText(entry.original_url), "url-cell");
+
+ row.addEventListener("click", () => selectEntry(entry));
+ row.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") selectEntry(entry);
+ });
+ entriesBody.append(row);
+ }
+}
+
+function renderContextDetail(detail) {
+ contextBody.innerHTML = "";
+ const title = document.createElement("strong");
+ title.textContent = valueText(detail.summary.title) || valueText(detail.summary.entry_uid);
+ contextBody.append(title);
+
+ const items = [
+ ["Type", detail.summary.entity_kind],
+ ["Visibility", detail.summary.visibility],
+ ["Artifacts", detail.artifacts.length],
+ ["Structured root", detail.structured_root_relpath],
+ ];
+
+ for (const [label, value] of items) {
+ const item = document.createElement("div");
+ item.className = "rail-item";
+ item.textContent = `${label}: ${valueText(value)}`;
+ contextBody.append(item);
+ }
+}
+
+async function selectEntry(entry) {
+ state.selectedEntryUid = entry.entry_uid;
+ renderEntries();
+ const detail = await getJson(`/api/archives/${state.archiveId}/entries/${entry.entry_uid}`);
+ renderContextDetail(detail);
+}
+
+async function loadRuns() {
+ const runs = await getJson(`/api/archives/${state.archiveId}/runs`);
+ runsBody.innerHTML = "";
+ for (const run of runs) {
+ const row = document.createElement("tr");
+ appendCell(row, valueText(run.started_at));
+ appendCell(row, valueText(run.status));
+ appendCell(row, String(run.requested_count));
+ appendCell(row, String(run.completed_count));
+ appendCell(row, String(run.failed_count));
+ runsBody.append(row);
+ }
+}
+
+async function loadEntries() {
+ state.entries = await getJson(`/api/archives/${state.archiveId}/entries`);
+ state.selectedEntryUid = null;
+ applyEntryFilter();
+ renderEntries();
+ contextBody.textContent = "Select an entry.";
+}
+
+async function loadArchives() {
+ state.archives = await getJson("/api/archives");
+ state.archiveId = state.archives[0]?.id ?? null;
+ renderArchives();
+ if (state.archiveId) {
+ await loadEntries();
+ await loadRuns();
+ } else {
+ contextBody.textContent = "No archives mounted.";
+ resultCount.textContent = "0 entries";
+ }
+}
+
+archiveSwitcher.addEventListener("change", async () => {
+ state.archiveId = archiveSwitcher.value;
+ await loadEntries();
+ await loadRuns();
+});
+
+searchInput.addEventListener("input", () => {
+ applyEntryFilter();
+ renderEntries();
+});
+
+navButtons.forEach((button) => {
+ button.addEventListener("click", () => {
+ navButtons.forEach((candidate) => candidate.classList.remove("is-active"));
+ document.querySelectorAll(".view").forEach((view) => view.classList.remove("is-active"));
+ button.classList.add("is-active");
+ document.querySelector(`#${button.dataset.view}-view`).classList.add("is-active");
+ });
+});
+
+loadArchives().catch((error) => {
+ contextBody.textContent = `Failed to load archives: ${error.message}`;
+});
diff --git a/crates/archivr-server/static/index.html b/crates/archivr-server/static/index.html
new file mode 100644
index 0000000..021ef15
--- /dev/null
+++ b/crates/archivr-server/static/index.html
@@ -0,0 +1,79 @@
+
+
+
+
+
+ Archivr
+
+
+
+
+ Archivr
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Added |
+ Title |
+ Type |
+ Size |
+ Original URL |
+
+
+
+
+
+
+
+
+
+
+ | Started |
+ Status |
+ Requested |
+ Completed |
+ Failed |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/archivr-server/static/styles.css b/crates/archivr-server/static/styles.css
new file mode 100644
index 0000000..c0e9216
--- /dev/null
+++ b/crates/archivr-server/static/styles.css
@@ -0,0 +1,314 @@
+:root {
+ color-scheme: light;
+ --ink: #20251f;
+ --muted: #666a61;
+ --paper: #f5f0e7;
+ --paper-2: #e9e1d2;
+ --paper-3: #fffaf0;
+ --line: #d2c6b5;
+ --line-soft: #e5dccd;
+ --accent: #8d3f30;
+ --accent-2: #b78342;
+ --link: #245f72;
+ --top: #141d18;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--paper);
+ color: var(--ink);
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+.topbar {
+ height: 56px;
+ display: grid;
+ grid-template-columns: auto minmax(180px, 250px) 1fr auto;
+ align-items: center;
+ gap: 18px;
+ padding: 0 18px;
+ background: var(--top);
+ color: #f7eedf;
+ border-bottom: 3px solid var(--accent);
+}
+
+.brand {
+ font-family: Georgia, "Times New Roman", serif;
+ font-size: 28px;
+ line-height: 1;
+}
+
+.archive-switcher {
+ width: 100%;
+ min-width: 0;
+ border: 1px solid rgba(247, 238, 223, 0.35);
+ background: #202b25;
+ color: #f7eedf;
+ padding: 7px 9px;
+}
+
+.nav {
+ display: flex;
+ gap: 16px;
+ justify-content: flex-end;
+ min-width: 0;
+}
+
+.nav-link {
+ border: 0;
+ background: transparent;
+ color: #d7cdbf;
+ cursor: pointer;
+ padding: 8px 0;
+}
+
+.nav-link.is-active {
+ color: #fffaf0;
+ border-bottom: 1px solid #fffaf0;
+}
+
+.capture-button {
+ border: 0;
+ background: #f7eedf;
+ color: var(--top);
+ padding: 9px 12px;
+ cursor: pointer;
+}
+
+.app-shell {
+ height: calc(100vh - 56px);
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 320px;
+}
+
+.workspace {
+ min-width: 0;
+ overflow: auto;
+}
+
+.search-row {
+ min-height: 76px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) max-content;
+ gap: 18px;
+ align-items: center;
+ padding: 14px 16px;
+ background: var(--paper-2);
+ border-bottom: 1px solid var(--line);
+}
+
+.search-input {
+ width: 100%;
+ height: 46px;
+ border: 2px solid var(--ink);
+ background: var(--paper-3);
+ color: var(--ink);
+ padding: 0 14px;
+ font-size: 16px;
+ outline-offset: 3px;
+}
+
+.result-count {
+ min-width: 76px;
+ color: var(--muted);
+ font-size: 13px;
+ text-align: right;
+}
+
+.view {
+ display: none;
+}
+
+.view.is-active {
+ display: block;
+}
+
+.entry-table {
+ width: 100%;
+ border-collapse: collapse;
+ table-layout: fixed;
+ font-size: 13px;
+}
+
+.entry-table th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ text-align: left;
+ padding: 10px;
+ background: #ded5c7;
+ color: #5d625e;
+ border-bottom: 1px solid #cec4b5;
+ font-size: 11px;
+ text-transform: uppercase;
+}
+
+.entry-table td {
+ padding: 10px;
+ border-bottom: 1px solid var(--line-soft);
+ vertical-align: top;
+}
+
+.entry-table tr:nth-child(even) td {
+ background: #f2ede5;
+}
+
+.entry-table tr:nth-child(odd) td {
+ background: var(--paper-3);
+}
+
+.entry-table tr {
+ cursor: default;
+}
+
+.entry-table tr.is-selected td {
+ background: #eee2d2;
+ border-top: 2px solid var(--accent);
+ border-bottom: 2px solid var(--accent);
+}
+
+.col-added {
+ width: 178px;
+}
+
+.col-title {
+ width: 38%;
+}
+
+.col-type {
+ width: 130px;
+}
+
+.col-size {
+ width: 110px;
+}
+
+.col-url {
+ width: 34%;
+}
+
+.entry-title {
+ color: var(--link);
+ font-weight: 750;
+}
+
+.url-cell {
+ color: #555b55;
+ word-break: break-all;
+}
+
+.type-pill {
+ display: inline-block;
+ padding: 2px 6px;
+ background: #d8e3df;
+ color: #275a5f;
+ border: 1px solid #bfd0ca;
+}
+
+.context-rail {
+ border-left: 1px solid var(--line);
+ background: #efe7dc;
+ padding: 18px;
+ overflow: auto;
+}
+
+.rail-title {
+ color: var(--accent);
+ font-weight: 800;
+ margin-bottom: 10px;
+ text-transform: uppercase;
+ font-size: 12px;
+ letter-spacing: 0;
+}
+
+.rail-body {
+ color: var(--muted);
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.rail-body strong {
+ color: var(--ink);
+}
+
+.rail-item {
+ padding: 10px 0;
+ border-bottom: 1px solid var(--line);
+}
+
+.admin-view {
+ padding: 22px;
+}
+
+.admin-view h1 {
+ margin: 0 0 16px;
+ font-size: 18px;
+}
+
+.admin-list {
+ display: grid;
+ gap: 10px;
+ max-width: 780px;
+}
+
+.admin-archive {
+ border: 1px solid var(--line);
+ background: var(--paper-3);
+ padding: 12px;
+}
+
+.muted {
+ color: var(--muted);
+}
+
+@media (max-width: 900px) {
+ .topbar {
+ grid-template-columns: 1fr auto;
+ height: auto;
+ min-height: 56px;
+ padding: 12px;
+ }
+
+ .archive-switcher,
+ .nav {
+ grid-column: 1 / -1;
+ }
+
+ .nav {
+ justify-content: flex-start;
+ overflow-x: auto;
+ }
+
+ .app-shell {
+ height: auto;
+ grid-template-columns: 1fr;
+ }
+
+ .search-row {
+ grid-template-columns: 1fr;
+ }
+
+ .result-count {
+ text-align: left;
+ }
+
+ .context-rail {
+ border-left: 0;
+ border-top: 1px solid var(--line);
+ }
+
+ .entry-table {
+ min-width: 860px;
+ }
+}