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
+ + + +
+ +
+
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + +
AddedTitleTypeSizeOriginal URL
+
+ +
+ + + + + + + + + + + +
StartedStatusRequestedCompletedFailed
+
+ +
+

Mounted Archives

+
+
+
+ + +
+ + + + 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; + } +}