Browse Source

feat: refine OpenCloud-inspired web UI

codex/vue-opencloud-design-system
Rain Mark 2 months ago
parent
commit
d3fe2e5190
  1. 3
      app/api/routes/directories.py
  2. 3
      app/schemas/directory.py
  3. 1
      app/schemas/job.py
  4. 31
      app/services/directory_service.py
  5. 2
      app/services/policy_service.py
  6. BIN
      app/web/dist/assets/OpenCloud500-Regular.woff2
  7. BIN
      app/web/dist/assets/OpenCloud750-Bold.woff2
  8. 2
      app/web/dist/assets/app.css
  9. 32
      app/web/dist/assets/app.js
  10. BIN
      app/web/dist/assets/inter.woff2
  11. 2
      docs/development.md
  12. 1
      docs/openlist-local-deployment-sop.md
  13. 5
      frontend/src/app/router.tsx
  14. BIN
      frontend/src/assets/fonts/OpenCloud500-Regular.woff2
  15. BIN
      frontend/src/assets/fonts/OpenCloud750-Bold.woff2
  16. BIN
      frontend/src/assets/fonts/inter.woff2
  17. 113
      frontend/src/components/DirectoryTree.tsx
  18. 117
      frontend/src/components/PaginationControls.tsx
  19. 122
      frontend/src/components/layout/AppLayout.tsx
  20. 4
      frontend/src/lib/api.ts
  21. 603
      frontend/src/routes/FilesPage.tsx
  22. 189
      frontend/src/routes/JobsPage.tsx
  23. 2
      frontend/src/routes/LoginPage.tsx
  24. 31
      frontend/src/routes/PoliciesPage.tsx
  25. 125
      frontend/src/routes/RecycleBinPage.tsx
  26. 142
      frontend/src/routes/StoragePage.tsx
  27. 257
      frontend/src/routes/UploadsPage.tsx
  28. 2393
      frontend/src/styles/app.css
  29. 69
      scripts/ui_playwright_smoke.py
  30. 3
      tests/test_directory_routes.py
  31. 7
      tests/test_policy_routes.py

3
app/api/routes/directories.py

@ -32,6 +32,9 @@ async def list_directory_children(
name=item.item.name, name=item.item.name,
mime_type=getattr(item.item, "mime_type", None), mime_type=getattr(item.item, "mime_type", None),
size_bytes=getattr(item.item, "size_bytes", None), size_bytes=getattr(item.item, "size_bytes", None),
replica_ready_count=item.replica_ready_count,
replica_desired_count=item.replica_desired_count,
replica_missing_count=item.replica_missing_count,
created_at=item.item.created_at, created_at=item.item.created_at,
updated_at=item.item.updated_at, updated_at=item.item.updated_at,
) )

3
app/schemas/directory.py

@ -19,6 +19,9 @@ class DirectoryItem(BaseModel):
name: str name: str
mime_type: str | None = None mime_type: str | None = None
size_bytes: int | None = None size_bytes: int | None = None
replica_ready_count: int | None = None
replica_desired_count: int | None = None
replica_missing_count: int | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

1
app/schemas/job.py

@ -9,6 +9,7 @@ class JobSummary(BaseModel):
id: str id: str
kind: str kind: str
status: str status: str
payload_json: str
attempt_count: int attempt_count: int
max_attempts: int max_attempts: int
run_after: datetime run_after: datetime

31
app/services/directory_service.py

@ -8,8 +8,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import DirectoryConflictError, DirectoryNotFoundError, ValidationError from app.core.exceptions import DirectoryConflictError, DirectoryNotFoundError, ValidationError
from app.core.ids import new_id from app.core.ids import new_id
from app.models.entities import Directory, FileEntry from app.models.entities import Directory, FileEntry
from app.repositories.blob_replica_repository import BlobReplicaRepository
from app.repositories.directory_repository import DirectoryRepository from app.repositories.directory_repository import DirectoryRepository
from app.repositories.file_repository import FileRepository from app.repositories.file_repository import FileRepository
from app.services.storage_service import StorageService
ROOT_DIRECTORY_ID = "dir_root" ROOT_DIRECTORY_ID = "dir_root"
ROOT_DIRECTORY_NAME = "/" ROOT_DIRECTORY_NAME = "/"
@ -20,6 +22,9 @@ ROOT_PATH_KEY = "/"
class NamespaceItem: class NamespaceItem:
kind: Literal["directory", "file"] kind: Literal["directory", "file"]
item: Directory | FileEntry item: Directory | FileEntry
replica_ready_count: int | None = None
replica_desired_count: int | None = None
replica_missing_count: int | None = None
@dataclass @dataclass
@ -33,6 +38,8 @@ class DirectoryService:
self.session = session self.session = session
self.repository = DirectoryRepository(session) self.repository = DirectoryRepository(session)
self.file_repository = FileRepository(session) self.file_repository = FileRepository(session)
self.blob_replica_repository = BlobReplicaRepository(session)
self.storage_service = StorageService(session)
async def ensure_root_directory(self) -> Directory: async def ensure_root_directory(self) -> Directory:
root = await self.repository.get_root() root = await self.repository.get_root()
@ -55,10 +62,32 @@ class DirectoryService:
child_files = await self.file_repository.list_by_directory(directory.id) child_files = await self.file_repository.list_by_directory(directory.id)
items = [NamespaceItem(kind="directory", item=child) for child in child_directories] items = [NamespaceItem(kind="directory", item=child) for child in child_directories]
items.extend(NamespaceItem(kind="file", item=file_entry) for file_entry in child_files) for file_entry in child_files:
items.append(await self._file_namespace_item(file_entry))
items.sort(key=lambda item: (item.kind != "directory", item.item.name.lower())) items.sort(key=lambda item: (item.kind != "directory", item.item.name.lower()))
return DirectoryChildrenResult(directory=directory, children=items) return DirectoryChildrenResult(directory=directory, children=items)
async def _file_namespace_item(self, file_entry: FileEntry) -> NamespaceItem:
blob = await self.file_repository.get_primary_blob_for_file(file_entry.id)
if blob is None:
return NamespaceItem(kind="file", item=file_entry, replica_ready_count=0, replica_desired_count=0, replica_missing_count=0)
replicas = await self.blob_replica_repository.list_by_blob(blob.id)
ready_backend_ids = {replica.backend_id for replica in replicas if replica.status == "ready"}
decision = await self.storage_service.get_replication_targets_for_mime_type(
file_entry.mime_type,
size_bytes=blob.size_bytes,
)
desired_backend_ids = {backend.id for backend in decision.backends}
missing_count = len(desired_backend_ids - ready_backend_ids)
return NamespaceItem(
kind="file",
item=file_entry,
replica_ready_count=len(ready_backend_ids & desired_backend_ids),
replica_desired_count=len(desired_backend_ids),
replica_missing_count=missing_count,
)
async def create_directory(self, parent_id: str, name: str) -> Directory: async def create_directory(self, parent_id: str, name: str) -> Directory:
cleaned_name = self._validate_directory_name(name) cleaned_name = self._validate_directory_name(name)

2
app/services/policy_service.py

@ -23,7 +23,7 @@ DEFAULT_PLACEMENT_POLICIES = {
"image": { "image": {
"require_local": True, "require_local": True,
"stable_replica_count": 1, "stable_replica_count": 1,
"opportunistic_replica_count": 1, "opportunistic_replica_count": 0,
"preferred_backend_ids": [], "preferred_backend_ids": [],
"excluded_backend_ids": [], "excluded_backend_ids": [],
"max_non_local_size_bytes": None, "max_non_local_size_bytes": None,

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

32
app/web/dist/assets/app.js

File diff suppressed because one or more lines are too long

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

Binary file not shown.

2
docs/development.md

@ -42,7 +42,7 @@ Full suite:
Current expected result: Current expected result:
```text ```text
74 passed 78 passed
``` ```
Browser E2E: Browser E2E:

1
docs/openlist-local-deployment-sop.md

@ -363,6 +363,7 @@ Recommended first production baseline:
Interpretation: Interpretation:
- Iron's default target is 2 total replicas
- every file keeps one local copy - every file keeps one local copy
- every file also gets one stable remote copy on OpenList/Aliyun - every file also gets one stable remote copy on OpenList/Aliyun

5
frontend/src/app/router.tsx

@ -8,7 +8,6 @@ import { AppLayout } from "../components/layout/AppLayout";
import { RequireAuth } from "../features/auth/RequireAuth"; import { RequireAuth } from "../features/auth/RequireAuth";
import { LoginPage } from "../routes/LoginPage"; import { LoginPage } from "../routes/LoginPage";
import { FilesPage } from "../routes/FilesPage"; import { FilesPage } from "../routes/FilesPage";
import { UploadsPage } from "../routes/UploadsPage";
import { RecycleBinPage } from "../routes/RecycleBinPage"; import { RecycleBinPage } from "../routes/RecycleBinPage";
import { StoragePage } from "../routes/StoragePage"; import { StoragePage } from "../routes/StoragePage";
import { PoliciesPage } from "../routes/PoliciesPage"; import { PoliciesPage } from "../routes/PoliciesPage";
@ -47,10 +46,6 @@ export const router = createBrowserRouter([
path: "files/:directoryId", path: "files/:directoryId",
element: <FilesPage />, element: <FilesPage />,
}, },
{
path: "uploads",
element: <UploadsPage />,
},
{ {
path: "recycle-bin", path: "recycle-bin",
element: <RecycleBinPage />, element: <RecycleBinPage />,

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.

113
frontend/src/components/DirectoryTree.tsx

@ -1,113 +0,0 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
type TreeNode = {
id: string;
name: string;
};
type DirectoryTreeProps = {
selectedId?: string;
expandedIds?: string[];
rootLabel?: string;
onOpenDirectory?: (node: TreeNode) => void;
onSelectDirectory?: (node: TreeNode) => void;
};
export function DirectoryTree({
selectedId,
expandedIds = [],
rootLabel = "All files",
onOpenDirectory,
onSelectDirectory,
}: DirectoryTreeProps) {
return (
<div className="tree-root">
<DirectoryNode
node={{ id: "dir_root", name: rootLabel }}
level={0}
selectedId={selectedId}
expandedIds={expandedIds}
onOpenDirectory={onOpenDirectory}
onSelectDirectory={onSelectDirectory}
/>
</div>
);
}
function DirectoryNode({
node,
level,
selectedId,
expandedIds,
onOpenDirectory,
onSelectDirectory,
}: {
node: TreeNode;
level: number;
selectedId?: string;
expandedIds: string[];
onOpenDirectory?: (node: TreeNode) => void;
onSelectDirectory?: (node: TreeNode) => void;
}) {
const [isExpanded, setIsExpanded] = useState(level === 0);
const childrenQuery = useQuery({
queryKey: ["directories", node.id, "tree"],
queryFn: () => api.listDirectoryChildren(node.id),
enabled: isExpanded,
});
useEffect(() => {
if (expandedIds.includes(node.id)) {
setIsExpanded(true);
}
}, [expandedIds, node.id]);
const directories =
childrenQuery.data?.items.filter((item) => item.kind === "directory") ?? [];
return (
<div className="tree-node">
<div
className={selectedId === node.id ? "tree-row active" : "tree-row"}
style={{ paddingLeft: `${level * 12}px` }}
>
<button
type="button"
className="tree-toggle"
onClick={() => setIsExpanded((current) => !current)}
aria-label={isExpanded ? "Collapse folder" : "Expand folder"}
>
{isExpanded ? "−" : "+"}
</button>
<button
type="button"
className="tree-label"
onClick={() => {
onSelectDirectory?.(node);
onOpenDirectory?.(node);
}}
>
{node.name}
</button>
</div>
{isExpanded && directories.length ? (
<div className="tree-children">
{directories.map((directory) => (
<DirectoryNode
key={directory.id}
node={{ id: directory.id, name: directory.name }}
level={level + 1}
selectedId={selectedId}
expandedIds={expandedIds}
onOpenDirectory={onOpenDirectory}
onSelectDirectory={onSelectDirectory}
/>
))}
</div>
) : null}
</div>
);
}

117
frontend/src/components/PaginationControls.tsx

@ -0,0 +1,117 @@
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];
});
}

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

@ -1,4 +1,5 @@
import { NavLink, useNavigate } from "react-router-dom"; import type { ChangeEvent } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { clearAccessToken } from "../../lib/auth"; import { clearAccessToken } from "../../lib/auth";
@ -10,17 +11,19 @@ type AppLayoutProps = {
}; };
const navItems = [ const navItems = [
{ to: "/app/files", label: "Files" }, { to: "/app/files", label: "Files", icon: "files" },
{ to: "/app/uploads", label: "Uploads" }, { to: "/app/recycle-bin", label: "Deleted files", icon: "trash" },
{ to: "/app/recycle-bin", label: "Recycle Bin" }, { to: "/app/storage", label: "Storage", icon: "storage" },
{ to: "/app/storage", label: "Storage" }, { to: "/app/policies", label: "Policies", icon: "policies" },
{ to: "/app/policies", label: "Policies" }, { to: "/app/jobs", label: "Jobs", icon: "jobs" },
{ to: "/app/jobs", label: "Jobs" },
]; ];
export function AppLayout({ currentPath, children }: AppLayoutProps) { export function AppLayout({ currentPath, children }: AppLayoutProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { pushToast } = useToast(); const { pushToast } = useToast();
const isFilesSearch = location.pathname.startsWith("/app/files");
const topbarSearchValue = isFilesSearch ? new URLSearchParams(location.search).get("q") ?? "" : "";
const meQuery = useQuery({ const meQuery = useQuery({
queryKey: ["auth", "me"], queryKey: ["auth", "me"],
queryFn: api.me, queryFn: api.me,
@ -38,11 +41,74 @@ export function AppLayout({ currentPath, children }: AppLayoutProps) {
navigate("/app/login", { replace: true }); 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 ( 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"> <div className="app-shell">
<aside className="sidebar"> <aside className="sidebar">
<div className="brand-block brand-block-compact"> <div className="brand-block brand-block-compact">
<span className="eyebrow">Iron Drive</span> <span className="eyebrow">Personal</span>
<h1>Drive</h1> <h1>Drive</h1>
</div> </div>
@ -53,47 +119,41 @@ export function AppLayout({ currentPath, children }: AppLayoutProps) {
to={item.to} to={item.to}
className={({ isActive }) => (isActive ? "nav-link active" : "nav-link")} className={({ isActive }) => (isActive ? "nav-link active" : "nav-link")}
> >
{item.label} <span className={`nav-icon nav-icon-${item.icon}`} aria-hidden="true" />
<span>{item.label}</span>
</NavLink> </NavLink>
))} ))}
</nav> </nav>
<div className="sidebar-card"> <div className="sidebar-footer">
<span className="eyebrow">Session</span> <span>Iron Web UI</span>
<strong>{meQuery.data?.user.username ?? "Loading"}</strong> <span>Personal cloud drive</span>
<p>Signed in with the local account for this drive.</p>
</div> </div>
</aside> </aside>
<div className="workspace"> <div className="workspace">
<header className="topbar">
<div className="topbar-title">
<span className="eyebrow">Workspace</span>
<h2>{getPageTitle(currentPath)}</h2>
</div>
<div className="topbar-actions">
<button className="ghost-button" type="button" onClick={() => navigate("/app/uploads")}>
Quick Upload
</button>
<button className="ghost-button" type="button" onClick={handleLogout}>
Logout
</button>
</div>
</header>
<main className="workspace-content">{children}</main> <main className="workspace-content">{children}</main>
</div> </div>
</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) { function getPageTitle(pathname: string) {
if (pathname.startsWith("/app/files")) { if (pathname.startsWith("/app/files")) {
return "Files"; return "Files";
} }
if (pathname.startsWith("/app/uploads")) {
return "Uploads";
}
if (pathname.startsWith("/app/recycle-bin")) { if (pathname.startsWith("/app/recycle-bin")) {
return "Recycle Bin"; return "Recycle Bin";
} }

4
frontend/src/lib/api.ts

@ -29,6 +29,9 @@ export type DirectoryItem = {
name: string; name: string;
mime_type: string | null; mime_type: string | null;
size_bytes: number | null; size_bytes: number | null;
replica_ready_count: number | null;
replica_desired_count: number | null;
replica_missing_count: number | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@ -196,6 +199,7 @@ export type JobSummary = {
id: string; id: string;
kind: string; kind: string;
status: string; status: string;
payload_json: string;
attempt_count: number; attempt_count: number;
max_attempts: number; max_attempts: number;
run_after: string; run_after: string;

603
frontend/src/routes/FilesPage.tsx

@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate, useParams } from "react-router-dom";
import { Dialog } from "../components/Dialog"; import { Dialog } from "../components/Dialog";
import { Menu } from "../components/Menu"; import { Menu } from "../components/Menu";
import { DirectoryTree } from "../components/DirectoryTree"; import { PaginationControls } from "../components/PaginationControls";
import { api, type DirectoryItem } from "../lib/api"; import { api, type DirectoryItem } from "../lib/api";
import { formatBackendType, formatBytes, formatDate, titleCase } from "../lib/format"; import { formatBackendType, formatBytes, formatDate, titleCase } from "../lib/format";
import { uploadFileToDirectory } from "../lib/uploads"; import { uploadFileToDirectory } from "../lib/uploads";
@ -39,11 +39,12 @@ export function FilesPage() {
const [fieldValue, setFieldValue] = useState(""); const [fieldValue, setFieldValue] = useState("");
const [moveTargetId, setMoveTargetId] = useState("dir_root"); const [moveTargetId, setMoveTargetId] = useState("dir_root");
const [uploads, setUploads] = useState<UploadRow[]>([]); const [uploads, setUploads] = useState<UploadRow[]>([]);
const [search, setSearch] = useState(""); const search = new URLSearchParams(location.search).get("q") ?? "";
const [sortBy, setSortBy] = useState<"name" | "updated" | "size">("name"); const [sortBy, setSortBy] = useState<"name" | "updated" | "size">("name");
const [openMenuId, setOpenMenuId] = useState<string | null>(null); const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [inspectorTab, setInspectorTab] = useState<"details" | "activity" | "storage">("details"); const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(8);
const crumbs = getCrumbs(location.state, directoryId, directoryId === "dir_root" ? "/" : directoryQueryNameFallback(location.state)); const crumbs = getCrumbs(location.state, directoryId, directoryId === "dir_root" ? "/" : directoryQueryNameFallback(location.state));
const directoryQuery = useQuery({ const directoryQuery = useQuery({
@ -62,7 +63,7 @@ export function FilesPage() {
}, [directoryId]); }, [directoryId]);
useEffect(() => { useEffect(() => {
setInspectorTab("details"); setIsDetailDrawerOpen(false);
}, [selectedItem?.id]); }, [selectedItem?.id]);
useEffect(() => { useEffect(() => {
@ -94,12 +95,12 @@ export function FilesPage() {
}, [items, search, sortBy]); }, [items, search, sortBy]);
const directories = useMemo(() => filteredItems.filter((item) => item.kind === "directory"), [filteredItems]); const directories = useMemo(() => filteredItems.filter((item) => item.kind === "directory"), [filteredItems]);
const files = useMemo(() => filteredItems.filter((item) => item.kind === "file"), [filteredItems]); const files = useMemo(() => filteredItems.filter((item) => item.kind === "file"), [filteredItems]);
const pageSize = viewMode === "list" ? 8 : 6;
const totalPages = Math.max(1, Math.ceil(filteredItems.length / pageSize)); const totalPages = Math.max(1, Math.ceil(filteredItems.length / pageSize));
const pagedItems = useMemo( const pagedItems = useMemo(
() => filteredItems.slice((page - 1) * pageSize, page * pageSize), () => filteredItems.slice((page - 1) * pageSize, page * pageSize),
[filteredItems, page, pageSize], [filteredItems, page, pageSize],
); );
const activeUploads = useMemo(() => uploads.filter((upload) => upload.progress < 100), [uploads]);
useEffect(() => { useEffect(() => {
if (page > totalPages) { if (page > totalPages) {
@ -201,16 +202,18 @@ export function FilesPage() {
}); });
const reconcileMutation = useMutation({ const reconcileMutation = useMutation({
mutationFn: async () => { mutationFn: async (fileId?: string) => {
if (!selectedItem || selectedItem.kind !== "file") { const targetFileId = fileId ?? (selectedItem?.kind === "file" ? selectedItem.id : null);
if (!targetFileId) {
throw new Error("Select a file to reconcile."); throw new Error("Select a file to reconcile.");
} }
return api.reconcileFile(selectedItem.id); return api.reconcileFile(targetFileId);
}, },
onSuccess: async (result) => { onSuccess: async (result, fileId) => {
pushToast(`Reconcile queued ${result.enqueued_jobs} job${result.enqueued_jobs === 1 ? "" : "s"}.`, "success"); pushToast(`Reconcile queued ${result.enqueued_jobs} job${result.enqueued_jobs === 1 ? "" : "s"}.`, "success");
if (selectedItem?.kind === "file") { const targetFileId = fileId ?? (selectedItem?.kind === "file" ? selectedItem.id : null);
await queryClient.invalidateQueries({ queryKey: ["files", selectedItem.id, "detail"] }); if (targetFileId) {
await queryClient.invalidateQueries({ queryKey: ["files", targetFileId, "detail"] });
} }
await queryClient.invalidateQueries({ queryKey: ["jobs"] }); await queryClient.invalidateQueries({ queryKey: ["jobs"] });
}, },
@ -274,6 +277,14 @@ export function FilesPage() {
}); });
} }
function goBackDirectory() {
const parentCrumbs = crumbs.slice(0, -1);
const parent = parentCrumbs[parentCrumbs.length - 1] ?? { id: "dir_root", name: "/" };
navigate(parent.id === "dir_root" ? "/app/files" : `/app/files/${parent.id}`, {
state: { crumbs: parentCrumbs.length ? parentCrumbs : [{ id: "dir_root", name: "/" }] },
});
}
function openDialog(mode: DialogMode) { function openDialog(mode: DialogMode) {
setDialogMode(mode); setDialogMode(mode);
if (mode === "create") { if (mode === "create") {
@ -324,55 +335,21 @@ export function FilesPage() {
return ( return (
<div className="files-page-layout"> <div className="files-page-layout">
<section className="panel main-panel files-main-shell"> <section className="panel main-panel files-main-shell">
<aside className="files-local-nav">
<div className="section-heading">
<span className="eyebrow">Files</span>
<h3>Navigation</h3>
</div>
<div className="files-sidebar-section">
<span className="files-sidebar-label">Library</span>
<div className="files-local-nav-list">
<button type="button" className={directoryId === "dir_root" ? "files-local-link active" : "files-local-link"} onClick={() => navigate("/app/files", { state: { crumbs: [{ id: "dir_root", name: "/" }] } })}>
All files
</button>
</div>
</div>
<div className="files-sidebar-section">
<span className="files-sidebar-label">Folders</span>
<div className="files-tree-card">
<DirectoryTree
rootLabel="Root"
selectedId={directoryId}
expandedIds={crumbs.map((crumb) => crumb.id)}
onOpenDirectory={(node) =>
navigate(node.id === "dir_root" ? "/app/files" : `/app/files/${node.id}`, {
state: { crumbs: rebuildCrumbsFromNode(crumbs, node) },
})
}
/>
</div>
</div>
</aside>
<div className="files-content-shell"> <div className="files-content-shell">
<div className="panel-toolbar files-toolbar-top"> <div className="panel-toolbar files-toolbar-top">
<div className="files-header"> <div className="files-header">
<span className="eyebrow">Directory</span> <div className="directory-title-row">
<div className="breadcrumbs"> {canGoBack(crumbs) ? (
{crumbs.map((crumb, index) => (
<button <button
key={crumb.id} className="directory-back-button"
type="button" type="button"
className="crumb-button" aria-label="Back to parent folder"
onClick={() => onClick={goBackDirectory}
navigate(crumb.id === "dir_root" ? "/app/files" : `/app/files/${crumb.id}`, {
state: { crumbs: crumbs.slice(0, index + 1) },
})
}
> >
{crumb.name}
</button> </button>
))} ) : null}
<h2>{getCurrentDirectoryName(crumbs)}</h2>
</div> </div>
<div className="inline-metrics"> <div className="inline-metrics">
<span>{directories.length} folders</span> <span>{directories.length} folders</span>
@ -381,10 +358,12 @@ export function FilesPage() {
</div> </div>
</div> </div>
<div className="toolbar-group files-command-bar"> <div className="toolbar-group files-command-bar">
<button type="button" onClick={() => openDialog("create")}> <button className="primary-button" type="button" onClick={() => openDialog("create")}>
<span className="button-glyph button-glyph-plus" aria-hidden="true" />
New New
</button> </button>
<button className="ghost-button" type="button" onClick={() => fileInputRef.current?.click()}> <button className="ghost-button" type="button" onClick={() => fileInputRef.current?.click()}>
<span className="button-glyph button-glyph-upload" aria-hidden="true" />
Upload Upload
</button> </button>
<button className="ghost-button" type="button" onClick={() => void directoryQuery.refetch()}> <button className="ghost-button" type="button" onClick={() => void directoryQuery.refetch()}>
@ -393,30 +372,8 @@ export function FilesPage() {
<button className="ghost-button" type="button" onClick={() => setViewMode(viewMode === "list" ? "grid" : "list")}> <button className="ghost-button" type="button" onClick={() => setViewMode(viewMode === "list" ? "grid" : "list")}>
{viewMode === "list" ? "Grid" : "List"} {viewMode === "list" ? "Grid" : "List"}
</button> </button>
</div>
</div>
<div className="files-toolbar">
<div className="filter-bar">
<div className="search-shell">
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search files and folders"
aria-label="Search current directory"
/>
{search ? (
<button
className="ghost-button compact-button"
type="button"
onClick={() => setSearch("")}
>
Clear
</button>
) : null}
</div>
<select <select
className="input-select" className="input-select compact-sort-select"
value={sortBy} value={sortBy}
onChange={(event) => setSortBy(event.target.value as "name" | "updated" | "size")} onChange={(event) => setSortBy(event.target.value as "name" | "updated" | "size")}
aria-label="Sort current directory" aria-label="Sort current directory"
@ -438,9 +395,9 @@ export function FilesPage() {
{message ? <p className="notice-text">{message}</p> : null} {message ? <p className="notice-text">{message}</p> : null}
{uploads.length ? ( {activeUploads.length ? (
<div className="upload-strip"> <div className="upload-strip">
{uploads.slice(0, 2).map((upload) => ( {activeUploads.slice(0, 2).map((upload) => (
<div key={upload.id} className="upload-pill"> <div key={upload.id} className="upload-pill">
<strong>{upload.name}</strong> <strong>{upload.name}</strong>
<span>{upload.stage}</span> <span>{upload.stage}</span>
@ -450,11 +407,12 @@ export function FilesPage() {
</div> </div>
) : null} ) : null}
<div className="list-surface"> <div className={viewMode === "grid" ? "list-surface list-surface-grid" : "list-surface"}>
<div className="list-header"> <div className="list-header">
<span>Name</span> <span>Name</span>
<span>Updated</span> <span>Updated</span>
<span>Size</span> <span>Size</span>
<span>Replicas</span>
<span className="list-header-actions">Actions</span> <span className="list-header-actions">Actions</span>
</div> </div>
@ -463,10 +421,9 @@ export function FilesPage() {
) : !items.length && !search ? ( ) : !items.length && !search ? (
<div className="empty-state empty-state-rich"> <div className="empty-state empty-state-rich">
<span className="eyebrow">Empty directory</span> <span className="eyebrow">Empty directory</span>
<strong>Start by adding a folder or uploading a file.</strong> <strong>Add a folder or upload a file.</strong>
<p>This space is ready for your first files.</p>
<div className="empty-state-actions"> <div className="empty-state-actions">
<button type="button" onClick={() => openDialog("create")}> <button className="primary-button" type="button" onClick={() => openDialog("create")}>
New Folder New Folder
</button> </button>
<button className="ghost-button" type="button" onClick={() => fileInputRef.current?.click()}> <button className="ghost-button" type="button" onClick={() => fileInputRef.current?.click()}>
@ -495,32 +452,18 @@ export function FilesPage() {
}} }}
> >
<div className="drive-item-main"> <div className="drive-item-main">
<span className="item-badge">{item.kind === "directory" ? "Folder" : "File"}</span> <span className={`resource-icon ${getResourceIconClass(item)}`} aria-hidden="true">
<strong>{item.name}</strong> {getResourceIconLabel(item)}
<span className="item-muted">
{item.kind === "file" ? item.mime_type || "unknown type" : "Double-click to open"}
</span> </span>
<div className="resource-name-stack">
<strong>{item.name}</strong>
</div>
</div> </div>
<div className="drive-item-meta"> <div className="drive-item-meta">
<span>{formatDate(item.updated_at)}</span> <span>{formatDate(item.updated_at)}</span>
<span>{formatBytes(item.size_bytes)}</span> <span>{formatBytes(item.size_bytes)}</span>
<span className={getReplicaRatioClass(item)}>{formatReplicaRatio(item)}</span>
<div className="row-actions"> <div className="row-actions">
<button
className="ghost-button compact-button row-action-button"
type="button"
onClick={(event) => {
event.stopPropagation();
if (item.kind === "directory") {
openDirectory(item);
} else {
void openFileAsset(item.id, item.name, "download").catch((error: unknown) =>
pushToast(error instanceof Error ? error.message : "Download failed.", "error"),
);
}
}}
>
Open
</button>
<button <button
className="ghost-button compact-button row-action-button" className="ghost-button compact-button row-action-button"
type="button" type="button"
@ -564,6 +507,13 @@ export function FilesPage() {
}, },
...(item.kind === "file" ...(item.kind === "file"
? [ ? [
{
label: "Reconcile",
onSelect: () => {
setSelectedItem(item);
reconcileMutation.mutate(item.id);
},
},
{ {
label: "Delete", label: "Delete",
tone: "danger" as const, tone: "danger" as const,
@ -592,31 +542,18 @@ export function FilesPage() {
)} )}
</div> </div>
{filteredItems.length > pageSize ? ( <PaginationControls
<div className="pagination-bar" aria-label="Files pagination"> label="Files pagination"
<span className="pagination-copy"> page={page}
Page {page} of {totalPages} pageSize={pageSize}
</span> totalItems={filteredItems.length}
<div className="toolbar-group"> totalPages={totalPages}
<button onPageChange={setPage}
className="ghost-button compact-button" onPageSizeChange={(nextPageSize) => {
type="button" setPageSize(nextPageSize);
disabled={page === 1} setPage(1);
onClick={() => setPage((current) => Math.max(1, current - 1))} }}
> />
Previous
</button>
<button
className="ghost-button compact-button"
type="button"
disabled={page === totalPages}
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
>
Next
</button>
</div>
</div>
) : null}
</div> </div>
</section> </section>
@ -625,187 +562,69 @@ export function FilesPage() {
<div className="detail-empty"> <div className="detail-empty">
<span className="eyebrow">Inspector</span> <span className="eyebrow">Inspector</span>
<h3>Select a file or folder</h3> <h3>Select a file or folder</h3>
<p>Preview, metadata, storage hints, and quick actions appear here.</p>
</div> </div>
) : ( ) : (
<> <>
<div className="section-heading"> <div className="section-heading">
<span className="eyebrow">Inspector</span> <span className="eyebrow">Inspector</span>
<h3>{selectedItem.name}</h3> <h3>{selectedItem.name}</h3>
<p className="item-muted">{selectedItem.kind === "directory" ? "Folder" : "File"} details and quick actions.</p>
</div>
<div className="inspector-tabs" role="tablist" aria-label="Inspector sections">
<button
type="button"
className={inspectorTab === "details" ? "inspector-tab active" : "inspector-tab"}
onClick={() => setInspectorTab("details")}
>
Details
</button>
<button
type="button"
className={inspectorTab === "activity" ? "inspector-tab active" : "inspector-tab"}
onClick={() => setInspectorTab("activity")}
>
Activity
</button>
<button
type="button"
className={inspectorTab === "storage" ? "inspector-tab active" : "inspector-tab"}
onClick={() => setInspectorTab("storage")}
>
Storage
</button>
</div>
{inspectorTab === "details" ? (
<>
<div className="inspector-hero">
<div className="inspector-hero-main">
<span className="item-badge">{selectedItem.kind === "directory" ? "Folder" : "File"}</span>
<strong>{selectedItem.name}</strong>
<span className="item-muted">
{selectedItem.kind === "directory"
? "Browsable container in the current namespace"
: selectedItem.mime_type || "Generic file"}
</span>
</div>
<div className="inspector-status-block">
<span className="metric-label">Status</span>
<strong>{selectedItem.kind === "directory" ? "Browsable" : "Available"}</strong>
</div>
</div>
<div className="meta-list inspector-meta-card">
<div>
<span>Updated</span>
<strong>{formatDate(selectedItem.updated_at)}</strong>
</div>
<div>
<span>Kind</span>
<strong>{selectedItem.kind === "directory" ? "Directory" : "File"}</strong>
</div>
<div>
<span>Size</span>
<strong>{formatBytes(selectedItem.size_bytes)}</strong>
</div>
</div> </div>
{selectedItem.kind === "directory" ? ( {selectedItem.kind === "file" ? (
<div className="action-stack detail-actions">
<button type="button" onClick={() => openDirectory(selectedItem)}>
Open Folder
</button>
<button className="ghost-button detail-action-secondary" type="button" onClick={() => openDialog("rename")}>
Rename
</button>
<button className="ghost-button detail-action-secondary" type="button" onClick={() => openDialog("move")}>
Move
</button>
</div>
) : (
<>
<FilePreview <FilePreview
fileId={selectedItem.id} fileId={selectedItem.id}
mimeType={selectedItem.mime_type} mimeType={selectedItem.mime_type}
fileName={selectedItem.name} fileName={selectedItem.name}
onError={(error) => pushToast(error, "error")} onError={(error) => pushToast(error, "error")}
/> />
) : null}
<div className="inspector-section"> {selectedItem.kind === "file" ? (
<span className="eyebrow">File Info</span> <>
<div className="meta-list compact-meta-list inspector-card"> <div className="inspector-storage-summary">
<div>
<span>Type</span>
<strong>{selectedItem.mime_type || "Unknown"}</strong>
</div>
<div> <div>
<span>File ID</span> <span>Replicas</span>
<strong className="mono-text">{selectedItem.id}</strong> <strong>{formatReplicaRatio(selectedItem)}</strong>
</div>
</div>
</div>
<div className="inspector-section">
<span className="eyebrow">Availability</span>
<div className="inspector-chip-row inspector-card">
<span className="inspector-chip">Ready</span>
<span className="inspector-chip">Preview aware</span>
<span className="inspector-chip">Gateway download</span>
</div> </div>
<span className={getReplicaRatioClass(selectedItem)}>
{(selectedItem.replica_missing_count ?? 0) > 0 ? "Needs sync" : "Ready"}
</span>
</div> </div>
<button className="ghost-button inspector-info-button" type="button" onClick={() => setIsDetailDrawerOpen(true)}>
<div className="action-stack detail-actions"> More Info
<button
type="button"
onClick={() =>
void openFileAsset(selectedItem.id, selectedItem.name, "download").catch((error: unknown) =>
pushToast(error instanceof Error ? error.message : "Download failed.", "error"),
)
}
>
Download
</button>
{selectedItem.mime_type?.startsWith("image/") ||
selectedItem.mime_type === "application/pdf" ||
selectedItem.mime_type?.startsWith("video/") ? (
<button
className="ghost-button detail-action-secondary"
type="button"
onClick={() =>
void openFileAsset(
selectedItem.id,
selectedItem.name,
selectedItem.mime_type?.startsWith("video/") ? "stream" : "preview",
true,
).catch((error: unknown) =>
pushToast(error instanceof Error ? error.message : "Preview failed.", "error"),
)
}
>
Open Preview
</button> </button>
) : null} </>
<button className="ghost-button detail-action-secondary" type="button" onClick={() => openDialog("rename")}>
Rename
</button>
<button className="ghost-button detail-action-secondary" type="button" onClick={() => openDialog("move")}>
Move
</button>
<button className="danger-button" type="button" onClick={() => openDialog("delete")}>
Delete
</button>
</div>
<div className="artifact-list inspector-section">
<span className="eyebrow">Preview Artifacts</span>
<div className="inspector-card">
{detailQuery.data?.preview_artifacts.length ? (
detailQuery.data.preview_artifacts.map((artifact) => (
<div key={artifact.id} className="artifact-row">
<strong>{titleCase(artifact.artifact_type)}</strong>
<span>{titleCase(artifact.status)}</span>
</div>
))
) : ( ) : (
<p className="item-muted">No generated artifacts yet.</p> <div className="detail-empty inspector-folder-note">
)} <span className="eyebrow">Folder</span>
</div> <h3>{selectedItem.name}</h3>
</div> </div>
</>
)} )}
</> </>
) : inspectorTab === "activity" ? ( )}
<div className="detail-empty detail-tab-panel"> </aside>
<span className="eyebrow">Activity</span>
<h3>No recent activity</h3> {selectedItem?.kind === "file" && isDetailDrawerOpen ? (
<p>Upload, rename, move, and preview events will surface here as the product grows.</p> <div className="detail-drawer-backdrop" role="presentation" onClick={() => setIsDetailDrawerOpen(false)}>
<aside
className="detail-drawer"
role="dialog"
aria-modal="true"
aria-label={`${selectedItem.name} details`}
onClick={(event) => event.stopPropagation()}
>
<div className="detail-drawer-header">
<div>
<span className="eyebrow">Replica Info</span>
<h3>{selectedItem.name}</h3>
</div> </div>
) : ( <button className="ghost-button compact-button" type="button" onClick={() => setIsDetailDrawerOpen(false)}>
selectedItem.kind === "file" ? ( Close
<div className="detail-tab-panel"> </button>
<div className="inspector-section"> </div>
<div className="detail-drawer-content">
<section className="inspector-section">
<span className="eyebrow">Placement</span> <span className="eyebrow">Placement</span>
<div className="inspector-card storage-summary-card"> <div className="inspector-card storage-summary-card">
<div> <div>
@ -813,12 +632,12 @@ export function FilesPage() {
<strong>{titleCase(detailQuery.data?.placement_file_class ?? "unknown")}</strong> <strong>{titleCase(detailQuery.data?.placement_file_class ?? "unknown")}</strong>
</div> </div>
<div> <div>
<span>Desired</span> <span>Replicas</span>
<strong>{detailQuery.data?.desired_backends.length ?? 0}</strong> <strong>{formatReplicaRatio(selectedItem)}</strong>
</div> </div>
<div> <div>
<span>Ready replicas</span> <span>Missing</span>
<strong>{detailQuery.data?.ready_replica_backend_ids.length ?? 0}</strong> <strong>{detailQuery.data?.missing_backend_ids.length ?? selectedItem.replica_missing_count ?? 0}</strong>
</div> </div>
</div> </div>
{detailQuery.data?.missing_backend_ids.length ? ( {detailQuery.data?.missing_backend_ids.length ? (
@ -830,17 +649,9 @@ export function FilesPage() {
</p> </p>
</div> </div>
) : null} ) : null}
<button </section>
className="ghost-button"
type="button"
disabled={reconcileMutation.isPending}
onClick={() => reconcileMutation.mutate()}
>
Reconcile File
</button>
</div>
<div className="inspector-section"> <section className="inspector-section">
<span className="eyebrow">Desired Backends</span> <span className="eyebrow">Desired Backends</span>
<div className="inspector-card replica-list"> <div className="inspector-card replica-list">
{detailQuery.data?.desired_backends.length ? ( {detailQuery.data?.desired_backends.length ? (
@ -867,9 +678,9 @@ export function FilesPage() {
<p className="item-muted">No placement targets selected.</p> <p className="item-muted">No placement targets selected.</p>
)} )}
</div> </div>
</div> </section>
<div className="inspector-section"> <section className="inspector-section">
<span className="eyebrow">Replicas</span> <span className="eyebrow">Replicas</span>
<div className="inspector-card replica-list"> <div className="inspector-card replica-list">
{detailQuery.data?.replicas.length ? ( {detailQuery.data?.replicas.length ? (
@ -890,19 +701,27 @@ export function FilesPage() {
<p className="item-muted">No recorded replicas for this file yet.</p> <p className="item-muted">No recorded replicas for this file yet.</p>
)} )}
</div> </div>
</section>
<section className="inspector-section">
<span className="eyebrow">Preview Artifacts</span>
<div className="inspector-card">
{detailQuery.data?.preview_artifacts.length ? (
detailQuery.data.preview_artifacts.map((artifact) => (
<div key={artifact.id} className="artifact-row">
<strong>{titleCase(artifact.artifact_type)}</strong>
<span>{titleCase(artifact.status)}</span>
</div> </div>
</div> ))
) : ( ) : (
<div className="detail-empty detail-tab-panel"> <p className="item-muted">No generated artifacts yet.</p>
<span className="eyebrow">Storage</span>
<h3>Folder storage</h3>
<p>Replica placement is tracked for files.</p>
</div>
)
)}
</>
)} )}
</div>
</section>
</div>
</aside> </aside>
</div>
) : null}
{dialogMode ? ( {dialogMode ? (
<Dialog <Dialog
@ -927,14 +746,6 @@ export function FilesPage() {
))} ))}
</select> </select>
</label> </label>
<div className="move-tree">
<span className="eyebrow">Browse folders</span>
<DirectoryTree
selectedId={moveTargetId}
expandedIds={crumbs.map((crumb) => crumb.id)}
onSelectDirectory={(node) => setMoveTargetId(node.id)}
/>
</div>
</div> </div>
) : dialogMode === "delete" ? ( ) : dialogMode === "delete" ? (
<p>Move <strong>{selectedItem?.name}</strong> to the recycle bin?</p> <p>Move <strong>{selectedItem?.name}</strong> to the recycle bin?</p>
@ -954,6 +765,7 @@ export function FilesPage() {
Cancel Cancel
</button> </button>
<button <button
className={dialogMode === "delete" ? "danger-button" : "primary-button"}
type="submit" type="submit"
disabled={ disabled={
createFolderMutation.isPending || createFolderMutation.isPending ||
@ -972,19 +784,6 @@ export function FilesPage() {
); );
} }
function rebuildCrumbsFromNode(existingCrumbs: Crumb[], node: Crumb): Crumb[] {
if (node.id === "dir_root") {
return [{ id: "dir_root", name: "/" }];
}
const existingIndex = existingCrumbs.findIndex((crumb) => crumb.id === node.id);
if (existingIndex >= 0) {
return existingCrumbs.slice(0, existingIndex + 1);
}
return [...existingCrumbs, node];
}
function FilePreview({ function FilePreview({
fileId, fileId,
mimeType, mimeType,
@ -996,22 +795,31 @@ function FilePreview({
fileName: string; fileName: string;
onError: (message: string) => void; onError: (message: string) => void;
}) { }) {
const isTextPreview = isTextPreviewMime(mimeType, fileName);
const isPdfPreview = mimeType === "application/pdf";
const supportsInlinePreview = const supportsInlinePreview =
mimeType?.startsWith("image/") || mimeType === "application/pdf" || mimeType?.startsWith("video/"); mimeType?.startsWith("image/") || isPdfPreview || mimeType?.startsWith("video/") || isTextPreview;
const variant = mimeType?.startsWith("video/") ? "stream" : "preview"; const variant = mimeType?.startsWith("video/") ? "stream" : isTextPreview ? "download" : "preview";
const assetQuery = useQuery({ const assetQuery = useQuery({
queryKey: ["files", fileId, "asset", variant], queryKey: ["files", fileId, "asset", variant],
queryFn: async () => { queryFn: async () => {
const blob = await api.fileBlob(fileId, variant); const blob = await api.fileBlob(fileId, variant);
return URL.createObjectURL(blob); if (isTextPreview) {
return {
text: await blob.text(),
};
}
return {
url: URL.createObjectURL(blob),
};
}, },
enabled: Boolean(fileId) && Boolean(supportsInlinePreview), enabled: Boolean(fileId) && Boolean(supportsInlinePreview),
}); });
useEffect(() => { useEffect(() => {
return () => { return () => {
if (assetQuery.data) { if (assetQuery.data?.url) {
URL.revokeObjectURL(assetQuery.data); URL.revokeObjectURL(assetQuery.data.url);
} }
}; };
}, [assetQuery.data]); }, [assetQuery.data]);
@ -1031,16 +839,44 @@ function FilePreview({
); );
} }
if (assetQuery.data && mimeType?.startsWith("image/")) { if (assetQuery.data?.url && mimeType?.startsWith("image/")) {
return <img className="preview-frame" src={assetQuery.data} alt={fileName} />; return <img className="preview-frame image-preview-frame" src={assetQuery.data.url} alt={fileName} />;
} }
if (assetQuery.data && mimeType === "application/pdf") { if (assetQuery.data?.url && isPdfPreview) {
return <iframe className="preview-frame" src={assetQuery.data} title={fileName} />; return (
<div className="preview-fallback pdf-preview-card">
<span className="resource-icon resource-icon-pdf" aria-hidden="true">
PDF
</span>
<strong>{fileName}</strong>
<p>Open the PDF in a full browser tab for a usable document preview.</p>
<div className="preview-actions">
<button className="ghost-button compact-button" type="button" onClick={() => window.open(assetQuery.data.url, "_blank", "noopener,noreferrer")}>
Open Preview
</button>
<button
className="ghost-button compact-button"
type="button"
onClick={() =>
openFileAsset(fileId, fileName, "download").catch((error: unknown) =>
onError(error instanceof Error ? error.message : "Download failed."),
)
}
>
Download
</button>
</div>
</div>
);
}
if (assetQuery.data?.text !== undefined) {
return <pre className="preview-frame text-preview-frame">{truncatePreviewText(assetQuery.data.text)}</pre>;
} }
if (assetQuery.data && mimeType?.startsWith("video/")) { if (assetQuery.data?.url && mimeType?.startsWith("video/")) {
return <video className="preview-frame" src={assetQuery.data} controls preload="metadata" />; return <video className="preview-frame" src={assetQuery.data.url} controls preload="metadata" />;
} }
return ( return (
@ -1051,6 +887,89 @@ function FilePreview({
); );
} }
function isTextPreviewMime(mimeType: string | null, fileName: string) {
if (mimeType?.startsWith("text/")) {
return true;
}
if (["application/json", "application/xml", "application/yaml"].includes(mimeType ?? "")) {
return true;
}
return [".md", ".txt", ".json", ".yaml", ".yml", ".csv", ".log"].some((suffix) =>
fileName.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 getFileExtension(name: string) {
const extension = name.includes(".") ? name.split(".").pop()?.toUpperCase() : "";
return extension || "File";
}
function getResourceIconLabel(item: DirectoryItem) {
if (item.kind === "directory") {
return "";
}
return getFileExtension(item.name).slice(0, 3);
}
function getResourceIconClass(item: DirectoryItem) {
if (item.kind === "directory") {
return "resource-icon-folder";
}
const extension = getFileExtension(item.name).toLowerCase();
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(extension)) {
return "resource-icon-image";
}
if (["pdf"].includes(extension)) {
return "resource-icon-pdf";
}
if (["doc", "docx", "odt", "txt", "md"].includes(extension)) {
return "resource-icon-document";
}
if (["xls", "xlsx", "csv", "ods"].includes(extension)) {
return "resource-icon-spreadsheet";
}
if (["ppt", "pptx", "odp"].includes(extension)) {
return "resource-icon-presentation";
}
if (["zip", "tar", "gz", "rar", "7z"].includes(extension)) {
return "resource-icon-archive";
}
if (["mp4", "mov", "webm", "wmv"].includes(extension)) {
return "resource-icon-video";
}
return "resource-icon-file";
}
function formatReplicaRatio(item: DirectoryItem) {
if (item.kind !== "file") {
return "--";
}
return `${item.replica_ready_count ?? 0}/${item.replica_desired_count ?? 0}`;
}
function getReplicaRatioClass(item: DirectoryItem) {
if (item.kind !== "file") {
return "replica-ratio replica-ratio-muted";
}
return (item.replica_missing_count ?? 0) > 0
? "replica-ratio replica-ratio-warning"
: "replica-ratio replica-ratio-ready";
}
function getCurrentDirectoryName(crumbs: Crumb[]) {
const current = crumbs[crumbs.length - 1];
return !current || current.id === "dir_root" || current.name === "/" ? "Personal" : current.name;
}
function canGoBack(crumbs: Crumb[]) {
return crumbs.length > 1;
}
async function openFileAsset( async function openFileAsset(
fileId: string, fileId: string,
fileName: string, fileName: string,

189
frontend/src/routes/JobsPage.tsx

@ -1,11 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { api } from "../lib/api"; import { PaginationControls } from "../components/PaginationControls";
import { api, type JobSummary } from "../lib/api";
import { formatDate, titleCase } from "../lib/format"; import { formatDate, titleCase } from "../lib/format";
export function JobsPage() { export function JobsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(8);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const jobsQuery = useQuery({ const jobsQuery = useQuery({
queryKey: ["jobs"], queryKey: ["jobs"],
queryFn: api.listJobs, queryFn: api.listJobs,
@ -35,26 +38,49 @@ export function JobsPage() {
const pendingCount = items.filter((job) => job.status === "pending").length; const pendingCount = items.filter((job) => job.status === "pending").length;
const completedCount = items.filter((job) => job.status === "completed").length; const completedCount = items.filter((job) => job.status === "completed").length;
const failedCount = items.filter((job) => job.status === "failed").length; const failedCount = items.filter((job) => job.status === "failed").length;
const pageSize = 8;
const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const pagedItems = useMemo( const pagedItems = useMemo(
() => items.slice((page - 1) * pageSize, page * pageSize), () => items.slice((page - 1) * pageSize, page * pageSize),
[items, page], [items, page, pageSize],
); );
const selectedJob = items.find((job) => job.id === selectedJobId) ?? null;
useEffect(() => { useEffect(() => {
if (page > totalPages) { if (page > totalPages) {
setPage(totalPages); setPage(totalPages);
} }
}, [page, totalPages]); }, [page, totalPages]);
useEffect(() => {
if (selectedJobId && !items.some((job) => job.id === selectedJobId)) {
setSelectedJobId(null);
}
}, [items, selectedJobId]);
return ( return (
<div className="compact-page-shell compact-page-shell-wide"> <div className="compact-page-shell compact-page-shell-wide">
<div className="dashboard-page-grid"> <div className="jobs-page-layout">
<section className="panel dashboard-main-panel"> <section className="panel main-panel dashboard-main-panel">
<div className="panel-toolbar"> <div className="panel-toolbar files-toolbar-top">
<div className="section-heading"> <div className="files-header">
<span className="eyebrow">Jobs</span> <span className="eyebrow">Jobs</span>
<h3>Repair and background activity</h3> <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> </div>
@ -68,7 +94,18 @@ export function JobsPage() {
<div className="list-table"> <div className="list-table">
{items.length ? ( {items.length ? (
pagedItems.map((job) => ( pagedItems.map((job) => (
<div key={job.id} className="table-row"> <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> <div>
<strong>{titleCase(job.kind)}</strong> <strong>{titleCase(job.kind)}</strong>
<span className="item-muted">Run after {formatDate(job.run_after)}</span> <span className="item-muted">Run after {formatDate(job.run_after)}</span>
@ -79,7 +116,14 @@ export function JobsPage() {
<span> <span>
{job.attempt_count}/{job.max_attempts} {job.attempt_count}/{job.max_attempts}
</span> </span>
<button className="ghost-button compact-button" type="button" onClick={() => retryMutation.mutate(job.id)}> <button
className="ghost-button compact-button"
type="button"
onClick={(event) => {
event.stopPropagation();
retryMutation.mutate(job.id);
}}
>
Retry Retry
</button> </button>
</div> </div>
@ -88,79 +132,84 @@ export function JobsPage() {
) : ( ) : (
<div className="empty-state empty-state-rich"> <div className="empty-state empty-state-rich">
<strong>No background jobs recorded yet.</strong> <strong>No background jobs recorded yet.</strong>
<p className="item-muted">Run pending work or enqueue checks to populate this activity list.</p>
</div> </div>
)} )}
</div> </div>
</div> </div>
{items.length > pageSize ? ( <PaginationControls
<div className="pagination-bar" aria-label="Jobs pagination"> label="Jobs pagination"
<span className="pagination-copy"> page={page}
Page {page} of {totalPages} 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> </span>
<div className="toolbar-group">
<button
className="ghost-button compact-button"
type="button"
disabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Previous
</button>
<button
className="ghost-button compact-button"
type="button"
disabled={page === totalPages}
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
>
Next
</button>
</div> </div>
<div className="meta-list inspector-card jobs-detail-meta">
<div>
<span>Run after</span>
<strong>{formatDate(selectedJob.run_after)}</strong>
</div> </div>
) : null} <div>
</section> <span>Created</span>
<strong>{formatDate(selectedJob.created_at)}</strong>
<aside className="dashboard-side-column">
<section className="panel dashboard-side-panel">
<div className="section-heading">
<span className="eyebrow">Overview</span>
<h3>Queue status</h3>
</div>
<div className="summary-stack">
<article className="summary-card">
<span className="metric-label">Pending</span>
<strong>{pendingCount}</strong>
</article>
<article className="summary-card">
<span className="metric-label">Completed</span>
<strong>{completedCount}</strong>
</article>
<article className="summary-card">
<span className="metric-label">Failed</span>
<strong>{failedCount}</strong>
</article>
</div> </div>
</section> <div>
<span>Updated</span>
<section className="panel dashboard-side-panel"> <strong>{formatDate(selectedJob.updated_at)}</strong>
<div className="section-heading"> </div>
<span className="eyebrow">Actions</span> <div>
<h3>Background controls</h3> <span>ID</span>
<strong className="mono-text">{selectedJob.id}</strong>
</div> </div>
<div className="action-stack">
<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 type="button" onClick={() => reconcileMutation.mutate()}>
Full Reconcile
</button>
</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> </section>
</>
)}
</aside> </aside>
</div> </div>
</div> </div>
); );
} }
function formatJobPayload(job: JobSummary) {
try {
return JSON.stringify(JSON.parse(job.payload_json), null, 2);
} catch {
return job.payload_json || "{}";
}
}

2
frontend/src/routes/LoginPage.tsx

@ -68,7 +68,7 @@ export function LoginPage() {
required required
/> />
</label> </label>
<button type="submit" disabled={isSubmitting}> <button className="primary-button" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Signing in…" : "Sign in"} {isSubmitting ? "Signing in…" : "Sign in"}
</button> </button>
{error ? <p className="error-text">{error}</p> : null} {error ? <p className="error-text">{error}</p> : null}

31
frontend/src/routes/PoliciesPage.tsx

@ -120,29 +120,18 @@ export function PoliciesPage() {
<div className="dashboard-page-grid"> <div className="dashboard-page-grid">
<section className="dashboard-main-column"> <section className="dashboard-main-column">
<div className="panel-toolbar storage-toolbar"> <div className="panel-toolbar storage-toolbar">
<div className="section-heading"> <div className="files-header">
<span className="eyebrow">Placement</span> <span className="eyebrow">Placement</span>
<h3>Replica policies</h3> <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>
<div className="summary-strip summary-strip-four-up">
<article className="summary-card">
<span className="metric-label">Policies</span>
<strong>{policies.length}</strong>
</article>
<article className="summary-card">
<span className="metric-label">Local</span>
<strong>{localBackendCount}</strong>
</article>
<article className="summary-card">
<span className="metric-label">Stable</span>
<strong>{stableBackendCount}</strong>
</article>
<article className="summary-card">
<span className="metric-label">Opportunistic</span>
<strong>{opportunisticBackendCount}</strong>
</article>
</div> </div>
<div className="policy-tabs" role="tablist" aria-label="File classes"> <div className="policy-tabs" role="tablist" aria-label="File classes">
@ -229,7 +218,7 @@ export function PoliciesPage() {
{formError ? <p className="error-text">{formError}</p> : null} {formError ? <p className="error-text">{formError}</p> : null}
<div className="dialog-actions"> <div className="dialog-actions">
<button type="submit" disabled={updateMutation.isPending}> <button className="primary-button" type="submit" disabled={updateMutation.isPending}>
Save Policy Save Policy
</button> </button>
</div> </div>

125
frontend/src/routes/RecycleBinPage.tsx

@ -1,11 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { PaginationControls } from "../components/PaginationControls";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { formatBytes, formatDate } from "../lib/format"; import { formatBytes, formatDate } from "../lib/format";
export function RecycleBinPage() { export function RecycleBinPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(8);
const recycleQuery = useQuery({ const recycleQuery = useQuery({
queryKey: ["recycle-bin"], queryKey: ["recycle-bin"],
queryFn: api.listRecycleBin, queryFn: api.listRecycleBin,
@ -19,11 +21,10 @@ export function RecycleBinPage() {
}); });
const items = recycleQuery.data?.items ?? []; const items = recycleQuery.data?.items ?? [];
const totalBytes = items.reduce((sum, item) => sum + item.size_bytes, 0); const totalBytes = items.reduce((sum, item) => sum + item.size_bytes, 0);
const pageSize = 8;
const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const pagedItems = useMemo( const pagedItems = useMemo(
() => items.slice((page - 1) * pageSize, page * pageSize), () => items.slice((page - 1) * pageSize, page * pageSize),
[items, page], [items, page, pageSize],
); );
useEffect(() => { useEffect(() => {
if (page > totalPages) { if (page > totalPages) {
@ -33,108 +34,64 @@ export function RecycleBinPage() {
return ( return (
<div className="compact-page-shell compact-page-shell-wide"> <div className="compact-page-shell compact-page-shell-wide">
<div className="dashboard-page-grid">
<section className="panel dashboard-main-panel"> <section className="panel dashboard-main-panel">
<div className="panel-toolbar"> <div className="panel-toolbar files-toolbar-top">
<div className="section-heading"> <div className="files-header">
<span className="eyebrow">Recycle Bin</span> <span className="eyebrow">Deleted files</span>
<h3>Recently deleted files</h3> <div className="directory-title-row">
<p className="item-muted">Restore files you still need before they are permanently removed.</p> <h2>Recycle Bin</h2>
</div>
<div className="inline-metrics">
<span>{items.length} items</span>
<span>{formatBytes(totalBytes)}</span>
</div> </div>
</div> </div>
<div className="summary-strip summary-strip-two-up">
<article className="summary-card">
<span className="metric-label">Items</span>
<strong>{items.length}</strong>
</article>
<article className="summary-card">
<span className="metric-label">Visible size</span>
<strong>{formatBytes(totalBytes)}</strong>
</article>
</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"> <div className="list-table">
{items.length ? ( {items.length ? (
pagedItems.map((item) => ( pagedItems.map((item) => (
<div key={item.id} className="table-row"> <div key={item.id} className="table-row recycle-row">
<div> <div>
<strong>{item.name}</strong> <strong>{item.name}</strong>
<span className="item-muted"> <span className="item-muted">Recoverable file</span>
{formatBytes(item.size_bytes)} · deleted {formatDate(item.deleted_at)}
</span>
</div> </div>
<button className="ghost-button" type="button" onClick={() => restoreMutation.mutate(item.id)}> <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 Restore
</button> </button>
</div> </div>
</div>
)) ))
) : ( ) : (
<div className="empty-state">Recycle bin is empty.</div> <div className="empty-state empty-state-rich">
)} <strong>Recycle bin is empty.</strong>
</div>
{items.length > pageSize ? (
<div className="pagination-bar" aria-label="Recycle bin pagination">
<span className="pagination-copy">
Page {page} of {totalPages}
</span>
<div className="toolbar-group">
<button
className="ghost-button compact-button"
type="button"
disabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Previous
</button>
<button
className="ghost-button compact-button"
type="button"
disabled={page === totalPages}
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
>
Next
</button>
</div>
</div>
) : null}
</section>
<aside className="dashboard-side-column">
<section className="panel dashboard-side-panel">
<div className="section-heading">
<span className="eyebrow">Overview</span>
<h3>Bin status</h3>
</div> </div>
<div className="meta-list compact-meta-list"> )}
<div>
<span>Recoverable files</span>
<strong>{items.length}</strong>
</div>
<div>
<span>Stored size</span>
<strong>{formatBytes(totalBytes)}</strong>
</div>
<div>
<span>Current page</span>
<strong>
{page} / {totalPages}
</strong>
</div>
</div>
</section>
<section className="panel dashboard-side-panel">
<div className="section-heading">
<span className="eyebrow">Guidance</span>
<h3>How it works</h3>
</div> </div>
<div className="empty-state">
<strong>Deleted files stay here until you restore or remove them permanently.</strong>
<p className="item-muted">Use this page as a short-term safety net, not long-term storage.</p>
</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> </section>
</aside>
</div>
</div> </div>
); );
} }

142
frontend/src/routes/StoragePage.tsx

@ -221,136 +221,88 @@ export function StoragePage() {
return ( return (
<div className="compact-page-shell compact-page-shell-wide"> <div className="compact-page-shell compact-page-shell-wide">
<div className="dashboard-page-grid"> <section className="panel dashboard-main-panel">
<section className="dashboard-main-column"> <div className="panel-toolbar files-toolbar-top">
<div className="panel-toolbar storage-toolbar"> <div className="files-header">
<div className="section-heading">
<span className="eyebrow">Storage</span> <span className="eyebrow">Storage</span>
<h3>Backends</h3> <div className="directory-title-row">
<h2>Backends</h2>
</div> </div>
<button type="button" onClick={() => setIsCreateOpen(true)}> <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 Add Backend
</button> </button>
<button className="ghost-button" type="button" onClick={() => runAllChecksMutation.mutate()}> <button className="ghost-button" type="button" onClick={() => runAllChecksMutation.mutate()}>
Run All Checks Run All Checks
</button> </button>
</div> </div>
<div className="summary-strip summary-strip-four-up">
<article className="summary-card">
<span className="metric-label">Backends</span>
<strong>{items.length}</strong>
</article>
<article className="summary-card">
<span className="metric-label">Healthy</span>
<strong>{healthyCount}</strong>
</article>
<article className="summary-card">
<span className="metric-label">Enabled</span>
<strong>{enabledCount}</strong>
</article>
<article className="summary-card">
<span className="metric-label">Disabled</span>
<strong>{disabledCount}</strong>
</article>
</div> </div>
<div className="stack-page"> <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) => ( {items.map((backend) => (
<section key={backend.id} className="panel storage-card"> <div key={backend.id} className="table-row storage-table-row">
<div className="section-heading"> <div>
<span className="eyebrow">{formatBackendType(backend.type)}</span> <strong>{backend.name}</strong>
<h3>{backend.name}</h3> <span className="item-muted">
{formatBackendType(backend.type)} · {formatBackendConfig(backend.config)}
{backend.secret_fields.length ? ` · ${backend.secret_fields.length} secrets` : ""}
</span>
</div> </div>
<div className="storage-status-row"> <div className="table-meta storage-row-meta">
<span className={backend.is_enabled ? "status-chip status-chip-success" : "status-chip"}> <span className={backend.is_enabled ? "status-chip status-chip-success" : "status-chip"}>
{backend.is_enabled ? "Enabled" : "Disabled"} {backend.is_enabled ? "Enabled" : "Disabled"}
</span> </span>
<span className="status-chip">{titleCase(backend.last_health_status ?? "unknown")}</span> <span className="status-chip">{titleCase(backend.last_health_status ?? "unknown")}</span>
</div> <span>{titleCase(backend.stability_class)}</span>
<div className="meta-list"> <span>{formatDate(backend.last_health_checked_at)}</span>
<div> <div className="toolbar-group storage-row-actions">
<span>Health</span> <button className="ghost-button compact-button" type="button" onClick={() => checkMutation.mutate(backend.id)}>
<strong>{titleCase(backend.last_health_status ?? "unknown")}</strong> Check
</div>
<div>
<span>Policy class</span>
<strong>{titleCase(backend.stability_class)}</strong>
</div>
<div>
<span>Last checked</span>
<strong>{formatDate(backend.last_health_checked_at)}</strong>
</div>
<div>
<span>Config</span>
<strong>{formatBackendConfig(backend.config)}</strong>
</div>
<div>
<span>Secrets</span>
<strong>{backend.secret_fields.length ? `${backend.secret_fields.length} configured` : "None"}</strong>
</div>
</div>
<div className="toolbar-group storage-actions">
<button className="ghost-button" type="button" onClick={() => checkMutation.mutate(backend.id)}>
Run Check
</button> </button>
<button className="ghost-button" type="button" onClick={() => openEditBackend(backend)}> <button className="ghost-button compact-button" type="button" onClick={() => openEditBackend(backend)}>
Edit Edit
</button> </button>
{backend.is_enabled ? ( {backend.is_enabled ? (
<button className="danger-button" type="button" onClick={() => disableMutation.mutate(backend.id)}> <button className="danger-button compact-button" type="button" onClick={() => disableMutation.mutate(backend.id)}>
Disable Disable
</button> </button>
) : ( ) : (
<> <>
<button className="ghost-button" type="button" onClick={() => enableMutation.mutate(backend.id)}> <button className="ghost-button compact-button" type="button" onClick={() => enableMutation.mutate(backend.id)}>
Enable Enable
</button> </button>
<button className="danger-button" type="button" onClick={() => setDeleteTarget(backend)}> <button className="danger-button compact-button" type="button" onClick={() => setDeleteTarget(backend)}>
Delete Delete
</button> </button>
</> </>
)} )}
</div> </div>
</section>
))}
</div>
</section>
<aside className="dashboard-side-column">
<section className="panel dashboard-side-panel">
<div className="section-heading">
<span className="eyebrow">Overview</span>
<h3>Storage health</h3>
</div>
<div className="meta-list compact-meta-list">
<div>
<span>Total backends</span>
<strong>{items.length}</strong>
</div>
<div>
<span>Healthy</span>
<strong>{healthyCount}</strong>
</div> </div>
<div>
<span>Disabled</span>
<strong>{disabledCount}</strong>
</div> </div>
))}
{!items.length ? (
<div className="empty-state empty-state-rich">
<strong>No storage backends configured.</strong>
</div> </div>
</section> ) : null}
<section className="panel dashboard-side-panel">
<div className="section-heading">
<span className="eyebrow">Notes</span>
<h3>Availability hints</h3>
</div> </div>
<div className="empty-state">
<strong>Keep at least one healthy enabled backend for normal drive access.</strong>
<p className="item-muted">Run checks here before troubleshooting missing files or reconcile issues.</p>
</div> </div>
</section> </section>
</aside>
</div>
{isCreateOpen ? ( {isCreateOpen ? (
<Dialog title="Add storage backend" onClose={() => setIsCreateOpen(false)}> <Dialog title="Add storage backend" onClose={() => setIsCreateOpen(false)}>
<form className="dialog-form" onSubmit={handleCreateBackend}> <form className="dialog-form" onSubmit={handleCreateBackend}>
@ -409,7 +361,7 @@ export function StoragePage() {
<button className="ghost-button" type="button" onClick={() => setIsCreateOpen(false)}> <button className="ghost-button" type="button" onClick={() => setIsCreateOpen(false)}>
Cancel Cancel
</button> </button>
<button type="submit" disabled={createMutation.isPending}> <button className="primary-button" type="submit" disabled={createMutation.isPending}>
Create Create
</button> </button>
</div> </div>
@ -485,7 +437,7 @@ export function StoragePage() {
<button className="ghost-button" type="button" onClick={() => setEditingBackend(null)}> <button className="ghost-button" type="button" onClick={() => setEditingBackend(null)}>
Cancel Cancel
</button> </button>
<button type="submit" disabled={updateMutation.isPending}> <button className="primary-button" type="submit" disabled={updateMutation.isPending}>
Save Save
</button> </button>
</div> </div>

257
frontend/src/routes/UploadsPage.tsx

@ -1,257 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { DirectoryTree } from "../components/DirectoryTree";
import { formatBytes } from "../lib/format";
import { uploadFileToDirectory } from "../lib/uploads";
import { useToast } from "../features/toast/ToastProvider";
type UploadItem = {
id: string;
name: string;
size: number;
status: string;
progress: number;
uploadedFileId?: string;
};
export function UploadsPage() {
const inputRef = useRef<HTMLInputElement | null>(null);
const { pushToast } = useToast();
const [items, setItems] = useState<UploadItem[]>([]);
const [directoryId, setDirectoryId] = useState("dir_root");
const [page, setPage] = useState(1);
const completedCount = useMemo(
() => items.filter((item) => item.status === "Completed").length,
[items],
);
const activeCount = useMemo(
() => items.filter((item) => item.status !== "Completed").length,
[items],
);
const pageSize = 5;
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const pagedItems = useMemo(
() => items.slice((page - 1) * pageSize, page * pageSize),
[items, page],
);
async function uploadFile(file: File) {
const tempId = crypto.randomUUID();
setItems((current) => [
{ id: tempId, name: file.name, size: file.size, status: "Starting", progress: 0 },
...current,
]);
try {
const payload = await uploadFileToDirectory(file, directoryId, (progress, stage, uploadedFileId) => {
setItems((current) =>
current.map((item) =>
item.id === tempId
? {
...item,
status: stage === "completed" ? "Completed" : stage[0].toUpperCase() + stage.slice(1),
progress,
uploadedFileId,
}
: item,
),
);
});
setItems((current) =>
current.map((item) =>
item.id === tempId
? {
...item,
status: "Completed",
progress: 100,
uploadedFileId: payload.file.id as string,
}
: item,
),
);
pushToast(`Uploaded ${file.name}.`, "success");
} catch (error) {
pushToast(error instanceof Error ? error.message : "Upload failed.", "error");
setItems((current) =>
current.map((item) =>
item.id === tempId
? {
...item,
status: error instanceof Error ? error.message : "Upload failed",
}
: item,
),
);
}
}
async function handleFiles(fileList: FileList | null) {
if (!fileList?.length) {
return;
}
for (const file of Array.from(fileList)) {
await uploadFile(file);
}
}
useEffect(() => {
if (page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
return (
<div className="compact-page-shell compact-page-shell-wide">
<div className="uploads-page-grid">
<div className="uploads-main">
<section className="panel uploads-primary-panel">
<div className="panel-toolbar">
<div className="section-heading">
<span className="eyebrow">Uploads</span>
<h3>Browser upload queue</h3>
<p className="item-muted">Send files to a selected folder and keep the queue in view.</p>
</div>
<button type="button" onClick={() => inputRef.current?.click()}>
Choose Files
</button>
</div>
<div className="summary-strip uploads-summary-strip">
<article className="summary-card">
<span className="metric-label">Target</span>
<strong>{directoryId}</strong>
</article>
<article className="summary-card">
<span className="metric-label">Active</span>
<strong>{activeCount}</strong>
</article>
<article className="summary-card">
<span className="metric-label">Completed</span>
<strong>{completedCount}</strong>
</article>
</div>
<div
className="dropzone uploads-dropzone"
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
void handleFiles(event.dataTransfer.files);
}}
>
<div className="uploads-dropzone-copy">
<strong>Drop files here</strong>
<p>Files upload into the selected folder and stay visible in the queue below.</p>
</div>
<div className="uploads-target-inline">
<span className="metric-label">Target directory ID</span>
<input
value={directoryId}
onChange={(event) => setDirectoryId(event.target.value)}
aria-label="Target directory id"
/>
<span className="item-muted">Current target: {directoryId}</span>
</div>
</div>
<input
ref={inputRef}
hidden
type="file"
multiple
onChange={(event) => void handleFiles(event.target.files)}
/>
<div className="uploads-queue-section">
<div className="panel-toolbar compact-toolbar">
<div className="section-heading">
<span className="eyebrow">Queue</span>
<h3>Recent uploads</h3>
</div>
<div className="inline-metrics">
<span>{items.length} queued</span>
<span>{completedCount} completed</span>
</div>
</div>
<div className="list-surface">
<div className="list-table">
{items.length ? (
pagedItems.map((item) => (
<div key={item.id} className="table-row">
<div>
<strong>{item.name}</strong>
<span className="item-muted">{formatBytes(item.size)}</span>
</div>
<div className="table-meta">
<span className="status-chip">{item.status}</span>
<span>{item.progress}%</span>
{item.uploadedFileId ? (
<a href={`/app/files`} className="inline-link">
Open files
</a>
) : null}
</div>
</div>
))
) : (
<div className="empty-state empty-state-rich">
<strong>Your upload queue will appear here.</strong>
<p className="item-muted">Choose files or drag them into the drop zone to start a new upload.</p>
</div>
)}
</div>
</div>
{items.length > pageSize ? (
<div className="pagination-bar" aria-label="Uploads pagination">
<span className="pagination-copy">
Page {page} of {totalPages}
</span>
<div className="toolbar-group">
<button
className="ghost-button compact-button"
type="button"
disabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Previous
</button>
<button
className="ghost-button compact-button"
type="button"
disabled={page === totalPages}
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
>
Next
</button>
</div>
</div>
) : null}
</div>
</section>
</div>
<aside className="panel uploads-side-panel">
<div className="section-heading">
<span className="eyebrow">Target Folder</span>
<h3>Choose destination</h3>
</div>
<DirectoryTree
selectedId={directoryId}
expandedIds={["dir_root"]}
rootLabel="Root"
onSelectDirectory={(node) => setDirectoryId(node.id)}
/>
<div className="meta-list compact-meta-list">
<div>
<span>Selected target</span>
<strong>{directoryId}</strong>
</div>
<div>
<span>Queued items</span>
<strong>{items.length}</strong>
</div>
</div>
</aside>
</div>
</div>
);
}

2393
frontend/src/styles/app.css

File diff suppressed because it is too large

69
scripts/ui_playwright_smoke.py

@ -16,7 +16,7 @@ from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
OUTPUT_DIR = ROOT / "output" / "playwright" OUTPUT_DIR = ROOT / "output" / "playwright"
RUNTIME_DIR = OUTPUT_DIR / "runtime" RUNTIME_DIR = OUTPUT_DIR / "runtime" / str(os.getpid())
CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
@ -145,14 +145,20 @@ def run_browser_flow(base_url: str) -> None:
page.get_by_label("Password").fill("changeme-iron") page.get_by_label("Password").fill("changeme-iron")
page.get_by_role("button", name="Sign in").click() page.get_by_role("button", name="Sign in").click()
page.wait_for_url(f"{base_url}/app/files") 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()
page.screenshot(path=str(OUTPUT_DIR / "files-initial.png"), full_page=True) page.screenshot(path=str(OUTPUT_DIR / "files-initial.png"), full_page=True)
page.locator(".panel-toolbar").get_by_role("button", name="New").click() page.locator(".panel-toolbar").get_by_role("button", name="New").click()
page.get_by_role("dialog").get_by_label("Name").fill("playwright-folder") page.get_by_role("dialog").get_by_label("Name").fill("playwright-folder")
page.get_by_role("dialog").get_by_role("button", name="Confirm").click() page.get_by_role("dialog").get_by_role("button", name="Confirm").click()
page.get_by_role("dialog").wait_for(state="detached")
page.locator(".drive-item strong", has_text="playwright-folder").first.wait_for() page.locator(".drive-item strong", has_text="playwright-folder").first.wait_for()
page.screenshot(path=str(OUTPUT_DIR / "files-after-create-folder.png"), full_page=True) page.screenshot(path=str(OUTPUT_DIR / "files-after-create-folder.png"), full_page=True)
page.locator(".drive-item strong", has_text="playwright-folder").first.dblclick()
page.locator(".directory-title-row", has_text="playwright-folder").wait_for()
page.screenshot(path=str(OUTPUT_DIR / "files-folder-open.png"), full_page=True)
page.get_by_label("Back to parent folder").click()
page.locator(".files-header").filter(has_text="Personal").first.wait_for()
temp_upload = create_temp_upload_file() temp_upload = create_temp_upload_file()
preview_upload = create_temp_preview_file() preview_upload = create_temp_preview_file()
@ -170,9 +176,10 @@ def run_browser_flow(base_url: str) -> None:
page.screenshot(path=str(OUTPUT_DIR / "files-after-upload.png"), full_page=True) page.screenshot(path=str(OUTPUT_DIR / "files-after-upload.png"), full_page=True)
page.locator(".drive-item strong", has_text="playwright-upload.txt").first.click() page.locator(".drive-item strong", has_text="playwright-upload.txt").first.click()
page.locator(".inspector-hero strong", has_text="playwright-upload.txt").wait_for() page.locator(".detail-panel h3", has_text="playwright-upload.txt").wait_for()
page.locator(".drive-item", has_text="playwright-upload.txt").get_by_role("button", name="More").click()
with page.expect_download() as download_info: with page.expect_download() as download_info:
page.locator(".detail-panel").get_by_role("button", name="Download").click() page.get_by_role("menu").get_by_role("button", name="Download").click()
download = download_info.value download = download_info.value
if download.suggested_filename != "playwright-upload.txt": if download.suggested_filename != "playwright-upload.txt":
failures.append(f"unexpected download filename: {download.suggested_filename}") failures.append(f"unexpected download filename: {download.suggested_filename}")
@ -180,18 +187,43 @@ def run_browser_flow(base_url: str) -> None:
page.locator(".drive-item strong", has_text="playwright-preview.png").first.click() page.locator(".drive-item strong", has_text="playwright-preview.png").first.click()
page.locator("img.preview-frame").wait_for(timeout=15000) 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.screenshot(path=str(OUTPUT_DIR / "files-preview-selected.png"), full_page=True)
page.get_by_role("button", name="Storage").click() page.locator(".detail-panel").get_by_role("button", name="More Info").click()
page.get_by_role("button", name="Reconcile File").wait_for() 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()
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) page.screenshot(path=str(OUTPUT_DIR / "files-storage-selected.png"), full_page=True)
page.locator(".detail-drawer").get_by_role("button", name="Close").click()
page.locator(".files-command-bar").get_by_role("button", name="Grid").click()
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="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.keyboard.press("Escape")
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")
delete_button.wait_for()
delete_styles = delete_button.evaluate(
"(node) => { const style = getComputedStyle(node); return { color: style.color, backgroundColor: style.backgroundColor, backgroundImage: style.backgroundImage }; }"
)
has_visible_background = delete_styles["backgroundColor"] not in {
"rgba(0, 0, 0, 0)",
"transparent",
} or delete_styles["backgroundImage"] != "none"
if delete_styles["color"] != "rgb(255, 255, 255)" or not has_visible_background:
failures.append(f"delete confirm button has unreadable styles: {delete_styles}")
page.screenshot(path=str(OUTPUT_DIR / "files-delete-dialog.png"), full_page=True)
page.get_by_role("dialog").get_by_role("button", name="Cancel").click()
page.get_by_role("dialog").wait_for(state="detached")
page.get_by_label("Search current directory").fill("playwright") page.get_by_label("Search").fill("playwright")
page.locator(".drive-item strong", has_text="playwright-folder").first.wait_for() 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.screenshot(path=str(OUTPUT_DIR / "files-search.png"), full_page=True)
page.goto(f"{base_url}/app/uploads", wait_until="networkidle")
page.screenshot(path=str(OUTPUT_DIR / "uploads-page.png"), full_page=True)
page.goto(f"{base_url}/app/recycle-bin", wait_until="networkidle") page.goto(f"{base_url}/app/recycle-bin", wait_until="networkidle")
page.screenshot(path=str(OUTPUT_DIR / "recycle-bin-page.png"), full_page=True) page.screenshot(path=str(OUTPUT_DIR / "recycle-bin-page.png"), full_page=True)
@ -211,8 +243,25 @@ def run_browser_flow(base_url: str) -> None:
page.screenshot(path=str(OUTPUT_DIR / "policies-page.png"), full_page=True) page.screenshot(path=str(OUTPUT_DIR / "policies-page.png"), full_page=True)
page.goto(f"{base_url}/app/jobs", wait_until="networkidle") page.goto(f"{base_url}/app/jobs", wait_until="networkidle")
page.locator(".jobs-table-row").first.click()
page.locator(".jobs-detail-panel", has_text="Job Detail").wait_for()
page.locator(".payload-preview").wait_for()
page.screenshot(path=str(OUTPUT_DIR / "jobs-page.png"), full_page=True) page.screenshot(path=str(OUTPUT_DIR / "jobs-page.png"), full_page=True)
mobile_context = browser.new_context(viewport={"width": 390, "height": 844})
mobile_page = mobile_context.new_page()
mobile_page.on("response", track_response)
mobile_page.on("requestfailed", track_request_failed)
mobile_page.on("pageerror", lambda error: failures.append(f"mobile page error: {error}"))
mobile_page.goto(f"{base_url}/app/login", wait_until="networkidle")
mobile_page.get_by_label("Username").fill("admin")
mobile_page.get_by_label("Password").fill("changeme-iron")
mobile_page.get_by_role("button", name="Sign in").click()
mobile_page.wait_for_url(f"{base_url}/app/files")
mobile_page.locator(".files-header").filter(has_text="Personal").first.wait_for()
mobile_page.screenshot(path=str(OUTPUT_DIR / "mobile-files.png"), full_page=True)
mobile_context.close()
context.close() context.close()
browser.close() browser.close()

3
tests/test_directory_routes.py

@ -55,6 +55,9 @@ async def test_list_children_includes_directories_before_files(api_client, creat
assert payload["items"][1]["name"] == "movie.mp4" assert payload["items"][1]["name"] == "movie.mp4"
assert payload["items"][1]["mime_type"] == "video/mp4" assert payload["items"][1]["mime_type"] == "video/mp4"
assert payload["items"][1]["size_bytes"] == 1048576 assert payload["items"][1]["size_bytes"] == 1048576
assert payload["items"][1]["replica_ready_count"] == 0
assert payload["items"][1]["replica_desired_count"] == 1
assert payload["items"][1]["replica_missing_count"] == 1
@pytest.mark.anyio @pytest.mark.anyio

7
tests/test_policy_routes.py

@ -12,6 +12,13 @@ async def test_list_default_placement_policies(api_client) -> None:
assert video_policy["preferred_backend_ids"] == [] assert video_policy["preferred_backend_ids"] == []
assert video_policy["excluded_backend_ids"] == [] assert video_policy["excluded_backend_ids"] == []
assert video_policy["max_non_local_size_bytes"] is None assert video_policy["max_non_local_size_bytes"] is None
for policy in items:
desired_total_replicas = (
int(policy["require_local"])
+ policy["stable_replica_count"]
+ policy["opportunistic_replica_count"]
)
assert desired_total_replicas == 2
@pytest.mark.anyio @pytest.mark.anyio

Loading…
Cancel
Save