Compare commits

...

5 Commits

  1. 2
      AGENTS.md
  2. 4
      CONTRIBUTING.md
  3. 4
      README.md
  4. BIN
      app/web/dist/assets/OpenCloud500-Regular.woff2
  5. BIN
      app/web/dist/assets/OpenCloud750-Bold.woff2
  6. 2
      app/web/dist/assets/app.css
  7. 106
      app/web/dist/assets/app.js
  8. 1
      app/web/dist/assets/icons/add-fill.svg
  9. 1
      app/web/dist/assets/icons/checkbox-blank-fill.svg
  10. 1
      app/web/dist/assets/icons/database-fill.svg
  11. 1
      app/web/dist/assets/icons/delete-bin-fill.svg
  12. 1
      app/web/dist/assets/icons/folder-fill.svg
  13. 1
      app/web/dist/assets/icons/menu-fill.svg
  14. 1
      app/web/dist/assets/icons/upload-fill.svg
  15. BIN
      app/web/dist/assets/inter.woff2
  16. 716
      app/web/dist/assets/module.js
  17. 1
      app/web/dist/assets/native.js
  18. 8
      docs/architecture.md
  19. BIN
      docs/assets/iron-demo.gif
  20. 15
      docs/development.md
  21. 2
      frontend/index.html
  22. 1
      frontend/public/assets/icons/add-fill.svg
  23. 1
      frontend/public/assets/icons/checkbox-blank-fill.svg
  24. 1
      frontend/public/assets/icons/database-fill.svg
  25. 1
      frontend/public/assets/icons/delete-bin-fill.svg
  26. 1
      frontend/public/assets/icons/folder-fill.svg
  27. 1
      frontend/public/assets/icons/menu-fill.svg
  28. 1
      frontend/public/assets/icons/upload-fill.svg
  29. 1072
      frontend/src/App.vue
  30. 16
      frontend/src/app/App.tsx
  31. 71
      frontend/src/app/router.tsx
  32. BIN
      frontend/src/assets/fonts/OpenCloud500-Regular.woff2
  33. BIN
      frontend/src/assets/fonts/OpenCloud750-Bold.woff2
  34. BIN
      frontend/src/assets/fonts/inter.woff2
  35. 32
      frontend/src/components/Dialog.tsx
  36. 55
      frontend/src/components/Menu.tsx
  37. 117
      frontend/src/components/PaginationControls.tsx
  38. 170
      frontend/src/components/layout/AppLayout.tsx
  39. 29
      frontend/src/features/auth/RequireAuth.tsx
  40. 50
      frontend/src/features/toast/ToastProvider.tsx
  41. 43
      frontend/src/main.ts
  42. 10
      frontend/src/main.tsx
  43. 1050
      frontend/src/routes/FilesPage.tsx
  44. 215
      frontend/src/routes/JobsPage.tsx
  45. 79
      frontend/src/routes/LoginPage.tsx
  46. 279
      frontend/src/routes/PoliciesPage.tsx
  47. 97
      frontend/src/routes/RecycleBinPage.tsx
  48. 518
      frontend/src/routes/StoragePage.tsx
  49. 3502
      frontend/src/styles/app.css
  50. 1035
      frontend/src/styles/opencloud-app.css
  51. 9
      frontend/src/vite-env.d.ts
  52. 6
      frontend/vite.config.ts
  53. 1740
      package-lock.json
  54. 13
      package.json
  55. 7
      scripts/generate_readme_demo.py
  56. 107
      scripts/ui_playwright_smoke.py
  57. 4
      tests/test_web_routes.py

2
AGENTS.md

@ -7,7 +7,7 @@ Guidance for coding agents and automated contributors working in this repository
Iron is a self-hosted personal cloud drive gateway with:
- FastAPI backend under `app/`
- Vite + React + TypeScript frontend under `frontend/`
- Vite + Vue + TypeScript frontend under `frontend/`
- built frontend assets served from `app/web/dist/`
- Alembic migrations under `alembic/`
- tests under `tests/`

4
CONTRIBUTING.md

@ -48,8 +48,8 @@ Review screenshots in `output/playwright/` before considering UI work complete.
## Code Style
- Backend code lives in `app/` and uses async FastAPI + SQLAlchemy patterns.
- Frontend code lives in `frontend/` and uses React, React Router, and
TanStack Query.
- Frontend code lives in `frontend/` and uses Vue 3, TypeScript, Pinia, and
the OpenCloud design system.
- Keep browser file preview/download flows authenticated through the API client.
- Prefer clear names and small modules over clever abstractions.

4
README.md

@ -17,7 +17,7 @@ coverage, and more real-world usage.
- Directory and file operations: browse, create folder, rename, move, delete,
restore, download, and preview
- `tus` upload flow with local object persistence
- Desktop-first React Web app at `/app`
- Desktop-first Vue Web app at `/app`, using the OpenCloud design system
- Storage backend management for local directories, S3 storage, and
OpenList/WebDAV runtime paths
- Placement policies, reconcile jobs, health checks, and retryable background jobs
@ -126,7 +126,7 @@ Playwright screenshots and runtime artifacts are written to `output/playwright/`
```text
app/ FastAPI application, services, repositories, schemas
alembic/ Database migrations
frontend/ Vite + React + TypeScript source
frontend/ Vite + Vue + TypeScript source
app/web/dist/ Built frontend assets served by FastAPI
scripts/ Local automation and E2E scripts
deploy/ OpenList compose, Iron env templates, and systemd examples

BIN
app/web/dist/assets/OpenCloud500-Regular.woff2

Binary file not shown.

BIN
app/web/dist/assets/OpenCloud750-Bold.woff2

Binary file not shown.

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

File diff suppressed because one or more lines are too long

106
app/web/dist/assets/app.js

File diff suppressed because one or more lines are too long

1
app/web/dist/assets/icons/add-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"/></svg>

After

Width:  |  Height:  |  Size: 139 B

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

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M4 3H20C20.5523 3 21 3.44772 21 4V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3Z"/></svg>

After

Width:  |  Height:  |  Size: 217 B

1
app/web/dist/assets/icons/database-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M11 7V4C11 3.44772 11.4477 3 12 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V8C2 7.44772 2.44772 7 3 7H11ZM5 16V18H10V16H5ZM14 16V18H19V16H14ZM14 13V15H19V13H14ZM14 10V12H19V10H14ZM5 13V15H10V13H5Z"/></svg>

After

Width:  |  Height:  |  Size: 340 B

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

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM9 11V17H11V11H9ZM13 11V17H15V11H13ZM9 4V6H15V4H9Z"/></svg>

After

Width:  |  Height:  |  Size: 286 B

1
app/web/dist/assets/icons/folder-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5Z"/></svg>

After

Width:  |  Height:  |  Size: 241 B

1
app/web/dist/assets/icons/menu-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M3 4H21V6H3V4ZM3 11H21V13H3V11ZM3 18H21V20H3V18Z"/></svg>

After

Width:  |  Height:  |  Size: 146 B

1
app/web/dist/assets/icons/upload-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M3 19H21V21H3V19ZM13 10V18H11V10H4L12 2L20 10H13Z"/></svg>

After

Width:  |  Height:  |  Size: 147 B

BIN
app/web/dist/assets/inter.woff2

Binary file not shown.

716
app/web/dist/assets/module.js

File diff suppressed because one or more lines are too long

1
app/web/dist/assets/native.js

File diff suppressed because one or more lines are too long

8
docs/architecture.md

@ -1,6 +1,6 @@
# Architecture
Iron is an async Python modular monolith with a React single-page Web app.
Iron is an async Python modular monolith with a Vue single-page Web app.
## System Overview
@ -42,10 +42,10 @@ Important packages:
Core stack:
- Vite
- React
- Vue 3
- TypeScript
- React Router
- TanStack Query
- Pinia
- OpenCloud design system
Source lives in `frontend/`. Built assets are emitted into `app/web/dist/` and
served by FastAPI at `/app`.

BIN
docs/assets/iron-demo.gif

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 KiB

After

Width:  |  Height:  |  Size: 328 KiB

15
docs/development.md

@ -85,16 +85,21 @@ Required workflow:
The E2E flow currently covers:
- login
- files page render
- files page render with OpenCloud-style navigation icons
- create folder
- enter a folder and return to the parent directory
- upload text file
- authenticated file download
- upload image file
- authenticated inline image preview
- file storage replica inspector
- search
- uploads, recycle bin, storage batch checks, backend edit dialog, policies save, and jobs page render
- browser `/api` 4xx/5xx failures during the flow
- file storage replica inspector and read-only replica detail drawer
- More menu actions, Escape dismissal, and click-outside dismissal
- delete confirmation button readability
- file search, file pagination, and page-size changes
- recycle bin, storage batch checks, backend edit/create dialogs, policies save, and jobs detail render
- desktop and mobile screenshots for visual review
- browser `/api`, `/assets`, and `/icons` 4xx/5xx failures during the flow
- label/value spacing checks for narrow inspector panels
## Secrets

2
frontend/index.html

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Iron</title>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/main.ts"></script>
</head>
<body>
<div id="root"></div>

1
frontend/public/assets/icons/add-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"/></svg>

After

Width:  |  Height:  |  Size: 139 B

1
frontend/public/assets/icons/checkbox-blank-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M4 3H20C20.5523 3 21 3.44772 21 4V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3Z"/></svg>

After

Width:  |  Height:  |  Size: 217 B

1
frontend/public/assets/icons/database-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M11 7V4C11 3.44772 11.4477 3 12 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V8C2 7.44772 2.44772 7 3 7H11ZM5 16V18H10V16H5ZM14 16V18H19V16H14ZM14 13V15H19V13H14ZM14 10V12H19V10H14ZM5 13V15H10V13H5Z"/></svg>

After

Width:  |  Height:  |  Size: 340 B

1
frontend/public/assets/icons/delete-bin-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM9 11V17H11V11H9ZM13 11V17H15V11H13ZM9 4V6H15V4H9Z"/></svg>

After

Width:  |  Height:  |  Size: 286 B

1
frontend/public/assets/icons/folder-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5Z"/></svg>

After

Width:  |  Height:  |  Size: 241 B

1
frontend/public/assets/icons/menu-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M3 4H21V6H3V4ZM3 11H21V13H3V11ZM3 18H21V20H3V18Z"/></svg>

After

Width:  |  Height:  |  Size: 146 B

1
frontend/public/assets/icons/upload-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M3 19H21V21H3V19ZM13 10V18H11V10H4L12 2L20 10H13Z"/></svg>

After

Width:  |  Height:  |  Size: 147 B

1072
frontend/src/App.vue

File diff suppressed because it is too large

16
frontend/src/app/App.tsx

@ -1,16 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "react-router-dom";
import { router } from "./router";
import { ToastProvider } from "../features/toast/ToastProvider";
const queryClient = new QueryClient();
export function App() {
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>
<RouterProvider router={router} />
</ToastProvider>
</QueryClientProvider>
);
}

71
frontend/src/app/router.tsx

@ -1,71 +0,0 @@
import {
Navigate,
Outlet,
createBrowserRouter,
useLocation,
} from "react-router-dom";
import { AppLayout } from "../components/layout/AppLayout";
import { RequireAuth } from "../features/auth/RequireAuth";
import { LoginPage } from "../routes/LoginPage";
import { FilesPage } from "../routes/FilesPage";
import { RecycleBinPage } from "../routes/RecycleBinPage";
import { StoragePage } from "../routes/StoragePage";
import { PoliciesPage } from "../routes/PoliciesPage";
import { JobsPage } from "../routes/JobsPage";
function AppShell() {
const location = useLocation();
return (
<RequireAuth>
<AppLayout currentPath={location.pathname}>
<Outlet />
</AppLayout>
</RequireAuth>
);
}
export const router = createBrowserRouter([
{
path: "/app/login",
element: <LoginPage />,
},
{
path: "/app",
element: <AppShell />,
children: [
{
index: true,
element: <Navigate to="/app/files" replace />,
},
{
path: "files",
element: <FilesPage />,
},
{
path: "files/:directoryId",
element: <FilesPage />,
},
{
path: "recycle-bin",
element: <RecycleBinPage />,
},
{
path: "storage",
element: <StoragePage />,
},
{
path: "policies",
element: <PoliciesPage />,
},
{
path: "jobs",
element: <JobsPage />,
},
],
},
{
path: "*",
element: <Navigate to="/app" replace />,
},
]);

BIN
frontend/src/assets/fonts/OpenCloud500-Regular.woff2

Binary file not shown.

BIN
frontend/src/assets/fonts/OpenCloud750-Bold.woff2

Binary file not shown.

BIN
frontend/src/assets/fonts/inter.woff2

Binary file not shown.

32
frontend/src/components/Dialog.tsx

@ -1,32 +0,0 @@
type DialogProps = {
title: string;
description?: string;
children: React.ReactNode;
onClose: () => void;
};
export function Dialog({ title, description, children, onClose }: DialogProps) {
return (
<div className="dialog-backdrop" role="presentation" onClick={onClose}>
<div
className="dialog-card"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(event) => event.stopPropagation()}
>
<div className="dialog-header">
<div>
<span className="eyebrow">Action</span>
<h3>{title}</h3>
{description ? <p className="item-muted">{description}</p> : null}
</div>
<button className="ghost-button" type="button" onClick={onClose}>
Close
</button>
</div>
{children}
</div>
</div>
);
}

55
frontend/src/components/Menu.tsx

@ -1,55 +0,0 @@
import { useEffect, useRef } from "react";
type MenuItem = {
label: string;
tone?: "default" | "danger";
onSelect: () => void;
};
type MenuProps = {
items: MenuItem[];
onClose: () => void;
};
export function Menu({ items, onClose }: MenuProps) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
function handlePointer(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
onClose();
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
window.addEventListener("mousedown", handlePointer);
window.addEventListener("keydown", handleEscape);
return () => {
window.removeEventListener("mousedown", handlePointer);
window.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
return (
<div ref={ref} className="menu-popover" role="menu">
{items.map((item) => (
<button
key={item.label}
type="button"
className={item.tone === "danger" ? "menu-item danger" : "menu-item"}
onClick={() => {
item.onSelect();
onClose();
}}
>
{item.label}
</button>
))}
</div>
);
}

117
frontend/src/components/PaginationControls.tsx

@ -1,117 +0,0 @@
type PaginationControlsProps = {
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
pageSizeOptions?: number[];
label: string;
};
export function PaginationControls({
page,
pageSize,
totalItems,
totalPages,
onPageChange,
onPageSizeChange,
pageSizeOptions = [8, 16, 32, 64],
label,
}: PaginationControlsProps) {
const safeTotalPages = Math.max(1, totalPages);
const pageItems = getPaginationItems(page, safeTotalPages);
return (
<div className="pagination-bar pagination-bar-full" aria-label={label}>
<span className="pagination-copy">
{totalItems} items
</span>
<div className="pagination-controls" role="group" aria-label={`${label} controls`}>
<button
className="pagination-page-button pagination-arrow-button"
type="button"
aria-label="Previous page"
disabled={page === 1}
onClick={() => onPageChange(Math.max(1, page - 1))}
>
</button>
<div className="pagination-page-list" aria-label={`Page ${page} of ${safeTotalPages}`}>
{pageItems.map((item, index) =>
item === "ellipsis" ? (
<span key={`ellipsis-${index}`} className="pagination-ellipsis" aria-hidden="true">
...
</span>
) : (
<button
key={item}
className={item === page ? "pagination-page-button active" : "pagination-page-button"}
type="button"
aria-current={item === page ? "page" : undefined}
onClick={() => onPageChange(item)}
>
{item}
</button>
),
)}
</div>
<button
className="pagination-page-button pagination-arrow-button"
type="button"
aria-label="Next page"
disabled={page === safeTotalPages}
onClick={() => onPageChange(Math.min(safeTotalPages, page + 1))}
>
</button>
<label className="pagination-size-control">
<select
className="input-select pagination-select"
aria-label="Rows per page"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option} / page
</option>
))}
</select>
</label>
</div>
</div>
);
}
function getPaginationItems(page: number, totalPages: number): Array<number | "ellipsis"> {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
const pages = new Set<number>([1, totalPages, page - 1, page, page + 1]);
if (page <= 4) {
[2, 3, 4, 5].forEach((item) => pages.add(item));
}
if (page >= totalPages - 3) {
[totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1].forEach((item) => pages.add(item));
}
const sortedPages = Array.from(pages)
.filter((item) => item >= 1 && item <= totalPages)
.sort((first, second) => first - second);
return sortedPages.flatMap((item, index) => {
const previous = sortedPages[index - 1];
if (!previous) {
return [item];
}
if (item - previous === 2) {
return [previous + 1, item];
}
if (item - previous > 2) {
return ["ellipsis", item];
}
return [item];
});
}

170
frontend/src/components/layout/AppLayout.tsx

@ -1,170 +0,0 @@
import type { ChangeEvent } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { api } from "../../lib/api";
import { clearAccessToken } from "../../lib/auth";
import { useToast } from "../../features/toast/ToastProvider";
type AppLayoutProps = {
currentPath: string;
children: React.ReactNode;
};
const navItems = [
{ to: "/app/files", label: "Files", icon: "files" },
{ to: "/app/recycle-bin", label: "Deleted files", icon: "trash" },
{ to: "/app/storage", label: "Storage", icon: "storage" },
{ to: "/app/policies", label: "Policies", icon: "policies" },
{ to: "/app/jobs", label: "Jobs", icon: "jobs" },
];
export function AppLayout({ currentPath, children }: AppLayoutProps) {
const navigate = useNavigate();
const location = useLocation();
const { pushToast } = useToast();
const isFilesSearch = location.pathname.startsWith("/app/files");
const topbarSearchValue = isFilesSearch ? new URLSearchParams(location.search).get("q") ?? "" : "";
const meQuery = useQuery({
queryKey: ["auth", "me"],
queryFn: api.me,
retry: false,
});
async function handleLogout() {
try {
await api.logout();
} catch {
// best effort
}
clearAccessToken();
pushToast("Signed out.", "info");
navigate("/app/login", { replace: true });
}
function handleTopbarSearchChange(event: ChangeEvent<HTMLInputElement>) {
if (!isFilesSearch) {
return;
}
const params = new URLSearchParams(location.search);
const value = event.target.value.trimStart();
if (value) {
params.set("q", value);
} else {
params.delete("q");
}
navigate(
{
pathname: location.pathname,
search: params.toString() ? `?${params.toString()}` : "",
},
{ replace: true, state: location.state },
);
}
return (
<div className="app-frame">
<header className="oc-topbar" aria-label="Top bar">
<div className="topbar-brand">
<button className="app-switcher-button" type="button" aria-label="Application menu">
<span />
<span />
<span />
<span />
<span />
<span />
<span />
<span />
<span />
</button>
<div className="brand-lockup" aria-label="Iron Drive">
<span className="brand-symbol">I</span>
<strong>Iron</strong>
</div>
</div>
<div className="topbar-center">
<div className="topbar-search" role="search">
<input
aria-label="Search"
placeholder="Enter search term"
readOnly={!isFilesSearch}
value={topbarSearchValue}
onChange={handleTopbarSearchChange}
/>
<span className="topbar-search-scope">{getPageSearchScope(currentPath)}</span>
<span className="topbar-search-icon" aria-hidden="true" />
</div>
</div>
<div className="topbar-actions">
<button className="user-chip" type="button" onClick={handleLogout}>
<span>{getInitial(meQuery.data?.user.username)}</span>
</button>
</div>
</header>
<div className="app-shell">
<aside className="sidebar">
<div className="brand-block brand-block-compact">
<span className="eyebrow">Personal</span>
<h1>Drive</h1>
</div>
<nav className="main-nav" aria-label="Primary">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => (isActive ? "nav-link active" : "nav-link")}
>
<span className={`nav-icon nav-icon-${item.icon}`} aria-hidden="true" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div className="sidebar-footer">
<span>Iron Web UI</span>
<span>Personal cloud drive</span>
</div>
</aside>
<div className="workspace">
<main className="workspace-content">{children}</main>
</div>
</div>
</div>
);
}
function getInitial(username: string | undefined) {
return (username?.trim().charAt(0) || "I").toUpperCase();
}
function getPageSearchScope(pathname: string) {
if (pathname.startsWith("/app/files")) {
return "All files";
}
return getPageTitle(pathname);
}
function getPageTitle(pathname: string) {
if (pathname.startsWith("/app/files")) {
return "Files";
}
if (pathname.startsWith("/app/recycle-bin")) {
return "Recycle Bin";
}
if (pathname.startsWith("/app/storage")) {
return "Storage";
}
if (pathname.startsWith("/app/policies")) {
return "Policies";
}
if (pathname.startsWith("/app/jobs")) {
return "Jobs";
}
return "Iron";
}

29
frontend/src/features/auth/RequireAuth.tsx

@ -1,29 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { Navigate, useLocation } from "react-router-dom";
import { api, ApiError } from "../../lib/api";
import { getAccessToken } from "../../lib/auth";
export function RequireAuth({ children }: { children: React.ReactNode }) {
const location = useLocation();
const token = getAccessToken();
const query = useQuery({
queryKey: ["auth", "me"],
queryFn: api.me,
enabled: Boolean(token),
retry: false,
});
if (!token) {
return <Navigate to="/app/login" replace state={{ from: `${location.pathname}${location.search}` }} />;
}
if (query.isLoading) {
return <div className="page-loading">Loading your drive</div>;
}
if (query.error instanceof ApiError && query.error.status === 401) {
return <Navigate to="/app/login" replace state={{ from: `${location.pathname}${location.search}` }} />;
}
return <>{children}</>;
}

50
frontend/src/features/toast/ToastProvider.tsx

@ -1,50 +0,0 @@
import { createContext, useCallback, useContext, useMemo, useState } from "react";
type ToastTone = "info" | "success" | "error";
type ToastItem = {
id: string;
title: string;
tone: ToastTone;
};
type ToastContextValue = {
pushToast: (title: string, tone?: ToastTone) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const pushToast = useCallback((title: string, tone: ToastTone = "info") => {
const id = crypto.randomUUID();
setToasts((current) => [...current.slice(-1), { id, title, tone }]);
window.setTimeout(() => {
setToasts((current) => current.filter((item) => item.id !== id));
}, 2200);
}, []);
const value = useMemo(() => ({ pushToast }), [pushToast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-stack" aria-live="polite" aria-atomic="true">
{toasts.map((toast) => (
<div key={toast.id} className={`toast-item toast-${toast.tone}`}>
{toast.title}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within ToastProvider.");
}
return context;
}

43
frontend/src/main.ts

@ -0,0 +1,43 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import DesignSystem from "@opencloud-eu/design-system";
import "@opencloud-eu/design-system/tailwind";
import "@opencloud-eu/design-system/dist/design-system.css";
import "./styles/opencloud-app.css";
import App from "./App.vue";
const app = createApp(App);
app.use(createPinia());
app.use(DesignSystem, {
iconUrlPrefix: "/assets/",
language: {
initGettext: true,
defaultLanguage: "en",
},
tokens: {
roles: {
primary: "#00677f",
onPrimary: "#ffffff",
primaryContainer: "#b7eaff",
secondary: "#20434f",
onSecondary: "#ffffff",
secondaryContainer: "#cfe6f1",
background: "#ffffff",
onBackground: "#191c1d",
surface: "#ffffff",
onSurface: "#191c1d",
surfaceContainer: "#f6f8fa",
surfaceContainerHigh: "#f2f4f5",
surfaceContainerHighest: "#eceef0",
outline: "#70787c",
outlineVariant: "#bfc8cc",
chrome: "#20434f",
onChrome: "#ffffff",
error: "#ba1a1a",
onError: "#ffffff",
},
},
});
app.mount("#root");

10
frontend/src/main.tsx

@ -1,10 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import "./styles/app.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

1050
frontend/src/routes/FilesPage.tsx

File diff suppressed because it is too large

215
frontend/src/routes/JobsPage.tsx

@ -1,215 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { PaginationControls } from "../components/PaginationControls";
import { api, type JobSummary } from "../lib/api";
import { formatDate, titleCase } from "../lib/format";
export function JobsPage() {
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(8);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const jobsQuery = useQuery({
queryKey: ["jobs"],
queryFn: api.listJobs,
});
const refreshJobs = async () => {
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
};
const retryMutation = useMutation({
mutationFn: (jobId: string) => api.retryJob(jobId),
onSuccess: refreshJobs,
});
const runMutation = useMutation({
mutationFn: api.runPendingJobs,
onSuccess: refreshJobs,
});
const healthMutation = useMutation({
mutationFn: api.enqueueHealthChecks,
onSuccess: refreshJobs,
});
const reconcileMutation = useMutation({
mutationFn: api.enqueueFullReconcile,
onSuccess: refreshJobs,
});
const items = jobsQuery.data?.items ?? [];
const pendingCount = items.filter((job) => job.status === "pending").length;
const completedCount = items.filter((job) => job.status === "completed").length;
const failedCount = items.filter((job) => job.status === "failed").length;
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const pagedItems = useMemo(
() => items.slice((page - 1) * pageSize, page * pageSize),
[items, page, pageSize],
);
const selectedJob = items.find((job) => job.id === selectedJobId) ?? null;
useEffect(() => {
if (page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
useEffect(() => {
if (selectedJobId && !items.some((job) => job.id === selectedJobId)) {
setSelectedJobId(null);
}
}, [items, selectedJobId]);
return (
<div className="compact-page-shell compact-page-shell-wide">
<div className="jobs-page-layout">
<section className="panel main-panel dashboard-main-panel">
<div className="panel-toolbar files-toolbar-top">
<div className="files-header">
<span className="eyebrow">Jobs</span>
<div className="directory-title-row">
<h2>Background activity</h2>
</div>
<div className="inline-metrics">
<span>{pendingCount} pending</span>
<span>{completedCount} completed</span>
<span>{failedCount} failed</span>
</div>
</div>
<div className="toolbar-group files-command-bar">
<button className="ghost-button" type="button" onClick={() => runMutation.mutate()}>
Run Pending
</button>
<button className="ghost-button" type="button" onClick={() => healthMutation.mutate()}>
Enqueue Checks
</button>
<button className="primary-button" type="button" onClick={() => reconcileMutation.mutate()}>
Full Reconcile
</button>
</div>
</div>
<div className="jobs-list-shell list-surface">
<div className="list-header jobs-list-header">
<span>Job</span>
<span>Status</span>
<span>Attempts</span>
<span className="list-header-actions">Actions</span>
</div>
<div className="list-table">
{items.length ? (
pagedItems.map((job) => (
<div
key={job.id}
className={selectedJobId === job.id ? "table-row jobs-table-row active" : "table-row jobs-table-row"}
role="button"
tabIndex={0}
onClick={() => setSelectedJobId(job.id)}
onKeyDown={(event) => {
if (event.key === "Enter") {
setSelectedJobId(job.id);
}
}}
>
<div>
<strong>{titleCase(job.kind)}</strong>
<span className="item-muted">Run after {formatDate(job.run_after)}</span>
{job.last_error ? <span className="error-text">{job.last_error}</span> : null}
</div>
<div className="table-meta jobs-row-meta">
<span className="status-chip">{titleCase(job.status)}</span>
<span>
{job.attempt_count}/{job.max_attempts}
</span>
<button
className="ghost-button compact-button"
type="button"
onClick={(event) => {
event.stopPropagation();
retryMutation.mutate(job.id);
}}
>
Retry
</button>
</div>
</div>
))
) : (
<div className="empty-state empty-state-rich">
<strong>No background jobs recorded yet.</strong>
</div>
)}
</div>
</div>
<PaginationControls
label="Jobs pagination"
page={page}
pageSize={pageSize}
totalItems={items.length}
totalPages={totalPages}
onPageChange={setPage}
onPageSizeChange={(nextPageSize) => {
setPageSize(nextPageSize);
setPage(1);
}}
/>
</section>
<aside className="panel detail-panel jobs-detail-panel">
{!selectedJob ? (
<div className="detail-empty">
<span className="eyebrow">Inspector</span>
<h3>Select a job</h3>
</div>
) : (
<>
<div className="section-heading">
<span className="eyebrow">Job Detail</span>
<h3>{titleCase(selectedJob.kind)}</h3>
</div>
<div className="inspector-storage-summary">
<div>
<span>Status</span>
<strong>{titleCase(selectedJob.status)}</strong>
</div>
<span className="status-chip">
{selectedJob.attempt_count}/{selectedJob.max_attempts}
</span>
</div>
<div className="meta-list inspector-card jobs-detail-meta">
<div>
<span>Run after</span>
<strong>{formatDate(selectedJob.run_after)}</strong>
</div>
<div>
<span>Created</span>
<strong>{formatDate(selectedJob.created_at)}</strong>
</div>
<div>
<span>Updated</span>
<strong>{formatDate(selectedJob.updated_at)}</strong>
</div>
<div>
<span>ID</span>
<strong className="mono-text">{selectedJob.id}</strong>
</div>
</div>
{selectedJob.last_error ? (
<div className="inspector-card replica-warning-card">
<strong>Last error</strong>
<p className="item-muted">{selectedJob.last_error}</p>
</div>
) : null}
<section className="inspector-section">
<span className="eyebrow">Payload</span>
<pre className="payload-preview">{formatJobPayload(selectedJob)}</pre>
</section>
</>
)}
</aside>
</div>
</div>
);
}
function formatJobPayload(job: JobSummary) {
try {
return JSON.stringify(JSON.parse(job.payload_json), null, 2);
} catch {
return job.payload_json || "{}";
}
}

79
frontend/src/routes/LoginPage.tsx

@ -1,79 +0,0 @@
import { FormEvent, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { api } from "../lib/api";
import { setAccessToken } from "../lib/auth";
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("changeme-iron");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsSubmitting(true);
setError("");
try {
const result = await api.login(username, password);
setAccessToken(result.access_token);
navigate((location.state as { from?: string } | null)?.from || "/app/files", { replace: true });
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to sign in.");
} finally {
setIsSubmitting(false);
}
}
return (
<div className="login-page">
<section className="login-hero">
<span className="eyebrow">Iron</span>
<h1>One personal drive, under your control.</h1>
<p>
Browse media, upload from the browser, and inspect storage health
without turning your daily file flow into an ops chore.
</p>
<div className="hero-metrics">
<article>
<span className="metric-label">Product target</span>
<strong>Personal drive UI</strong>
</article>
<article>
<span className="metric-label">Runtime model</span>
<strong>Gateway + multi-backend storage</strong>
</article>
</div>
</section>
<section className="login-card">
<div className="section-heading">
<span className="eyebrow">Local Login</span>
<h2>Enter your drive</h2>
</div>
<form className="stack-form" onSubmit={handleSubmit}>
<label>
<span>Username</span>
<input value={username} onChange={(event) => setUsername(event.target.value)} required />
</label>
<label>
<span>Password</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
<button className="primary-button" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Signing in…" : "Sign in"}
</button>
{error ? <p className="error-text">{error}</p> : null}
</form>
</section>
</div>
);
}

279
frontend/src/routes/PoliciesPage.tsx

@ -1,279 +0,0 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type PlacementPolicyPayload, type PlacementPolicySummary } from "../lib/api";
import { useToast } from "../features/toast/ToastProvider";
import { formatBackendType, formatBytes, titleCase } from "../lib/format";
const FILE_CLASSES: PlacementPolicySummary["file_class"][] = ["document", "image", "video", "other"];
const MIME_PRESETS: Record<PlacementPolicySummary["file_class"], string> = {
document: "application/pdf",
image: "image/png",
video: "video/mp4",
other: "application/octet-stream",
};
export function PoliciesPage() {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [selectedClass, setSelectedClass] = useState<PlacementPolicySummary["file_class"]>("document");
const [requireLocal, setRequireLocal] = useState(true);
const [stableCount, setStableCount] = useState(1);
const [opportunisticCount, setOpportunisticCount] = useState(0);
const [preferredBackendIds, setPreferredBackendIds] = useState<string[]>([]);
const [excludedBackendIds, setExcludedBackendIds] = useState<string[]>([]);
const [maxNonLocalSize, setMaxNonLocalSize] = useState("");
const [mimeType, setMimeType] = useState(MIME_PRESETS.document);
const [previewSize, setPreviewSize] = useState("1048576");
const [formError, setFormError] = useState("");
const policiesQuery = useQuery({
queryKey: ["placement-policies"],
queryFn: api.listPlacementPolicies,
});
const backendQuery = useQuery({
queryKey: ["backends"],
queryFn: api.listBackends,
});
const policies = policiesQuery.data?.items ?? [];
const backends = backendQuery.data?.items ?? [];
const selectedPolicy = policies.find((policy) => policy.file_class === selectedClass);
useEffect(() => {
if (!selectedPolicy) {
return;
}
setRequireLocal(selectedPolicy.require_local);
setStableCount(selectedPolicy.stable_replica_count);
setOpportunisticCount(selectedPolicy.opportunistic_replica_count);
setPreferredBackendIds(selectedPolicy.preferred_backend_ids);
setExcludedBackendIds(selectedPolicy.excluded_backend_ids);
setMaxNonLocalSize(
selectedPolicy.max_non_local_size_bytes === null ? "" : String(selectedPolicy.max_non_local_size_bytes),
);
}, [selectedPolicy?.id, selectedPolicy?.updated_at]);
useEffect(() => {
setMimeType(MIME_PRESETS[selectedClass]);
}, [selectedClass]);
const previewSizeBytes = parseOptionalInt(previewSize);
const previewQuery = useQuery({
queryKey: ["placement-preview", mimeType, previewSizeBytes],
queryFn: () => api.previewPlacementDecision(mimeType, previewSizeBytes),
});
const updateMutation = useMutation({
mutationFn: (payload: PlacementPolicyPayload) => api.updatePlacementPolicy(selectedClass, payload),
onSuccess: async (result) => {
setFormError("");
pushToast(
`Policy saved. Full reconcile queued ${result.reconcile_enqueued_jobs} job${result.reconcile_enqueued_jobs === 1 ? "" : "s"}.`,
"success",
);
await queryClient.invalidateQueries({ queryKey: ["placement-policies"] });
await queryClient.invalidateQueries({ queryKey: ["placement-preview"] });
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
},
onError: (error) => {
setFormError(error instanceof Error ? error.message : "Policy update failed.");
pushToast(error instanceof Error ? error.message : "Policy update failed.", "error");
},
});
const localBackendCount = backends.filter((backend) => backend.type === "local_directory" && backend.is_enabled).length;
const stableBackendCount = backends.filter((backend) => backend.stability_class === "stable" && backend.is_enabled).length;
const opportunisticBackendCount = backends.filter(
(backend) => backend.stability_class === "opportunistic" && backend.is_enabled,
).length;
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const maxSize = parseOptionalInt(maxNonLocalSize);
if (maxNonLocalSize.trim() && maxSize === null) {
setFormError("Max non-local size must be a whole number.");
return;
}
updateMutation.mutate({
require_local: requireLocal,
stable_replica_count: Math.max(0, stableCount),
opportunistic_replica_count: Math.max(0, opportunisticCount),
preferred_backend_ids: preferredBackendIds,
excluded_backend_ids: excludedBackendIds,
max_non_local_size_bytes: maxSize,
});
}
function toggleBackend(listName: "preferred" | "excluded", backendId: string) {
if (listName === "preferred") {
setPreferredBackendIds((current) => toggleId(current, backendId));
setExcludedBackendIds((current) => current.filter((id) => id !== backendId));
return;
}
setExcludedBackendIds((current) => toggleId(current, backendId));
setPreferredBackendIds((current) => current.filter((id) => id !== backendId));
}
return (
<div className="compact-page-shell compact-page-shell-wide">
<div className="dashboard-page-grid">
<section className="dashboard-main-column">
<div className="panel-toolbar storage-toolbar">
<div className="files-header">
<span className="eyebrow">Placement</span>
<div className="directory-title-row">
<h2>Replica policies</h2>
</div>
<div className="inline-metrics">
<span>{policies.length} policies</span>
<span>{localBackendCount} local</span>
<span>{stableBackendCount} stable</span>
<span>{opportunisticBackendCount} opportunistic</span>
</div>
</div>
</div>
<div className="policy-tabs" role="tablist" aria-label="File classes">
{FILE_CLASSES.map((fileClass) => (
<button
key={fileClass}
className={selectedClass === fileClass ? "policy-tab active" : "policy-tab"}
type="button"
onClick={() => setSelectedClass(fileClass)}
>
{titleCase(fileClass)}
</button>
))}
</div>
<form className="panel policy-editor" onSubmit={handleSubmit}>
<div className="policy-editor-grid">
<label className="checkbox-field">
<input
type="checkbox"
checked={requireLocal}
onChange={(event) => setRequireLocal(event.target.checked)}
/>
<span>Require local replica</span>
</label>
<label>
<span>Stable replicas</span>
<input
type="number"
min={0}
value={stableCount}
onChange={(event) => setStableCount(Number(event.target.value))}
/>
</label>
<label>
<span>Opportunistic replicas</span>
<input
type="number"
min={0}
value={opportunisticCount}
onChange={(event) => setOpportunisticCount(Number(event.target.value))}
/>
</label>
<label>
<span>Max non-local bytes</span>
<input
value={maxNonLocalSize}
onChange={(event) => setMaxNonLocalSize(event.target.value)}
placeholder="No limit"
/>
</label>
</div>
<div className="backend-policy-list">
{backends.map((backend) => (
<div key={backend.id} className="backend-policy-row">
<div>
<strong>{backend.name}</strong>
<span className="item-muted">
{formatBackendType(backend.type)} · {titleCase(backend.stability_class)}
</span>
</div>
<div className="backend-policy-actions">
<label className="checkbox-field compact-checkbox">
<input
type="checkbox"
checked={preferredBackendIds.includes(backend.id)}
onChange={() => toggleBackend("preferred", backend.id)}
/>
<span>Prefer</span>
</label>
<label className="checkbox-field compact-checkbox">
<input
type="checkbox"
checked={excludedBackendIds.includes(backend.id)}
onChange={() => toggleBackend("excluded", backend.id)}
/>
<span>Exclude</span>
</label>
</div>
</div>
))}
</div>
{formError ? <p className="error-text">{formError}</p> : null}
<div className="dialog-actions">
<button className="primary-button" type="submit" disabled={updateMutation.isPending}>
Save Policy
</button>
</div>
</form>
</section>
<aside className="dashboard-side-column">
<section className="panel dashboard-side-panel">
<div className="section-heading">
<span className="eyebrow">Preview</span>
<h3>Placement decision</h3>
</div>
<div className="dialog-form">
<label>
<span>Media type</span>
<input value={mimeType} onChange={(event) => setMimeType(event.target.value)} />
</label>
<label>
<span>Object size</span>
<input value={previewSize} onChange={(event) => setPreviewSize(event.target.value)} />
</label>
</div>
<div className="decision-list">
<span className="status-chip">
{previewQuery.data?.non_local_allowed === false ? "Local only" : "Non-local allowed"}
</span>
<span className="item-muted">
{previewSizeBytes === null ? "No size input" : formatBytes(previewSizeBytes)}
</span>
{(previewQuery.data?.selected_backends ?? []).map((backend) => (
<div key={backend.id} className="decision-backend">
<strong>{backend.name}</strong>
<span>{formatBackendType(backend.type)}</span>
</div>
))}
</div>
</section>
</aside>
</div>
</div>
);
}
function toggleId(values: string[], id: string) {
return values.includes(id) ? values.filter((value) => value !== id) : [...values, id];
}
function parseOptionalInt(value: string) {
const cleaned = value.trim();
if (!cleaned) {
return null;
}
const parsed = Number(cleaned);
if (!Number.isInteger(parsed) || parsed < 0) {
return null;
}
return parsed;
}

97
frontend/src/routes/RecycleBinPage.tsx

@ -1,97 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { PaginationControls } from "../components/PaginationControls";
import { api } from "../lib/api";
import { formatBytes, formatDate } from "../lib/format";
export function RecycleBinPage() {
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(8);
const recycleQuery = useQuery({
queryKey: ["recycle-bin"],
queryFn: api.listRecycleBin,
});
const restoreMutation = useMutation({
mutationFn: (fileId: string) => api.restoreFile(fileId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["recycle-bin"] });
},
});
const items = recycleQuery.data?.items ?? [];
const totalBytes = items.reduce((sum, item) => sum + item.size_bytes, 0);
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const pagedItems = useMemo(
() => items.slice((page - 1) * pageSize, page * pageSize),
[items, page, pageSize],
);
useEffect(() => {
if (page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
return (
<div className="compact-page-shell compact-page-shell-wide">
<section className="panel dashboard-main-panel">
<div className="panel-toolbar files-toolbar-top">
<div className="files-header">
<span className="eyebrow">Deleted files</span>
<div className="directory-title-row">
<h2>Recycle Bin</h2>
</div>
<div className="inline-metrics">
<span>{items.length} items</span>
<span>{formatBytes(totalBytes)}</span>
</div>
</div>
</div>
<div className="list-surface">
<div className="list-header recycle-list-header">
<span>Name</span>
<span>Deleted</span>
<span>Size</span>
<span className="list-header-actions">Actions</span>
</div>
<div className="list-table">
{items.length ? (
pagedItems.map((item) => (
<div key={item.id} className="table-row recycle-row">
<div>
<strong>{item.name}</strong>
<span className="item-muted">Recoverable file</span>
</div>
<div className="table-meta recycle-row-meta">
<span>{formatDate(item.deleted_at)}</span>
<span>{formatBytes(item.size_bytes)}</span>
<button className="ghost-button compact-button" type="button" onClick={() => restoreMutation.mutate(item.id)}>
Restore
</button>
</div>
</div>
))
) : (
<div className="empty-state empty-state-rich">
<strong>Recycle bin is empty.</strong>
</div>
)}
</div>
</div>
<PaginationControls
label="Recycle bin pagination"
page={page}
pageSize={pageSize}
totalItems={items.length}
totalPages={totalPages}
onPageChange={setPage}
onPageSizeChange={(nextPageSize) => {
setPageSize(nextPageSize);
setPage(1);
}}
/>
</section>
</div>
);
}

518
frontend/src/routes/StoragePage.tsx

@ -1,518 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FormEvent, useState } from "react";
import { Dialog } from "../components/Dialog";
import { useToast } from "../features/toast/ToastProvider";
import { api } from "../lib/api";
import type { BackendSummary } from "../lib/api";
import { formatBackendType, formatDate, titleCase } from "../lib/format";
type BackendType = "local_directory" | "s3" | "webdav";
type StabilityClass = "local" | "stable" | "opportunistic";
const CONFIG_TEMPLATES: Record<BackendType, string> = {
local_directory: JSON.stringify({ base_path: "./.iron-storage/archive" }, null, 2),
s3: JSON.stringify(
{
bucket: "iron",
region: "us-east-1",
endpoint_url: "http://127.0.0.1:9000",
access_key_id: "",
secret_access_key: "",
prefix: "iron",
force_path_style: true,
},
null,
2,
),
webdav: JSON.stringify(
{
endpoint_url: "http://127.0.0.1:5244/dav",
username: "iron",
password: "",
root_path: "iron",
verify_ssl: true,
},
null,
2,
),
};
export function StoragePage() {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [backendType, setBackendType] = useState<BackendType>("s3");
const [stabilityClass, setStabilityClass] = useState<StabilityClass>("stable");
const [name, setName] = useState("");
const [readPriority, setReadPriority] = useState(80);
const [writePriority, setWritePriority] = useState(80);
const [configText, setConfigText] = useState(CONFIG_TEMPLATES.s3);
const [formError, setFormError] = useState("");
const [editingBackend, setEditingBackend] = useState<BackendSummary | null>(null);
const [editName, setEditName] = useState("");
const [editStabilityClass, setEditStabilityClass] = useState<StabilityClass>("stable");
const [editReadPriority, setEditReadPriority] = useState(80);
const [editWritePriority, setEditWritePriority] = useState(80);
const [editConfigText, setEditConfigText] = useState("{}");
const [editSecretText, setEditSecretText] = useState("{}");
const [editError, setEditError] = useState("");
const [deleteTarget, setDeleteTarget] = useState<BackendSummary | null>(null);
const backendQuery = useQuery({
queryKey: ["backends"],
queryFn: api.listBackends,
});
function handleTypeChange(nextType: BackendType) {
setBackendType(nextType);
setConfigText(CONFIG_TEMPLATES[nextType]);
setStabilityClass(nextType === "local_directory" ? "local" : "stable");
if (!name.trim()) {
setName(
nextType === "local_directory"
? "local-archive"
: nextType === "s3"
? "s3-main"
: "openlist-main",
);
}
}
const createMutation = useMutation({
mutationFn: api.createBackend,
onSuccess: async () => {
setIsCreateOpen(false);
setFormError("");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
setFormError(error instanceof Error ? error.message : "Backend creation failed.");
},
});
const checkMutation = useMutation({
mutationFn: (backendId: string) => api.checkBackend(backendId),
onSuccess: async () => {
pushToast("Backend check finished.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Backend check failed.", "error");
},
});
const disableMutation = useMutation({
mutationFn: (backendId: string) => api.disableBackend(backendId),
onSuccess: async () => {
pushToast("Backend disabled.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Disable failed.", "error");
},
});
const enableMutation = useMutation({
mutationFn: (backendId: string) => api.enableBackend(backendId),
onSuccess: async () => {
pushToast("Backend enabled.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Enable failed.", "error");
},
});
const runAllChecksMutation = useMutation({
mutationFn: api.enqueueHealthChecks,
onSuccess: async (result) => {
pushToast(`Queued ${result.enqueued_jobs} backend health check job${result.enqueued_jobs === 1 ? "" : "s"}.`, "success");
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Could not queue health checks.", "error");
},
});
const updateMutation = useMutation({
mutationFn: (payload: { backendId: string; form: ReturnType<typeof buildEditPayload> }) =>
api.updateBackend(payload.backendId, payload.form),
onSuccess: async () => {
setEditingBackend(null);
setEditError("");
pushToast("Backend updated.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
setEditError(error instanceof Error ? error.message : "Backend update failed.");
},
});
const deleteMutation = useMutation({
mutationFn: (backendId: string) => api.deleteBackend(backendId),
onSuccess: async () => {
const deletedName = deleteTarget?.name;
setDeleteTarget(null);
pushToast(deletedName ? `Deleted ${deletedName}.` : "Backend deleted.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
await queryClient.invalidateQueries({ queryKey: ["placement-policies"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Delete failed.", "error");
},
});
const items = backendQuery.data?.items ?? [];
const healthyCount = items.filter((backend) => (backend.last_health_status ?? "unknown") === "healthy").length;
const enabledCount = items.filter((backend) => backend.is_enabled).length;
const disabledCount = items.filter((backend) => !backend.is_enabled).length;
function handleCreateBackend(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setFormError("");
let config: Record<string, unknown>;
try {
config = JSON.parse(configText) as Record<string, unknown>;
} catch {
setFormError("Config must be valid JSON.");
return;
}
createMutation.mutate({
name,
type: backendType,
stability_class: stabilityClass,
read_priority: readPriority,
write_priority: writePriority,
config,
});
}
function openEditBackend(backend: BackendSummary) {
setEditingBackend(backend);
setEditName(backend.name);
setEditStabilityClass(backend.stability_class as StabilityClass);
setEditReadPriority(backend.read_priority);
setEditWritePriority(backend.write_priority);
setEditConfigText(JSON.stringify(backend.config ?? {}, null, 2));
setEditSecretText("{}");
setEditError("");
}
function handleUpdateBackend(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!editingBackend) {
return;
}
setEditError("");
let form;
try {
form = buildEditPayload({
name: editName,
stabilityClass: editStabilityClass,
readPriority: editReadPriority,
writePriority: editWritePriority,
configText: editConfigText,
secretText: editSecretText,
});
} catch (error) {
setEditError(error instanceof Error ? error.message : "Config must be valid JSON.");
return;
}
updateMutation.mutate({ backendId: editingBackend.id, form });
}
return (
<div className="compact-page-shell compact-page-shell-wide">
<section className="panel dashboard-main-panel">
<div className="panel-toolbar files-toolbar-top">
<div className="files-header">
<span className="eyebrow">Storage</span>
<div className="directory-title-row">
<h2>Backends</h2>
</div>
<div className="inline-metrics">
<span>{items.length} backends</span>
<span>{healthyCount} healthy</span>
<span>{enabledCount} enabled</span>
<span>{disabledCount} disabled</span>
</div>
</div>
<div className="toolbar-group files-command-bar">
<button className="primary-button" type="button" onClick={() => setIsCreateOpen(true)}>
Add Backend
</button>
<button className="ghost-button" type="button" onClick={() => runAllChecksMutation.mutate()}>
Run All Checks
</button>
</div>
</div>
<div className="list-surface storage-list-surface">
<div className="list-header storage-list-header">
<span>Backend</span>
<span>Status</span>
<span>Class</span>
<span>Checked</span>
<span className="list-header-actions">Actions</span>
</div>
<div className="list-table">
{items.map((backend) => (
<div key={backend.id} className="table-row storage-table-row">
<div>
<strong>{backend.name}</strong>
<span className="item-muted">
{formatBackendType(backend.type)} · {formatBackendConfig(backend.config)}
{backend.secret_fields.length ? ` · ${backend.secret_fields.length} secrets` : ""}
</span>
</div>
<div className="table-meta storage-row-meta">
<span className={backend.is_enabled ? "status-chip status-chip-success" : "status-chip"}>
{backend.is_enabled ? "Enabled" : "Disabled"}
</span>
<span className="status-chip">{titleCase(backend.last_health_status ?? "unknown")}</span>
<span>{titleCase(backend.stability_class)}</span>
<span>{formatDate(backend.last_health_checked_at)}</span>
<div className="toolbar-group storage-row-actions">
<button className="ghost-button compact-button" type="button" onClick={() => checkMutation.mutate(backend.id)}>
Check
</button>
<button className="ghost-button compact-button" type="button" onClick={() => openEditBackend(backend)}>
Edit
</button>
{backend.is_enabled ? (
<button className="danger-button compact-button" type="button" onClick={() => disableMutation.mutate(backend.id)}>
Disable
</button>
) : (
<>
<button className="ghost-button compact-button" type="button" onClick={() => enableMutation.mutate(backend.id)}>
Enable
</button>
<button className="danger-button compact-button" type="button" onClick={() => setDeleteTarget(backend)}>
Delete
</button>
</>
)}
</div>
</div>
</div>
))}
{!items.length ? (
<div className="empty-state empty-state-rich">
<strong>No storage backends configured.</strong>
</div>
) : null}
</div>
</div>
</section>
{isCreateOpen ? (
<Dialog title="Add storage backend" onClose={() => setIsCreateOpen(false)}>
<form className="dialog-form" onSubmit={handleCreateBackend}>
<label>
<span>Type</span>
<select value={backendType} onChange={(event) => handleTypeChange(event.target.value as BackendType)}>
<option value="local_directory">Local directory</option>
<option value="s3">S3</option>
<option value="webdav">OpenList / WebDAV</option>
</select>
</label>
<label>
<span>Name</span>
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="storage-main" />
</label>
<label>
<span>Policy class</span>
<select
value={stabilityClass}
onChange={(event) => setStabilityClass(event.target.value as StabilityClass)}
>
<option value="local">Local</option>
<option value="stable">Stable</option>
<option value="opportunistic">Opportunistic</option>
</select>
</label>
<div className="split-fields">
<label>
<span>Read priority</span>
<input
type="number"
value={readPriority}
onChange={(event) => setReadPriority(Number(event.target.value))}
/>
</label>
<label>
<span>Write priority</span>
<input
type="number"
value={writePriority}
onChange={(event) => setWritePriority(Number(event.target.value))}
/>
</label>
</div>
<label>
<span>Config JSON</span>
<textarea
value={configText}
onChange={(event) => setConfigText(event.target.value)}
rows={backendType === "webdav" ? 8 : 9}
spellCheck={false}
/>
</label>
{formError ? <p className="error-text">{formError}</p> : null}
<div className="dialog-actions">
<button className="ghost-button" type="button" onClick={() => setIsCreateOpen(false)}>
Cancel
</button>
<button className="primary-button" type="submit" disabled={createMutation.isPending}>
Create
</button>
</div>
</form>
</Dialog>
) : null}
{editingBackend ? (
<Dialog title="Edit storage backend" onClose={() => setEditingBackend(null)}>
<form className="dialog-form" onSubmit={handleUpdateBackend}>
<label>
<span>Type</span>
<input value={editingBackend.type} disabled />
</label>
<label>
<span>Name</span>
<input value={editName} onChange={(event) => setEditName(event.target.value)} />
</label>
<label>
<span>Policy class</span>
<select
value={editStabilityClass}
onChange={(event) => setEditStabilityClass(event.target.value as StabilityClass)}
>
<option value="local">Local</option>
<option value="stable">Stable</option>
<option value="opportunistic">Opportunistic</option>
</select>
</label>
<div className="split-fields">
<label>
<span>Read priority</span>
<input
type="number"
value={editReadPriority}
onChange={(event) => setEditReadPriority(Number(event.target.value))}
/>
</label>
<label>
<span>Write priority</span>
<input
type="number"
value={editWritePriority}
onChange={(event) => setEditWritePriority(Number(event.target.value))}
/>
</label>
</div>
<label>
<span>Public config JSON</span>
<textarea
value={editConfigText}
onChange={(event) => setEditConfigText(event.target.value)}
rows={8}
spellCheck={false}
/>
</label>
<label>
<span>Secret override JSON</span>
<textarea
value={editSecretText}
onChange={(event) => setEditSecretText(event.target.value)}
rows={4}
spellCheck={false}
/>
</label>
<div className="meta-list compact-meta-list">
<div>
<span>Stored secrets</span>
<strong>{editingBackend.secret_fields.length ? editingBackend.secret_fields.join(", ") : "None"}</strong>
</div>
</div>
{editError ? <p className="error-text">{editError}</p> : null}
<div className="dialog-actions">
<button className="ghost-button" type="button" onClick={() => setEditingBackend(null)}>
Cancel
</button>
<button className="primary-button" type="submit" disabled={updateMutation.isPending}>
Save
</button>
</div>
</form>
</Dialog>
) : null}
{deleteTarget ? (
<Dialog title="Delete storage backend" onClose={() => setDeleteTarget(null)}>
<div className="dialog-form">
<div className="empty-state">
<strong>{deleteTarget.name}</strong>
<p className="item-muted">
Delete only works for disabled backends with no stored replicas and no placement policy references.
</p>
</div>
<div className="dialog-actions">
<button className="ghost-button" type="button" onClick={() => setDeleteTarget(null)}>
Cancel
</button>
<button
className="danger-button"
type="button"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(deleteTarget.id)}
>
Delete
</button>
</div>
</div>
</Dialog>
) : null}
</div>
);
}
function buildEditPayload(input: {
name: string;
stabilityClass: StabilityClass;
readPriority: number;
writePriority: number;
configText: string;
secretText: string;
}) {
const publicConfig = JSON.parse(input.configText) as Record<string, unknown>;
const secretConfig = JSON.parse(input.secretText) as Record<string, unknown>;
if (!isObject(publicConfig) || !isObject(secretConfig)) {
throw new Error("Config must be a JSON object.");
}
return {
name: input.name,
stability_class: input.stabilityClass,
read_priority: input.readPriority,
write_priority: input.writePriority,
config: { ...publicConfig, ...secretConfig },
};
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function formatBackendConfig(config: Record<string, unknown> | null) {
if (!config) {
return "Configured";
}
if (typeof config.base_path === "string") {
return config.base_path;
}
if (typeof config.bucket === "string") {
const prefix = typeof config.prefix === "string" && config.prefix ? `/${config.prefix}` : "";
return `${config.bucket}${prefix}`;
}
if (typeof config.endpoint_url === "string") {
const rootPath = typeof config.root_path === "string" ? config.root_path : "iron";
return `${config.endpoint_url}/${rootPath}`;
}
return "Configured";
}

3502
frontend/src/styles/app.css

File diff suppressed because it is too large

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

File diff suppressed because it is too large

9
frontend/src/vite-env.d.ts

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
/// <reference types="@opencloud-eu/design-system/types" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}

6
frontend/vite.config.ts

@ -1,11 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import vue from "@vitejs/plugin-vue";
import path from "node:path";
export default defineConfig({
plugins: [react()],
plugins: [vue()],
root: path.resolve(__dirname),
publicDir: false,
publicDir: path.resolve(__dirname, "public"),
build: {
outDir: path.resolve(__dirname, "../app/web/dist"),
emptyOutDir: true,

1740
package-lock.json

File diff suppressed because it is too large

13
package.json

@ -9,15 +9,14 @@
"preview": "vite preview --config frontend/vite.config.ts"
},
"dependencies": {
"@tanstack/react-query": "^5.76.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0"
"@opencloud-eu/design-system": "^6.2.0",
"@vitejs/plugin-vue": "^6.0.6",
"pinia": "^3.0.4",
"tailwindcss": "^4.3.0",
"vue": "^3.5.34",
"vue3-gettext": "^4.0.0-beta.1"
},
"devDependencies": {
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "^5.8.3",
"vite": "^6.3.5"
}

7
scripts/generate_readme_demo.py

@ -74,7 +74,7 @@ def capture_demo_frames(base_url: str) -> list[Path]:
page.get_by_label("Password").fill("changeme-iron")
page.get_by_role("button", name="Sign in").click()
page.wait_for_url(f"{base_url}/app/files")
page.locator(".files-header .crumb-button").filter(has_text="/").first.wait_for()
page.locator(".files-header").filter(has_text="Personal").first.wait_for()
frame_paths.append(capture_frame(page, "02-files"))
page.locator(".panel-toolbar").get_by_role("button", name="New").click()
@ -102,8 +102,9 @@ def capture_demo_frames(base_url: str) -> list[Path]:
page.locator("img.preview-frame").wait_for(timeout=15000)
frame_paths.append(capture_frame(page, "05-preview"))
page.get_by_role("button", name="Storage").click()
page.get_by_role("button", name="Reconcile File").wait_for()
page.locator(".detail-panel").get_by_role("button", name="More Info").click()
page.locator(".detail-drawer", has_text="Replica Info").wait_for()
page.locator(".detail-drawer", has_text="Desired Backends").wait_for()
frame_paths.append(capture_frame(page, "06-storage"))
page.goto(f"{base_url}/app/storage", wait_until="networkidle")

107
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()
@ -188,11 +191,18 @@ def run_browser_flow(base_url: str) -> None:
page.locator(".drive-item strong", has_text="playwright-preview.png").first.click()
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()
assert_label_value_layout(page, ".detail-panel .inspector-storage-summary > div", "File inspector summary", failures)
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)
@ -202,7 +212,12 @@ def run_browser_flow(base_url: str) -> None:
page.get_by_role("menu").get_by_role("button", name="Download").wait_for()
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.mouse.click(40, 120)
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").wait_for()
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 +235,47 @@ 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.mouse.click(24, 24)
page.get_by_role("dialog").wait_for(state="detached")
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.mouse.click(24, 24)
page.get_by_role("dialog").wait_for(state="detached")
page.goto(f"{base_url}/app/policies", wait_until="networkidle")
page.get_by_role("button", name="Save Policy").wait_for()
@ -243,9 +284,11 @@ 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()
assert_label_value_layout(page, ".jobs-detail-meta > div, .jobs-status-main, .jobs-status-attempts", "Jobs detail", failures)
page.screenshot(path=str(OUTPUT_DIR / "jobs-page.png"), full_page=True)
mobile_context = browser.new_context(viewport={"width": 390, "height": 844})
@ -292,5 +335,67 @@ 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")
def assert_label_value_layout(page, selector: str, label: str, failures: list[str]) -> None: # type: ignore[no-untyped-def]
layout_errors = page.locator(selector).evaluate_all(
"""(nodes) => nodes
.map((node) => {
const label = node.querySelector("span");
const value = node.querySelector("strong");
if (!label || !value) return "";
const left = label.getBoundingClientRect();
const right = value.getBoundingClientRect();
const overlaps = !(left.right <= right.left || right.right <= left.left || left.bottom <= right.top || right.bottom <= left.top);
const sameLineTooTight = Math.abs(left.top - right.top) < 4 && right.left - left.right < 8;
return overlaps || sameLineTooTight ? (node.textContent || "unknown").trim() : "";
})
.filter(Boolean)"""
)
if layout_errors:
failures.append(f"{label} label/value layout is too tight: {layout_errors}")
if __name__ == "__main__":
raise SystemExit(main())

4
tests/test_web_routes.py

@ -17,8 +17,8 @@ async def test_web_assets_are_served(raw_api_client) -> None:
assert script_response.status_code == 200
assert "javascript" in script_response.headers["content-type"]
assert "createRoot" in script_response.text
assert '.mount("#root")' in script_response.text
assert style_response.status_code == 200
assert "text/css" in style_response.headers["content-type"]
assert ".app-shell" in style_response.text
assert ".iron-app" in style_response.text

Loading…
Cancel
Save