You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1056 lines
53 KiB

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { api, type BackendSummary, type DirectoryItem, type FileDetailResponse, type JobSummary, type PlacementPolicySummary } from "./lib/api";
import { clearAccessToken, getAccessToken, setAccessToken } from "./lib/auth";
import { formatBackendType, formatBytes, formatDate, titleCase } from "./lib/format";
import { uploadFileToDirectory } from "./lib/uploads";
type RouteName = "login" | "files" | "recycle" | "storage" | "policies" | "jobs";
type DialogMode = "create" | "rename" | "move" | "delete" | null;
type ViewMode = "list" | "grid";
type BackendType = "local_directory" | "s3" | "webdav";
type StabilityClass = "local" | "stable" | "opportunistic";
const CONFIG_TEMPLATES: Record<BackendType, string> = {
local_directory: JSON.stringify({ base_path: "./.iron-storage/archive" }, null, 2),
s3: JSON.stringify(
{
bucket: "iron",
region: "us-east-1",
endpoint_url: "http://127.0.0.1:9000",
access_key_id: "",
secret_access_key: "",
prefix: "iron",
force_path_style: true,
},
null,
2,
),
webdav: JSON.stringify(
{
endpoint_url: "http://127.0.0.1:5244/dav",
username: "iron",
password: "",
root_path: "iron",
verify_ssl: true,
},
null,
2,
),
};
const route = ref(parseRoute());
const userInitial = ref("A");
const loginUsername = ref("admin");
const loginPassword = ref("changeme-iron");
const loginError = ref("");
const isLoginPending = ref(false);
const search = ref(new URLSearchParams(window.location.search).get("q") ?? "");
const toast = ref("");
const directoryId = computed(() => route.value.directoryId ?? "dir_root");
const directoryName = ref("Personal");
const parentStack = ref<Array<{ id: string; name: string }>>([{ id: "dir_root", name: "Personal" }]);
const directoryItems = ref<DirectoryItem[]>([]);
const selectedItem = ref<DirectoryItem | null>(null);
const sortBy = ref<"name" | "updated" | "size">("name");
const viewMode = ref<ViewMode>("list");
const fileInput = ref<HTMLInputElement | null>(null);
const page = ref(1);
const pageSize = ref(8);
const openMenuId = ref<string | null>(null);
const dialogMode = ref<DialogMode>(null);
const fieldValue = ref("");
const moveTargetId = ref("dir_root");
const fileDetail = ref<FileDetailResponse | null>(null);
const previewUrl = ref("");
const previewText = ref("");
const isDetailDrawerOpen = ref(false);
const recycleItems = ref<Array<DirectoryItem & { deleted_at?: string }>>([]);
const recyclePage = ref(1);
const recyclePageSize = ref(8);
const backends = ref<BackendSummary[]>([]);
const backendPage = ref(1);
const backendPageSize = ref(8);
const isCreateBackendOpen = ref(false);
const editingBackend = ref<BackendSummary | null>(null);
const backendType = ref<BackendType>("s3");
const backendName = ref("s3-main");
const backendStabilityClass = ref<StabilityClass>("stable");
const backendReadPriority = ref(80);
const backendWritePriority = ref(80);
const backendConfigText = ref(CONFIG_TEMPLATES.s3);
const backendFormError = ref("");
const editName = ref("");
const editStabilityClass = ref<StabilityClass>("stable");
const editReadPriority = ref(80);
const editWritePriority = ref(80);
const editConfigText = ref("{}");
const editSecretText = ref("{}");
const editError = ref("");
const policies = ref<PlacementPolicySummary[]>([]);
const selectedPolicyClass = ref<PlacementPolicySummary["file_class"]>("document");
const jobs = ref<JobSummary[]>([]);
const jobsPage = ref(1);
const jobsPageSize = ref(8);
const selectedJob = ref<JobSummary | null>(null);
const currentPolicy = computed(() => policies.value.find((policy) => policy.file_class === selectedPolicyClass.value) ?? null);
const folders = computed(() => filteredItems.value.filter((item) => item.kind === "directory"));
const files = computed(() => filteredItems.value.filter((item) => item.kind === "file"));
const filteredItems = computed(() => {
const query = search.value.trim().toLowerCase();
return [...directoryItems.value]
.filter((item) => (query ? item.name.toLowerCase().includes(query) : true))
.sort((first, second) => {
if (sortBy.value === "updated") {
return second.updated_at.localeCompare(first.updated_at);
}
if (sortBy.value === "size") {
return (second.size_bytes ?? 0) - (first.size_bytes ?? 0);
}
if (first.kind !== second.kind) {
return first.kind === "directory" ? -1 : 1;
}
return first.name.localeCompare(second.name);
});
});
const totalPages = computed(() => Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)));
const pagedItems = computed(() => filteredItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value));
const fileTotalBytes = computed(() => files.value.reduce((sum, item) => sum + (item.size_bytes ?? 0), 0));
const pageItems = computed(() => getPaginationItems(page.value, totalPages.value));
const moveTargets = computed(() => {
const entries = [...parentStack.value, ...folders.value.map((folder) => ({ id: folder.id, name: folder.name }))];
const unique = new Map<string, string>();
for (const entry of [...entries, { id: "dir_root", name: "Personal" }]) unique.set(entry.id, entry.name);
if (selectedItem.value?.kind === "directory") unique.delete(selectedItem.value.id);
return Array.from(unique.entries()).map(([id, name]) => ({ id, name }));
});
const recycleTotalBytes = computed(() => recycleItems.value.reduce((sum, item) => sum + (item.size_bytes ?? 0), 0));
const recycleTotalPages = computed(() => Math.max(1, Math.ceil(recycleItems.value.length / recyclePageSize.value)));
const recyclePageItems = computed(() => getPaginationItems(recyclePage.value, recycleTotalPages.value));
const pagedRecycleItems = computed(() => recycleItems.value.slice((recyclePage.value - 1) * recyclePageSize.value, recyclePage.value * recyclePageSize.value));
const healthyBackendCount = computed(() => backends.value.filter((backend) => backend.last_health_status === "healthy").length);
const enabledBackendCount = computed(() => backends.value.filter((backend) => backend.is_enabled).length);
const backendTotalPages = computed(() => Math.max(1, Math.ceil(backends.value.length / backendPageSize.value)));
const backendPageItems = computed(() => getPaginationItems(backendPage.value, backendTotalPages.value));
const pagedBackends = computed(() => backends.value.slice((backendPage.value - 1) * backendPageSize.value, backendPage.value * backendPageSize.value));
const pendingJobsCount = computed(() => jobs.value.filter((job) => job.status === "queued" || job.status === "pending").length);
const completedJobsCount = computed(() => jobs.value.filter((job) => job.status === "completed").length);
const failedJobsCount = computed(() => jobs.value.filter((job) => job.status === "failed").length);
const jobsTotalPages = computed(() => Math.max(1, Math.ceil(jobs.value.length / jobsPageSize.value)));
const jobsPageItems = computed(() => getPaginationItems(jobsPage.value, jobsTotalPages.value));
const pagedJobs = computed(() => jobs.value.slice((jobsPage.value - 1) * jobsPageSize.value, jobsPage.value * jobsPageSize.value));
watch(route, async () => {
if (route.value.name === "login") {
return;
}
await loadRouteData();
});
watch(selectedItem, async (item) => {
isDetailDrawerOpen.value = false;
fileDetail.value = null;
previewText.value = "";
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value);
previewUrl.value = "";
}
if (item?.kind === "file") {
await loadFileDetailAndPreview(item);
}
});
watch([page, totalPages], () => {
if (page.value > totalPages.value) page.value = totalPages.value;
});
watch([search, sortBy, viewMode], () => {
page.value = 1;
openMenuId.value = null;
});
watch([recyclePage, recycleTotalPages], () => {
if (recyclePage.value > recycleTotalPages.value) recyclePage.value = recycleTotalPages.value;
});
watch([backendPage, backendTotalPages], () => {
if (backendPage.value > backendTotalPages.value) backendPage.value = backendTotalPages.value;
});
watch([jobsPage, jobsTotalPages], () => {
if (jobsPage.value > jobsTotalPages.value) jobsPage.value = jobsTotalPages.value;
});
onMounted(async () => {
window.addEventListener("popstate", handlePopState);
window.addEventListener("keydown", handleGlobalKeydown);
if (!getAccessToken() && route.value.name !== "login") {
navigate("/app/login");
return;
}
await loadRouteData();
});
onUnmounted(() => {
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("keydown", handleGlobalKeydown);
});
function handlePopState() {
route.value = parseRoute();
}
function handleGlobalKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
openMenuId.value = null;
}
}
function parseRoute(): { name: RouteName; directoryId?: string } {
const path = window.location.pathname;
if (path.startsWith("/app/login")) return { name: "login" };
if (path.startsWith("/app/recycle-bin")) return { name: "recycle" };
if (path.startsWith("/app/storage")) return { name: "storage" };
if (path.startsWith("/app/policies")) return { name: "policies" };
if (path.startsWith("/app/jobs")) return { name: "jobs" };
const match = path.match(/^\/app\/files\/([^/]+)/);
return { name: "files", directoryId: match?.[1] ?? "dir_root" };
}
function navigate(path: string) {
window.history.pushState({}, "", path);
route.value = parseRoute();
}
async function loadRouteData() {
try {
if (route.value.name === "files") await loadDirectory();
if (route.value.name === "recycle") await loadRecycleBin();
if (route.value.name === "storage") await loadStorage();
if (route.value.name === "policies") await loadPolicies();
if (route.value.name === "jobs") await loadJobs();
} catch (error) {
showError(error);
}
}
async function login() {
isLoginPending.value = true;
loginError.value = "";
try {
const result = await api.login(loginUsername.value, loginPassword.value);
setAccessToken(result.access_token);
userInitial.value = result.user.username.slice(0, 1).toUpperCase();
navigate("/app/files");
} catch (error) {
loginError.value = error instanceof Error ? error.message : "Sign in failed.";
} finally {
isLoginPending.value = false;
}
}
function logout() {
clearAccessToken();
navigate("/app/login");
}
async function loadDirectory() {
const result = await api.listDirectoryChildren(directoryId.value);
directoryItems.value = result.items;
directoryName.value = directoryId.value === "dir_root" ? "Personal" : result.directory.name;
updateStack(result.directory.id, result.directory.name);
selectedItem.value = null;
page.value = 1;
}
function updateStack(id: string, name: string) {
const index = parentStack.value.findIndex((item) => item.id === id);
if (index >= 0) {
parentStack.value = parentStack.value.slice(0, index + 1);
} else {
parentStack.value = [...parentStack.value, { id, name }];
}
}
function openDirectory(item: DirectoryItem) {
navigate(item.id === "dir_root" ? "/app/files" : `/app/files/${item.id}`);
}
function goBackDirectory() {
const nextStack = parentStack.value.slice(0, -1);
const parent = nextStack[nextStack.length - 1] ?? { id: "dir_root", name: "Personal" };
parentStack.value = nextStack.length ? nextStack : [{ id: "dir_root", name: "Personal" }];
navigate(parent.id === "dir_root" ? "/app/files" : `/app/files/${parent.id}`);
}
async function uploadSelectedFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
await uploadFileToDirectory(file, directoryId.value);
toast.value = `Uploaded ${file.name}.`;
await loadDirectory();
} catch (error) {
showError(error);
} finally {
input.value = "";
}
}
function openDialog(mode: DialogMode, item?: DirectoryItem) {
if (item) selectedItem.value = item;
openMenuId.value = null;
dialogMode.value = mode;
fieldValue.value = mode === "rename" ? selectedItem.value?.name ?? "" : "";
moveTargetId.value = "dir_root";
}
async function confirmDialog() {
if (!dialogMode.value) return;
try {
if (dialogMode.value === "create") {
await api.createDirectory(directoryId.value, fieldValue.value || "New Folder");
toast.value = "Folder created.";
}
if (dialogMode.value === "rename" && selectedItem.value) {
if (selectedItem.value.kind === "directory") {
await api.renameDirectory(selectedItem.value.id, fieldValue.value);
} else {
await api.renameFile(selectedItem.value.id, fieldValue.value);
}
toast.value = "Renamed.";
}
if (dialogMode.value === "move" && selectedItem.value) {
if (selectedItem.value.kind === "directory") {
await api.moveDirectory(selectedItem.value.id, moveTargetId.value);
} else {
await api.moveFile(selectedItem.value.id, moveTargetId.value);
}
toast.value = "Moved.";
}
if (dialogMode.value === "delete" && selectedItem.value?.kind === "file") {
await api.deleteFile(selectedItem.value.id);
toast.value = "Moved to recycle bin.";
}
dialogMode.value = null;
await loadDirectory();
} catch (error) {
showError(error);
}
}
async function loadFileDetailAndPreview(item: DirectoryItem) {
fileDetail.value = await api.fileDetail(item.id);
if (isImage(item.mime_type)) {
const blob = await api.fileBlob(item.id, "preview");
previewUrl.value = URL.createObjectURL(blob);
} else if (item.mime_type === "application/pdf" || item.mime_type?.startsWith("video/")) {
const blob = await api.fileBlob(item.id, item.mime_type.startsWith("video/") ? "stream" : "preview");
previewUrl.value = URL.createObjectURL(blob);
} else if (isText(item.mime_type, item.name)) {
const blob = await api.fileBlob(item.id, "download");
previewText.value = truncatePreviewText(await blob.text());
}
}
async function downloadFile(item: DirectoryItem) {
if (item.kind !== "file") return;
openMenuId.value = null;
const blob = await api.fileBlob(item.id, "download");
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = item.name;
link.click();
URL.revokeObjectURL(url);
}
async function reconcileFile(item: DirectoryItem) {
if (item.kind !== "file") return;
openMenuId.value = null;
await api.reconcileFile(item.id);
toast.value = "Reconcile queued.";
await loadJobs();
}
function selectItem(item: DirectoryItem) {
selectedItem.value = item;
}
function toggleItemMenu(item: DirectoryItem) {
selectedItem.value = item;
openMenuId.value = item.id;
}
function openItemFromMenu(item: DirectoryItem) {
openMenuId.value = null;
if (item.kind === "directory") {
openDirectory(item);
} else {
void downloadFile(item).catch(showError);
}
}
function openDetailDrawer() {
openMenuId.value = null;
isDetailDrawerOpen.value = true;
}
async function loadRecycleBin() {
const result = await api.listRecycleBin();
recycleItems.value = result.items.map((item) => ({ ...item, kind: "file" as const, replica_ready_count: null, replica_desired_count: null, replica_missing_count: null }));
recyclePage.value = 1;
}
async function restoreFile(item: DirectoryItem) {
await api.restoreFile(item.id);
toast.value = "File restored.";
await loadRecycleBin();
}
async function loadStorage() {
const result = await api.listBackends();
backends.value = result.items;
backendPage.value = 1;
}
async function checkBackend(backend: BackendSummary) {
await api.checkBackend(backend.id);
toast.value = "Backend check queued.";
await loadStorage();
}
async function runAllBackendChecks() {
const result = await api.enqueueHealthChecks();
const count = "enqueued_jobs" in (result as Record<string, unknown>) ? (result as { enqueued_jobs: number }).enqueued_jobs : backends.value.length;
toast.value = `Queued ${count} backend health check job${count === 1 ? "" : "s"}.`;
await loadJobs();
}
function resetBackendCreateForm(type: BackendType = "s3") {
backendType.value = type;
backendName.value = type === "local_directory" ? "local-archive" : type === "webdav" ? "openlist-main" : "s3-main";
backendStabilityClass.value = type === "local_directory" ? "local" : "stable";
backendReadPriority.value = 80;
backendWritePriority.value = 80;
backendConfigText.value = CONFIG_TEMPLATES[type];
backendFormError.value = "";
}
function handleBackendTypeChange(event: Event) {
resetBackendCreateForm((event.target as HTMLSelectElement).value as BackendType);
}
async function createBackend() {
backendFormError.value = "";
try {
const config = parseJsonObject(backendConfigText.value);
await api.createBackend({
name: backendName.value,
type: backendType.value,
stability_class: backendStabilityClass.value,
read_priority: backendReadPriority.value,
write_priority: backendWritePriority.value,
config,
});
isCreateBackendOpen.value = false;
toast.value = "Backend created.";
await loadStorage();
} catch (error) {
backendFormError.value = error instanceof Error ? error.message : "Backend creation failed.";
}
}
function openCreateBackend() {
resetBackendCreateForm();
isCreateBackendOpen.value = true;
}
function openEditBackend(backend: BackendSummary) {
editingBackend.value = backend;
editName.value = backend.name;
editStabilityClass.value = backend.stability_class as StabilityClass;
editReadPriority.value = backend.read_priority;
editWritePriority.value = backend.write_priority;
editConfigText.value = JSON.stringify(backend.config ?? {}, null, 2);
editSecretText.value = "{}";
editError.value = "";
}
async function saveBackend() {
if (!editingBackend.value) return;
editError.value = "";
try {
const publicConfig = parseJsonObject(editConfigText.value);
const secretConfig = parseJsonObject(editSecretText.value);
await api.updateBackend(editingBackend.value.id, {
name: editName.value,
stability_class: editStabilityClass.value,
read_priority: editReadPriority.value,
write_priority: editWritePriority.value,
config: { ...publicConfig, ...secretConfig },
});
editingBackend.value = null;
toast.value = "Backend updated.";
await loadStorage();
} catch (error) {
editError.value = error instanceof Error ? error.message : "Backend update failed.";
}
}
async function toggleBackendEnabled(backend: BackendSummary) {
if (backend.is_enabled) {
await api.disableBackend(backend.id);
toast.value = "Backend disabled.";
} else {
await api.enableBackend(backend.id);
toast.value = "Backend enabled.";
}
await loadStorage();
}
function parseJsonObject(value: string): Record<string, unknown> {
const parsed = JSON.parse(value || "{}") as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Config must be a JSON object.");
}
return parsed as Record<string, unknown>;
}
async function loadPolicies() {
const result = await api.listPlacementPolicies();
policies.value = result.items;
}
async function savePolicy() {
if (!currentPolicy.value) return;
const policy = currentPolicy.value;
await api.updatePlacementPolicy(policy.file_class, {
require_local: policy.require_local,
stable_replica_count: policy.stable_replica_count,
opportunistic_replica_count: policy.opportunistic_replica_count,
preferred_backend_ids: policy.preferred_backend_ids,
excluded_backend_ids: policy.excluded_backend_ids,
max_non_local_size_bytes: policy.max_non_local_size_bytes,
});
toast.value = "Policy saved.";
await loadPolicies();
}
async function loadJobs() {
const result = await api.listJobs();
jobs.value = result.items;
if (!selectedJob.value || !jobs.value.some((job) => job.id === selectedJob.value?.id)) {
selectedJob.value = jobs.value[0] ?? null;
}
}
async function retryJob(job: JobSummary) {
await api.retryJob(job.id);
await loadJobs();
}
async function runPendingJobs() {
await api.runPendingJobs();
await loadJobs();
}
async function enqueueHealthChecks() {
await api.enqueueHealthChecks();
await loadJobs();
}
async function enqueueFullReconcile() {
await api.enqueueFullReconcile();
await loadJobs();
}
function showError(error: unknown) {
toast.value = error instanceof Error ? error.message : "Something went wrong.";
}
function setPageSize(value: Event) {
pageSize.value = Number((value.target as HTMLSelectElement).value);
page.value = 1;
}
function setRecyclePageSize(value: Event) {
recyclePageSize.value = Number((value.target as HTMLSelectElement).value);
recyclePage.value = 1;
}
function setBackendPageSize(value: Event) {
backendPageSize.value = Number((value.target as HTMLSelectElement).value);
backendPage.value = 1;
}
function setJobsPageSize(value: Event) {
jobsPageSize.value = Number((value.target as HTMLSelectElement).value);
jobsPage.value = 1;
}
function formatReplicaRatio(item: DirectoryItem) {
if (item.kind !== "file") return "--";
return `${item.replica_ready_count ?? 0}/${item.replica_desired_count ?? 0}`;
}
function replicaClass(item: DirectoryItem) {
return item.replica_missing_count ? "replica-ratio" : "replica-ratio replica-ratio-ready";
}
function iconClass(item: DirectoryItem) {
if (item.kind === "directory") return "resource-icon resource-icon-folder";
if (item.mime_type?.includes("image")) return "resource-icon resource-icon-image";
if (item.mime_type?.includes("pdf")) return "resource-icon resource-icon-pdf";
if (item.name.endsWith(".md") || item.name.endsWith(".txt")) return "resource-icon resource-icon-document";
if (item.name.endsWith(".zip")) return "resource-icon resource-icon-archive";
return "resource-icon";
}
function iconLabel(item: DirectoryItem) {
if (item.kind === "directory") return "";
const ext = item.name.split(".").pop();
return (ext ?? "FILE").slice(0, 3).toUpperCase();
}
function isImage(mime: string | null) {
return Boolean(mime?.startsWith("image/"));
}
function isText(mime: string | null, name: string) {
return Boolean(
mime?.startsWith("text/") ||
["application/json", "application/xml", "application/yaml"].includes(mime ?? "") ||
[".md", ".txt", ".json", ".yaml", ".yml", ".csv", ".log"].some((suffix) => name.toLowerCase().endsWith(suffix)),
);
}
function truncatePreviewText(value: string) {
const maxLength = 16_000;
return value.length > maxLength ? `${value.slice(0, maxLength)}\n\n... preview truncated ...` : value;
}
function formatReplicaVerification(value: string | null) {
return value ? `Verified ${formatDate(value)}` : "Not verified yet";
}
function formatBackendConfig(config: Record<string, unknown> | null) {
if (!config) return "Configured";
if (typeof config.base_path === "string") return config.base_path;
if (typeof config.bucket === "string") {
const prefix = typeof config.prefix === "string" && config.prefix ? `/${config.prefix}` : "";
return `${config.bucket}${prefix}`;
}
if (typeof config.endpoint_url === "string") {
const rootPath = typeof config.root_path === "string" ? config.root_path : "iron";
return `${config.endpoint_url}/${rootPath}`;
}
return "Configured";
}
function getPaginationItems(current: number, total: number): Array<number | "ellipsis"> {
if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1);
const values = new Set<number>([1, total, current - 1, current, current + 1]);
if (current <= 4) [2, 3, 4, 5].forEach((item) => values.add(item));
if (current >= total - 3) [total - 4, total - 3, total - 2, total - 1].forEach((item) => values.add(item));
const sorted = Array.from(values).filter((item) => item >= 1 && item <= total).sort((a, b) => a - b);
return sorted.flatMap((item, index) => {
const previous = sorted[index - 1];
if (!previous) return [item];
if (item - previous === 2) return [previous + 1, item];
if (item - previous > 2) return ["ellipsis", item];
return [item];
});
}
</script>
<template>
<main v-if="route.name === 'login'" class="login-page">
<form class="login-form" @submit.prevent="login">
<div class="section-heading">
<span class="eyebrow">Iron</span>
<h2>Sign in</h2>
</div>
<label>
<span>Username</span>
<input v-model="loginUsername" required />
</label>
<label>
<span>Password</span>
<input v-model="loginPassword" type="password" required />
</label>
<OcButton appearance="filled" color-role="primary" submit="submit" :disabled="isLoginPending">
{{ isLoginPending ? "Signing in..." : "Sign in" }}
</OcButton>
<p v-if="loginError" class="error-text">{{ loginError }}</p>
</form>
</main>
<div v-else class="iron-app">
<header class="iron-topbar">
<div class="brand-area">
<button class="app-switcher-button" type="button" aria-label="Application menu">
<span v-for="item in 9" :key="item" />
</button>
<div class="brand-lockup"><span class="brand-mark">I</span><span>Iron</span></div>
</div>
<form class="topbar-search" role="search" @submit.prevent>
<input v-model="search" aria-label="Search" placeholder="Enter search term" />
</form>
<button class="user-chip" type="button" @click="logout">{{ userInitial }}</button>
</header>
<div class="iron-workspace">
<aside class="sidebar">
<span class="sidebar-kicker">Personal</span>
<h1 class="sidebar-title">Drive</h1>
<nav class="main-nav">
<a :class="route.name === 'files' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/files')"><span class="nav-symbol nav-symbol-files" />Files</a>
<a :class="route.name === 'recycle' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/recycle-bin')"><span class="nav-symbol nav-symbol-recycle" />Deleted files</a>
<a :class="route.name === 'storage' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/storage')"><span class="nav-symbol nav-symbol-storage" />Storage</a>
<a :class="route.name === 'policies' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/policies')"><span class="nav-symbol nav-symbol-policies" />Policies</a>
<a :class="route.name === 'jobs' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/jobs')"><span class="nav-symbol nav-symbol-jobs" />Jobs</a>
</nav>
<div class="sidebar-footer">Iron Web UI<br />Personal cloud drive</div>
</aside>
<section class="workspace-content">
<div v-if="route.name === 'files'" class="files-page-layout">
<section class="files-content-shell">
<div class="panel-toolbar">
<div class="files-header">
<div class="directory-title-row">
<button v-if="directoryId !== 'dir_root'" class="directory-back-button" type="button" aria-label="Back to parent folder" @click="goBackDirectory">‹</button>
<h2>{{ directoryName }}</h2>
</div>
<div class="inline-metrics">
<span>{{ folders.length }} folders</span>
<span>{{ files.length }} files</span>
<span>{{ formatBytes(fileTotalBytes) }}</span>
</div>
</div>
<div class="command-bar files-command-bar">
<OcButton appearance="filled" color-role="secondary" @click="openDialog('create')"><span class="button-symbol button-symbol-add" />New</OcButton>
<OcButton appearance="outline" @click="fileInput?.click()"><span class="button-symbol button-symbol-upload" />Upload</OcButton>
<OcButton appearance="outline" @click="loadDirectory">Refresh</OcButton>
<OcButton appearance="outline" @click="viewMode = viewMode === 'list' ? 'grid' : 'list'">{{ viewMode === "list" ? "Grid" : "List" }}</OcButton>
<select v-model="sortBy" class="compact-sort-select" aria-label="Sort current directory">
<option value="name">Sort by name</option>
<option value="updated">Sort by updated</option>
<option value="size">Sort by size</option>
</select>
</div>
</div>
<input ref="fileInput" hidden type="file" @change="uploadSelectedFile" />
<p v-if="toast" class="notice-text">{{ toast }}</p>
<div :class="viewMode === 'grid' ? 'list-surface grid-surface' : 'list-surface'">
<div class="list-header"><span>Name</span><span>Updated</span><span>Size</span><span>Replicas</span><span>Actions</span></div>
<div v-if="!pagedItems.length" class="empty-row">Add a folder or upload a file.</div>
<div :class="viewMode === 'grid' ? 'drive-grid' : 'drive-list'">
<div
v-for="item in pagedItems"
:key="item.id"
:class="selectedItem?.id === item.id ? 'drive-item active' : 'drive-item'"
role="button"
tabindex="0"
@click="selectItem(item)"
@dblclick="item.kind === 'directory' ? openDirectory(item) : undefined"
>
<div class="drive-item-main">
<span :class="iconClass(item)">{{ iconLabel(item) }}</span>
<span class="resource-name-stack"><strong>{{ item.name }}</strong></span>
</div>
<span>{{ formatDate(item.updated_at) }}</span>
<span>{{ formatBytes(item.size_bytes) }}</span>
<span v-if="viewMode === 'list' || item.kind === 'file'" :class="replicaClass(item)">{{ formatReplicaRatio(item) }}</span>
<span class="row-actions" @click.stop>
<button class="row-action-button" type="button" @click="toggleItemMenu(item)">More</button>
<div v-if="openMenuId === item.id" class="menu-popover" role="menu">
<button class="menu-item" type="button" @click="openItemFromMenu(item)">{{ item.kind === "directory" ? "Open" : "Download" }}</button>
<button v-if="item.kind === 'file'" class="menu-item" type="button" @click="reconcileFile(item)">Reconcile</button>
<button class="menu-item" type="button" @click="openDialog('rename', item)">Rename</button>
<button class="menu-item" type="button" @click="openDialog('move', item)">Move</button>
<button v-if="item.kind === 'file'" class="menu-item menu-item-danger" type="button" @click="openDialog('delete', item)">Delete</button>
</div>
</span>
</div>
</div>
</div>
<div class="pagination-bar">
<span class="pagination-summary">{{ filteredItems.length }} items</span>
<div class="pagination-controls">
<button class="pagination-page-button" type="button" :disabled="page === 1" @click="page = Math.max(1, page - 1)">‹</button>
<span class="pagination-page-list">
<template v-for="(item, index) in pageItems" :key="`${item}-${index}`">
<span v-if="item === 'ellipsis'" class="pagination-ellipsis">...</span>
<button v-else :class="item === page ? 'pagination-page-button active' : 'pagination-page-button'" type="button" @click="page = item">{{ item }}</button>
</template>
</span>
<button class="pagination-page-button" type="button" :disabled="page === totalPages" @click="page = Math.min(totalPages, page + 1)">›</button>
<select class="page-size-select" :value="pageSize" @change="setPageSize">
<option :value="8">8 / page</option>
<option :value="16">16 / page</option>
<option :value="32">32 / page</option>
</select>
</div>
</div>
</section>
<aside class="detail-panel">
<div v-if="!selectedItem" class="detail-empty"><span class="eyebrow">Inspector</span><h3>Select a file or folder.</h3></div>
<template v-else>
<div class="section-heading"><span class="eyebrow">Inspector</span><h3>{{ selectedItem.name }}</h3></div>
<img v-if="previewUrl && isImage(selectedItem.mime_type)" class="preview-frame" :src="previewUrl" alt="" />
<iframe v-else-if="previewUrl && selectedItem.mime_type === 'application/pdf'" class="preview-frame" :src="previewUrl" title="PDF preview" />
<video v-else-if="previewUrl && selectedItem.mime_type?.startsWith('video/')" class="preview-frame" :src="previewUrl" controls preload="metadata" />
<pre v-else-if="previewText" class="preview-frame text-preview-frame">{{ previewText }}</pre>
<div v-else-if="selectedItem.kind === 'file'" class="preview-fallback"><strong>No inline preview</strong><p>Download this file to open it in another app.</p></div>
<div v-if="selectedItem.kind === 'file'" class="inspector-storage-summary">
<div><span>Replicas</span><strong>{{ formatReplicaRatio(selectedItem) }}</strong></div>
<span :class="replicaClass(selectedItem)">{{ selectedItem.replica_missing_count ? "Needs sync" : "Ready" }}</span>
</div>
<OcButton v-if="selectedItem.kind === 'file'" class="inspector-info-button" appearance="outline" @click="openDetailDrawer">More Info</OcButton>
</template>
</aside>
</div>
<div v-if="route.name === 'recycle'" class="page-frame files-content-shell">
<div class="section-heading"><span>Deleted files</span><h2>Recycle Bin</h2><div class="inline-metrics"><span>{{ recycleItems.length }} items</span><span>{{ formatBytes(recycleTotalBytes) }}</span></div></div>
<div class="list-surface">
<div class="list-header recycle-list-header"><span>Name</span><span>Deleted</span><span>Size</span><span>Actions</span></div>
<div v-if="!recycleItems.length" class="empty-row">Recycle bin is empty.</div>
<div v-for="item in pagedRecycleItems" :key="item.id" class="drive-item recycle-row" role="button" tabindex="0">
<div class="drive-item-main"><span :class="iconClass(item)">{{ iconLabel(item) }}</span><strong>{{ item.name }}</strong></div>
<span>{{ formatDate(item.deleted_at ?? item.updated_at) }}</span>
<span>{{ formatBytes(item.size_bytes) }}</span>
<OcButton appearance="outline" size="small" @click="restoreFile(item)">Restore</OcButton>
</div>
</div>
<div class="pagination-bar">
<span class="pagination-summary">{{ recycleItems.length }} items</span>
<div class="pagination-controls">
<button class="pagination-page-button" type="button" :disabled="recyclePage === 1" @click="recyclePage = Math.max(1, recyclePage - 1)">‹</button>
<span class="pagination-page-list">
<template v-for="(item, index) in recyclePageItems" :key="`recycle-${item}-${index}`">
<span v-if="item === 'ellipsis'" class="pagination-ellipsis">...</span>
<button v-else :class="item === recyclePage ? 'pagination-page-button active' : 'pagination-page-button'" type="button" @click="recyclePage = item">{{ item }}</button>
</template>
</span>
<button class="pagination-page-button" type="button" :disabled="recyclePage === recycleTotalPages" @click="recyclePage = Math.min(recycleTotalPages, recyclePage + 1)">›</button>
<select class="page-size-select" :value="recyclePageSize" @change="setRecyclePageSize">
<option :value="8">8 / page</option>
<option :value="16">16 / page</option>
<option :value="32">32 / page</option>
</select>
</div>
</div>
</div>
<div v-if="route.name === 'storage'" class="page-frame files-content-shell">
<div class="page-toolbar">
<div class="section-heading"><span>Storage</span><h2>Backends</h2><div class="inline-metrics"><span>{{ backends.length }} backends</span><span>{{ healthyBackendCount }} healthy</span><span>{{ enabledBackendCount }} enabled</span></div></div>
<div class="command-bar"><OcButton appearance="filled" color-role="secondary" @click="openCreateBackend">Add Backend</OcButton><OcButton appearance="outline" @click="runAllBackendChecks">Run All Checks</OcButton></div>
</div>
<div class="list-surface">
<div class="list-header storage-list-header"><span>Name</span><span>Class</span><span>Status</span><span>Updated</span><span>Actions</span></div>
<div v-if="!backends.length" class="empty-row">No storage backends configured.</div>
<div v-for="backend in pagedBackends" :key="backend.id" class="drive-item storage-row">
<div><strong>{{ backend.name }}</strong><br /><span class="eyebrow">{{ formatBackendType(backend.type) }} · {{ formatBackendConfig(backend.config) }}{{ backend.secret_fields.length ? ` · ${backend.secret_fields.length} secrets` : "" }}</span></div>
<span>{{ titleCase(backend.stability_class) }}</span>
<span :class="backend.last_health_status === 'healthy' ? 'status-chip status-chip-success' : 'status-chip'">{{ backend.is_enabled ? titleCase(backend.last_health_status ?? "unknown") : "Disabled" }}</span>
<span>{{ formatDate(backend.last_health_checked_at ?? backend.updated_at) }}</span>
<span class="row-actions storage-row-actions">
<OcButton appearance="outline" size="small" @click="checkBackend(backend)">Check</OcButton>
<OcButton appearance="outline" size="small" @click="openEditBackend(backend)">Edit</OcButton>
<OcButton appearance="outline" size="small" @click="toggleBackendEnabled(backend)">{{ backend.is_enabled ? "Disable" : "Enable" }}</OcButton>
</span>
</div>
</div>
<div class="pagination-bar">
<span class="pagination-summary">{{ backends.length }} backends</span>
<div class="pagination-controls">
<button class="pagination-page-button" type="button" :disabled="backendPage === 1" @click="backendPage = Math.max(1, backendPage - 1)">‹</button>
<span class="pagination-page-list">
<template v-for="(item, index) in backendPageItems" :key="`backend-${item}-${index}`">
<span v-if="item === 'ellipsis'" class="pagination-ellipsis">...</span>
<button v-else :class="item === backendPage ? 'pagination-page-button active' : 'pagination-page-button'" type="button" @click="backendPage = item">{{ item }}</button>
</template>
</span>
<button class="pagination-page-button" type="button" :disabled="backendPage === backendTotalPages" @click="backendPage = Math.min(backendTotalPages, backendPage + 1)">›</button>
<select class="page-size-select" :value="backendPageSize" @change="setBackendPageSize">
<option :value="8">8 / page</option>
<option :value="16">16 / page</option>
<option :value="32">32 / page</option>
</select>
</div>
</div>
</div>
<div v-if="route.name === 'policies'" class="page-frame files-content-shell">
<div class="section-heading"><span>Placement</span><h2>Replica policies</h2><div class="inline-metrics"><span>{{ policies.length }} policies</span><span>2 default replicas</span></div></div>
<div class="command-bar" style="justify-content:flex-start;margin:.8rem 0"><OcButton v-for="policy in policies" :key="policy.file_class" :appearance="policy.file_class === selectedPolicyClass ? 'filled' : 'outline'" @click="selectedPolicyClass = policy.file_class">{{ titleCase(policy.file_class) }}</OcButton></div>
<form v-if="currentPolicy" class="dialog-form policy-form" @submit.prevent="savePolicy"><label><span>Stable replicas</span><input v-model.number="currentPolicy.stable_replica_count" class="dialog-input" type="number" min="0" /></label><label><span>Opportunistic replicas</span><input v-model.number="currentPolicy.opportunistic_replica_count" class="dialog-input" type="number" min="0" /></label><OcButton appearance="filled" color-role="secondary" submit="submit">Save Policy</OcButton></form>
</div>
<div v-if="route.name === 'jobs'" class="files-page-layout">
<section class="files-content-shell">
<div class="page-toolbar">
<div class="section-heading"><span>Jobs</span><h2>Background activity</h2><div class="inline-metrics"><span>{{ pendingJobsCount }} pending</span><span>{{ completedJobsCount }} completed</span><span>{{ failedJobsCount }} failed</span></div></div>
<div class="command-bar"><OcButton appearance="outline" @click="runPendingJobs">Run Pending</OcButton><OcButton appearance="outline" @click="enqueueHealthChecks">Enqueue Checks</OcButton><OcButton appearance="filled" color-role="secondary" @click="enqueueFullReconcile">Full Reconcile</OcButton></div>
</div>
<div class="list-surface">
<div class="list-header jobs-list-header"><span>Job</span><span>Status</span><span>Attempts</span><span>Actions</span></div>
<div v-if="!jobs.length" class="empty-row">No background jobs recorded yet.</div>
<div v-for="job in pagedJobs" :key="job.id" :class="selectedJob?.id === job.id ? 'drive-item jobs-row jobs-table-row active' : 'drive-item jobs-row jobs-table-row'" role="button" tabindex="0" @click="selectedJob = job">
<div><strong>{{ titleCase(job.kind) }}</strong><br /><span class="eyebrow">Run after {{ formatDate(job.run_after) }}</span><br /><span v-if="job.last_error" class="error-text">{{ job.last_error }}</span></div>
<span class="status-chip">{{ titleCase(job.status) }}</span>
<span>{{ job.attempt_count }}/{{ job.max_attempts }}</span>
<OcButton appearance="outline" size="small" @click.stop="retryJob(job)">Retry</OcButton>
</div>
</div>
<div class="pagination-bar">
<span class="pagination-summary">{{ jobs.length }} jobs</span>
<div class="pagination-controls">
<button class="pagination-page-button" type="button" :disabled="jobsPage === 1" @click="jobsPage = Math.max(1, jobsPage - 1)">‹</button>
<span class="pagination-page-list">
<template v-for="(item, index) in jobsPageItems" :key="`jobs-${item}-${index}`">
<span v-if="item === 'ellipsis'" class="pagination-ellipsis">...</span>
<button v-else :class="item === jobsPage ? 'pagination-page-button active' : 'pagination-page-button'" type="button" @click="jobsPage = item">{{ item }}</button>
</template>
</span>
<button class="pagination-page-button" type="button" :disabled="jobsPage === jobsTotalPages" @click="jobsPage = Math.min(jobsTotalPages, jobsPage + 1)">›</button>
<select class="page-size-select" :value="jobsPageSize" @change="setJobsPageSize">
<option :value="8">8 / page</option>
<option :value="16">16 / page</option>
<option :value="32">32 / page</option>
</select>
</div>
</div>
</section>
<aside class="detail-panel jobs-detail-panel">
<div v-if="!selectedJob" class="detail-empty"><span class="eyebrow">Inspector</span><h3>Select a job</h3></div>
<template v-else>
<div class="section-heading"><span>Job Detail</span><h3>{{ titleCase(selectedJob.kind) }}</h3></div>
<div class="inspector-storage-summary"><div><span>Status</span><strong>{{ titleCase(selectedJob.status) }}</strong></div><span class="status-chip">{{ selectedJob.attempt_count }}/{{ selectedJob.max_attempts }}</span></div>
<div class="meta-list inspector-card jobs-detail-meta">
<div><span>Run after</span><strong>{{ formatDate(selectedJob.run_after) }}</strong></div>
<div><span>Created</span><strong>{{ formatDate(selectedJob.created_at) }}</strong></div>
<div><span>Updated</span><strong>{{ formatDate(selectedJob.updated_at) }}</strong></div>
<div><span>ID</span><strong class="mono-text">{{ selectedJob.id }}</strong></div>
</div>
<div v-if="selectedJob.last_error" class="inspector-card replica-warning-card"><strong>Last error</strong><p>{{ selectedJob.last_error }}</p></div>
<section class="inspector-section"><span class="eyebrow">Payload</span><pre class="payload-preview">{{ selectedJob.payload_json || '{}' }}</pre></section>
</template>
</aside>
</div>
</section>
</div>
</div>
<div v-if="toast" class="toast-item">{{ toast }}</div>
<div v-if="dialogMode" class="dialog-backdrop">
<form class="dialog-card" role="dialog" aria-modal="true" @submit.prevent="confirmDialog">
<div class="dialog-header">
<div><span class="eyebrow">Action</span><h3>{{ dialogMode === "delete" ? "Delete file" : dialogMode === "create" ? "New folder" : dialogMode === "move" ? "Move item" : "Rename" }}</h3></div>
<OcButton appearance="outline" @click="dialogMode = null">Close</OcButton>
</div>
<p v-if="dialogMode === 'delete'">Move <strong>{{ selectedItem?.name }}</strong> to the recycle bin?</p>
<label v-else-if="dialogMode === 'move'"><span>Target folder</span><select v-model="moveTargetId" class="dialog-select"><option v-for="target in moveTargets" :key="target.id" :value="target.id">{{ target.name }} ({{ target.id }})</option></select></label>
<label v-else><span>Name</span><input v-model="fieldValue" class="dialog-input" required /></label>
<div class="dialog-actions">
<OcButton appearance="outline" @click="dialogMode = null">Cancel</OcButton>
<button v-if="dialogMode === 'delete'" class="danger-confirm-button" type="submit">Confirm</button>
<OcButton v-else appearance="filled" color-role="secondary" submit="submit">Confirm</OcButton>
</div>
</form>
</div>
<div v-if="isCreateBackendOpen" class="dialog-backdrop">
<form class="dialog-card dialog-card-wide" role="dialog" aria-modal="true" @submit.prevent="createBackend">
<div class="dialog-header">
<div><span class="eyebrow">Storage</span><h3>Add storage backend</h3></div>
<OcButton appearance="outline" @click="isCreateBackendOpen = false">Close</OcButton>
</div>
<label><span>Type</span><select class="dialog-select" :value="backendType" @change="handleBackendTypeChange"><option value="local_directory">Local directory</option><option value="s3">S3</option><option value="webdav">OpenList / WebDAV</option></select></label>
<label><span>Name</span><input v-model="backendName" class="dialog-input" required /></label>
<label><span>Policy class</span><select v-model="backendStabilityClass" class="dialog-select"><option value="local">Local</option><option value="stable">Stable</option><option value="opportunistic">Opportunistic</option></select></label>
<div class="split-fields"><label><span>Read priority</span><input v-model.number="backendReadPriority" class="dialog-input" type="number" /></label><label><span>Write priority</span><input v-model.number="backendWritePriority" class="dialog-input" type="number" /></label></div>
<label><span>Config JSON</span><textarea v-model="backendConfigText" class="dialog-textarea" spellcheck="false" /></label>
<p v-if="backendFormError" class="error-text">{{ backendFormError }}</p>
<div class="dialog-actions">
<OcButton appearance="outline" @click="isCreateBackendOpen = false">Cancel</OcButton>
<OcButton appearance="filled" color-role="secondary" submit="submit">Create</OcButton>
</div>
</form>
</div>
<div v-if="editingBackend" class="dialog-backdrop">
<form class="dialog-card dialog-card-wide" role="dialog" aria-modal="true" @submit.prevent="saveBackend">
<div class="dialog-header">
<div><span class="eyebrow">Storage</span><h3>Edit storage backend</h3></div>
<OcButton appearance="outline" @click="editingBackend = null">Close</OcButton>
</div>
<label><span>Type</span><input class="dialog-input" :value="formatBackendType(editingBackend.type)" disabled /></label>
<label><span>Name</span><input v-model="editName" class="dialog-input" required /></label>
<label><span>Policy class</span><select v-model="editStabilityClass" class="dialog-select"><option value="local">Local</option><option value="stable">Stable</option><option value="opportunistic">Opportunistic</option></select></label>
<div class="split-fields"><label><span>Read priority</span><input v-model.number="editReadPriority" class="dialog-input" type="number" /></label><label><span>Write priority</span><input v-model.number="editWritePriority" class="dialog-input" type="number" /></label></div>
<label><span>Public config JSON</span><textarea v-model="editConfigText" class="dialog-textarea" spellcheck="false" /></label>
<label><span>Secret override JSON</span><textarea v-model="editSecretText" class="dialog-textarea dialog-textarea-small" spellcheck="false" /></label>
<div class="inspector-card"><strong>Stored secrets</strong><p>{{ editingBackend.secret_fields.length ? editingBackend.secret_fields.join(", ") : "None" }}</p></div>
<p v-if="editError" class="error-text">{{ editError }}</p>
<div class="dialog-actions">
<OcButton appearance="outline" @click="editingBackend = null">Cancel</OcButton>
<OcButton appearance="filled" color-role="secondary" submit="submit">Save</OcButton>
</div>
</form>
</div>
<div v-if="isDetailDrawerOpen && selectedItem?.kind === 'file'" class="detail-drawer-backdrop" @click="isDetailDrawerOpen = false">
<aside class="detail-drawer" role="dialog" aria-modal="true" @click.stop>
<div class="detail-drawer-header"><div><span class="eyebrow">Replica Info</span><h3>{{ selectedItem.name }}</h3></div><OcButton appearance="outline" @click="isDetailDrawerOpen = false">Close</OcButton></div>
<div class="detail-drawer-content">
<section class="inspector-section">
<span class="eyebrow">Placement</span>
<div class="inspector-card storage-summary-card">
<div><span>Class</span><strong>{{ titleCase(fileDetail?.placement_file_class ?? "unknown") }}</strong></div>
<div><span>Replicas</span><strong>{{ formatReplicaRatio(selectedItem) }}</strong></div>
<div><span>Missing</span><strong>{{ fileDetail?.missing_backend_ids.length ?? selectedItem.replica_missing_count ?? 0 }}</strong></div>
</div>
<div v-if="fileDetail?.missing_backend_ids.length" class="inspector-card replica-warning-card"><strong>Replica gap detected</strong><p>Missing {{ fileDetail.missing_backend_ids.length }} desired backend{{ fileDetail.missing_backend_ids.length === 1 ? "" : "s" }}.</p></div>
</section>
<section class="inspector-section">
<span class="eyebrow">Desired Backends</span>
<div class="inspector-card replica-list">
<div v-for="backend in fileDetail?.desired_backends ?? []" :key="backend.id" class="replica-row">
<div><strong>{{ backend.name }}</strong><span class="item-muted">{{ formatBackendType(backend.type) }} · {{ titleCase(backend.stability_class) }}</span></div>
<span :class="fileDetail?.missing_backend_ids.includes(backend.id) ? 'status-chip' : 'status-chip status-chip-success'">{{ fileDetail?.missing_backend_ids.includes(backend.id) ? "Missing" : "Ready" }}</span>
</div>
<p v-if="!fileDetail?.desired_backends.length" class="item-muted">No placement targets selected.</p>
</div>
</section>
<section class="inspector-section">
<span class="eyebrow">Replicas</span>
<div class="inspector-card replica-list">
<div v-for="replica in fileDetail?.replicas ?? []" :key="replica.id" class="replica-row">
<div><strong>{{ replica.backend_name }}</strong><span class="item-muted">{{ formatBytes(replica.size_bytes) }} · {{ formatReplicaVerification(replica.last_verified_at) }}</span></div>
<span :class="replica.status === 'ready' ? 'status-chip status-chip-success' : 'status-chip'">{{ titleCase(replica.status) }}</span>
</div>
<p v-if="!fileDetail?.replicas.length" class="item-muted">No recorded replicas for this file yet.</p>
</div>
</section>
<section class="inspector-section">
<span class="eyebrow">Preview Artifacts</span>
<div class="inspector-card">
<div v-for="artifact in fileDetail?.preview_artifacts ?? []" :key="artifact.id" class="artifact-row"><strong>{{ titleCase(artifact.artifact_type) }}</strong><span>{{ titleCase(artifact.status) }}</span></div>
<p v-if="!fileDetail?.preview_artifacts.length" class="item-muted">No generated artifacts yet.</p>
</div>
</section>
</div>
</aside>
</div>
</template>