Browse Source

fix: restore Vue web UI interactions

codex/vue-opencloud-design-system
Rain Mark 2 months ago
parent
commit
5f1148fe83
  1. 2
      app/web/dist/assets/app.css
  2. 42
      app/web/dist/assets/app.js
  3. 0
      app/web/dist/assets/icons/add-fill.svg
  4. 0
      app/web/dist/assets/icons/checkbox-blank-fill.svg
  5. 0
      app/web/dist/assets/icons/database-fill.svg
  6. 0
      app/web/dist/assets/icons/delete-bin-fill.svg
  7. 0
      app/web/dist/assets/icons/folder-fill.svg
  8. 0
      app/web/dist/assets/icons/menu-fill.svg
  9. 0
      app/web/dist/assets/icons/upload-fill.svg
  10. 0
      frontend/public/assets/icons/add-fill.svg
  11. 0
      frontend/public/assets/icons/checkbox-blank-fill.svg
  12. 0
      frontend/public/assets/icons/database-fill.svg
  13. 0
      frontend/public/assets/icons/delete-bin-fill.svg
  14. 0
      frontend/public/assets/icons/folder-fill.svg
  15. 0
      frontend/public/assets/icons/menu-fill.svg
  16. 0
      frontend/public/assets/icons/upload-fill.svg
  17. 527
      frontend/src/App.vue
  18. 2
      frontend/src/main.ts
  19. 225
      frontend/src/styles/opencloud-app.css
  20. 78
      scripts/ui_playwright_smoke.py

2
app/web/dist/assets/app.css

File diff suppressed because one or more lines are too long

42
app/web/dist/assets/app.js

File diff suppressed because one or more lines are too long

0
app/web/dist/icons/add-fill.svg → app/web/dist/assets/icons/add-fill.svg

Before

Width:  |  Height:  |  Size: 139 B

After

Width:  |  Height:  |  Size: 139 B

0
app/web/dist/icons/checkbox-blank-fill.svg → app/web/dist/assets/icons/checkbox-blank-fill.svg

Before

Width:  |  Height:  |  Size: 217 B

After

Width:  |  Height:  |  Size: 217 B

0
app/web/dist/icons/database-fill.svg → app/web/dist/assets/icons/database-fill.svg

Before

Width:  |  Height:  |  Size: 340 B

After

Width:  |  Height:  |  Size: 340 B

0
app/web/dist/icons/delete-bin-fill.svg → app/web/dist/assets/icons/delete-bin-fill.svg

Before

Width:  |  Height:  |  Size: 286 B

After

Width:  |  Height:  |  Size: 286 B

0
app/web/dist/icons/folder-fill.svg → app/web/dist/assets/icons/folder-fill.svg

Before

Width:  |  Height:  |  Size: 241 B

After

Width:  |  Height:  |  Size: 241 B

0
app/web/dist/icons/menu-fill.svg → app/web/dist/assets/icons/menu-fill.svg

Before

Width:  |  Height:  |  Size: 146 B

After

Width:  |  Height:  |  Size: 146 B

0
app/web/dist/icons/upload-fill.svg → app/web/dist/assets/icons/upload-fill.svg

Before

Width:  |  Height:  |  Size: 147 B

After

Width:  |  Height:  |  Size: 147 B

0
frontend/public/icons/add-fill.svg → frontend/public/assets/icons/add-fill.svg

Before

Width:  |  Height:  |  Size: 139 B

After

Width:  |  Height:  |  Size: 139 B

0
frontend/public/icons/checkbox-blank-fill.svg → frontend/public/assets/icons/checkbox-blank-fill.svg

Before

Width:  |  Height:  |  Size: 217 B

After

Width:  |  Height:  |  Size: 217 B

0
frontend/public/icons/database-fill.svg → frontend/public/assets/icons/database-fill.svg

Before

Width:  |  Height:  |  Size: 340 B

After

Width:  |  Height:  |  Size: 340 B

0
frontend/public/icons/delete-bin-fill.svg → frontend/public/assets/icons/delete-bin-fill.svg

Before

Width:  |  Height:  |  Size: 286 B

After

Width:  |  Height:  |  Size: 286 B

0
frontend/public/icons/folder-fill.svg → frontend/public/assets/icons/folder-fill.svg

Before

Width:  |  Height:  |  Size: 241 B

After

Width:  |  Height:  |  Size: 241 B

0
frontend/public/icons/menu-fill.svg → frontend/public/assets/icons/menu-fill.svg

Before

Width:  |  Height:  |  Size: 146 B

After

Width:  |  Height:  |  Size: 146 B

0
frontend/public/icons/upload-fill.svg → frontend/public/assets/icons/upload-fill.svg

Before

Width:  |  Height:  |  Size: 147 B

After

Width:  |  Height:  |  Size: 147 B

527
frontend/src/App.vue

@ -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,8 +544,10 @@ async function savePolicy() {
async function loadJobs() {
const result = await api.listJobs();
jobs.value = result.items;
if (!selectedJob.value || !jobs.value.some((job) => job.id === selectedJob.value?.id)) {
selectedJob.value = jobs.value[0] ?? null;
}
}
async function retryJob(job: JobSummary) {
await api.retryJob(job.id);
@ -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>

2
frontend/src/main.ts

@ -10,7 +10,7 @@ const app = createApp(App);
app.use(createPinia());
app.use(DesignSystem, {
iconUrlPrefix: "/icons/",
iconUrlPrefix: "/assets/",
language: {
initGettext: true,
defaultLanguage: "en",

225
frontend/src/styles/opencloud-app.css

@ -165,6 +165,56 @@ textarea {
background: var(--oc-role-surface);
}
.nav-link .oc-icon,
.nav-symbol {
width: 18px;
height: 18px;
color: var(--oc-role-chrome);
}
.nav-symbol,
.button-symbol {
display: inline-block;
flex: 0 0 auto;
background: currentColor;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
}
.button-symbol {
width: 16px;
height: 16px;
}
.nav-symbol-files {
mask-image: url("/assets/icons/folder-fill.svg");
}
.nav-symbol-recycle {
mask-image: url("/assets/icons/delete-bin-fill.svg");
}
.nav-symbol-storage {
mask-image: url("/assets/icons/database-fill.svg");
}
.nav-symbol-policies {
mask-image: url("/assets/icons/checkbox-blank-fill.svg");
}
.nav-symbol-jobs {
mask-image: url("/assets/icons/menu-fill.svg");
}
.button-symbol-add {
mask-image: url("/assets/icons/add-fill.svg");
}
.button-symbol-upload {
mask-image: url("/assets/icons/upload-fill.svg");
}
.nav-icon {
width: 18px;
height: 18px;
@ -311,7 +361,7 @@ textarea {
.storage-list-header,
.storage-row {
grid-template-columns: minmax(0, 1fr) 92px 92px 132px 190px;
grid-template-columns: minmax(0, 1fr) 92px 92px 132px 250px;
}
.list-header {
@ -350,7 +400,7 @@ textarea {
}
.drive-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
}
.grid-surface .list-header {
@ -358,15 +408,30 @@ textarea {
}
.drive-grid .drive-item {
min-height: 142px;
grid-template-columns: minmax(0, 1fr);
align-content: space-between;
min-height: 112px;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: 1fr auto;
align-content: stretch;
padding: 0.78rem;
}
.drive-grid .drive-item-main {
grid-column: 1 / -1;
padding-right: 4.25rem;
}
.drive-grid .drive-item > span:nth-of-type(1) {
.drive-grid .drive-item > span:nth-of-type(1),
.drive-grid .drive-item > span:nth-of-type(2) {
display: none;
}
.drive-grid .replica-ratio {
grid-column: 1;
grid-row: 2;
justify-self: start;
align-self: end;
}
.drive-grid .row-actions {
position: absolute;
top: 0.58rem;
@ -437,9 +502,22 @@ textarea {
min-width: 58px;
}
.storage-row-actions {
gap: 0.42rem;
}
.row-action-button {
min-height: 30px;
padding: 0.24rem 0.48rem;
border: 1px solid color-mix(in srgb, var(--oc-role-outline) 46%, transparent);
border-radius: 5px;
color: var(--oc-role-on-surface);
background: var(--oc-role-surface);
cursor: pointer;
}
.row-action-button:hover {
background: var(--oc-role-surface-container);
}
.menu-popover {
@ -447,19 +525,28 @@ textarea {
top: calc(100% + 6px);
right: 0;
z-index: 80;
min-width: 150px;
padding: 0.25rem;
min-width: 156px;
padding: 0.35rem 0;
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);
box-shadow: 0 14px 28px rgba(25, 28, 29, 0.12);
}
.menu-item {
justify-content: flex-start;
width: 100%;
min-height: 34px;
padding: 0.4rem 0.8rem;
border: 0;
text-align: left;
color: var(--oc-role-on-surface);
background: transparent;
cursor: pointer;
}
.menu-item:hover {
background: var(--oc-role-surface-container);
}
.menu-item-danger {
@ -491,14 +578,24 @@ textarea {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
padding-top: 0.8rem;
}
.pagination-summary {
min-width: max-content;
white-space: nowrap;
color: var(--oc-role-on-surface-variant);
}
.pagination-controls {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
margin-left: auto;
}
.pagination-page-list {
@ -620,6 +717,12 @@ textarea {
border-radius: 5px;
}
.dialog-card-wide {
width: min(680px, calc(100% - 2rem));
max-height: calc(100vh - 2rem);
overflow: auto;
}
.detail-drawer-header,
.dialog-header,
.dialog-actions {
@ -654,6 +757,12 @@ textarea {
max-width: 440px;
}
.split-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.dialog-actions {
justify-content: flex-end;
}
@ -672,6 +781,7 @@ textarea {
background: #b42318;
}
.dialog-card label,
.dialog-form label,
.login-form label {
display: grid;
@ -685,6 +795,73 @@ textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.dialog-textarea-small {
min-height: 88px;
}
.item-muted {
display: block;
color: var(--oc-role-on-surface-variant);
font-size: 0.78rem;
}
.inspector-section {
display: grid;
gap: 0.55rem;
}
.storage-summary-card {
grid-template-columns: repeat(3, 1fr);
}
.storage-summary-card > div {
display: grid;
gap: 0.25rem;
}
.storage-summary-card,
.replica-row,
.artifact-row,
.meta-list > div {
display: grid;
gap: 0.25rem;
}
.replica-list,
.meta-list {
gap: 0;
padding: 0;
overflow: hidden;
border-style: solid;
}
.replica-row,
.artifact-row,
.meta-list > div {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
padding: 0.68rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--oc-role-outline-variant) 46%, transparent);
}
.replica-row:last-child,
.artifact-row:last-child,
.meta-list > div:last-child {
border-bottom: 0;
}
.replica-warning-card {
border-style: solid;
color: var(--oc-role-error);
background: color-mix(in srgb, var(--oc-role-error) 6%, var(--oc-role-surface));
}
.mono-text {
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.78rem;
}
.notice-text {
color: #008b63;
}
@ -764,4 +941,30 @@ textarea {
border-left: 0;
border-top: 1px solid var(--oc-role-outline-variant);
}
.list-header {
display: none;
}
.drive-list .drive-item {
grid-template-columns: minmax(0, 1fr) 48px 58px;
gap: 0.5rem;
}
.drive-list .drive-item > span:nth-of-type(1),
.drive-list .drive-item > span:nth-of-type(2) {
display: none;
}
.storage-row,
.jobs-row,
.recycle-row {
grid-template-columns: minmax(0, 1fr);
}
.storage-row > span,
.jobs-row > span,
.recycle-row > span {
justify-self: start;
}
}

78
scripts/ui_playwright_smoke.py

@ -128,6 +128,8 @@ def run_browser_flow(base_url: str) -> None:
def track_response(response) -> None: # type: ignore[no-untyped-def]
if "/api/" in response.url and response.status >= 400:
failures.append(f"{response.status} {response.url}")
if ("/assets/" in response.url or "/icons/" in response.url) and response.status >= 400:
failures.append(f"static asset {response.status} {response.url}")
def track_request_failed(request) -> None: # type: ignore[no-untyped-def]
if "/api/" in request.url:
@ -146,6 +148,7 @@ def run_browser_flow(base_url: str) -> None:
page.get_by_role("button", name="Sign in").click()
page.wait_for_url(f"{base_url}/app/files")
page.locator(".files-header").filter(has_text="Personal").first.wait_for()
assert_sidebar_icons_render(page, failures)
page.screenshot(path=str(OUTPUT_DIR / "files-initial.png"), full_page=True)
page.locator(".panel-toolbar").get_by_role("button", name="New").click()
@ -189,10 +192,16 @@ def run_browser_flow(base_url: str) -> None:
page.locator("img.preview-frame").wait_for(timeout=15000)
page.locator(".drive-item", has_text="playwright-preview.png").locator(".replica-ratio", has_text="1/1").wait_for()
page.screenshot(path=str(OUTPUT_DIR / "files-preview-selected.png"), full_page=True)
page.locator(".drive-item", has_text="playwright-preview.png").get_by_role("button", name="More").click()
page.get_by_role("menu").wait_for()
page.locator(".detail-panel").get_by_role("button", name="More Info").click()
if page.get_by_role("menu").count():
failures.append("file action menu should close when opening More Info drawer")
page.locator(".detail-drawer", has_text="Replica Info").wait_for()
page.locator(".detail-drawer", has_text="Desired Backends").wait_for()
page.locator(".detail-drawer", has_text="Replicas").wait_for()
page.locator(".detail-drawer", has_text="Placement").wait_for()
page.locator(".detail-drawer", has_text="Preview Artifacts").wait_for()
if page.locator(".detail-drawer").get_by_role("button", name="Reconcile File").count():
failures.append("detail drawer should be read-only; found Reconcile File action")
page.screenshot(path=str(OUTPUT_DIR / "files-storage-selected.png"), full_page=True)
@ -203,6 +212,7 @@ def run_browser_flow(base_url: str) -> None:
page.get_by_role("menu").get_by_role("button", name="Reconcile").wait_for()
page.screenshot(path=str(OUTPUT_DIR / "files-grid-menu.png"), full_page=True)
page.keyboard.press("Escape")
page.get_by_role("menu").wait_for(state="detached")
page.locator(".drive-grid .drive-item", has_text="playwright-preview.png").get_by_role("button", name="More").click()
page.get_by_role("menu").get_by_role("button", name="Delete").click()
delete_button = page.get_by_role("dialog").get_by_role("button", name="Confirm")
@ -220,21 +230,45 @@ def run_browser_flow(base_url: str) -> None:
page.get_by_role("dialog").get_by_role("button", name="Cancel").click()
page.get_by_role("dialog").wait_for(state="detached")
seed_file_pagination(page)
page.locator(".files-command-bar").get_by_role("button", name="Refresh").click()
page.locator(".drive-item strong", has_text="bulk-folder-00").wait_for(timeout=15000)
page.locator(".pagination-page-button", has_text="2").wait_for()
assert_pagination_controls(page, "Files", failures)
page.screenshot(path=str(OUTPUT_DIR / "files-pagination.png"), full_page=True)
page.locator(".pagination-page-button", has_text="2").click()
page.locator(".drive-item strong", has_text="bulk-folder-08").wait_for()
page.locator(".page-size-select").select_option("16")
page.locator(".drive-item strong", has_text="playwright-folder").wait_for()
page.screenshot(path=str(OUTPUT_DIR / "files-pagination-page-size.png"), full_page=True)
page.get_by_label("Search").fill("playwright")
page.locator(".drive-item strong", has_text="playwright-folder").first.wait_for()
page.screenshot(path=str(OUTPUT_DIR / "files-search.png"), full_page=True)
page.goto(f"{base_url}/app/recycle-bin", wait_until="networkidle")
assert_pagination_controls(page, "Recycle bin", failures)
page.screenshot(path=str(OUTPUT_DIR / "recycle-bin-page.png"), full_page=True)
page.goto(f"{base_url}/app/storage", wait_until="networkidle")
assert_pagination_controls(page, "Storage", failures)
page.get_by_role("button", name="Run All Checks").click()
page.locator(".toast-item").first.wait_for()
page.get_by_role("button", name="Edit").first.click()
page.get_by_role("dialog").get_by_text("Stored secrets").wait_for()
page.get_by_role("dialog").get_by_label("Name").fill("local-default-renamed")
page.get_by_role("dialog").get_by_role("button", name="Save").click()
page.get_by_role("dialog").wait_for(state="detached")
page.locator(".drive-item strong", has_text="local-default-renamed").wait_for()
page.get_by_role("button", name="Edit").first.click()
page.get_by_role("dialog").get_by_text("Stored secrets").wait_for()
page.screenshot(path=str(OUTPUT_DIR / "storage-edit-dialog.png"), full_page=True)
page.get_by_role("dialog").get_by_role("button", name="Cancel").click()
page.screenshot(path=str(OUTPUT_DIR / "storage-page.png"), full_page=True)
page.get_by_role("button", name="Add Backend").click()
page.get_by_role("dialog").get_by_label("Config JSON").wait_for()
page.screenshot(path=str(OUTPUT_DIR / "storage-create-dialog.png"), full_page=True)
page.get_by_role("dialog").get_by_role("button", name="Cancel").click()
page.goto(f"{base_url}/app/policies", wait_until="networkidle")
page.get_by_role("button", name="Save Policy").wait_for()
@ -243,6 +277,7 @@ def run_browser_flow(base_url: str) -> None:
page.screenshot(path=str(OUTPUT_DIR / "policies-page.png"), full_page=True)
page.goto(f"{base_url}/app/jobs", wait_until="networkidle")
assert_pagination_controls(page, "Jobs", failures)
page.locator(".jobs-table-row").first.click()
page.locator(".jobs-detail-panel", has_text="Job Detail").wait_for()
page.locator(".payload-preview").wait_for()
@ -292,5 +327,48 @@ def create_temp_preview_file() -> Path:
return target
def assert_sidebar_icons_render(page, failures: list[str]) -> None: # type: ignore[no-untyped-def]
missing = page.locator(".main-nav .nav-symbol").evaluate_all(
"""(nodes) => nodes
.map((node) => getComputedStyle(node))
.filter((style) => style.webkitMaskImage === 'none' && style.maskImage === 'none')
.length"""
)
if missing:
failures.append(f"{missing} sidebar icon masks did not render")
def seed_file_pagination(page) -> None: # type: ignore[no-untyped-def]
page.evaluate(
"""async () => {
const token = window.localStorage.getItem("iron_access_token");
for (let index = 0; index < 10; index += 1) {
const name = `bulk-folder-${String(index).padStart(2, "0")}`;
const response = await fetch("/api/directories", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ parent_id: "dir_root", name }),
});
if (!response.ok && response.status !== 409) {
throw new Error(`Failed to seed ${name}: ${response.status}`);
}
}
}"""
)
def assert_pagination_controls(page, label: str, failures: list[str]) -> None: # type: ignore[no-untyped-def]
if not page.locator(".pagination-bar").count():
failures.append(f"{label} pagination bar missing")
return
if not page.locator(".pagination-page-button").count():
failures.append(f"{label} page buttons missing")
if not page.locator(".page-size-select").count():
failures.append(f"{label} page size selector missing")
if __name__ == "__main__":
raise SystemExit(main())

Loading…
Cancel
Save