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