|
|
|
@ -8,6 +8,36 @@ import { uploadFileToDirectory } from "./lib/uploads"; |
|
|
|
type RouteName = "login" | "files" | "recycle" | "storage" | "policies" | "jobs"; |
|
|
|
type DialogMode = "create" | "rename" | "move" | "delete" | null; |
|
|
|
type ViewMode = "list" | "grid"; |
|
|
|
type BackendType = "local_directory" | "s3" | "webdav"; |
|
|
|
type StabilityClass = "local" | "stable" | "opportunistic"; |
|
|
|
|
|
|
|
const CONFIG_TEMPLATES: Record<BackendType, string> = { |
|
|
|
local_directory: JSON.stringify({ base_path: "./.iron-storage/archive" }, null, 2), |
|
|
|
s3: JSON.stringify( |
|
|
|
{ |
|
|
|
bucket: "iron", |
|
|
|
region: "us-east-1", |
|
|
|
endpoint_url: "http://127.0.0.1:9000", |
|
|
|
access_key_id: "", |
|
|
|
secret_access_key: "", |
|
|
|
prefix: "iron", |
|
|
|
force_path_style: true, |
|
|
|
}, |
|
|
|
null, |
|
|
|
2, |
|
|
|
), |
|
|
|
webdav: JSON.stringify( |
|
|
|
{ |
|
|
|
endpoint_url: "http://127.0.0.1:5244/dav", |
|
|
|
username: "iron", |
|
|
|
password: "", |
|
|
|
root_path: "iron", |
|
|
|
verify_ssl: true, |
|
|
|
}, |
|
|
|
null, |
|
|
|
2, |
|
|
|
), |
|
|
|
}; |
|
|
|
|
|
|
|
const route = ref(parseRoute()); |
|
|
|
const userInitial = ref("A"); |
|
|
|
@ -31,17 +61,39 @@ const pageSize = ref(8); |
|
|
|
const openMenuId = ref<string | null>(null); |
|
|
|
const dialogMode = ref<DialogMode>(null); |
|
|
|
const fieldValue = ref(""); |
|
|
|
const moveTargetId = ref("dir_root"); |
|
|
|
const fileDetail = ref<FileDetailResponse | null>(null); |
|
|
|
const previewUrl = ref(""); |
|
|
|
const previewText = ref(""); |
|
|
|
const isDetailDrawerOpen = ref(false); |
|
|
|
|
|
|
|
const recycleItems = ref<Array<DirectoryItem & { deleted_at?: string }>>([]); |
|
|
|
const recyclePage = ref(1); |
|
|
|
const recyclePageSize = ref(8); |
|
|
|
const backends = ref<BackendSummary[]>([]); |
|
|
|
const backendPage = ref(1); |
|
|
|
const backendPageSize = ref(8); |
|
|
|
const isCreateBackendOpen = ref(false); |
|
|
|
const editingBackend = ref<BackendSummary | null>(null); |
|
|
|
const backendType = ref<BackendType>("s3"); |
|
|
|
const backendName = ref("s3-main"); |
|
|
|
const backendStabilityClass = ref<StabilityClass>("stable"); |
|
|
|
const backendReadPriority = ref(80); |
|
|
|
const backendWritePriority = ref(80); |
|
|
|
const backendConfigText = ref(CONFIG_TEMPLATES.s3); |
|
|
|
const backendFormError = ref(""); |
|
|
|
const editName = ref(""); |
|
|
|
const editStabilityClass = ref<StabilityClass>("stable"); |
|
|
|
const editReadPriority = ref(80); |
|
|
|
const editWritePriority = ref(80); |
|
|
|
const editConfigText = ref("{}"); |
|
|
|
const editSecretText = ref("{}"); |
|
|
|
const editError = ref(""); |
|
|
|
const policies = ref<PlacementPolicySummary[]>([]); |
|
|
|
const selectedPolicyClass = ref<PlacementPolicySummary["file_class"]>("document"); |
|
|
|
const jobs = ref<JobSummary[]>([]); |
|
|
|
const jobsPage = ref(1); |
|
|
|
const jobsPageSize = ref(8); |
|
|
|
const selectedJob = ref<JobSummary | null>(null); |
|
|
|
|
|
|
|
const currentPolicy = computed(() => policies.value.find((policy) => policy.file_class === selectedPolicyClass.value) ?? null); |
|
|
|
@ -68,6 +120,28 @@ const totalPages = computed(() => Math.max(1, Math.ceil(filteredItems.value.leng |
|
|
|
const pagedItems = computed(() => filteredItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value)); |
|
|
|
const fileTotalBytes = computed(() => files.value.reduce((sum, item) => sum + (item.size_bytes ?? 0), 0)); |
|
|
|
const pageItems = computed(() => getPaginationItems(page.value, totalPages.value)); |
|
|
|
const moveTargets = computed(() => { |
|
|
|
const entries = [...parentStack.value, ...folders.value.map((folder) => ({ id: folder.id, name: folder.name }))]; |
|
|
|
const unique = new Map<string, string>(); |
|
|
|
for (const entry of [...entries, { id: "dir_root", name: "Personal" }]) unique.set(entry.id, entry.name); |
|
|
|
if (selectedItem.value?.kind === "directory") unique.delete(selectedItem.value.id); |
|
|
|
return Array.from(unique.entries()).map(([id, name]) => ({ id, name })); |
|
|
|
}); |
|
|
|
const recycleTotalBytes = computed(() => recycleItems.value.reduce((sum, item) => sum + (item.size_bytes ?? 0), 0)); |
|
|
|
const recycleTotalPages = computed(() => Math.max(1, Math.ceil(recycleItems.value.length / recyclePageSize.value))); |
|
|
|
const recyclePageItems = computed(() => getPaginationItems(recyclePage.value, recycleTotalPages.value)); |
|
|
|
const pagedRecycleItems = computed(() => recycleItems.value.slice((recyclePage.value - 1) * recyclePageSize.value, recyclePage.value * recyclePageSize.value)); |
|
|
|
const healthyBackendCount = computed(() => backends.value.filter((backend) => backend.last_health_status === "healthy").length); |
|
|
|
const enabledBackendCount = computed(() => backends.value.filter((backend) => backend.is_enabled).length); |
|
|
|
const backendTotalPages = computed(() => Math.max(1, Math.ceil(backends.value.length / backendPageSize.value))); |
|
|
|
const backendPageItems = computed(() => getPaginationItems(backendPage.value, backendTotalPages.value)); |
|
|
|
const pagedBackends = computed(() => backends.value.slice((backendPage.value - 1) * backendPageSize.value, backendPage.value * backendPageSize.value)); |
|
|
|
const pendingJobsCount = computed(() => jobs.value.filter((job) => job.status === "queued" || job.status === "pending").length); |
|
|
|
const completedJobsCount = computed(() => jobs.value.filter((job) => job.status === "completed").length); |
|
|
|
const failedJobsCount = computed(() => jobs.value.filter((job) => job.status === "failed").length); |
|
|
|
const jobsTotalPages = computed(() => Math.max(1, Math.ceil(jobs.value.length / jobsPageSize.value))); |
|
|
|
const jobsPageItems = computed(() => getPaginationItems(jobsPage.value, jobsTotalPages.value)); |
|
|
|
const pagedJobs = computed(() => jobs.value.slice((jobsPage.value - 1) * jobsPageSize.value, jobsPage.value * jobsPageSize.value)); |
|
|
|
|
|
|
|
watch(route, async () => { |
|
|
|
if (route.value.name === "login") { |
|
|
|
@ -89,8 +163,30 @@ watch(selectedItem, async (item) => { |
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
watch([page, totalPages], () => { |
|
|
|
if (page.value > totalPages.value) page.value = totalPages.value; |
|
|
|
}); |
|
|
|
|
|
|
|
watch([search, sortBy, viewMode], () => { |
|
|
|
page.value = 1; |
|
|
|
openMenuId.value = null; |
|
|
|
}); |
|
|
|
|
|
|
|
watch([recyclePage, recycleTotalPages], () => { |
|
|
|
if (recyclePage.value > recycleTotalPages.value) recyclePage.value = recycleTotalPages.value; |
|
|
|
}); |
|
|
|
|
|
|
|
watch([backendPage, backendTotalPages], () => { |
|
|
|
if (backendPage.value > backendTotalPages.value) backendPage.value = backendTotalPages.value; |
|
|
|
}); |
|
|
|
|
|
|
|
watch([jobsPage, jobsTotalPages], () => { |
|
|
|
if (jobsPage.value > jobsTotalPages.value) jobsPage.value = jobsTotalPages.value; |
|
|
|
}); |
|
|
|
|
|
|
|
onMounted(async () => { |
|
|
|
window.addEventListener("popstate", handlePopState); |
|
|
|
window.addEventListener("keydown", handleGlobalKeydown); |
|
|
|
if (!getAccessToken() && route.value.name !== "login") { |
|
|
|
navigate("/app/login"); |
|
|
|
return; |
|
|
|
@ -98,12 +194,21 @@ onMounted(async () => { |
|
|
|
await loadRouteData(); |
|
|
|
}); |
|
|
|
|
|
|
|
onUnmounted(() => window.removeEventListener("popstate", handlePopState)); |
|
|
|
onUnmounted(() => { |
|
|
|
window.removeEventListener("popstate", handlePopState); |
|
|
|
window.removeEventListener("keydown", handleGlobalKeydown); |
|
|
|
}); |
|
|
|
|
|
|
|
function handlePopState() { |
|
|
|
route.value = parseRoute(); |
|
|
|
} |
|
|
|
|
|
|
|
function handleGlobalKeydown(event: KeyboardEvent) { |
|
|
|
if (event.key === "Escape") { |
|
|
|
openMenuId.value = null; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function parseRoute(): { name: RouteName; directoryId?: string } { |
|
|
|
const path = window.location.pathname; |
|
|
|
if (path.startsWith("/app/login")) return { name: "login" }; |
|
|
|
@ -198,8 +303,10 @@ async function uploadSelectedFile(event: Event) { |
|
|
|
|
|
|
|
function openDialog(mode: DialogMode, item?: DirectoryItem) { |
|
|
|
if (item) selectedItem.value = item; |
|
|
|
openMenuId.value = null; |
|
|
|
dialogMode.value = mode; |
|
|
|
fieldValue.value = mode === "rename" ? selectedItem.value?.name ?? "" : ""; |
|
|
|
moveTargetId.value = "dir_root"; |
|
|
|
} |
|
|
|
|
|
|
|
async function confirmDialog() { |
|
|
|
@ -217,6 +324,14 @@ async function confirmDialog() { |
|
|
|
} |
|
|
|
toast.value = "Renamed."; |
|
|
|
} |
|
|
|
if (dialogMode.value === "move" && selectedItem.value) { |
|
|
|
if (selectedItem.value.kind === "directory") { |
|
|
|
await api.moveDirectory(selectedItem.value.id, moveTargetId.value); |
|
|
|
} else { |
|
|
|
await api.moveFile(selectedItem.value.id, moveTargetId.value); |
|
|
|
} |
|
|
|
toast.value = "Moved."; |
|
|
|
} |
|
|
|
if (dialogMode.value === "delete" && selectedItem.value?.kind === "file") { |
|
|
|
await api.deleteFile(selectedItem.value.id); |
|
|
|
toast.value = "Moved to recycle bin."; |
|
|
|
@ -233,14 +348,18 @@ async function loadFileDetailAndPreview(item: DirectoryItem) { |
|
|
|
if (isImage(item.mime_type)) { |
|
|
|
const blob = await api.fileBlob(item.id, "preview"); |
|
|
|
previewUrl.value = URL.createObjectURL(blob); |
|
|
|
} else if (item.mime_type === "application/pdf" || item.mime_type?.startsWith("video/")) { |
|
|
|
const blob = await api.fileBlob(item.id, item.mime_type.startsWith("video/") ? "stream" : "preview"); |
|
|
|
previewUrl.value = URL.createObjectURL(blob); |
|
|
|
} else if (isText(item.mime_type, item.name)) { |
|
|
|
const blob = await api.fileBlob(item.id, "preview"); |
|
|
|
previewText.value = (await blob.text()).slice(0, 8000); |
|
|
|
const blob = await api.fileBlob(item.id, "download"); |
|
|
|
previewText.value = truncatePreviewText(await blob.text()); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
async function downloadFile(item: DirectoryItem) { |
|
|
|
if (item.kind !== "file") return; |
|
|
|
openMenuId.value = null; |
|
|
|
const blob = await api.fileBlob(item.id, "download"); |
|
|
|
const url = URL.createObjectURL(blob); |
|
|
|
const link = document.createElement("a"); |
|
|
|
@ -252,24 +371,51 @@ async function downloadFile(item: DirectoryItem) { |
|
|
|
|
|
|
|
async function reconcileFile(item: DirectoryItem) { |
|
|
|
if (item.kind !== "file") return; |
|
|
|
openMenuId.value = null; |
|
|
|
await api.reconcileFile(item.id); |
|
|
|
toast.value = "Reconcile queued."; |
|
|
|
await loadJobs(); |
|
|
|
} |
|
|
|
|
|
|
|
function selectItem(item: DirectoryItem) { |
|
|
|
selectedItem.value = item; |
|
|
|
} |
|
|
|
|
|
|
|
function toggleItemMenu(item: DirectoryItem) { |
|
|
|
selectedItem.value = item; |
|
|
|
openMenuId.value = item.id; |
|
|
|
} |
|
|
|
|
|
|
|
function openItemFromMenu(item: DirectoryItem) { |
|
|
|
openMenuId.value = null; |
|
|
|
if (item.kind === "directory") { |
|
|
|
openDirectory(item); |
|
|
|
} else { |
|
|
|
void downloadFile(item).catch(showError); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function openDetailDrawer() { |
|
|
|
openMenuId.value = null; |
|
|
|
isDetailDrawerOpen.value = true; |
|
|
|
} |
|
|
|
|
|
|
|
async function loadRecycleBin() { |
|
|
|
const result = await api.listRecycleBin(); |
|
|
|
recycleItems.value = result.items.map((item) => ({ ...item, kind: "file" as const, replica_ready_count: null, replica_desired_count: null, replica_missing_count: null })); |
|
|
|
recyclePage.value = 1; |
|
|
|
} |
|
|
|
|
|
|
|
async function restoreFile(item: DirectoryItem) { |
|
|
|
await api.restoreFile(item.id); |
|
|
|
toast.value = "File restored."; |
|
|
|
await loadRecycleBin(); |
|
|
|
} |
|
|
|
|
|
|
|
async function loadStorage() { |
|
|
|
const result = await api.listBackends(); |
|
|
|
backends.value = result.items; |
|
|
|
backendPage.value = 1; |
|
|
|
} |
|
|
|
|
|
|
|
async function checkBackend(backend: BackendSummary) { |
|
|
|
@ -279,8 +425,100 @@ async function checkBackend(backend: BackendSummary) { |
|
|
|
} |
|
|
|
|
|
|
|
async function runAllBackendChecks() { |
|
|
|
toast.value = "Backend checks queued."; |
|
|
|
await Promise.all(backends.value.map(checkBackend)); |
|
|
|
const result = await api.enqueueHealthChecks(); |
|
|
|
const count = "enqueued_jobs" in (result as Record<string, unknown>) ? (result as { enqueued_jobs: number }).enqueued_jobs : backends.value.length; |
|
|
|
toast.value = `Queued ${count} backend health check job${count === 1 ? "" : "s"}.`; |
|
|
|
await loadJobs(); |
|
|
|
} |
|
|
|
|
|
|
|
function resetBackendCreateForm(type: BackendType = "s3") { |
|
|
|
backendType.value = type; |
|
|
|
backendName.value = type === "local_directory" ? "local-archive" : type === "webdav" ? "openlist-main" : "s3-main"; |
|
|
|
backendStabilityClass.value = type === "local_directory" ? "local" : "stable"; |
|
|
|
backendReadPriority.value = 80; |
|
|
|
backendWritePriority.value = 80; |
|
|
|
backendConfigText.value = CONFIG_TEMPLATES[type]; |
|
|
|
backendFormError.value = ""; |
|
|
|
} |
|
|
|
|
|
|
|
function handleBackendTypeChange(event: Event) { |
|
|
|
resetBackendCreateForm((event.target as HTMLSelectElement).value as BackendType); |
|
|
|
} |
|
|
|
|
|
|
|
async function createBackend() { |
|
|
|
backendFormError.value = ""; |
|
|
|
try { |
|
|
|
const config = parseJsonObject(backendConfigText.value); |
|
|
|
await api.createBackend({ |
|
|
|
name: backendName.value, |
|
|
|
type: backendType.value, |
|
|
|
stability_class: backendStabilityClass.value, |
|
|
|
read_priority: backendReadPriority.value, |
|
|
|
write_priority: backendWritePriority.value, |
|
|
|
config, |
|
|
|
}); |
|
|
|
isCreateBackendOpen.value = false; |
|
|
|
toast.value = "Backend created."; |
|
|
|
await loadStorage(); |
|
|
|
} catch (error) { |
|
|
|
backendFormError.value = error instanceof Error ? error.message : "Backend creation failed."; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function openCreateBackend() { |
|
|
|
resetBackendCreateForm(); |
|
|
|
isCreateBackendOpen.value = true; |
|
|
|
} |
|
|
|
|
|
|
|
function openEditBackend(backend: BackendSummary) { |
|
|
|
editingBackend.value = backend; |
|
|
|
editName.value = backend.name; |
|
|
|
editStabilityClass.value = backend.stability_class as StabilityClass; |
|
|
|
editReadPriority.value = backend.read_priority; |
|
|
|
editWritePriority.value = backend.write_priority; |
|
|
|
editConfigText.value = JSON.stringify(backend.config ?? {}, null, 2); |
|
|
|
editSecretText.value = "{}"; |
|
|
|
editError.value = ""; |
|
|
|
} |
|
|
|
|
|
|
|
async function saveBackend() { |
|
|
|
if (!editingBackend.value) return; |
|
|
|
editError.value = ""; |
|
|
|
try { |
|
|
|
const publicConfig = parseJsonObject(editConfigText.value); |
|
|
|
const secretConfig = parseJsonObject(editSecretText.value); |
|
|
|
await api.updateBackend(editingBackend.value.id, { |
|
|
|
name: editName.value, |
|
|
|
stability_class: editStabilityClass.value, |
|
|
|
read_priority: editReadPriority.value, |
|
|
|
write_priority: editWritePriority.value, |
|
|
|
config: { ...publicConfig, ...secretConfig }, |
|
|
|
}); |
|
|
|
editingBackend.value = null; |
|
|
|
toast.value = "Backend updated."; |
|
|
|
await loadStorage(); |
|
|
|
} catch (error) { |
|
|
|
editError.value = error instanceof Error ? error.message : "Backend update failed."; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
async function toggleBackendEnabled(backend: BackendSummary) { |
|
|
|
if (backend.is_enabled) { |
|
|
|
await api.disableBackend(backend.id); |
|
|
|
toast.value = "Backend disabled."; |
|
|
|
} else { |
|
|
|
await api.enableBackend(backend.id); |
|
|
|
toast.value = "Backend enabled."; |
|
|
|
} |
|
|
|
await loadStorage(); |
|
|
|
} |
|
|
|
|
|
|
|
function parseJsonObject(value: string): Record<string, unknown> { |
|
|
|
const parsed = JSON.parse(value || "{}") as unknown; |
|
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { |
|
|
|
throw new Error("Config must be a JSON object."); |
|
|
|
} |
|
|
|
return parsed as Record<string, unknown>; |
|
|
|
} |
|
|
|
|
|
|
|
async function loadPolicies() { |
|
|
|
@ -306,7 +544,9 @@ async function savePolicy() { |
|
|
|
async function loadJobs() { |
|
|
|
const result = await api.listJobs(); |
|
|
|
jobs.value = result.items; |
|
|
|
selectedJob.value = jobs.value[0] ?? null; |
|
|
|
if (!selectedJob.value || !jobs.value.some((job) => job.id === selectedJob.value?.id)) { |
|
|
|
selectedJob.value = jobs.value[0] ?? null; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
async function retryJob(job: JobSummary) { |
|
|
|
@ -338,6 +578,21 @@ function setPageSize(value: Event) { |
|
|
|
page.value = 1; |
|
|
|
} |
|
|
|
|
|
|
|
function setRecyclePageSize(value: Event) { |
|
|
|
recyclePageSize.value = Number((value.target as HTMLSelectElement).value); |
|
|
|
recyclePage.value = 1; |
|
|
|
} |
|
|
|
|
|
|
|
function setBackendPageSize(value: Event) { |
|
|
|
backendPageSize.value = Number((value.target as HTMLSelectElement).value); |
|
|
|
backendPage.value = 1; |
|
|
|
} |
|
|
|
|
|
|
|
function setJobsPageSize(value: Event) { |
|
|
|
jobsPageSize.value = Number((value.target as HTMLSelectElement).value); |
|
|
|
jobsPage.value = 1; |
|
|
|
} |
|
|
|
|
|
|
|
function formatReplicaRatio(item: DirectoryItem) { |
|
|
|
if (item.kind !== "file") return "--"; |
|
|
|
return `${item.replica_ready_count ?? 0}/${item.replica_desired_count ?? 0}`; |
|
|
|
@ -367,7 +622,34 @@ function isImage(mime: string | null) { |
|
|
|
} |
|
|
|
|
|
|
|
function isText(mime: string | null, name: string) { |
|
|
|
return Boolean(mime?.startsWith("text/") || name.endsWith(".md") || name.endsWith(".json") || name.endsWith(".csv")); |
|
|
|
return Boolean( |
|
|
|
mime?.startsWith("text/") || |
|
|
|
["application/json", "application/xml", "application/yaml"].includes(mime ?? "") || |
|
|
|
[".md", ".txt", ".json", ".yaml", ".yml", ".csv", ".log"].some((suffix) => name.toLowerCase().endsWith(suffix)), |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
function truncatePreviewText(value: string) { |
|
|
|
const maxLength = 16_000; |
|
|
|
return value.length > maxLength ? `${value.slice(0, maxLength)}\n\n... preview truncated ...` : value; |
|
|
|
} |
|
|
|
|
|
|
|
function formatReplicaVerification(value: string | null) { |
|
|
|
return value ? `Verified ${formatDate(value)}` : "Not verified yet"; |
|
|
|
} |
|
|
|
|
|
|
|
function formatBackendConfig(config: Record<string, unknown> | null) { |
|
|
|
if (!config) return "Configured"; |
|
|
|
if (typeof config.base_path === "string") return config.base_path; |
|
|
|
if (typeof config.bucket === "string") { |
|
|
|
const prefix = typeof config.prefix === "string" && config.prefix ? `/${config.prefix}` : ""; |
|
|
|
return `${config.bucket}${prefix}`; |
|
|
|
} |
|
|
|
if (typeof config.endpoint_url === "string") { |
|
|
|
const rootPath = typeof config.root_path === "string" ? config.root_path : "iron"; |
|
|
|
return `${config.endpoint_url}/${rootPath}`; |
|
|
|
} |
|
|
|
return "Configured"; |
|
|
|
} |
|
|
|
|
|
|
|
function getPaginationItems(current: number, total: number): Array<number | "ellipsis"> { |
|
|
|
@ -427,11 +709,11 @@ function getPaginationItems(current: number, total: number): Array<number | "ell |
|
|
|
<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> |
|
|
|
<a :class="route.name === 'files' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/files')"><span class="nav-symbol nav-symbol-files" />Files</a> |
|
|
|
<a :class="route.name === 'recycle' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/recycle-bin')"><span class="nav-symbol nav-symbol-recycle" />Deleted files</a> |
|
|
|
<a :class="route.name === 'storage' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/storage')"><span class="nav-symbol nav-symbol-storage" />Storage</a> |
|
|
|
<a :class="route.name === 'policies' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/policies')"><span class="nav-symbol nav-symbol-policies" />Policies</a> |
|
|
|
<a :class="route.name === 'jobs' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/jobs')"><span class="nav-symbol nav-symbol-jobs" />Jobs</a> |
|
|
|
</nav> |
|
|
|
<div class="sidebar-footer">Iron Web UI<br />Personal cloud drive</div> |
|
|
|
</aside> |
|
|
|
@ -452,8 +734,8 @@ function getPaginationItems(current: number, total: number): Array<number | "ell |
|
|
|
</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="filled" color-role="secondary" @click="openDialog('create')"><span class="button-symbol button-symbol-add" />New</OcButton> |
|
|
|
<OcButton appearance="outline" @click="fileInput?.click()"><span class="button-symbol button-symbol-upload" />Upload</OcButton> |
|
|
|
<OcButton appearance="outline" @click="loadDirectory">Refresh</OcButton> |
|
|
|
<OcButton appearance="outline" @click="viewMode = viewMode === 'list' ? 'grid' : 'list'">{{ viewMode === "list" ? "Grid" : "List" }}</OcButton> |
|
|
|
<select v-model="sortBy" class="compact-sort-select" aria-label="Sort current directory"> |
|
|
|
@ -475,7 +757,7 @@ function getPaginationItems(current: number, total: number): Array<number | "ell |
|
|
|
:class="selectedItem?.id === item.id ? 'drive-item active' : 'drive-item'" |
|
|
|
role="button" |
|
|
|
tabindex="0" |
|
|
|
@click="selectedItem = item" |
|
|
|
@click="selectItem(item)" |
|
|
|
@dblclick="item.kind === 'directory' ? openDirectory(item) : undefined" |
|
|
|
> |
|
|
|
<div class="drive-item-main"> |
|
|
|
@ -484,21 +766,22 @@ function getPaginationItems(current: number, total: number): Array<number | "ell |
|
|
|
</div> |
|
|
|
<span>{{ formatDate(item.updated_at) }}</span> |
|
|
|
<span>{{ formatBytes(item.size_bytes) }}</span> |
|
|
|
<span :class="replicaClass(item)">{{ formatReplicaRatio(item) }}</span> |
|
|
|
<span v-if="viewMode === 'list' || item.kind === 'file'" :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> |
|
|
|
<button class="row-action-button" type="button" @click="toggleItemMenu(item)">More</button> |
|
|
|
<div v-if="openMenuId === item.id" class="menu-popover" role="menu"> |
|
|
|
<button class="menu-item" type="button" @click="openItemFromMenu(item)">{{ item.kind === "directory" ? "Open" : "Download" }}</button> |
|
|
|
<button v-if="item.kind === 'file'" class="menu-item" type="button" @click="reconcileFile(item)">Reconcile</button> |
|
|
|
<button class="menu-item" type="button" @click="openDialog('rename', item)">Rename</button> |
|
|
|
<button class="menu-item" type="button" @click="openDialog('move', item)">Move</button> |
|
|
|
<button v-if="item.kind === 'file'" class="menu-item menu-item-danger" type="button" @click="openDialog('delete', item)">Delete</button> |
|
|
|
</div> |
|
|
|
</span> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<div class="pagination-bar"> |
|
|
|
<span>{{ filteredItems.length }} items</span> |
|
|
|
<span class="pagination-summary">{{ filteredItems.length }} items</span> |
|
|
|
<div class="pagination-controls"> |
|
|
|
<button class="pagination-page-button" type="button" :disabled="page === 1" @click="page = Math.max(1, page - 1)">‹</button> |
|
|
|
<span class="pagination-page-list"> |
|
|
|
@ -522,25 +805,89 @@ function getPaginationItems(current: number, total: number): Array<number | "ell |
|
|
|
<template v-else> |
|
|
|
<div class="section-heading"><span class="eyebrow">Inspector</span><h3>{{ selectedItem.name }}</h3></div> |
|
|
|
<img v-if="previewUrl && isImage(selectedItem.mime_type)" class="preview-frame" :src="previewUrl" alt="" /> |
|
|
|
<iframe v-else-if="previewUrl && selectedItem.mime_type === 'application/pdf'" class="preview-frame" :src="previewUrl" title="PDF preview" /> |
|
|
|
<video v-else-if="previewUrl && selectedItem.mime_type?.startsWith('video/')" class="preview-frame" :src="previewUrl" controls preload="metadata" /> |
|
|
|
<pre v-else-if="previewText" class="preview-frame text-preview-frame">{{ previewText }}</pre> |
|
|
|
<div v-else-if="selectedItem.kind === 'file'" class="preview-fallback">No inline preview</div> |
|
|
|
<div v-else-if="selectedItem.kind === 'file'" class="preview-fallback"><strong>No inline preview</strong><p>Download this file to open it in another app.</p></div> |
|
|
|
<div v-if="selectedItem.kind === 'file'" class="inspector-storage-summary"> |
|
|
|
<div><span>Replicas</span><strong>{{ formatReplicaRatio(selectedItem) }}</strong></div> |
|
|
|
<span :class="replicaClass(selectedItem)">{{ selectedItem.replica_missing_count ? "Needs sync" : "Ready" }}</span> |
|
|
|
</div> |
|
|
|
<OcButton v-if="selectedItem.kind === 'file'" class="inspector-info-button" appearance="outline" @click="isDetailDrawerOpen = true">More Info</OcButton> |
|
|
|
<OcButton v-if="selectedItem.kind === 'file'" class="inspector-info-button" appearance="outline" @click="openDetailDrawer">More Info</OcButton> |
|
|
|
</template> |
|
|
|
</aside> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div v-if="route.name === 'recycle'" class="page-frame files-content-shell"> |
|
|
|
<div class="section-heading"><span>Deleted files</span><h2>Recycle Bin</h2><div class="inline-metrics"><span>{{ recycleItems.length }} items</span><span>{{ formatBytes(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 class="section-heading"><span>Deleted files</span><h2>Recycle Bin</h2><div class="inline-metrics"><span>{{ recycleItems.length }} items</span><span>{{ formatBytes(recycleTotalBytes) }}</span></div></div> |
|
|
|
<div class="list-surface"> |
|
|
|
<div class="list-header recycle-list-header"><span>Name</span><span>Deleted</span><span>Size</span><span>Actions</span></div> |
|
|
|
<div v-if="!recycleItems.length" class="empty-row">Recycle bin is empty.</div> |
|
|
|
<div v-for="item in pagedRecycleItems" :key="item.id" class="drive-item recycle-row" role="button" tabindex="0"> |
|
|
|
<div class="drive-item-main"><span :class="iconClass(item)">{{ iconLabel(item) }}</span><strong>{{ item.name }}</strong></div> |
|
|
|
<span>{{ formatDate(item.deleted_at ?? item.updated_at) }}</span> |
|
|
|
<span>{{ formatBytes(item.size_bytes) }}</span> |
|
|
|
<OcButton appearance="outline" size="small" @click="restoreFile(item)">Restore</OcButton> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<div class="pagination-bar"> |
|
|
|
<span class="pagination-summary">{{ recycleItems.length }} items</span> |
|
|
|
<div class="pagination-controls"> |
|
|
|
<button class="pagination-page-button" type="button" :disabled="recyclePage === 1" @click="recyclePage = Math.max(1, recyclePage - 1)">‹</button> |
|
|
|
<span class="pagination-page-list"> |
|
|
|
<template v-for="(item, index) in recyclePageItems" :key="`recycle-${item}-${index}`"> |
|
|
|
<span v-if="item === 'ellipsis'" class="pagination-ellipsis">...</span> |
|
|
|
<button v-else :class="item === recyclePage ? 'pagination-page-button active' : 'pagination-page-button'" type="button" @click="recyclePage = item">{{ item }}</button> |
|
|
|
</template> |
|
|
|
</span> |
|
|
|
<button class="pagination-page-button" type="button" :disabled="recyclePage === recycleTotalPages" @click="recyclePage = Math.min(recycleTotalPages, recyclePage + 1)">›</button> |
|
|
|
<select class="page-size-select" :value="recyclePageSize" @change="setRecyclePageSize"> |
|
|
|
<option :value="8">8 / page</option> |
|
|
|
<option :value="16">16 / page</option> |
|
|
|
<option :value="32">32 / page</option> |
|
|
|
</select> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div v-if="route.name === 'storage'" class="page-frame files-content-shell"> |
|
|
|
<div class="page-toolbar"><div class="section-heading"><span>Storage</span><h2>Backends</h2><div class="inline-metrics"><span>{{ backends.length }} backends</span><span>{{ 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 class="page-toolbar"> |
|
|
|
<div class="section-heading"><span>Storage</span><h2>Backends</h2><div class="inline-metrics"><span>{{ backends.length }} backends</span><span>{{ healthyBackendCount }} healthy</span><span>{{ enabledBackendCount }} enabled</span></div></div> |
|
|
|
<div class="command-bar"><OcButton appearance="filled" color-role="secondary" @click="openCreateBackend">Add Backend</OcButton><OcButton appearance="outline" @click="runAllBackendChecks">Run All Checks</OcButton></div> |
|
|
|
</div> |
|
|
|
<div class="list-surface"> |
|
|
|
<div class="list-header storage-list-header"><span>Name</span><span>Class</span><span>Status</span><span>Updated</span><span>Actions</span></div> |
|
|
|
<div v-if="!backends.length" class="empty-row">No storage backends configured.</div> |
|
|
|
<div v-for="backend in pagedBackends" :key="backend.id" class="drive-item storage-row"> |
|
|
|
<div><strong>{{ backend.name }}</strong><br /><span class="eyebrow">{{ formatBackendType(backend.type) }} · {{ formatBackendConfig(backend.config) }}{{ backend.secret_fields.length ? ` · ${backend.secret_fields.length} secrets` : "" }}</span></div> |
|
|
|
<span>{{ titleCase(backend.stability_class) }}</span> |
|
|
|
<span :class="backend.last_health_status === 'healthy' ? 'status-chip status-chip-success' : 'status-chip'">{{ backend.is_enabled ? titleCase(backend.last_health_status ?? "unknown") : "Disabled" }}</span> |
|
|
|
<span>{{ formatDate(backend.last_health_checked_at ?? backend.updated_at) }}</span> |
|
|
|
<span class="row-actions storage-row-actions"> |
|
|
|
<OcButton appearance="outline" size="small" @click="checkBackend(backend)">Check</OcButton> |
|
|
|
<OcButton appearance="outline" size="small" @click="openEditBackend(backend)">Edit</OcButton> |
|
|
|
<OcButton appearance="outline" size="small" @click="toggleBackendEnabled(backend)">{{ backend.is_enabled ? "Disable" : "Enable" }}</OcButton> |
|
|
|
</span> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<div class="pagination-bar"> |
|
|
|
<span class="pagination-summary">{{ backends.length }} backends</span> |
|
|
|
<div class="pagination-controls"> |
|
|
|
<button class="pagination-page-button" type="button" :disabled="backendPage === 1" @click="backendPage = Math.max(1, backendPage - 1)">‹</button> |
|
|
|
<span class="pagination-page-list"> |
|
|
|
<template v-for="(item, index) in backendPageItems" :key="`backend-${item}-${index}`"> |
|
|
|
<span v-if="item === 'ellipsis'" class="pagination-ellipsis">...</span> |
|
|
|
<button v-else :class="item === backendPage ? 'pagination-page-button active' : 'pagination-page-button'" type="button" @click="backendPage = item">{{ item }}</button> |
|
|
|
</template> |
|
|
|
</span> |
|
|
|
<button class="pagination-page-button" type="button" :disabled="backendPage === backendTotalPages" @click="backendPage = Math.min(backendTotalPages, backendPage + 1)">›</button> |
|
|
|
<select class="page-size-select" :value="backendPageSize" @change="setBackendPageSize"> |
|
|
|
<option :value="8">8 / page</option> |
|
|
|
<option :value="16">16 / page</option> |
|
|
|
<option :value="32">32 / page</option> |
|
|
|
</select> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div v-if="route.name === 'policies'" class="page-frame files-content-shell"> |
|
|
|
@ -550,7 +897,55 @@ function getPaginationItems(current: number, total: number): Array<number | "ell |
|
|
|
</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> |
|
|
|
<section class="files-content-shell"> |
|
|
|
<div class="page-toolbar"> |
|
|
|
<div class="section-heading"><span>Jobs</span><h2>Background activity</h2><div class="inline-metrics"><span>{{ pendingJobsCount }} pending</span><span>{{ completedJobsCount }} completed</span><span>{{ failedJobsCount }} failed</span></div></div> |
|
|
|
<div class="command-bar"><OcButton appearance="outline" @click="runPendingJobs">Run Pending</OcButton><OcButton appearance="outline" @click="enqueueHealthChecks">Enqueue Checks</OcButton><OcButton appearance="filled" color-role="secondary" @click="enqueueFullReconcile">Full Reconcile</OcButton></div> |
|
|
|
</div> |
|
|
|
<div class="list-surface"> |
|
|
|
<div class="list-header jobs-list-header"><span>Job</span><span>Status</span><span>Attempts</span><span>Actions</span></div> |
|
|
|
<div v-if="!jobs.length" class="empty-row">No background jobs recorded yet.</div> |
|
|
|
<div v-for="job in pagedJobs" :key="job.id" :class="selectedJob?.id === job.id ? 'drive-item jobs-row jobs-table-row active' : 'drive-item jobs-row jobs-table-row'" role="button" tabindex="0" @click="selectedJob = job"> |
|
|
|
<div><strong>{{ titleCase(job.kind) }}</strong><br /><span class="eyebrow">Run after {{ formatDate(job.run_after) }}</span><br /><span v-if="job.last_error" class="error-text">{{ job.last_error }}</span></div> |
|
|
|
<span class="status-chip">{{ titleCase(job.status) }}</span> |
|
|
|
<span>{{ job.attempt_count }}/{{ job.max_attempts }}</span> |
|
|
|
<OcButton appearance="outline" size="small" @click.stop="retryJob(job)">Retry</OcButton> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<div class="pagination-bar"> |
|
|
|
<span class="pagination-summary">{{ jobs.length }} jobs</span> |
|
|
|
<div class="pagination-controls"> |
|
|
|
<button class="pagination-page-button" type="button" :disabled="jobsPage === 1" @click="jobsPage = Math.max(1, jobsPage - 1)">‹</button> |
|
|
|
<span class="pagination-page-list"> |
|
|
|
<template v-for="(item, index) in jobsPageItems" :key="`jobs-${item}-${index}`"> |
|
|
|
<span v-if="item === 'ellipsis'" class="pagination-ellipsis">...</span> |
|
|
|
<button v-else :class="item === jobsPage ? 'pagination-page-button active' : 'pagination-page-button'" type="button" @click="jobsPage = item">{{ item }}</button> |
|
|
|
</template> |
|
|
|
</span> |
|
|
|
<button class="pagination-page-button" type="button" :disabled="jobsPage === jobsTotalPages" @click="jobsPage = Math.min(jobsTotalPages, jobsPage + 1)">›</button> |
|
|
|
<select class="page-size-select" :value="jobsPageSize" @change="setJobsPageSize"> |
|
|
|
<option :value="8">8 / page</option> |
|
|
|
<option :value="16">16 / page</option> |
|
|
|
<option :value="32">32 / page</option> |
|
|
|
</select> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</section> |
|
|
|
<aside class="detail-panel jobs-detail-panel"> |
|
|
|
<div v-if="!selectedJob" class="detail-empty"><span class="eyebrow">Inspector</span><h3>Select a job</h3></div> |
|
|
|
<template v-else> |
|
|
|
<div class="section-heading"><span>Job Detail</span><h3>{{ titleCase(selectedJob.kind) }}</h3></div> |
|
|
|
<div class="inspector-storage-summary"><div><span>Status</span><strong>{{ titleCase(selectedJob.status) }}</strong></div><span class="status-chip">{{ selectedJob.attempt_count }}/{{ selectedJob.max_attempts }}</span></div> |
|
|
|
<div class="meta-list inspector-card jobs-detail-meta"> |
|
|
|
<div><span>Run after</span><strong>{{ formatDate(selectedJob.run_after) }}</strong></div> |
|
|
|
<div><span>Created</span><strong>{{ formatDate(selectedJob.created_at) }}</strong></div> |
|
|
|
<div><span>Updated</span><strong>{{ formatDate(selectedJob.updated_at) }}</strong></div> |
|
|
|
<div><span>ID</span><strong class="mono-text">{{ selectedJob.id }}</strong></div> |
|
|
|
</div> |
|
|
|
<div v-if="selectedJob.last_error" class="inspector-card replica-warning-card"><strong>Last error</strong><p>{{ selectedJob.last_error }}</p></div> |
|
|
|
<section class="inspector-section"><span class="eyebrow">Payload</span><pre class="payload-preview">{{ selectedJob.payload_json || '{}' }}</pre></section> |
|
|
|
</template> |
|
|
|
</aside> |
|
|
|
</div> |
|
|
|
</section> |
|
|
|
</div> |
|
|
|
@ -561,10 +956,11 @@ function getPaginationItems(current: number, total: number): Array<number | "ell |
|
|
|
<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> |
|
|
|
<div><span class="eyebrow">Action</span><h3>{{ dialogMode === "delete" ? "Delete file" : dialogMode === "create" ? "New folder" : dialogMode === "move" ? "Move item" : "Rename" }}</h3></div> |
|
|
|
<OcButton appearance="outline" @click="dialogMode = null">Close</OcButton> |
|
|
|
</div> |
|
|
|
<p v-if="dialogMode === 'delete'">Move <strong>{{ selectedItem?.name }}</strong> to the recycle bin?</p> |
|
|
|
<label v-else-if="dialogMode === 'move'"><span>Target folder</span><select v-model="moveTargetId" class="dialog-select"><option v-for="target in moveTargets" :key="target.id" :value="target.id">{{ target.name }} ({{ target.id }})</option></select></label> |
|
|
|
<label v-else><span>Name</span><input v-model="fieldValue" class="dialog-input" required /></label> |
|
|
|
<div class="dialog-actions"> |
|
|
|
<OcButton appearance="outline" @click="dialogMode = null">Cancel</OcButton> |
|
|
|
@ -574,15 +970,39 @@ function getPaginationItems(current: number, total: number): Array<number | "ell |
|
|
|
</form> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div v-if="isCreateBackendOpen" class="dialog-backdrop"> |
|
|
|
<form class="dialog-card dialog-card-wide" role="dialog" aria-modal="true" @submit.prevent="createBackend"> |
|
|
|
<div class="dialog-header"> |
|
|
|
<div><span class="eyebrow">Storage</span><h3>Add storage backend</h3></div> |
|
|
|
<OcButton appearance="outline" @click="isCreateBackendOpen = false">Close</OcButton> |
|
|
|
</div> |
|
|
|
<label><span>Type</span><select class="dialog-select" :value="backendType" @change="handleBackendTypeChange"><option value="local_directory">Local directory</option><option value="s3">S3</option><option value="webdav">OpenList / WebDAV</option></select></label> |
|
|
|
<label><span>Name</span><input v-model="backendName" class="dialog-input" required /></label> |
|
|
|
<label><span>Policy class</span><select v-model="backendStabilityClass" class="dialog-select"><option value="local">Local</option><option value="stable">Stable</option><option value="opportunistic">Opportunistic</option></select></label> |
|
|
|
<div class="split-fields"><label><span>Read priority</span><input v-model.number="backendReadPriority" class="dialog-input" type="number" /></label><label><span>Write priority</span><input v-model.number="backendWritePriority" class="dialog-input" type="number" /></label></div> |
|
|
|
<label><span>Config JSON</span><textarea v-model="backendConfigText" class="dialog-textarea" spellcheck="false" /></label> |
|
|
|
<p v-if="backendFormError" class="error-text">{{ backendFormError }}</p> |
|
|
|
<div class="dialog-actions"> |
|
|
|
<OcButton appearance="outline" @click="isCreateBackendOpen = false">Cancel</OcButton> |
|
|
|
<OcButton appearance="filled" color-role="secondary" submit="submit">Create</OcButton> |
|
|
|
</div> |
|
|
|
</form> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div v-if="editingBackend" class="dialog-backdrop"> |
|
|
|
<form class="dialog-card" role="dialog" aria-modal="true" @submit.prevent="editingBackend = null"> |
|
|
|
<form class="dialog-card dialog-card-wide" role="dialog" aria-modal="true" @submit.prevent="saveBackend"> |
|
|
|
<div class="dialog-header"> |
|
|
|
<div><span class="eyebrow">Storage</span><h3>Edit storage backend</h3></div> |
|
|
|
<OcButton appearance="outline" @click="editingBackend = null">Close</OcButton> |
|
|
|
</div> |
|
|
|
<label><span>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> |
|
|
|
<label><span>Type</span><input class="dialog-input" :value="formatBackendType(editingBackend.type)" disabled /></label> |
|
|
|
<label><span>Name</span><input v-model="editName" class="dialog-input" required /></label> |
|
|
|
<label><span>Policy class</span><select v-model="editStabilityClass" class="dialog-select"><option value="local">Local</option><option value="stable">Stable</option><option value="opportunistic">Opportunistic</option></select></label> |
|
|
|
<div class="split-fields"><label><span>Read priority</span><input v-model.number="editReadPriority" class="dialog-input" type="number" /></label><label><span>Write priority</span><input v-model.number="editWritePriority" class="dialog-input" type="number" /></label></div> |
|
|
|
<label><span>Public config JSON</span><textarea v-model="editConfigText" class="dialog-textarea" spellcheck="false" /></label> |
|
|
|
<label><span>Secret override JSON</span><textarea v-model="editSecretText" class="dialog-textarea dialog-textarea-small" spellcheck="false" /></label> |
|
|
|
<div class="inspector-card"><strong>Stored secrets</strong><p>{{ editingBackend.secret_fields.length ? editingBackend.secret_fields.join(", ") : "None" }}</p></div> |
|
|
|
<p v-if="editError" class="error-text">{{ editError }}</p> |
|
|
|
<div class="dialog-actions"> |
|
|
|
<OcButton appearance="outline" @click="editingBackend = null">Cancel</OcButton> |
|
|
|
<OcButton appearance="filled" color-role="secondary" submit="submit">Save</OcButton> |
|
|
|
@ -593,7 +1013,44 @@ function getPaginationItems(current: number, total: number): Array<number | "ell |
|
|
|
<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> |
|
|
|
<div class="detail-drawer-content"> |
|
|
|
<section class="inspector-section"> |
|
|
|
<span class="eyebrow">Placement</span> |
|
|
|
<div class="inspector-card storage-summary-card"> |
|
|
|
<div><span>Class</span><strong>{{ titleCase(fileDetail?.placement_file_class ?? "unknown") }}</strong></div> |
|
|
|
<div><span>Replicas</span><strong>{{ formatReplicaRatio(selectedItem) }}</strong></div> |
|
|
|
<div><span>Missing</span><strong>{{ fileDetail?.missing_backend_ids.length ?? selectedItem.replica_missing_count ?? 0 }}</strong></div> |
|
|
|
</div> |
|
|
|
<div v-if="fileDetail?.missing_backend_ids.length" class="inspector-card replica-warning-card"><strong>Replica gap detected</strong><p>Missing {{ fileDetail.missing_backend_ids.length }} desired backend{{ fileDetail.missing_backend_ids.length === 1 ? "" : "s" }}.</p></div> |
|
|
|
</section> |
|
|
|
<section class="inspector-section"> |
|
|
|
<span class="eyebrow">Desired Backends</span> |
|
|
|
<div class="inspector-card replica-list"> |
|
|
|
<div v-for="backend in fileDetail?.desired_backends ?? []" :key="backend.id" class="replica-row"> |
|
|
|
<div><strong>{{ backend.name }}</strong><span class="item-muted">{{ formatBackendType(backend.type) }} · {{ titleCase(backend.stability_class) }}</span></div> |
|
|
|
<span :class="fileDetail?.missing_backend_ids.includes(backend.id) ? 'status-chip' : 'status-chip status-chip-success'">{{ fileDetail?.missing_backend_ids.includes(backend.id) ? "Missing" : "Ready" }}</span> |
|
|
|
</div> |
|
|
|
<p v-if="!fileDetail?.desired_backends.length" class="item-muted">No placement targets selected.</p> |
|
|
|
</div> |
|
|
|
</section> |
|
|
|
<section class="inspector-section"> |
|
|
|
<span class="eyebrow">Replicas</span> |
|
|
|
<div class="inspector-card replica-list"> |
|
|
|
<div v-for="replica in fileDetail?.replicas ?? []" :key="replica.id" class="replica-row"> |
|
|
|
<div><strong>{{ replica.backend_name }}</strong><span class="item-muted">{{ formatBytes(replica.size_bytes) }} · {{ formatReplicaVerification(replica.last_verified_at) }}</span></div> |
|
|
|
<span :class="replica.status === 'ready' ? 'status-chip status-chip-success' : 'status-chip'">{{ titleCase(replica.status) }}</span> |
|
|
|
</div> |
|
|
|
<p v-if="!fileDetail?.replicas.length" class="item-muted">No recorded replicas for this file yet.</p> |
|
|
|
</div> |
|
|
|
</section> |
|
|
|
<section class="inspector-section"> |
|
|
|
<span class="eyebrow">Preview Artifacts</span> |
|
|
|
<div class="inspector-card"> |
|
|
|
<div v-for="artifact in fileDetail?.preview_artifacts ?? []" :key="artifact.id" class="artifact-row"><strong>{{ titleCase(artifact.artifact_type) }}</strong><span>{{ titleCase(artifact.status) }}</span></div> |
|
|
|
<p v-if="!fileDetail?.preview_artifacts.length" class="item-muted">No generated artifacts yet.</p> |
|
|
|
</div> |
|
|
|
</section> |
|
|
|
</div> |
|
|
|
</aside> |
|
|
|
</div> |
|
|
|
</template> |
|
|
|
|