|
After Width: | Height: | Size: 139 B |
|
After Width: | Height: | Size: 217 B |
|
After Width: | Height: | Size: 340 B |
|
After Width: | Height: | Size: 286 B |
|
After Width: | Height: | Size: 241 B |
|
After Width: | Height: | Size: 146 B |
|
After Width: | Height: | Size: 147 B |
|
After Width: | Height: | Size: 139 B |
|
After Width: | Height: | Size: 217 B |
|
After Width: | Height: | Size: 340 B |
|
After Width: | Height: | Size: 286 B |
|
After Width: | Height: | Size: 241 B |
|
After Width: | Height: | Size: 146 B |
|
After Width: | Height: | Size: 147 B |
@ -0,0 +1,599 @@ |
|||||
|
<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"; |
||||
|
|
||||
|
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 fileDetail = ref<FileDetailResponse | null>(null); |
||||
|
const previewUrl = ref(""); |
||||
|
const previewText = ref(""); |
||||
|
const isDetailDrawerOpen = ref(false); |
||||
|
|
||||
|
const recycleItems = ref<Array<DirectoryItem & { deleted_at?: string }>>([]); |
||||
|
const backends = ref<BackendSummary[]>([]); |
||||
|
const editingBackend = ref<BackendSummary | null>(null); |
||||
|
const policies = ref<PlacementPolicySummary[]>([]); |
||||
|
const selectedPolicyClass = ref<PlacementPolicySummary["file_class"]>("document"); |
||||
|
const jobs = ref<JobSummary[]>([]); |
||||
|
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)); |
||||
|
|
||||
|
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); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
onMounted(async () => { |
||||
|
window.addEventListener("popstate", handlePopState); |
||||
|
if (!getAccessToken() && route.value.name !== "login") { |
||||
|
navigate("/app/login"); |
||||
|
return; |
||||
|
} |
||||
|
await loadRouteData(); |
||||
|
}); |
||||
|
|
||||
|
onUnmounted(() => window.removeEventListener("popstate", handlePopState)); |
||||
|
|
||||
|
function handlePopState() { |
||||
|
route.value = parseRoute(); |
||||
|
} |
||||
|
|
||||
|
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; |
||||
|
dialogMode.value = mode; |
||||
|
fieldValue.value = mode === "rename" ? selectedItem.value?.name ?? "" : ""; |
||||
|
} |
||||
|
|
||||
|
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 === "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 (isText(item.mime_type, item.name)) { |
||||
|
const blob = await api.fileBlob(item.id, "preview"); |
||||
|
previewText.value = (await blob.text()).slice(0, 8000); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function downloadFile(item: DirectoryItem) { |
||||
|
if (item.kind !== "file") return; |
||||
|
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; |
||||
|
await api.reconcileFile(item.id); |
||||
|
toast.value = "Reconcile queued."; |
||||
|
await loadJobs(); |
||||
|
} |
||||
|
|
||||
|
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 })); |
||||
|
} |
||||
|
|
||||
|
async function restoreFile(item: DirectoryItem) { |
||||
|
await api.restoreFile(item.id); |
||||
|
await loadRecycleBin(); |
||||
|
} |
||||
|
|
||||
|
async function loadStorage() { |
||||
|
const result = await api.listBackends(); |
||||
|
backends.value = result.items; |
||||
|
} |
||||
|
|
||||
|
async function checkBackend(backend: BackendSummary) { |
||||
|
await api.checkBackend(backend.id); |
||||
|
toast.value = "Backend check queued."; |
||||
|
await loadStorage(); |
||||
|
} |
||||
|
|
||||
|
async function runAllBackendChecks() { |
||||
|
toast.value = "Backend checks queued."; |
||||
|
await Promise.all(backends.value.map(checkBackend)); |
||||
|
} |
||||
|
|
||||
|
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; |
||||
|
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 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/") || name.endsWith(".md") || name.endsWith(".json") || name.endsWith(".csv")); |
||||
|
} |
||||
|
|
||||
|
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')"><OcIcon name="folder" size="small" />Files</a> |
||||
|
<a :class="route.name === 'recycle' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/recycle-bin')"><OcIcon name="delete-bin" size="small" />Deleted files</a> |
||||
|
<a :class="route.name === 'storage' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/storage')"><OcIcon name="database" size="small" />Storage</a> |
||||
|
<a :class="route.name === 'policies' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/policies')"><OcIcon name="checkbox-blank" size="small" />Policies</a> |
||||
|
<a :class="route.name === 'jobs' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/jobs')"><OcIcon name="menu" size="small" />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')"><OcIcon name="add" size="small" />New</OcButton> |
||||
|
<OcButton appearance="outline" @click="fileInput?.click()"><OcIcon name="upload" size="small" />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="selectedItem = 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 :class="replicaClass(item)">{{ formatReplicaRatio(item) }}</span> |
||||
|
<span class="row-actions" @click.stop> |
||||
|
<OcButton class="row-action-button" appearance="outline" size="small" @click="openMenuId = item.id">More</OcButton> |
||||
|
<span v-if="openMenuId === item.id" class="menu-popover" role="menu"> |
||||
|
<OcButton class="menu-item" appearance="raw" justify-content="left" @click="item.kind === 'directory' ? openDirectory(item) : downloadFile(item)">Download</OcButton> |
||||
|
<OcButton v-if="item.kind === 'file'" class="menu-item" appearance="raw" justify-content="left" @click="reconcileFile(item)">Reconcile</OcButton> |
||||
|
<OcButton class="menu-item" appearance="raw" justify-content="left" @click="openDialog('rename', item)">Rename</OcButton> |
||||
|
<OcButton v-if="item.kind === 'file'" class="menu-item menu-item-danger" appearance="raw" justify-content="left" @click="openDialog('delete', item)">Delete</OcButton> |
||||
|
</span> |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="pagination-bar"> |
||||
|
<span>{{ 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="" /> |
||||
|
<pre v-else-if="previewText" class="preview-frame text-preview-frame">{{ previewText }}</pre> |
||||
|
<div v-else-if="selectedItem.kind === 'file'" class="preview-fallback">No inline preview</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="isDetailDrawerOpen = true">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(recycleItems.reduce((sum, item) => sum + (item.size_bytes ?? 0), 0)) }}</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 recycleItems" :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> |
||||
|
|
||||
|
<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>{{ backends.filter((b) => b.last_health_status === 'healthy').length }} healthy</span></div></div><div class="command-bar"><OcButton appearance="filled" color-role="secondary">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-for="backend in backends" :key="backend.id" class="drive-item storage-row"><div><strong>{{ backend.name }}</strong><br /><span class="eyebrow">{{ formatBackendType(backend.type) }}</span></div><span>{{ titleCase(backend.stability_class) }}</span><span :class="backend.last_health_status === 'healthy' ? 'status-chip status-chip-success' : 'status-chip'">{{ backend.last_health_status ?? 'Unknown' }}</span><span>{{ formatDate(backend.updated_at) }}</span><span class="row-actions"><OcButton appearance="outline" size="small" @click="checkBackend(backend)">Check</OcButton><OcButton appearance="outline" size="small" @click="editingBackend = backend">Edit</OcButton></span></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>{{ jobs.filter((job) => job.status === 'queued').length }} pending</span><span>{{ jobs.filter((job) => job.status === 'completed').length }} completed</span><span>{{ jobs.filter((job) => job.status === 'failed').length }} 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-for="job in jobs" :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></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></section><aside class="detail-panel jobs-detail-panel"><div v-if="selectedJob" class="section-heading"><span>Job Detail</span><h3>{{ titleCase(selectedJob.kind) }}</h3></div><pre v-if="selectedJob" class="payload-preview">{{ selectedJob.payload_json || '{}' }}</pre></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" : "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><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="editingBackend" class="dialog-backdrop"> |
||||
|
<form class="dialog-card" role="dialog" aria-modal="true" @submit.prevent="editingBackend = null"> |
||||
|
<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>Name</span><input class="dialog-input" :value="editingBackend.name" readonly /></label> |
||||
|
<label><span>Type</span><input class="dialog-input" :value="formatBackendType(editingBackend.type)" readonly /></label> |
||||
|
<div class="inspector-card"><strong>Stored secrets</strong><p>{{ editingBackend.secret_fields.length ? editingBackend.secret_fields.join(", ") : "None" }}</p></div> |
||||
|
<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"><div class="inspector-card"><strong>Desired Backends</strong><p>{{ fileDetail?.desired_backends.map((backend) => backend.name).join(', ') || 'None' }}</p></div><div class="inspector-card"><strong>Replicas</strong><p v-for="replica in fileDetail?.replicas ?? []" :key="replica.id">{{ replica.backend_name }} · {{ titleCase(replica.status) }} · {{ formatBytes(replica.size_bytes) }}</p></div></div> |
||||
|
</aside> |
||||
|
</div> |
||||
|
</template> |
||||
@ -1,16 +0,0 @@ |
|||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
|
||||
import { RouterProvider } from "react-router-dom"; |
|
||||
import { router } from "./router"; |
|
||||
import { ToastProvider } from "../features/toast/ToastProvider"; |
|
||||
|
|
||||
const queryClient = new QueryClient(); |
|
||||
|
|
||||
export function App() { |
|
||||
return ( |
|
||||
<QueryClientProvider client={queryClient}> |
|
||||
<ToastProvider> |
|
||||
<RouterProvider router={router} /> |
|
||||
</ToastProvider> |
|
||||
</QueryClientProvider> |
|
||||
); |
|
||||
} |
|
||||
@ -1,71 +0,0 @@ |
|||||
import { |
|
||||
Navigate, |
|
||||
Outlet, |
|
||||
createBrowserRouter, |
|
||||
useLocation, |
|
||||
} from "react-router-dom"; |
|
||||
import { AppLayout } from "../components/layout/AppLayout"; |
|
||||
import { RequireAuth } from "../features/auth/RequireAuth"; |
|
||||
import { LoginPage } from "../routes/LoginPage"; |
|
||||
import { FilesPage } from "../routes/FilesPage"; |
|
||||
import { RecycleBinPage } from "../routes/RecycleBinPage"; |
|
||||
import { StoragePage } from "../routes/StoragePage"; |
|
||||
import { PoliciesPage } from "../routes/PoliciesPage"; |
|
||||
import { JobsPage } from "../routes/JobsPage"; |
|
||||
|
|
||||
function AppShell() { |
|
||||
const location = useLocation(); |
|
||||
|
|
||||
return ( |
|
||||
<RequireAuth> |
|
||||
<AppLayout currentPath={location.pathname}> |
|
||||
<Outlet /> |
|
||||
</AppLayout> |
|
||||
</RequireAuth> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
export const router = createBrowserRouter([ |
|
||||
{ |
|
||||
path: "/app/login", |
|
||||
element: <LoginPage />, |
|
||||
}, |
|
||||
{ |
|
||||
path: "/app", |
|
||||
element: <AppShell />, |
|
||||
children: [ |
|
||||
{ |
|
||||
index: true, |
|
||||
element: <Navigate to="/app/files" replace />, |
|
||||
}, |
|
||||
{ |
|
||||
path: "files", |
|
||||
element: <FilesPage />, |
|
||||
}, |
|
||||
{ |
|
||||
path: "files/:directoryId", |
|
||||
element: <FilesPage />, |
|
||||
}, |
|
||||
{ |
|
||||
path: "recycle-bin", |
|
||||
element: <RecycleBinPage />, |
|
||||
}, |
|
||||
{ |
|
||||
path: "storage", |
|
||||
element: <StoragePage />, |
|
||||
}, |
|
||||
{ |
|
||||
path: "policies", |
|
||||
element: <PoliciesPage />, |
|
||||
}, |
|
||||
{ |
|
||||
path: "jobs", |
|
||||
element: <JobsPage />, |
|
||||
}, |
|
||||
], |
|
||||
}, |
|
||||
{ |
|
||||
path: "*", |
|
||||
element: <Navigate to="/app" replace />, |
|
||||
}, |
|
||||
]); |
|
||||
@ -1,32 +0,0 @@ |
|||||
type DialogProps = { |
|
||||
title: string; |
|
||||
description?: string; |
|
||||
children: React.ReactNode; |
|
||||
onClose: () => void; |
|
||||
}; |
|
||||
|
|
||||
export function Dialog({ title, description, children, onClose }: DialogProps) { |
|
||||
return ( |
|
||||
<div className="dialog-backdrop" role="presentation" onClick={onClose}> |
|
||||
<div |
|
||||
className="dialog-card" |
|
||||
role="dialog" |
|
||||
aria-modal="true" |
|
||||
aria-label={title} |
|
||||
onClick={(event) => event.stopPropagation()} |
|
||||
> |
|
||||
<div className="dialog-header"> |
|
||||
<div> |
|
||||
<span className="eyebrow">Action</span> |
|
||||
<h3>{title}</h3> |
|
||||
{description ? <p className="item-muted">{description}</p> : null} |
|
||||
</div> |
|
||||
<button className="ghost-button" type="button" onClick={onClose}> |
|
||||
Close |
|
||||
</button> |
|
||||
</div> |
|
||||
{children} |
|
||||
</div> |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
@ -1,55 +0,0 @@ |
|||||
import { useEffect, useRef } from "react"; |
|
||||
|
|
||||
type MenuItem = { |
|
||||
label: string; |
|
||||
tone?: "default" | "danger"; |
|
||||
onSelect: () => void; |
|
||||
}; |
|
||||
|
|
||||
type MenuProps = { |
|
||||
items: MenuItem[]; |
|
||||
onClose: () => void; |
|
||||
}; |
|
||||
|
|
||||
export function Menu({ items, onClose }: MenuProps) { |
|
||||
const ref = useRef<HTMLDivElement | null>(null); |
|
||||
|
|
||||
useEffect(() => { |
|
||||
function handlePointer(event: MouseEvent) { |
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) { |
|
||||
onClose(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
function handleEscape(event: KeyboardEvent) { |
|
||||
if (event.key === "Escape") { |
|
||||
onClose(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
window.addEventListener("mousedown", handlePointer); |
|
||||
window.addEventListener("keydown", handleEscape); |
|
||||
return () => { |
|
||||
window.removeEventListener("mousedown", handlePointer); |
|
||||
window.removeEventListener("keydown", handleEscape); |
|
||||
}; |
|
||||
}, [onClose]); |
|
||||
|
|
||||
return ( |
|
||||
<div ref={ref} className="menu-popover" role="menu"> |
|
||||
{items.map((item) => ( |
|
||||
<button |
|
||||
key={item.label} |
|
||||
type="button" |
|
||||
className={item.tone === "danger" ? "menu-item danger" : "menu-item"} |
|
||||
onClick={() => { |
|
||||
item.onSelect(); |
|
||||
onClose(); |
|
||||
}} |
|
||||
> |
|
||||
{item.label} |
|
||||
</button> |
|
||||
))} |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
@ -1,117 +0,0 @@ |
|||||
type PaginationControlsProps = { |
|
||||
page: number; |
|
||||
pageSize: number; |
|
||||
totalItems: number; |
|
||||
totalPages: number; |
|
||||
onPageChange: (page: number) => void; |
|
||||
onPageSizeChange: (pageSize: number) => void; |
|
||||
pageSizeOptions?: number[]; |
|
||||
label: string; |
|
||||
}; |
|
||||
|
|
||||
export function PaginationControls({ |
|
||||
page, |
|
||||
pageSize, |
|
||||
totalItems, |
|
||||
totalPages, |
|
||||
onPageChange, |
|
||||
onPageSizeChange, |
|
||||
pageSizeOptions = [8, 16, 32, 64], |
|
||||
label, |
|
||||
}: PaginationControlsProps) { |
|
||||
const safeTotalPages = Math.max(1, totalPages); |
|
||||
const pageItems = getPaginationItems(page, safeTotalPages); |
|
||||
|
|
||||
return ( |
|
||||
<div className="pagination-bar pagination-bar-full" aria-label={label}> |
|
||||
<span className="pagination-copy"> |
|
||||
{totalItems} items |
|
||||
</span> |
|
||||
<div className="pagination-controls" role="group" aria-label={`${label} controls`}> |
|
||||
<button |
|
||||
className="pagination-page-button pagination-arrow-button" |
|
||||
type="button" |
|
||||
aria-label="Previous page" |
|
||||
disabled={page === 1} |
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))} |
|
||||
> |
|
||||
‹ |
|
||||
</button> |
|
||||
<div className="pagination-page-list" aria-label={`Page ${page} of ${safeTotalPages}`}> |
|
||||
{pageItems.map((item, index) => |
|
||||
item === "ellipsis" ? ( |
|
||||
<span key={`ellipsis-${index}`} className="pagination-ellipsis" aria-hidden="true"> |
|
||||
... |
|
||||
</span> |
|
||||
) : ( |
|
||||
<button |
|
||||
key={item} |
|
||||
className={item === page ? "pagination-page-button active" : "pagination-page-button"} |
|
||||
type="button" |
|
||||
aria-current={item === page ? "page" : undefined} |
|
||||
onClick={() => onPageChange(item)} |
|
||||
> |
|
||||
{item} |
|
||||
</button> |
|
||||
), |
|
||||
)} |
|
||||
</div> |
|
||||
<button |
|
||||
className="pagination-page-button pagination-arrow-button" |
|
||||
type="button" |
|
||||
aria-label="Next page" |
|
||||
disabled={page === safeTotalPages} |
|
||||
onClick={() => onPageChange(Math.min(safeTotalPages, page + 1))} |
|
||||
> |
|
||||
› |
|
||||
</button> |
|
||||
<label className="pagination-size-control"> |
|
||||
<select |
|
||||
className="input-select pagination-select" |
|
||||
aria-label="Rows per page" |
|
||||
value={pageSize} |
|
||||
onChange={(event) => onPageSizeChange(Number(event.target.value))} |
|
||||
> |
|
||||
{pageSizeOptions.map((option) => ( |
|
||||
<option key={option} value={option}> |
|
||||
{option} / page |
|
||||
</option> |
|
||||
))} |
|
||||
</select> |
|
||||
</label> |
|
||||
</div> |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
function getPaginationItems(page: number, totalPages: number): Array<number | "ellipsis"> { |
|
||||
if (totalPages <= 7) { |
|
||||
return Array.from({ length: totalPages }, (_, index) => index + 1); |
|
||||
} |
|
||||
|
|
||||
const pages = new Set<number>([1, totalPages, page - 1, page, page + 1]); |
|
||||
if (page <= 4) { |
|
||||
[2, 3, 4, 5].forEach((item) => pages.add(item)); |
|
||||
} |
|
||||
if (page >= totalPages - 3) { |
|
||||
[totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1].forEach((item) => pages.add(item)); |
|
||||
} |
|
||||
|
|
||||
const sortedPages = Array.from(pages) |
|
||||
.filter((item) => item >= 1 && item <= totalPages) |
|
||||
.sort((first, second) => first - second); |
|
||||
|
|
||||
return sortedPages.flatMap((item, index) => { |
|
||||
const previous = sortedPages[index - 1]; |
|
||||
if (!previous) { |
|
||||
return [item]; |
|
||||
} |
|
||||
if (item - previous === 2) { |
|
||||
return [previous + 1, item]; |
|
||||
} |
|
||||
if (item - previous > 2) { |
|
||||
return ["ellipsis", item]; |
|
||||
} |
|
||||
return [item]; |
|
||||
}); |
|
||||
} |
|
||||
@ -1,170 +0,0 @@ |
|||||
import type { ChangeEvent } from "react"; |
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom"; |
|
||||
import { useQuery } from "@tanstack/react-query"; |
|
||||
import { api } from "../../lib/api"; |
|
||||
import { clearAccessToken } from "../../lib/auth"; |
|
||||
import { useToast } from "../../features/toast/ToastProvider"; |
|
||||
|
|
||||
type AppLayoutProps = { |
|
||||
currentPath: string; |
|
||||
children: React.ReactNode; |
|
||||
}; |
|
||||
|
|
||||
const navItems = [ |
|
||||
{ to: "/app/files", label: "Files", icon: "files" }, |
|
||||
{ to: "/app/recycle-bin", label: "Deleted files", icon: "trash" }, |
|
||||
{ to: "/app/storage", label: "Storage", icon: "storage" }, |
|
||||
{ to: "/app/policies", label: "Policies", icon: "policies" }, |
|
||||
{ to: "/app/jobs", label: "Jobs", icon: "jobs" }, |
|
||||
]; |
|
||||
|
|
||||
export function AppLayout({ currentPath, children }: AppLayoutProps) { |
|
||||
const navigate = useNavigate(); |
|
||||
const location = useLocation(); |
|
||||
const { pushToast } = useToast(); |
|
||||
const isFilesSearch = location.pathname.startsWith("/app/files"); |
|
||||
const topbarSearchValue = isFilesSearch ? new URLSearchParams(location.search).get("q") ?? "" : ""; |
|
||||
const meQuery = useQuery({ |
|
||||
queryKey: ["auth", "me"], |
|
||||
queryFn: api.me, |
|
||||
retry: false, |
|
||||
}); |
|
||||
|
|
||||
async function handleLogout() { |
|
||||
try { |
|
||||
await api.logout(); |
|
||||
} catch { |
|
||||
// best effort
|
|
||||
} |
|
||||
clearAccessToken(); |
|
||||
pushToast("Signed out.", "info"); |
|
||||
navigate("/app/login", { replace: true }); |
|
||||
} |
|
||||
|
|
||||
function handleTopbarSearchChange(event: ChangeEvent<HTMLInputElement>) { |
|
||||
if (!isFilesSearch) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
const params = new URLSearchParams(location.search); |
|
||||
const value = event.target.value.trimStart(); |
|
||||
if (value) { |
|
||||
params.set("q", value); |
|
||||
} else { |
|
||||
params.delete("q"); |
|
||||
} |
|
||||
|
|
||||
navigate( |
|
||||
{ |
|
||||
pathname: location.pathname, |
|
||||
search: params.toString() ? `?${params.toString()}` : "", |
|
||||
}, |
|
||||
{ replace: true, state: location.state }, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
return ( |
|
||||
<div className="app-frame"> |
|
||||
<header className="oc-topbar" aria-label="Top bar"> |
|
||||
<div className="topbar-brand"> |
|
||||
<button className="app-switcher-button" type="button" aria-label="Application menu"> |
|
||||
<span /> |
|
||||
<span /> |
|
||||
<span /> |
|
||||
<span /> |
|
||||
<span /> |
|
||||
<span /> |
|
||||
<span /> |
|
||||
<span /> |
|
||||
<span /> |
|
||||
</button> |
|
||||
<div className="brand-lockup" aria-label="Iron Drive"> |
|
||||
<span className="brand-symbol">I</span> |
|
||||
<strong>Iron</strong> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div className="topbar-center"> |
|
||||
<div className="topbar-search" role="search"> |
|
||||
<input |
|
||||
aria-label="Search" |
|
||||
placeholder="Enter search term" |
|
||||
readOnly={!isFilesSearch} |
|
||||
value={topbarSearchValue} |
|
||||
onChange={handleTopbarSearchChange} |
|
||||
/> |
|
||||
<span className="topbar-search-scope">{getPageSearchScope(currentPath)}</span> |
|
||||
<span className="topbar-search-icon" aria-hidden="true" /> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div className="topbar-actions"> |
|
||||
<button className="user-chip" type="button" onClick={handleLogout}> |
|
||||
<span>{getInitial(meQuery.data?.user.username)}</span> |
|
||||
</button> |
|
||||
</div> |
|
||||
</header> |
|
||||
|
|
||||
<div className="app-shell"> |
|
||||
<aside className="sidebar"> |
|
||||
<div className="brand-block brand-block-compact"> |
|
||||
<span className="eyebrow">Personal</span> |
|
||||
<h1>Drive</h1> |
|
||||
</div> |
|
||||
|
|
||||
<nav className="main-nav" aria-label="Primary"> |
|
||||
{navItems.map((item) => ( |
|
||||
<NavLink |
|
||||
key={item.to} |
|
||||
to={item.to} |
|
||||
className={({ isActive }) => (isActive ? "nav-link active" : "nav-link")} |
|
||||
> |
|
||||
<span className={`nav-icon nav-icon-${item.icon}`} aria-hidden="true" /> |
|
||||
<span>{item.label}</span> |
|
||||
</NavLink> |
|
||||
))} |
|
||||
</nav> |
|
||||
|
|
||||
<div className="sidebar-footer"> |
|
||||
<span>Iron Web UI</span> |
|
||||
<span>Personal cloud drive</span> |
|
||||
</div> |
|
||||
</aside> |
|
||||
|
|
||||
<div className="workspace"> |
|
||||
<main className="workspace-content">{children}</main> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
function getInitial(username: string | undefined) { |
|
||||
return (username?.trim().charAt(0) || "I").toUpperCase(); |
|
||||
} |
|
||||
|
|
||||
function getPageSearchScope(pathname: string) { |
|
||||
if (pathname.startsWith("/app/files")) { |
|
||||
return "All files"; |
|
||||
} |
|
||||
return getPageTitle(pathname); |
|
||||
} |
|
||||
|
|
||||
function getPageTitle(pathname: string) { |
|
||||
if (pathname.startsWith("/app/files")) { |
|
||||
return "Files"; |
|
||||
} |
|
||||
if (pathname.startsWith("/app/recycle-bin")) { |
|
||||
return "Recycle Bin"; |
|
||||
} |
|
||||
if (pathname.startsWith("/app/storage")) { |
|
||||
return "Storage"; |
|
||||
} |
|
||||
if (pathname.startsWith("/app/policies")) { |
|
||||
return "Policies"; |
|
||||
} |
|
||||
if (pathname.startsWith("/app/jobs")) { |
|
||||
return "Jobs"; |
|
||||
} |
|
||||
return "Iron"; |
|
||||
} |
|
||||
@ -1,29 +0,0 @@ |
|||||
import { useQuery } from "@tanstack/react-query"; |
|
||||
import { Navigate, useLocation } from "react-router-dom"; |
|
||||
import { api, ApiError } from "../../lib/api"; |
|
||||
import { getAccessToken } from "../../lib/auth"; |
|
||||
|
|
||||
export function RequireAuth({ children }: { children: React.ReactNode }) { |
|
||||
const location = useLocation(); |
|
||||
const token = getAccessToken(); |
|
||||
const query = useQuery({ |
|
||||
queryKey: ["auth", "me"], |
|
||||
queryFn: api.me, |
|
||||
enabled: Boolean(token), |
|
||||
retry: false, |
|
||||
}); |
|
||||
|
|
||||
if (!token) { |
|
||||
return <Navigate to="/app/login" replace state={{ from: `${location.pathname}${location.search}` }} />; |
|
||||
} |
|
||||
|
|
||||
if (query.isLoading) { |
|
||||
return <div className="page-loading">Loading your drive…</div>; |
|
||||
} |
|
||||
|
|
||||
if (query.error instanceof ApiError && query.error.status === 401) { |
|
||||
return <Navigate to="/app/login" replace state={{ from: `${location.pathname}${location.search}` }} />; |
|
||||
} |
|
||||
|
|
||||
return <>{children}</>; |
|
||||
} |
|
||||
@ -1,50 +0,0 @@ |
|||||
import { createContext, useCallback, useContext, useMemo, useState } from "react"; |
|
||||
|
|
||||
type ToastTone = "info" | "success" | "error"; |
|
||||
|
|
||||
type ToastItem = { |
|
||||
id: string; |
|
||||
title: string; |
|
||||
tone: ToastTone; |
|
||||
}; |
|
||||
|
|
||||
type ToastContextValue = { |
|
||||
pushToast: (title: string, tone?: ToastTone) => void; |
|
||||
}; |
|
||||
|
|
||||
const ToastContext = createContext<ToastContextValue | null>(null); |
|
||||
|
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) { |
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]); |
|
||||
|
|
||||
const pushToast = useCallback((title: string, tone: ToastTone = "info") => { |
|
||||
const id = crypto.randomUUID(); |
|
||||
setToasts((current) => [...current.slice(-1), { id, title, tone }]); |
|
||||
window.setTimeout(() => { |
|
||||
setToasts((current) => current.filter((item) => item.id !== id)); |
|
||||
}, 2200); |
|
||||
}, []); |
|
||||
|
|
||||
const value = useMemo(() => ({ pushToast }), [pushToast]); |
|
||||
|
|
||||
return ( |
|
||||
<ToastContext.Provider value={value}> |
|
||||
{children} |
|
||||
<div className="toast-stack" aria-live="polite" aria-atomic="true"> |
|
||||
{toasts.map((toast) => ( |
|
||||
<div key={toast.id} className={`toast-item toast-${toast.tone}`}> |
|
||||
{toast.title} |
|
||||
</div> |
|
||||
))} |
|
||||
</div> |
|
||||
</ToastContext.Provider> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
export function useToast() { |
|
||||
const context = useContext(ToastContext); |
|
||||
if (!context) { |
|
||||
throw new Error("useToast must be used within ToastProvider."); |
|
||||
} |
|
||||
return context; |
|
||||
} |
|
||||
@ -0,0 +1,43 @@ |
|||||
|
import { createApp } from "vue"; |
||||
|
import { createPinia } from "pinia"; |
||||
|
import DesignSystem from "@opencloud-eu/design-system"; |
||||
|
import "@opencloud-eu/design-system/tailwind"; |
||||
|
import "@opencloud-eu/design-system/dist/design-system.css"; |
||||
|
import "./styles/opencloud-app.css"; |
||||
|
import App from "./App.vue"; |
||||
|
|
||||
|
const app = createApp(App); |
||||
|
|
||||
|
app.use(createPinia()); |
||||
|
app.use(DesignSystem, { |
||||
|
iconUrlPrefix: "/icons/", |
||||
|
language: { |
||||
|
initGettext: true, |
||||
|
defaultLanguage: "en", |
||||
|
}, |
||||
|
tokens: { |
||||
|
roles: { |
||||
|
primary: "#00677f", |
||||
|
onPrimary: "#ffffff", |
||||
|
primaryContainer: "#b7eaff", |
||||
|
secondary: "#20434f", |
||||
|
onSecondary: "#ffffff", |
||||
|
secondaryContainer: "#cfe6f1", |
||||
|
background: "#ffffff", |
||||
|
onBackground: "#191c1d", |
||||
|
surface: "#ffffff", |
||||
|
onSurface: "#191c1d", |
||||
|
surfaceContainer: "#f6f8fa", |
||||
|
surfaceContainerHigh: "#f2f4f5", |
||||
|
surfaceContainerHighest: "#eceef0", |
||||
|
outline: "#70787c", |
||||
|
outlineVariant: "#bfc8cc", |
||||
|
chrome: "#20434f", |
||||
|
onChrome: "#ffffff", |
||||
|
error: "#ba1a1a", |
||||
|
onError: "#ffffff", |
||||
|
}, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
app.mount("#root"); |
||||
@ -1,10 +0,0 @@ |
|||||
import React from "react"; |
|
||||
import ReactDOM from "react-dom/client"; |
|
||||
import { App } from "./app/App"; |
|
||||
import "./styles/app.css"; |
|
||||
|
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render( |
|
||||
<React.StrictMode> |
|
||||
<App /> |
|
||||
</React.StrictMode>, |
|
||||
); |
|
||||
@ -1,215 +0,0 @@ |
|||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
|
||||
import { useEffect, useMemo, useState } from "react"; |
|
||||
import { PaginationControls } from "../components/PaginationControls"; |
|
||||
import { api, type JobSummary } from "../lib/api"; |
|
||||
import { formatDate, titleCase } from "../lib/format"; |
|
||||
|
|
||||
export function JobsPage() { |
|
||||
const queryClient = useQueryClient(); |
|
||||
const [page, setPage] = useState(1); |
|
||||
const [pageSize, setPageSize] = useState(8); |
|
||||
const [selectedJobId, setSelectedJobId] = useState<string | null>(null); |
|
||||
const jobsQuery = useQuery({ |
|
||||
queryKey: ["jobs"], |
|
||||
queryFn: api.listJobs, |
|
||||
}); |
|
||||
|
|
||||
const refreshJobs = async () => { |
|
||||
await queryClient.invalidateQueries({ queryKey: ["jobs"] }); |
|
||||
}; |
|
||||
|
|
||||
const retryMutation = useMutation({ |
|
||||
mutationFn: (jobId: string) => api.retryJob(jobId), |
|
||||
onSuccess: refreshJobs, |
|
||||
}); |
|
||||
const runMutation = useMutation({ |
|
||||
mutationFn: api.runPendingJobs, |
|
||||
onSuccess: refreshJobs, |
|
||||
}); |
|
||||
const healthMutation = useMutation({ |
|
||||
mutationFn: api.enqueueHealthChecks, |
|
||||
onSuccess: refreshJobs, |
|
||||
}); |
|
||||
const reconcileMutation = useMutation({ |
|
||||
mutationFn: api.enqueueFullReconcile, |
|
||||
onSuccess: refreshJobs, |
|
||||
}); |
|
||||
const items = jobsQuery.data?.items ?? []; |
|
||||
const pendingCount = items.filter((job) => job.status === "pending").length; |
|
||||
const completedCount = items.filter((job) => job.status === "completed").length; |
|
||||
const failedCount = items.filter((job) => job.status === "failed").length; |
|
||||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); |
|
||||
const pagedItems = useMemo( |
|
||||
() => items.slice((page - 1) * pageSize, page * pageSize), |
|
||||
[items, page, pageSize], |
|
||||
); |
|
||||
const selectedJob = items.find((job) => job.id === selectedJobId) ?? null; |
|
||||
useEffect(() => { |
|
||||
if (page > totalPages) { |
|
||||
setPage(totalPages); |
|
||||
} |
|
||||
}, [page, totalPages]); |
|
||||
useEffect(() => { |
|
||||
if (selectedJobId && !items.some((job) => job.id === selectedJobId)) { |
|
||||
setSelectedJobId(null); |
|
||||
} |
|
||||
}, [items, selectedJobId]); |
|
||||
|
|
||||
return ( |
|
||||
<div className="compact-page-shell compact-page-shell-wide"> |
|
||||
<div className="jobs-page-layout"> |
|
||||
<section className="panel main-panel dashboard-main-panel"> |
|
||||
<div className="panel-toolbar files-toolbar-top"> |
|
||||
<div className="files-header"> |
|
||||
<span className="eyebrow">Jobs</span> |
|
||||
<div className="directory-title-row"> |
|
||||
<h2>Background activity</h2> |
|
||||
</div> |
|
||||
<div className="inline-metrics"> |
|
||||
<span>{pendingCount} pending</span> |
|
||||
<span>{completedCount} completed</span> |
|
||||
<span>{failedCount} failed</span> |
|
||||
</div> |
|
||||
</div> |
|
||||
<div className="toolbar-group files-command-bar"> |
|
||||
<button className="ghost-button" type="button" onClick={() => runMutation.mutate()}> |
|
||||
Run Pending |
|
||||
</button> |
|
||||
<button className="ghost-button" type="button" onClick={() => healthMutation.mutate()}> |
|
||||
Enqueue Checks |
|
||||
</button> |
|
||||
<button className="primary-button" type="button" onClick={() => reconcileMutation.mutate()}> |
|
||||
Full Reconcile |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div className="jobs-list-shell list-surface"> |
|
||||
<div className="list-header jobs-list-header"> |
|
||||
<span>Job</span> |
|
||||
<span>Status</span> |
|
||||
<span>Attempts</span> |
|
||||
<span className="list-header-actions">Actions</span> |
|
||||
</div> |
|
||||
<div className="list-table"> |
|
||||
{items.length ? ( |
|
||||
pagedItems.map((job) => ( |
|
||||
<div |
|
||||
key={job.id} |
|
||||
className={selectedJobId === job.id ? "table-row jobs-table-row active" : "table-row jobs-table-row"} |
|
||||
role="button" |
|
||||
tabIndex={0} |
|
||||
onClick={() => setSelectedJobId(job.id)} |
|
||||
onKeyDown={(event) => { |
|
||||
if (event.key === "Enter") { |
|
||||
setSelectedJobId(job.id); |
|
||||
} |
|
||||
}} |
|
||||
> |
|
||||
<div> |
|
||||
<strong>{titleCase(job.kind)}</strong> |
|
||||
<span className="item-muted">Run after {formatDate(job.run_after)}</span> |
|
||||
{job.last_error ? <span className="error-text">{job.last_error}</span> : null} |
|
||||
</div> |
|
||||
<div className="table-meta jobs-row-meta"> |
|
||||
<span className="status-chip">{titleCase(job.status)}</span> |
|
||||
<span> |
|
||||
{job.attempt_count}/{job.max_attempts} |
|
||||
</span> |
|
||||
<button |
|
||||
className="ghost-button compact-button" |
|
||||
type="button" |
|
||||
onClick={(event) => { |
|
||||
event.stopPropagation(); |
|
||||
retryMutation.mutate(job.id); |
|
||||
}} |
|
||||
> |
|
||||
Retry |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
)) |
|
||||
) : ( |
|
||||
<div className="empty-state empty-state-rich"> |
|
||||
<strong>No background jobs recorded yet.</strong> |
|
||||
</div> |
|
||||
)} |
|
||||
</div> |
|
||||
</div> |
|
||||
<PaginationControls |
|
||||
label="Jobs pagination" |
|
||||
page={page} |
|
||||
pageSize={pageSize} |
|
||||
totalItems={items.length} |
|
||||
totalPages={totalPages} |
|
||||
onPageChange={setPage} |
|
||||
onPageSizeChange={(nextPageSize) => { |
|
||||
setPageSize(nextPageSize); |
|
||||
setPage(1); |
|
||||
}} |
|
||||
/> |
|
||||
</section> |
|
||||
<aside className="panel detail-panel jobs-detail-panel"> |
|
||||
{!selectedJob ? ( |
|
||||
<div className="detail-empty"> |
|
||||
<span className="eyebrow">Inspector</span> |
|
||||
<h3>Select a job</h3> |
|
||||
</div> |
|
||||
) : ( |
|
||||
<> |
|
||||
<div className="section-heading"> |
|
||||
<span className="eyebrow">Job Detail</span> |
|
||||
<h3>{titleCase(selectedJob.kind)}</h3> |
|
||||
</div> |
|
||||
<div className="inspector-storage-summary"> |
|
||||
<div> |
|
||||
<span>Status</span> |
|
||||
<strong>{titleCase(selectedJob.status)}</strong> |
|
||||
</div> |
|
||||
<span className="status-chip"> |
|
||||
{selectedJob.attempt_count}/{selectedJob.max_attempts} |
|
||||
</span> |
|
||||
</div> |
|
||||
<div className="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 className="mono-text">{selectedJob.id}</strong> |
|
||||
</div> |
|
||||
</div> |
|
||||
{selectedJob.last_error ? ( |
|
||||
<div className="inspector-card replica-warning-card"> |
|
||||
<strong>Last error</strong> |
|
||||
<p className="item-muted">{selectedJob.last_error}</p> |
|
||||
</div> |
|
||||
) : null} |
|
||||
<section className="inspector-section"> |
|
||||
<span className="eyebrow">Payload</span> |
|
||||
<pre className="payload-preview">{formatJobPayload(selectedJob)}</pre> |
|
||||
</section> |
|
||||
</> |
|
||||
)} |
|
||||
</aside> |
|
||||
</div> |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
function formatJobPayload(job: JobSummary) { |
|
||||
try { |
|
||||
return JSON.stringify(JSON.parse(job.payload_json), null, 2); |
|
||||
} catch { |
|
||||
return job.payload_json || "{}"; |
|
||||
} |
|
||||
} |
|
||||
@ -1,79 +0,0 @@ |
|||||
import { FormEvent, useState } from "react"; |
|
||||
import { useLocation, useNavigate } from "react-router-dom"; |
|
||||
import { api } from "../lib/api"; |
|
||||
import { setAccessToken } from "../lib/auth"; |
|
||||
|
|
||||
export function LoginPage() { |
|
||||
const navigate = useNavigate(); |
|
||||
const location = useLocation(); |
|
||||
const [username, setUsername] = useState("admin"); |
|
||||
const [password, setPassword] = useState("changeme-iron"); |
|
||||
const [error, setError] = useState(""); |
|
||||
const [isSubmitting, setIsSubmitting] = useState(false); |
|
||||
|
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) { |
|
||||
event.preventDefault(); |
|
||||
setIsSubmitting(true); |
|
||||
setError(""); |
|
||||
|
|
||||
try { |
|
||||
const result = await api.login(username, password); |
|
||||
setAccessToken(result.access_token); |
|
||||
navigate((location.state as { from?: string } | null)?.from || "/app/files", { replace: true }); |
|
||||
} catch (submitError) { |
|
||||
setError(submitError instanceof Error ? submitError.message : "Unable to sign in."); |
|
||||
} finally { |
|
||||
setIsSubmitting(false); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return ( |
|
||||
<div className="login-page"> |
|
||||
<section className="login-hero"> |
|
||||
<span className="eyebrow">Iron</span> |
|
||||
<h1>One personal drive, under your control.</h1> |
|
||||
<p> |
|
||||
Browse media, upload from the browser, and inspect storage health |
|
||||
without turning your daily file flow into an ops chore. |
|
||||
</p> |
|
||||
<div className="hero-metrics"> |
|
||||
<article> |
|
||||
<span className="metric-label">Product target</span> |
|
||||
<strong>Personal drive UI</strong> |
|
||||
</article> |
|
||||
<article> |
|
||||
<span className="metric-label">Runtime model</span> |
|
||||
<strong>Gateway + multi-backend storage</strong> |
|
||||
</article> |
|
||||
</div> |
|
||||
</section> |
|
||||
|
|
||||
<section className="login-card"> |
|
||||
<div className="section-heading"> |
|
||||
<span className="eyebrow">Local Login</span> |
|
||||
<h2>Enter your drive</h2> |
|
||||
</div> |
|
||||
|
|
||||
<form className="stack-form" onSubmit={handleSubmit}> |
|
||||
<label> |
|
||||
<span>Username</span> |
|
||||
<input value={username} onChange={(event) => setUsername(event.target.value)} required /> |
|
||||
</label> |
|
||||
<label> |
|
||||
<span>Password</span> |
|
||||
<input |
|
||||
type="password" |
|
||||
value={password} |
|
||||
onChange={(event) => setPassword(event.target.value)} |
|
||||
required |
|
||||
/> |
|
||||
</label> |
|
||||
<button className="primary-button" type="submit" disabled={isSubmitting}> |
|
||||
{isSubmitting ? "Signing in…" : "Sign in"} |
|
||||
</button> |
|
||||
{error ? <p className="error-text">{error}</p> : null} |
|
||||
</form> |
|
||||
</section> |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
@ -1,279 +0,0 @@ |
|||||
import { FormEvent, useEffect, useMemo, useState } from "react"; |
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
|
||||
import { api, type PlacementPolicyPayload, type PlacementPolicySummary } from "../lib/api"; |
|
||||
import { useToast } from "../features/toast/ToastProvider"; |
|
||||
import { formatBackendType, formatBytes, titleCase } from "../lib/format"; |
|
||||
|
|
||||
const FILE_CLASSES: PlacementPolicySummary["file_class"][] = ["document", "image", "video", "other"]; |
|
||||
|
|
||||
const MIME_PRESETS: Record<PlacementPolicySummary["file_class"], string> = { |
|
||||
document: "application/pdf", |
|
||||
image: "image/png", |
|
||||
video: "video/mp4", |
|
||||
other: "application/octet-stream", |
|
||||
}; |
|
||||
|
|
||||
export function PoliciesPage() { |
|
||||
const queryClient = useQueryClient(); |
|
||||
const { pushToast } = useToast(); |
|
||||
const [selectedClass, setSelectedClass] = useState<PlacementPolicySummary["file_class"]>("document"); |
|
||||
const [requireLocal, setRequireLocal] = useState(true); |
|
||||
const [stableCount, setStableCount] = useState(1); |
|
||||
const [opportunisticCount, setOpportunisticCount] = useState(0); |
|
||||
const [preferredBackendIds, setPreferredBackendIds] = useState<string[]>([]); |
|
||||
const [excludedBackendIds, setExcludedBackendIds] = useState<string[]>([]); |
|
||||
const [maxNonLocalSize, setMaxNonLocalSize] = useState(""); |
|
||||
const [mimeType, setMimeType] = useState(MIME_PRESETS.document); |
|
||||
const [previewSize, setPreviewSize] = useState("1048576"); |
|
||||
const [formError, setFormError] = useState(""); |
|
||||
|
|
||||
const policiesQuery = useQuery({ |
|
||||
queryKey: ["placement-policies"], |
|
||||
queryFn: api.listPlacementPolicies, |
|
||||
}); |
|
||||
const backendQuery = useQuery({ |
|
||||
queryKey: ["backends"], |
|
||||
queryFn: api.listBackends, |
|
||||
}); |
|
||||
|
|
||||
const policies = policiesQuery.data?.items ?? []; |
|
||||
const backends = backendQuery.data?.items ?? []; |
|
||||
const selectedPolicy = policies.find((policy) => policy.file_class === selectedClass); |
|
||||
|
|
||||
useEffect(() => { |
|
||||
if (!selectedPolicy) { |
|
||||
return; |
|
||||
} |
|
||||
setRequireLocal(selectedPolicy.require_local); |
|
||||
setStableCount(selectedPolicy.stable_replica_count); |
|
||||
setOpportunisticCount(selectedPolicy.opportunistic_replica_count); |
|
||||
setPreferredBackendIds(selectedPolicy.preferred_backend_ids); |
|
||||
setExcludedBackendIds(selectedPolicy.excluded_backend_ids); |
|
||||
setMaxNonLocalSize( |
|
||||
selectedPolicy.max_non_local_size_bytes === null ? "" : String(selectedPolicy.max_non_local_size_bytes), |
|
||||
); |
|
||||
}, [selectedPolicy?.id, selectedPolicy?.updated_at]); |
|
||||
|
|
||||
useEffect(() => { |
|
||||
setMimeType(MIME_PRESETS[selectedClass]); |
|
||||
}, [selectedClass]); |
|
||||
|
|
||||
const previewSizeBytes = parseOptionalInt(previewSize); |
|
||||
const previewQuery = useQuery({ |
|
||||
queryKey: ["placement-preview", mimeType, previewSizeBytes], |
|
||||
queryFn: () => api.previewPlacementDecision(mimeType, previewSizeBytes), |
|
||||
}); |
|
||||
|
|
||||
const updateMutation = useMutation({ |
|
||||
mutationFn: (payload: PlacementPolicyPayload) => api.updatePlacementPolicy(selectedClass, payload), |
|
||||
onSuccess: async (result) => { |
|
||||
setFormError(""); |
|
||||
pushToast( |
|
||||
`Policy saved. Full reconcile queued ${result.reconcile_enqueued_jobs} job${result.reconcile_enqueued_jobs === 1 ? "" : "s"}.`, |
|
||||
"success", |
|
||||
); |
|
||||
await queryClient.invalidateQueries({ queryKey: ["placement-policies"] }); |
|
||||
await queryClient.invalidateQueries({ queryKey: ["placement-preview"] }); |
|
||||
await queryClient.invalidateQueries({ queryKey: ["jobs"] }); |
|
||||
}, |
|
||||
onError: (error) => { |
|
||||
setFormError(error instanceof Error ? error.message : "Policy update failed."); |
|
||||
pushToast(error instanceof Error ? error.message : "Policy update failed.", "error"); |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
const localBackendCount = backends.filter((backend) => backend.type === "local_directory" && backend.is_enabled).length; |
|
||||
const stableBackendCount = backends.filter((backend) => backend.stability_class === "stable" && backend.is_enabled).length; |
|
||||
const opportunisticBackendCount = backends.filter( |
|
||||
(backend) => backend.stability_class === "opportunistic" && backend.is_enabled, |
|
||||
).length; |
|
||||
|
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) { |
|
||||
event.preventDefault(); |
|
||||
const maxSize = parseOptionalInt(maxNonLocalSize); |
|
||||
if (maxNonLocalSize.trim() && maxSize === null) { |
|
||||
setFormError("Max non-local size must be a whole number."); |
|
||||
return; |
|
||||
} |
|
||||
updateMutation.mutate({ |
|
||||
require_local: requireLocal, |
|
||||
stable_replica_count: Math.max(0, stableCount), |
|
||||
opportunistic_replica_count: Math.max(0, opportunisticCount), |
|
||||
preferred_backend_ids: preferredBackendIds, |
|
||||
excluded_backend_ids: excludedBackendIds, |
|
||||
max_non_local_size_bytes: maxSize, |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
function toggleBackend(listName: "preferred" | "excluded", backendId: string) { |
|
||||
if (listName === "preferred") { |
|
||||
setPreferredBackendIds((current) => toggleId(current, backendId)); |
|
||||
setExcludedBackendIds((current) => current.filter((id) => id !== backendId)); |
|
||||
return; |
|
||||
} |
|
||||
setExcludedBackendIds((current) => toggleId(current, backendId)); |
|
||||
setPreferredBackendIds((current) => current.filter((id) => id !== backendId)); |
|
||||
} |
|
||||
|
|
||||
return ( |
|
||||
<div className="compact-page-shell compact-page-shell-wide"> |
|
||||
<div className="dashboard-page-grid"> |
|
||||
<section className="dashboard-main-column"> |
|
||||
<div className="panel-toolbar storage-toolbar"> |
|
||||
<div className="files-header"> |
|
||||
<span className="eyebrow">Placement</span> |
|
||||
<div className="directory-title-row"> |
|
||||
<h2>Replica policies</h2> |
|
||||
</div> |
|
||||
<div className="inline-metrics"> |
|
||||
<span>{policies.length} policies</span> |
|
||||
<span>{localBackendCount} local</span> |
|
||||
<span>{stableBackendCount} stable</span> |
|
||||
<span>{opportunisticBackendCount} opportunistic</span> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div className="policy-tabs" role="tablist" aria-label="File classes"> |
|
||||
{FILE_CLASSES.map((fileClass) => ( |
|
||||
<button |
|
||||
key={fileClass} |
|
||||
className={selectedClass === fileClass ? "policy-tab active" : "policy-tab"} |
|
||||
type="button" |
|
||||
onClick={() => setSelectedClass(fileClass)} |
|
||||
> |
|
||||
{titleCase(fileClass)} |
|
||||
</button> |
|
||||
))} |
|
||||
</div> |
|
||||
|
|
||||
<form className="panel policy-editor" onSubmit={handleSubmit}> |
|
||||
<div className="policy-editor-grid"> |
|
||||
<label className="checkbox-field"> |
|
||||
<input |
|
||||
type="checkbox" |
|
||||
checked={requireLocal} |
|
||||
onChange={(event) => setRequireLocal(event.target.checked)} |
|
||||
/> |
|
||||
<span>Require local replica</span> |
|
||||
</label> |
|
||||
<label> |
|
||||
<span>Stable replicas</span> |
|
||||
<input |
|
||||
type="number" |
|
||||
min={0} |
|
||||
value={stableCount} |
|
||||
onChange={(event) => setStableCount(Number(event.target.value))} |
|
||||
/> |
|
||||
</label> |
|
||||
<label> |
|
||||
<span>Opportunistic replicas</span> |
|
||||
<input |
|
||||
type="number" |
|
||||
min={0} |
|
||||
value={opportunisticCount} |
|
||||
onChange={(event) => setOpportunisticCount(Number(event.target.value))} |
|
||||
/> |
|
||||
</label> |
|
||||
<label> |
|
||||
<span>Max non-local bytes</span> |
|
||||
<input |
|
||||
value={maxNonLocalSize} |
|
||||
onChange={(event) => setMaxNonLocalSize(event.target.value)} |
|
||||
placeholder="No limit" |
|
||||
/> |
|
||||
</label> |
|
||||
</div> |
|
||||
|
|
||||
<div className="backend-policy-list"> |
|
||||
{backends.map((backend) => ( |
|
||||
<div key={backend.id} className="backend-policy-row"> |
|
||||
<div> |
|
||||
<strong>{backend.name}</strong> |
|
||||
<span className="item-muted"> |
|
||||
{formatBackendType(backend.type)} · {titleCase(backend.stability_class)} |
|
||||
</span> |
|
||||
</div> |
|
||||
<div className="backend-policy-actions"> |
|
||||
<label className="checkbox-field compact-checkbox"> |
|
||||
<input |
|
||||
type="checkbox" |
|
||||
checked={preferredBackendIds.includes(backend.id)} |
|
||||
onChange={() => toggleBackend("preferred", backend.id)} |
|
||||
/> |
|
||||
<span>Prefer</span> |
|
||||
</label> |
|
||||
<label className="checkbox-field compact-checkbox"> |
|
||||
<input |
|
||||
type="checkbox" |
|
||||
checked={excludedBackendIds.includes(backend.id)} |
|
||||
onChange={() => toggleBackend("excluded", backend.id)} |
|
||||
/> |
|
||||
<span>Exclude</span> |
|
||||
</label> |
|
||||
</div> |
|
||||
</div> |
|
||||
))} |
|
||||
</div> |
|
||||
|
|
||||
{formError ? <p className="error-text">{formError}</p> : null} |
|
||||
<div className="dialog-actions"> |
|
||||
<button className="primary-button" type="submit" disabled={updateMutation.isPending}> |
|
||||
Save Policy |
|
||||
</button> |
|
||||
</div> |
|
||||
</form> |
|
||||
</section> |
|
||||
|
|
||||
<aside className="dashboard-side-column"> |
|
||||
<section className="panel dashboard-side-panel"> |
|
||||
<div className="section-heading"> |
|
||||
<span className="eyebrow">Preview</span> |
|
||||
<h3>Placement decision</h3> |
|
||||
</div> |
|
||||
<div className="dialog-form"> |
|
||||
<label> |
|
||||
<span>Media type</span> |
|
||||
<input value={mimeType} onChange={(event) => setMimeType(event.target.value)} /> |
|
||||
</label> |
|
||||
<label> |
|
||||
<span>Object size</span> |
|
||||
<input value={previewSize} onChange={(event) => setPreviewSize(event.target.value)} /> |
|
||||
</label> |
|
||||
</div> |
|
||||
<div className="decision-list"> |
|
||||
<span className="status-chip"> |
|
||||
{previewQuery.data?.non_local_allowed === false ? "Local only" : "Non-local allowed"} |
|
||||
</span> |
|
||||
<span className="item-muted"> |
|
||||
{previewSizeBytes === null ? "No size input" : formatBytes(previewSizeBytes)} |
|
||||
</span> |
|
||||
{(previewQuery.data?.selected_backends ?? []).map((backend) => ( |
|
||||
<div key={backend.id} className="decision-backend"> |
|
||||
<strong>{backend.name}</strong> |
|
||||
<span>{formatBackendType(backend.type)}</span> |
|
||||
</div> |
|
||||
))} |
|
||||
</div> |
|
||||
</section> |
|
||||
</aside> |
|
||||
</div> |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
function toggleId(values: string[], id: string) { |
|
||||
return values.includes(id) ? values.filter((value) => value !== id) : [...values, id]; |
|
||||
} |
|
||||
|
|
||||
function parseOptionalInt(value: string) { |
|
||||
const cleaned = value.trim(); |
|
||||
if (!cleaned) { |
|
||||
return null; |
|
||||
} |
|
||||
const parsed = Number(cleaned); |
|
||||
if (!Number.isInteger(parsed) || parsed < 0) { |
|
||||
return null; |
|
||||
} |
|
||||
return parsed; |
|
||||
} |
|
||||
@ -1,97 +0,0 @@ |
|||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
|
||||
import { useEffect, useMemo, useState } from "react"; |
|
||||
import { PaginationControls } from "../components/PaginationControls"; |
|
||||
import { api } from "../lib/api"; |
|
||||
import { formatBytes, formatDate } from "../lib/format"; |
|
||||
|
|
||||
export function RecycleBinPage() { |
|
||||
const queryClient = useQueryClient(); |
|
||||
const [page, setPage] = useState(1); |
|
||||
const [pageSize, setPageSize] = useState(8); |
|
||||
const recycleQuery = useQuery({ |
|
||||
queryKey: ["recycle-bin"], |
|
||||
queryFn: api.listRecycleBin, |
|
||||
}); |
|
||||
|
|
||||
const restoreMutation = useMutation({ |
|
||||
mutationFn: (fileId: string) => api.restoreFile(fileId), |
|
||||
onSuccess: async () => { |
|
||||
await queryClient.invalidateQueries({ queryKey: ["recycle-bin"] }); |
|
||||
}, |
|
||||
}); |
|
||||
const items = recycleQuery.data?.items ?? []; |
|
||||
const totalBytes = items.reduce((sum, item) => sum + item.size_bytes, 0); |
|
||||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); |
|
||||
const pagedItems = useMemo( |
|
||||
() => items.slice((page - 1) * pageSize, page * pageSize), |
|
||||
[items, page, pageSize], |
|
||||
); |
|
||||
useEffect(() => { |
|
||||
if (page > totalPages) { |
|
||||
setPage(totalPages); |
|
||||
} |
|
||||
}, [page, totalPages]); |
|
||||
|
|
||||
return ( |
|
||||
<div className="compact-page-shell compact-page-shell-wide"> |
|
||||
<section className="panel dashboard-main-panel"> |
|
||||
<div className="panel-toolbar files-toolbar-top"> |
|
||||
<div className="files-header"> |
|
||||
<span className="eyebrow">Deleted files</span> |
|
||||
<div className="directory-title-row"> |
|
||||
<h2>Recycle Bin</h2> |
|
||||
</div> |
|
||||
<div className="inline-metrics"> |
|
||||
<span>{items.length} items</span> |
|
||||
<span>{formatBytes(totalBytes)}</span> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div className="list-surface"> |
|
||||
<div className="list-header recycle-list-header"> |
|
||||
<span>Name</span> |
|
||||
<span>Deleted</span> |
|
||||
<span>Size</span> |
|
||||
<span className="list-header-actions">Actions</span> |
|
||||
</div> |
|
||||
<div className="list-table"> |
|
||||
{items.length ? ( |
|
||||
pagedItems.map((item) => ( |
|
||||
<div key={item.id} className="table-row recycle-row"> |
|
||||
<div> |
|
||||
<strong>{item.name}</strong> |
|
||||
<span className="item-muted">Recoverable file</span> |
|
||||
</div> |
|
||||
<div className="table-meta recycle-row-meta"> |
|
||||
<span>{formatDate(item.deleted_at)}</span> |
|
||||
<span>{formatBytes(item.size_bytes)}</span> |
|
||||
<button className="ghost-button compact-button" type="button" onClick={() => restoreMutation.mutate(item.id)}> |
|
||||
Restore |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
)) |
|
||||
) : ( |
|
||||
<div className="empty-state empty-state-rich"> |
|
||||
<strong>Recycle bin is empty.</strong> |
|
||||
</div> |
|
||||
)} |
|
||||
</div> |
|
||||
</div> |
|
||||
<PaginationControls |
|
||||
label="Recycle bin pagination" |
|
||||
page={page} |
|
||||
pageSize={pageSize} |
|
||||
totalItems={items.length} |
|
||||
totalPages={totalPages} |
|
||||
onPageChange={setPage} |
|
||||
onPageSizeChange={(nextPageSize) => { |
|
||||
setPageSize(nextPageSize); |
|
||||
setPage(1); |
|
||||
}} |
|
||||
/> |
|
||||
</section> |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
@ -1,518 +0,0 @@ |
|||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
|
||||
import { FormEvent, useState } from "react"; |
|
||||
import { Dialog } from "../components/Dialog"; |
|
||||
import { useToast } from "../features/toast/ToastProvider"; |
|
||||
import { api } from "../lib/api"; |
|
||||
import type { BackendSummary } from "../lib/api"; |
|
||||
import { formatBackendType, formatDate, titleCase } from "../lib/format"; |
|
||||
|
|
||||
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, |
|
||||
), |
|
||||
}; |
|
||||
|
|
||||
export function StoragePage() { |
|
||||
const queryClient = useQueryClient(); |
|
||||
const { pushToast } = useToast(); |
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false); |
|
||||
const [backendType, setBackendType] = useState<BackendType>("s3"); |
|
||||
const [stabilityClass, setStabilityClass] = useState<StabilityClass>("stable"); |
|
||||
const [name, setName] = useState(""); |
|
||||
const [readPriority, setReadPriority] = useState(80); |
|
||||
const [writePriority, setWritePriority] = useState(80); |
|
||||
const [configText, setConfigText] = useState(CONFIG_TEMPLATES.s3); |
|
||||
const [formError, setFormError] = useState(""); |
|
||||
const [editingBackend, setEditingBackend] = useState<BackendSummary | null>(null); |
|
||||
const [editName, setEditName] = useState(""); |
|
||||
const [editStabilityClass, setEditStabilityClass] = useState<StabilityClass>("stable"); |
|
||||
const [editReadPriority, setEditReadPriority] = useState(80); |
|
||||
const [editWritePriority, setEditWritePriority] = useState(80); |
|
||||
const [editConfigText, setEditConfigText] = useState("{}"); |
|
||||
const [editSecretText, setEditSecretText] = useState("{}"); |
|
||||
const [editError, setEditError] = useState(""); |
|
||||
const [deleteTarget, setDeleteTarget] = useState<BackendSummary | null>(null); |
|
||||
const backendQuery = useQuery({ |
|
||||
queryKey: ["backends"], |
|
||||
queryFn: api.listBackends, |
|
||||
}); |
|
||||
|
|
||||
function handleTypeChange(nextType: BackendType) { |
|
||||
setBackendType(nextType); |
|
||||
setConfigText(CONFIG_TEMPLATES[nextType]); |
|
||||
setStabilityClass(nextType === "local_directory" ? "local" : "stable"); |
|
||||
if (!name.trim()) { |
|
||||
setName( |
|
||||
nextType === "local_directory" |
|
||||
? "local-archive" |
|
||||
: nextType === "s3" |
|
||||
? "s3-main" |
|
||||
: "openlist-main", |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const createMutation = useMutation({ |
|
||||
mutationFn: api.createBackend, |
|
||||
onSuccess: async () => { |
|
||||
setIsCreateOpen(false); |
|
||||
setFormError(""); |
|
||||
await queryClient.invalidateQueries({ queryKey: ["backends"] }); |
|
||||
}, |
|
||||
onError: (error) => { |
|
||||
setFormError(error instanceof Error ? error.message : "Backend creation failed."); |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
const checkMutation = useMutation({ |
|
||||
mutationFn: (backendId: string) => api.checkBackend(backendId), |
|
||||
onSuccess: async () => { |
|
||||
pushToast("Backend check finished.", "success"); |
|
||||
await queryClient.invalidateQueries({ queryKey: ["backends"] }); |
|
||||
}, |
|
||||
onError: (error) => { |
|
||||
pushToast(error instanceof Error ? error.message : "Backend check failed.", "error"); |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
const disableMutation = useMutation({ |
|
||||
mutationFn: (backendId: string) => api.disableBackend(backendId), |
|
||||
onSuccess: async () => { |
|
||||
pushToast("Backend disabled.", "success"); |
|
||||
await queryClient.invalidateQueries({ queryKey: ["backends"] }); |
|
||||
}, |
|
||||
onError: (error) => { |
|
||||
pushToast(error instanceof Error ? error.message : "Disable failed.", "error"); |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
const enableMutation = useMutation({ |
|
||||
mutationFn: (backendId: string) => api.enableBackend(backendId), |
|
||||
onSuccess: async () => { |
|
||||
pushToast("Backend enabled.", "success"); |
|
||||
await queryClient.invalidateQueries({ queryKey: ["backends"] }); |
|
||||
}, |
|
||||
onError: (error) => { |
|
||||
pushToast(error instanceof Error ? error.message : "Enable failed.", "error"); |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
const runAllChecksMutation = useMutation({ |
|
||||
mutationFn: api.enqueueHealthChecks, |
|
||||
onSuccess: async (result) => { |
|
||||
pushToast(`Queued ${result.enqueued_jobs} backend health check job${result.enqueued_jobs === 1 ? "" : "s"}.`, "success"); |
|
||||
await queryClient.invalidateQueries({ queryKey: ["jobs"] }); |
|
||||
}, |
|
||||
onError: (error) => { |
|
||||
pushToast(error instanceof Error ? error.message : "Could not queue health checks.", "error"); |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
const updateMutation = useMutation({ |
|
||||
mutationFn: (payload: { backendId: string; form: ReturnType<typeof buildEditPayload> }) => |
|
||||
api.updateBackend(payload.backendId, payload.form), |
|
||||
onSuccess: async () => { |
|
||||
setEditingBackend(null); |
|
||||
setEditError(""); |
|
||||
pushToast("Backend updated.", "success"); |
|
||||
await queryClient.invalidateQueries({ queryKey: ["backends"] }); |
|
||||
}, |
|
||||
onError: (error) => { |
|
||||
setEditError(error instanceof Error ? error.message : "Backend update failed."); |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
const deleteMutation = useMutation({ |
|
||||
mutationFn: (backendId: string) => api.deleteBackend(backendId), |
|
||||
onSuccess: async () => { |
|
||||
const deletedName = deleteTarget?.name; |
|
||||
setDeleteTarget(null); |
|
||||
pushToast(deletedName ? `Deleted ${deletedName}.` : "Backend deleted.", "success"); |
|
||||
await queryClient.invalidateQueries({ queryKey: ["backends"] }); |
|
||||
await queryClient.invalidateQueries({ queryKey: ["placement-policies"] }); |
|
||||
}, |
|
||||
onError: (error) => { |
|
||||
pushToast(error instanceof Error ? error.message : "Delete failed.", "error"); |
|
||||
}, |
|
||||
}); |
|
||||
const items = backendQuery.data?.items ?? []; |
|
||||
const healthyCount = items.filter((backend) => (backend.last_health_status ?? "unknown") === "healthy").length; |
|
||||
const enabledCount = items.filter((backend) => backend.is_enabled).length; |
|
||||
const disabledCount = items.filter((backend) => !backend.is_enabled).length; |
|
||||
|
|
||||
function handleCreateBackend(event: FormEvent<HTMLFormElement>) { |
|
||||
event.preventDefault(); |
|
||||
setFormError(""); |
|
||||
let config: Record<string, unknown>; |
|
||||
try { |
|
||||
config = JSON.parse(configText) as Record<string, unknown>; |
|
||||
} catch { |
|
||||
setFormError("Config must be valid JSON."); |
|
||||
return; |
|
||||
} |
|
||||
createMutation.mutate({ |
|
||||
name, |
|
||||
type: backendType, |
|
||||
stability_class: stabilityClass, |
|
||||
read_priority: readPriority, |
|
||||
write_priority: writePriority, |
|
||||
config, |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
function openEditBackend(backend: BackendSummary) { |
|
||||
setEditingBackend(backend); |
|
||||
setEditName(backend.name); |
|
||||
setEditStabilityClass(backend.stability_class as StabilityClass); |
|
||||
setEditReadPriority(backend.read_priority); |
|
||||
setEditWritePriority(backend.write_priority); |
|
||||
setEditConfigText(JSON.stringify(backend.config ?? {}, null, 2)); |
|
||||
setEditSecretText("{}"); |
|
||||
setEditError(""); |
|
||||
} |
|
||||
|
|
||||
function handleUpdateBackend(event: FormEvent<HTMLFormElement>) { |
|
||||
event.preventDefault(); |
|
||||
if (!editingBackend) { |
|
||||
return; |
|
||||
} |
|
||||
setEditError(""); |
|
||||
let form; |
|
||||
try { |
|
||||
form = buildEditPayload({ |
|
||||
name: editName, |
|
||||
stabilityClass: editStabilityClass, |
|
||||
readPriority: editReadPriority, |
|
||||
writePriority: editWritePriority, |
|
||||
configText: editConfigText, |
|
||||
secretText: editSecretText, |
|
||||
}); |
|
||||
} catch (error) { |
|
||||
setEditError(error instanceof Error ? error.message : "Config must be valid JSON."); |
|
||||
return; |
|
||||
} |
|
||||
updateMutation.mutate({ backendId: editingBackend.id, form }); |
|
||||
} |
|
||||
|
|
||||
return ( |
|
||||
<div className="compact-page-shell compact-page-shell-wide"> |
|
||||
<section className="panel dashboard-main-panel"> |
|
||||
<div className="panel-toolbar files-toolbar-top"> |
|
||||
<div className="files-header"> |
|
||||
<span className="eyebrow">Storage</span> |
|
||||
<div className="directory-title-row"> |
|
||||
<h2>Backends</h2> |
|
||||
</div> |
|
||||
<div className="inline-metrics"> |
|
||||
<span>{items.length} backends</span> |
|
||||
<span>{healthyCount} healthy</span> |
|
||||
<span>{enabledCount} enabled</span> |
|
||||
<span>{disabledCount} disabled</span> |
|
||||
</div> |
|
||||
</div> |
|
||||
<div className="toolbar-group files-command-bar"> |
|
||||
<button className="primary-button" type="button" onClick={() => setIsCreateOpen(true)}> |
|
||||
Add Backend |
|
||||
</button> |
|
||||
<button className="ghost-button" type="button" onClick={() => runAllChecksMutation.mutate()}> |
|
||||
Run All Checks |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div className="list-surface storage-list-surface"> |
|
||||
<div className="list-header storage-list-header"> |
|
||||
<span>Backend</span> |
|
||||
<span>Status</span> |
|
||||
<span>Class</span> |
|
||||
<span>Checked</span> |
|
||||
<span className="list-header-actions">Actions</span> |
|
||||
</div> |
|
||||
<div className="list-table"> |
|
||||
{items.map((backend) => ( |
|
||||
<div key={backend.id} className="table-row storage-table-row"> |
|
||||
<div> |
|
||||
<strong>{backend.name}</strong> |
|
||||
<span className="item-muted"> |
|
||||
{formatBackendType(backend.type)} · {formatBackendConfig(backend.config)} |
|
||||
{backend.secret_fields.length ? ` · ${backend.secret_fields.length} secrets` : ""} |
|
||||
</span> |
|
||||
</div> |
|
||||
<div className="table-meta storage-row-meta"> |
|
||||
<span className={backend.is_enabled ? "status-chip status-chip-success" : "status-chip"}> |
|
||||
{backend.is_enabled ? "Enabled" : "Disabled"} |
|
||||
</span> |
|
||||
<span className="status-chip">{titleCase(backend.last_health_status ?? "unknown")}</span> |
|
||||
<span>{titleCase(backend.stability_class)}</span> |
|
||||
<span>{formatDate(backend.last_health_checked_at)}</span> |
|
||||
<div className="toolbar-group storage-row-actions"> |
|
||||
<button className="ghost-button compact-button" type="button" onClick={() => checkMutation.mutate(backend.id)}> |
|
||||
Check |
|
||||
</button> |
|
||||
<button className="ghost-button compact-button" type="button" onClick={() => openEditBackend(backend)}> |
|
||||
Edit |
|
||||
</button> |
|
||||
{backend.is_enabled ? ( |
|
||||
<button className="danger-button compact-button" type="button" onClick={() => disableMutation.mutate(backend.id)}> |
|
||||
Disable |
|
||||
</button> |
|
||||
) : ( |
|
||||
<> |
|
||||
<button className="ghost-button compact-button" type="button" onClick={() => enableMutation.mutate(backend.id)}> |
|
||||
Enable |
|
||||
</button> |
|
||||
<button className="danger-button compact-button" type="button" onClick={() => setDeleteTarget(backend)}> |
|
||||
Delete |
|
||||
</button> |
|
||||
</> |
|
||||
)} |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
))} |
|
||||
{!items.length ? ( |
|
||||
<div className="empty-state empty-state-rich"> |
|
||||
<strong>No storage backends configured.</strong> |
|
||||
</div> |
|
||||
) : null} |
|
||||
</div> |
|
||||
</div> |
|
||||
</section> |
|
||||
{isCreateOpen ? ( |
|
||||
<Dialog title="Add storage backend" onClose={() => setIsCreateOpen(false)}> |
|
||||
<form className="dialog-form" onSubmit={handleCreateBackend}> |
|
||||
<label> |
|
||||
<span>Type</span> |
|
||||
<select value={backendType} onChange={(event) => handleTypeChange(event.target.value as BackendType)}> |
|
||||
<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 value={name} onChange={(event) => setName(event.target.value)} placeholder="storage-main" /> |
|
||||
</label> |
|
||||
<label> |
|
||||
<span>Policy class</span> |
|
||||
<select |
|
||||
value={stabilityClass} |
|
||||
onChange={(event) => setStabilityClass(event.target.value as StabilityClass)} |
|
||||
> |
|
||||
<option value="local">Local</option> |
|
||||
<option value="stable">Stable</option> |
|
||||
<option value="opportunistic">Opportunistic</option> |
|
||||
</select> |
|
||||
</label> |
|
||||
<div className="split-fields"> |
|
||||
<label> |
|
||||
<span>Read priority</span> |
|
||||
<input |
|
||||
type="number" |
|
||||
value={readPriority} |
|
||||
onChange={(event) => setReadPriority(Number(event.target.value))} |
|
||||
/> |
|
||||
</label> |
|
||||
<label> |
|
||||
<span>Write priority</span> |
|
||||
<input |
|
||||
type="number" |
|
||||
value={writePriority} |
|
||||
onChange={(event) => setWritePriority(Number(event.target.value))} |
|
||||
/> |
|
||||
</label> |
|
||||
</div> |
|
||||
<label> |
|
||||
<span>Config JSON</span> |
|
||||
<textarea |
|
||||
value={configText} |
|
||||
onChange={(event) => setConfigText(event.target.value)} |
|
||||
rows={backendType === "webdav" ? 8 : 9} |
|
||||
spellCheck={false} |
|
||||
/> |
|
||||
</label> |
|
||||
{formError ? <p className="error-text">{formError}</p> : null} |
|
||||
<div className="dialog-actions"> |
|
||||
<button className="ghost-button" type="button" onClick={() => setIsCreateOpen(false)}> |
|
||||
Cancel |
|
||||
</button> |
|
||||
<button className="primary-button" type="submit" disabled={createMutation.isPending}> |
|
||||
Create |
|
||||
</button> |
|
||||
</div> |
|
||||
</form> |
|
||||
</Dialog> |
|
||||
) : null} |
|
||||
{editingBackend ? ( |
|
||||
<Dialog title="Edit storage backend" onClose={() => setEditingBackend(null)}> |
|
||||
<form className="dialog-form" onSubmit={handleUpdateBackend}> |
|
||||
<label> |
|
||||
<span>Type</span> |
|
||||
<input value={editingBackend.type} disabled /> |
|
||||
</label> |
|
||||
<label> |
|
||||
<span>Name</span> |
|
||||
<input value={editName} onChange={(event) => setEditName(event.target.value)} /> |
|
||||
</label> |
|
||||
<label> |
|
||||
<span>Policy class</span> |
|
||||
<select |
|
||||
value={editStabilityClass} |
|
||||
onChange={(event) => setEditStabilityClass(event.target.value as StabilityClass)} |
|
||||
> |
|
||||
<option value="local">Local</option> |
|
||||
<option value="stable">Stable</option> |
|
||||
<option value="opportunistic">Opportunistic</option> |
|
||||
</select> |
|
||||
</label> |
|
||||
<div className="split-fields"> |
|
||||
<label> |
|
||||
<span>Read priority</span> |
|
||||
<input |
|
||||
type="number" |
|
||||
value={editReadPriority} |
|
||||
onChange={(event) => setEditReadPriority(Number(event.target.value))} |
|
||||
/> |
|
||||
</label> |
|
||||
<label> |
|
||||
<span>Write priority</span> |
|
||||
<input |
|
||||
type="number" |
|
||||
value={editWritePriority} |
|
||||
onChange={(event) => setEditWritePriority(Number(event.target.value))} |
|
||||
/> |
|
||||
</label> |
|
||||
</div> |
|
||||
<label> |
|
||||
<span>Public config JSON</span> |
|
||||
<textarea |
|
||||
value={editConfigText} |
|
||||
onChange={(event) => setEditConfigText(event.target.value)} |
|
||||
rows={8} |
|
||||
spellCheck={false} |
|
||||
/> |
|
||||
</label> |
|
||||
<label> |
|
||||
<span>Secret override JSON</span> |
|
||||
<textarea |
|
||||
value={editSecretText} |
|
||||
onChange={(event) => setEditSecretText(event.target.value)} |
|
||||
rows={4} |
|
||||
spellCheck={false} |
|
||||
/> |
|
||||
</label> |
|
||||
<div className="meta-list compact-meta-list"> |
|
||||
<div> |
|
||||
<span>Stored secrets</span> |
|
||||
<strong>{editingBackend.secret_fields.length ? editingBackend.secret_fields.join(", ") : "None"}</strong> |
|
||||
</div> |
|
||||
</div> |
|
||||
{editError ? <p className="error-text">{editError}</p> : null} |
|
||||
<div className="dialog-actions"> |
|
||||
<button className="ghost-button" type="button" onClick={() => setEditingBackend(null)}> |
|
||||
Cancel |
|
||||
</button> |
|
||||
<button className="primary-button" type="submit" disabled={updateMutation.isPending}> |
|
||||
Save |
|
||||
</button> |
|
||||
</div> |
|
||||
</form> |
|
||||
</Dialog> |
|
||||
) : null} |
|
||||
{deleteTarget ? ( |
|
||||
<Dialog title="Delete storage backend" onClose={() => setDeleteTarget(null)}> |
|
||||
<div className="dialog-form"> |
|
||||
<div className="empty-state"> |
|
||||
<strong>{deleteTarget.name}</strong> |
|
||||
<p className="item-muted"> |
|
||||
Delete only works for disabled backends with no stored replicas and no placement policy references. |
|
||||
</p> |
|
||||
</div> |
|
||||
<div className="dialog-actions"> |
|
||||
<button className="ghost-button" type="button" onClick={() => setDeleteTarget(null)}> |
|
||||
Cancel |
|
||||
</button> |
|
||||
<button |
|
||||
className="danger-button" |
|
||||
type="button" |
|
||||
disabled={deleteMutation.isPending} |
|
||||
onClick={() => deleteMutation.mutate(deleteTarget.id)} |
|
||||
> |
|
||||
Delete |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
</Dialog> |
|
||||
) : null} |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
function buildEditPayload(input: { |
|
||||
name: string; |
|
||||
stabilityClass: StabilityClass; |
|
||||
readPriority: number; |
|
||||
writePriority: number; |
|
||||
configText: string; |
|
||||
secretText: string; |
|
||||
}) { |
|
||||
const publicConfig = JSON.parse(input.configText) as Record<string, unknown>; |
|
||||
const secretConfig = JSON.parse(input.secretText) as Record<string, unknown>; |
|
||||
if (!isObject(publicConfig) || !isObject(secretConfig)) { |
|
||||
throw new Error("Config must be a JSON object."); |
|
||||
} |
|
||||
return { |
|
||||
name: input.name, |
|
||||
stability_class: input.stabilityClass, |
|
||||
read_priority: input.readPriority, |
|
||||
write_priority: input.writePriority, |
|
||||
config: { ...publicConfig, ...secretConfig }, |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
function isObject(value: unknown): value is Record<string, unknown> { |
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value); |
|
||||
} |
|
||||
|
|
||||
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"; |
|
||||
} |
|
||||
@ -0,0 +1,767 @@ |
|||||
|
html, |
||||
|
body, |
||||
|
#root { |
||||
|
min-height: 100%; |
||||
|
} |
||||
|
|
||||
|
body { |
||||
|
margin: 0; |
||||
|
background: var(--oc-role-background); |
||||
|
color: var(--oc-role-on-surface); |
||||
|
font-family: var(--oc-font-family), Inter, "Segoe UI", sans-serif; |
||||
|
overflow: auto; |
||||
|
} |
||||
|
|
||||
|
button, |
||||
|
input, |
||||
|
select, |
||||
|
textarea { |
||||
|
font: inherit; |
||||
|
} |
||||
|
|
||||
|
.iron-app { |
||||
|
min-height: 100vh; |
||||
|
display: grid; |
||||
|
grid-template-rows: 52px minmax(0, 1fr); |
||||
|
background: var(--oc-role-background); |
||||
|
} |
||||
|
|
||||
|
.iron-topbar { |
||||
|
display: grid; |
||||
|
grid-template-columns: 230px minmax(260px, 590px) 44px; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
gap: 1rem; |
||||
|
padding: 0 1rem; |
||||
|
color: var(--oc-role-on-chrome); |
||||
|
background: var(--oc-role-chrome); |
||||
|
} |
||||
|
|
||||
|
.brand-area, |
||||
|
.brand-lockup, |
||||
|
.app-switcher-button, |
||||
|
.user-chip, |
||||
|
.nav-link, |
||||
|
.command-bar, |
||||
|
.directory-title-row, |
||||
|
.row-actions, |
||||
|
.pagination-controls, |
||||
|
.preview-actions { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
.brand-area { |
||||
|
gap: 0.85rem; |
||||
|
} |
||||
|
|
||||
|
.brand-lockup { |
||||
|
gap: 0.5rem; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
.brand-mark { |
||||
|
width: 22px; |
||||
|
height: 22px; |
||||
|
display: inline-grid; |
||||
|
place-items: center; |
||||
|
border: 1px solid color-mix(in srgb, var(--oc-role-on-chrome) 72%, transparent); |
||||
|
border-radius: 5px; |
||||
|
color: #d9b8ff; |
||||
|
font-size: 0.78rem; |
||||
|
} |
||||
|
|
||||
|
.app-switcher-button, |
||||
|
.user-chip { |
||||
|
justify-content: center; |
||||
|
padding: 0; |
||||
|
color: var(--oc-role-on-chrome); |
||||
|
background: transparent; |
||||
|
border: 0; |
||||
|
} |
||||
|
|
||||
|
.app-switcher-button { |
||||
|
width: 26px; |
||||
|
height: 26px; |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(3, 4px); |
||||
|
grid-template-rows: repeat(3, 4px); |
||||
|
gap: 4px; |
||||
|
} |
||||
|
|
||||
|
.app-switcher-button span { |
||||
|
width: 4px; |
||||
|
height: 4px; |
||||
|
background: currentColor; |
||||
|
border-radius: 1px; |
||||
|
} |
||||
|
|
||||
|
.user-chip { |
||||
|
width: 34px; |
||||
|
height: 34px; |
||||
|
border: 1px solid color-mix(in srgb, var(--oc-role-on-chrome) 38%, transparent); |
||||
|
border-radius: 50%; |
||||
|
} |
||||
|
|
||||
|
.topbar-search { |
||||
|
width: 100%; |
||||
|
} |
||||
|
|
||||
|
.topbar-search input { |
||||
|
width: 100%; |
||||
|
min-height: 34px; |
||||
|
padding: 0.35rem 0.85rem; |
||||
|
border: 1px solid color-mix(in srgb, var(--oc-role-outline) 24%, transparent); |
||||
|
border-radius: 999px; |
||||
|
color: var(--oc-role-on-surface); |
||||
|
background: var(--oc-role-surface); |
||||
|
} |
||||
|
|
||||
|
.iron-workspace { |
||||
|
min-height: 0; |
||||
|
display: grid; |
||||
|
grid-template-columns: 238px minmax(0, 1fr); |
||||
|
background: var(--oc-role-surface); |
||||
|
} |
||||
|
|
||||
|
.sidebar { |
||||
|
display: grid; |
||||
|
align-content: start; |
||||
|
gap: 0.5rem; |
||||
|
padding: 1.05rem 1rem; |
||||
|
background: var(--oc-role-surface-container); |
||||
|
border-right: 1px solid var(--oc-role-outline-variant); |
||||
|
} |
||||
|
|
||||
|
.sidebar-kicker, |
||||
|
.eyebrow, |
||||
|
.metric-label { |
||||
|
color: var(--oc-role-on-surface-variant); |
||||
|
font-size: 0.78rem; |
||||
|
} |
||||
|
|
||||
|
.sidebar-title { |
||||
|
margin: 0 0 0.8rem; |
||||
|
font-size: 1.08rem; |
||||
|
font-weight: 400; |
||||
|
} |
||||
|
|
||||
|
.main-nav { |
||||
|
display: grid; |
||||
|
gap: 0.2rem; |
||||
|
} |
||||
|
|
||||
|
.nav-link { |
||||
|
gap: 0.65rem; |
||||
|
min-height: 38px; |
||||
|
padding: 0.48rem 0.75rem; |
||||
|
color: color-mix(in srgb, var(--oc-role-on-surface) 78%, transparent); |
||||
|
text-decoration: none; |
||||
|
border-radius: 5px; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
.nav-link.active { |
||||
|
background: var(--oc-role-surface); |
||||
|
} |
||||
|
|
||||
|
.nav-icon { |
||||
|
width: 18px; |
||||
|
height: 18px; |
||||
|
display: inline-block; |
||||
|
color: var(--oc-role-chrome); |
||||
|
} |
||||
|
|
||||
|
.sidebar-footer { |
||||
|
margin-top: 1.6rem; |
||||
|
color: var(--oc-role-on-surface-variant); |
||||
|
font-size: 0.78rem; |
||||
|
} |
||||
|
|
||||
|
.workspace-content { |
||||
|
min-width: 0; |
||||
|
padding: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
.page-frame, |
||||
|
.files-page-layout, |
||||
|
.login-page { |
||||
|
min-height: calc(100vh - 68px); |
||||
|
border: 1px solid var(--oc-role-outline-variant); |
||||
|
border-radius: 10px; |
||||
|
background: var(--oc-role-surface); |
||||
|
} |
||||
|
|
||||
|
.files-page-layout { |
||||
|
display: grid; |
||||
|
grid-template-columns: minmax(0, 1fr) 300px; |
||||
|
} |
||||
|
|
||||
|
.files-content-shell { |
||||
|
min-width: 0; |
||||
|
padding: 1.25rem 1rem; |
||||
|
} |
||||
|
|
||||
|
.detail-panel { |
||||
|
min-width: 0; |
||||
|
padding: 1.25rem 1rem; |
||||
|
border-left: 1px solid var(--oc-role-outline-variant); |
||||
|
} |
||||
|
|
||||
|
.panel-toolbar, |
||||
|
.page-toolbar { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: flex-start; |
||||
|
gap: 1rem; |
||||
|
margin-bottom: 0.7rem; |
||||
|
} |
||||
|
|
||||
|
.files-header, |
||||
|
.section-heading { |
||||
|
display: grid; |
||||
|
gap: 0.22rem; |
||||
|
} |
||||
|
|
||||
|
.directory-title-row { |
||||
|
min-height: 34px; |
||||
|
gap: 0.55rem; |
||||
|
} |
||||
|
|
||||
|
.directory-title-row h2, |
||||
|
.section-heading h2, |
||||
|
.section-heading h3 { |
||||
|
margin: 0; |
||||
|
font-size: 1.1rem; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
.directory-back-button { |
||||
|
width: 32px; |
||||
|
height: 32px; |
||||
|
border: 1px solid color-mix(in srgb, var(--oc-role-outline) 24%, transparent); |
||||
|
border-radius: 5px; |
||||
|
background: var(--oc-role-surface); |
||||
|
color: var(--oc-role-on-surface); |
||||
|
font-size: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.inline-metrics { |
||||
|
display: flex; |
||||
|
flex-wrap: wrap; |
||||
|
gap: 0.52rem; |
||||
|
color: var(--oc-role-on-surface-variant); |
||||
|
font-size: 0.84rem; |
||||
|
} |
||||
|
|
||||
|
.inline-metrics span + span::before { |
||||
|
content: "/"; |
||||
|
margin-right: 0.52rem; |
||||
|
color: color-mix(in srgb, var(--oc-role-outline) 24%, transparent); |
||||
|
} |
||||
|
|
||||
|
.command-bar { |
||||
|
justify-content: flex-end; |
||||
|
flex-wrap: wrap; |
||||
|
gap: 0.42rem; |
||||
|
} |
||||
|
|
||||
|
.compact-sort-select, |
||||
|
.page-size-select, |
||||
|
.dialog-input, |
||||
|
.dialog-select, |
||||
|
.dialog-textarea, |
||||
|
.login-form input { |
||||
|
min-height: 34px; |
||||
|
padding: 0.38rem 0.55rem; |
||||
|
border: 1px solid color-mix(in srgb, var(--oc-role-outline) 24%, transparent); |
||||
|
border-radius: 5px; |
||||
|
color: var(--oc-role-on-surface); |
||||
|
background: var(--oc-role-surface); |
||||
|
} |
||||
|
|
||||
|
.compact-sort-select { |
||||
|
width: 142px; |
||||
|
} |
||||
|
|
||||
|
.list-surface { |
||||
|
overflow: visible; |
||||
|
border: 1px solid color-mix(in srgb, var(--oc-role-outline) 24%, transparent); |
||||
|
border-radius: 5px; |
||||
|
background: var(--oc-role-surface); |
||||
|
} |
||||
|
|
||||
|
.list-header, |
||||
|
.drive-item { |
||||
|
display: grid; |
||||
|
grid-template-columns: minmax(0, 1fr) 124px 76px 64px 58px; |
||||
|
gap: 0.7rem; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
.jobs-list-header, |
||||
|
.jobs-row { |
||||
|
grid-template-columns: minmax(0, 1fr) 92px 72px 70px; |
||||
|
} |
||||
|
|
||||
|
.recycle-list-header, |
||||
|
.recycle-row { |
||||
|
grid-template-columns: minmax(0, 1fr) 132px 82px 86px; |
||||
|
} |
||||
|
|
||||
|
.storage-list-header, |
||||
|
.storage-row { |
||||
|
grid-template-columns: minmax(0, 1fr) 92px 92px 132px 190px; |
||||
|
} |
||||
|
|
||||
|
.list-header { |
||||
|
min-height: 38px; |
||||
|
padding: 0.5rem 0.85rem; |
||||
|
color: color-mix(in srgb, var(--oc-role-on-surface-variant) 78%, transparent); |
||||
|
font-size: 0.82rem; |
||||
|
border-bottom: 1px solid color-mix(in srgb, var(--oc-role-outline-variant) 46%, transparent); |
||||
|
} |
||||
|
|
||||
|
.drive-list, |
||||
|
.drive-grid, |
||||
|
.table-list { |
||||
|
display: grid; |
||||
|
} |
||||
|
|
||||
|
.drive-item { |
||||
|
position: relative; |
||||
|
min-height: 50px; |
||||
|
width: 100%; |
||||
|
padding: 0.48rem 0.85rem; |
||||
|
border: 0; |
||||
|
border-bottom: 1px solid color-mix(in srgb, var(--oc-role-outline-variant) 46%, transparent); |
||||
|
text-align: left; |
||||
|
color: var(--oc-role-on-surface); |
||||
|
background: var(--oc-role-surface); |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
.drive-item:hover { |
||||
|
background: var(--oc-role-surface-container); |
||||
|
} |
||||
|
|
||||
|
.drive-item.active { |
||||
|
background: color-mix(in srgb, var(--oc-role-primary-container) 46%, var(--oc-role-surface)); |
||||
|
} |
||||
|
|
||||
|
.drive-grid { |
||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); |
||||
|
} |
||||
|
|
||||
|
.grid-surface .list-header { |
||||
|
display: none; |
||||
|
} |
||||
|
|
||||
|
.drive-grid .drive-item { |
||||
|
min-height: 142px; |
||||
|
grid-template-columns: minmax(0, 1fr); |
||||
|
align-content: space-between; |
||||
|
} |
||||
|
|
||||
|
.drive-grid .drive-item > span:nth-of-type(1) { |
||||
|
display: none; |
||||
|
} |
||||
|
|
||||
|
.drive-grid .row-actions { |
||||
|
position: absolute; |
||||
|
top: 0.58rem; |
||||
|
right: 0.58rem; |
||||
|
} |
||||
|
|
||||
|
.drive-item-main { |
||||
|
min-width: 0; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 0.82rem; |
||||
|
} |
||||
|
|
||||
|
.resource-name-stack { |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
.resource-name-stack strong { |
||||
|
display: block; |
||||
|
overflow: hidden; |
||||
|
color: color-mix(in srgb, var(--oc-role-on-surface) 78%, transparent); |
||||
|
font-size: 0.98rem; |
||||
|
font-weight: 500; |
||||
|
text-overflow: ellipsis; |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
|
||||
|
.resource-icon { |
||||
|
width: 30px; |
||||
|
height: 30px; |
||||
|
flex: 0 0 auto; |
||||
|
display: inline-grid; |
||||
|
place-items: center; |
||||
|
border-radius: 3px; |
||||
|
background: #171c1f; |
||||
|
color: #fff; |
||||
|
font-size: 0.5rem; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
.resource-icon-folder { |
||||
|
width: 32px; |
||||
|
height: 24px; |
||||
|
margin-top: 3px; |
||||
|
background: #4d7eaf; |
||||
|
} |
||||
|
|
||||
|
.resource-icon-image, |
||||
|
.resource-icon-presentation { |
||||
|
background: #ee6b3b; |
||||
|
} |
||||
|
|
||||
|
.resource-icon-document { |
||||
|
background: #3b44a6; |
||||
|
} |
||||
|
|
||||
|
.resource-icon-pdf { |
||||
|
background: #ec0d47; |
||||
|
} |
||||
|
|
||||
|
.resource-icon-archive { |
||||
|
background: #fbbe54; |
||||
|
color: #191c1d; |
||||
|
} |
||||
|
|
||||
|
.row-actions { |
||||
|
justify-content: flex-end; |
||||
|
min-width: 58px; |
||||
|
} |
||||
|
|
||||
|
.row-action-button { |
||||
|
min-height: 30px; |
||||
|
padding: 0.24rem 0.48rem; |
||||
|
} |
||||
|
|
||||
|
.menu-popover { |
||||
|
position: absolute; |
||||
|
top: calc(100% + 6px); |
||||
|
right: 0; |
||||
|
z-index: 80; |
||||
|
min-width: 150px; |
||||
|
padding: 0.25rem; |
||||
|
display: grid; |
||||
|
gap: 0.2rem; |
||||
|
border: 1px solid var(--oc-role-outline-variant); |
||||
|
border-radius: 5px; |
||||
|
background: var(--oc-role-surface); |
||||
|
box-shadow: 0 8px 18px rgba(25, 28, 29, 0.08); |
||||
|
} |
||||
|
|
||||
|
.menu-item { |
||||
|
justify-content: flex-start; |
||||
|
width: 100%; |
||||
|
} |
||||
|
|
||||
|
.menu-item-danger { |
||||
|
color: var(--oc-role-error); |
||||
|
} |
||||
|
|
||||
|
.replica-ratio { |
||||
|
width: fit-content; |
||||
|
justify-self: end; |
||||
|
min-width: 44px; |
||||
|
min-height: 24px; |
||||
|
display: inline-flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
padding: 0.12rem 0.42rem; |
||||
|
border: 1px solid color-mix(in srgb, var(--oc-role-outline-variant) 46%, transparent); |
||||
|
border-radius: 5px; |
||||
|
background: var(--oc-role-surface-container); |
||||
|
color: var(--oc-role-on-surface-variant); |
||||
|
font-size: 0.78rem; |
||||
|
} |
||||
|
|
||||
|
.replica-ratio-ready { |
||||
|
color: color-mix(in srgb, var(--oc-role-primary) 76%, var(--oc-role-on-surface)); |
||||
|
background: color-mix(in srgb, var(--oc-role-primary-container) 56%, var(--oc-role-surface)); |
||||
|
} |
||||
|
|
||||
|
.pagination-bar { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
gap: 1rem; |
||||
|
padding-top: 0.8rem; |
||||
|
} |
||||
|
|
||||
|
.pagination-controls { |
||||
|
justify-content: flex-end; |
||||
|
flex-wrap: wrap; |
||||
|
gap: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
.pagination-page-list { |
||||
|
display: flex; |
||||
|
gap: 0.42rem; |
||||
|
} |
||||
|
|
||||
|
.pagination-page-button, |
||||
|
.pagination-ellipsis { |
||||
|
width: 34px; |
||||
|
height: 34px; |
||||
|
display: inline-grid; |
||||
|
place-items: center; |
||||
|
border: 1px solid color-mix(in srgb, var(--oc-role-outline) 24%, transparent); |
||||
|
border-radius: 5px; |
||||
|
background: var(--oc-role-surface); |
||||
|
color: var(--oc-role-on-surface); |
||||
|
} |
||||
|
|
||||
|
.pagination-page-button.active { |
||||
|
color: var(--oc-role-primary); |
||||
|
border-color: color-mix(in srgb, var(--oc-role-primary) 74%, transparent); |
||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--oc-role-primary) 50%, transparent); |
||||
|
} |
||||
|
|
||||
|
.page-size-select { |
||||
|
min-width: 118px; |
||||
|
} |
||||
|
|
||||
|
.detail-empty, |
||||
|
.preview-fallback, |
||||
|
.inspector-card, |
||||
|
.dropzone { |
||||
|
display: grid; |
||||
|
gap: 0.7rem; |
||||
|
padding: 0.78rem; |
||||
|
border: 1px dashed color-mix(in srgb, var(--oc-role-outline-variant) 70%, transparent); |
||||
|
border-radius: 5px; |
||||
|
background: color-mix(in srgb, var(--oc-role-surface-container) 50%, var(--oc-role-surface)); |
||||
|
} |
||||
|
|
||||
|
.detail-empty h3, |
||||
|
.preview-fallback h3 { |
||||
|
margin: 0; |
||||
|
font-size: 1.05rem; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
.preview-frame { |
||||
|
width: 100%; |
||||
|
min-height: 210px; |
||||
|
max-height: 360px; |
||||
|
border: 1px solid var(--oc-role-outline-variant); |
||||
|
border-radius: 5px; |
||||
|
object-fit: contain; |
||||
|
background: var(--oc-role-surface-container); |
||||
|
} |
||||
|
|
||||
|
.text-preview-frame { |
||||
|
overflow: auto; |
||||
|
min-height: 180px; |
||||
|
padding: 0.75rem; |
||||
|
white-space: pre-wrap; |
||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; |
||||
|
font-size: 0.78rem; |
||||
|
} |
||||
|
|
||||
|
.inspector-storage-summary { |
||||
|
margin-top: 0.8rem; |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
gap: 0.75rem; |
||||
|
padding: 0.62rem 0.68rem; |
||||
|
border: 1px solid color-mix(in srgb, var(--oc-role-outline-variant) 46%, transparent); |
||||
|
border-radius: 5px; |
||||
|
background: color-mix(in srgb, var(--oc-role-surface-container) 48%, var(--oc-role-surface)); |
||||
|
} |
||||
|
|
||||
|
.inspector-info-button { |
||||
|
width: 100%; |
||||
|
margin-top: 0.85rem; |
||||
|
} |
||||
|
|
||||
|
.detail-drawer-backdrop, |
||||
|
.dialog-backdrop { |
||||
|
position: fixed; |
||||
|
inset: 0; |
||||
|
z-index: 70; |
||||
|
display: flex; |
||||
|
justify-content: flex-end; |
||||
|
background: rgba(25, 28, 29, 0.38); |
||||
|
} |
||||
|
|
||||
|
.dialog-backdrop { |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
} |
||||
|
|
||||
|
.detail-drawer, |
||||
|
.dialog-card { |
||||
|
background: var(--oc-role-surface); |
||||
|
box-shadow: 0 16px 32px rgba(25, 28, 29, 0.14); |
||||
|
} |
||||
|
|
||||
|
.detail-drawer { |
||||
|
width: min(560px, 100%); |
||||
|
height: 100%; |
||||
|
display: grid; |
||||
|
grid-template-rows: auto minmax(0, 1fr); |
||||
|
border-left: 1px solid var(--oc-role-outline-variant); |
||||
|
} |
||||
|
|
||||
|
.dialog-card { |
||||
|
width: min(520px, calc(100% - 2rem)); |
||||
|
display: grid; |
||||
|
gap: 1rem; |
||||
|
padding: 1.2rem; |
||||
|
border: 1px solid var(--oc-role-outline-variant); |
||||
|
border-radius: 5px; |
||||
|
} |
||||
|
|
||||
|
.detail-drawer-header, |
||||
|
.dialog-header, |
||||
|
.dialog-actions { |
||||
|
display: flex; |
||||
|
align-items: flex-start; |
||||
|
justify-content: space-between; |
||||
|
gap: 1rem; |
||||
|
} |
||||
|
|
||||
|
.detail-drawer-header { |
||||
|
padding: 1.05rem 1.15rem; |
||||
|
border-bottom: 1px solid var(--oc-role-outline-variant); |
||||
|
} |
||||
|
|
||||
|
.detail-drawer-content { |
||||
|
overflow: auto; |
||||
|
display: grid; |
||||
|
align-content: start; |
||||
|
gap: 1rem; |
||||
|
padding: 1rem 1.15rem 1.25rem; |
||||
|
} |
||||
|
|
||||
|
.dialog-form, |
||||
|
.stack-form, |
||||
|
.page-stack, |
||||
|
.login-form { |
||||
|
display: grid; |
||||
|
gap: 0.85rem; |
||||
|
} |
||||
|
|
||||
|
.policy-form { |
||||
|
max-width: 440px; |
||||
|
} |
||||
|
|
||||
|
.dialog-actions { |
||||
|
justify-content: flex-end; |
||||
|
} |
||||
|
|
||||
|
.danger-confirm-button { |
||||
|
min-height: 34px; |
||||
|
padding: 0.38rem 0.8rem; |
||||
|
border: 1px solid #b42318; |
||||
|
border-radius: 5px; |
||||
|
color: #fff; |
||||
|
background: #d92d20; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
.danger-confirm-button:hover { |
||||
|
background: #b42318; |
||||
|
} |
||||
|
|
||||
|
.dialog-form label, |
||||
|
.login-form label { |
||||
|
display: grid; |
||||
|
gap: 0.35rem; |
||||
|
color: var(--oc-role-on-surface-variant); |
||||
|
font-size: 0.84rem; |
||||
|
} |
||||
|
|
||||
|
.dialog-textarea { |
||||
|
min-height: 130px; |
||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; |
||||
|
} |
||||
|
|
||||
|
.notice-text { |
||||
|
color: #008b63; |
||||
|
} |
||||
|
|
||||
|
.error-text { |
||||
|
color: var(--oc-role-error); |
||||
|
} |
||||
|
|
||||
|
.status-chip { |
||||
|
width: fit-content; |
||||
|
padding: 0.22rem 0.5rem; |
||||
|
border: 1px solid color-mix(in srgb, var(--oc-role-outline) 20%, transparent); |
||||
|
border-radius: 5px; |
||||
|
color: var(--oc-role-on-surface-variant); |
||||
|
background: var(--oc-role-surface-container); |
||||
|
font-size: 0.78rem; |
||||
|
} |
||||
|
|
||||
|
.status-chip-success { |
||||
|
color: #008b63; |
||||
|
background: #ecfdf5; |
||||
|
} |
||||
|
|
||||
|
.toast-item { |
||||
|
position: fixed; |
||||
|
left: 50%; |
||||
|
bottom: 1rem; |
||||
|
z-index: 100; |
||||
|
min-width: 220px; |
||||
|
max-width: 380px; |
||||
|
transform: translateX(-50%); |
||||
|
padding: 0.74rem 0.88rem; |
||||
|
border: 1px solid var(--oc-role-outline-variant); |
||||
|
border-radius: 5px; |
||||
|
background: var(--oc-role-surface); |
||||
|
box-shadow: 0 8px 20px rgba(25, 28, 29, 0.12); |
||||
|
} |
||||
|
|
||||
|
.payload-preview { |
||||
|
overflow: auto; |
||||
|
max-height: 260px; |
||||
|
padding: 0.72rem; |
||||
|
border: 1px solid var(--oc-role-outline-variant); |
||||
|
border-radius: 5px; |
||||
|
background: var(--oc-role-surface-container); |
||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; |
||||
|
font-size: 0.78rem; |
||||
|
white-space: pre-wrap; |
||||
|
} |
||||
|
|
||||
|
.login-page { |
||||
|
width: min(420px, calc(100% - 2rem)); |
||||
|
min-height: auto; |
||||
|
margin: 6rem auto; |
||||
|
padding: 1.4rem; |
||||
|
} |
||||
|
|
||||
|
.empty-row { |
||||
|
padding: 1.2rem 0.85rem; |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 900px) { |
||||
|
.iron-topbar { |
||||
|
grid-template-columns: auto minmax(0, 1fr) auto; |
||||
|
} |
||||
|
|
||||
|
.iron-workspace, |
||||
|
.files-page-layout { |
||||
|
grid-template-columns: 1fr; |
||||
|
} |
||||
|
|
||||
|
.sidebar { |
||||
|
display: none; |
||||
|
} |
||||
|
|
||||
|
.detail-panel { |
||||
|
border-left: 0; |
||||
|
border-top: 1px solid var(--oc-role-outline-variant); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
/// <reference types="vite/client" />
|
||||
|
/// <reference types="@opencloud-eu/design-system/types" />
|
||||
|
|
||||
|
declare module "*.vue" { |
||||
|
import type { DefineComponent } from "vue"; |
||||
|
|
||||
|
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>; |
||||
|
export default component; |
||||
|
} |
||||