Compare commits
5 Commits
d3fe2e5190
...
93b7e23168
| Author | SHA1 | Date |
|---|---|---|
|
|
93b7e23168 | 2 months ago |
|
|
93f8b8e10d | 2 months ago |
|
|
3681540c38 | 2 months ago |
|
|
5f1148fe83 | 2 months ago |
|
|
70c35acbe2 | 2 months ago |
|
After Width: | Height: | Size: 139 B |
|
After Width: | Height: | Size: 217 B |
|
After Width: | Height: | Size: 340 B |
|
After Width: | Height: | Size: 286 B |
|
After Width: | Height: | Size: 241 B |
|
After Width: | Height: | Size: 146 B |
|
After Width: | Height: | Size: 147 B |
|
Before Width: | Height: | Size: 933 KiB After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 139 B |
|
After Width: | Height: | Size: 217 B |
|
After Width: | Height: | Size: 340 B |
|
After Width: | Height: | Size: 286 B |
|
After Width: | Height: | Size: 241 B |
|
After Width: | Height: | Size: 146 B |
|
After Width: | Height: | Size: 147 B |
@ -1,16 +0,0 @@ |
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
|||
import { RouterProvider } from "react-router-dom"; |
|||
import { router } from "./router"; |
|||
import { ToastProvider } from "../features/toast/ToastProvider"; |
|||
|
|||
const queryClient = new QueryClient(); |
|||
|
|||
export function App() { |
|||
return ( |
|||
<QueryClientProvider client={queryClient}> |
|||
<ToastProvider> |
|||
<RouterProvider router={router} /> |
|||
</ToastProvider> |
|||
</QueryClientProvider> |
|||
); |
|||
} |
|||
@ -1,71 +0,0 @@ |
|||
import { |
|||
Navigate, |
|||
Outlet, |
|||
createBrowserRouter, |
|||
useLocation, |
|||
} from "react-router-dom"; |
|||
import { AppLayout } from "../components/layout/AppLayout"; |
|||
import { RequireAuth } from "../features/auth/RequireAuth"; |
|||
import { LoginPage } from "../routes/LoginPage"; |
|||
import { FilesPage } from "../routes/FilesPage"; |
|||
import { RecycleBinPage } from "../routes/RecycleBinPage"; |
|||
import { StoragePage } from "../routes/StoragePage"; |
|||
import { PoliciesPage } from "../routes/PoliciesPage"; |
|||
import { JobsPage } from "../routes/JobsPage"; |
|||
|
|||
function AppShell() { |
|||
const location = useLocation(); |
|||
|
|||
return ( |
|||
<RequireAuth> |
|||
<AppLayout currentPath={location.pathname}> |
|||
<Outlet /> |
|||
</AppLayout> |
|||
</RequireAuth> |
|||
); |
|||
} |
|||
|
|||
export const router = createBrowserRouter([ |
|||
{ |
|||
path: "/app/login", |
|||
element: <LoginPage />, |
|||
}, |
|||
{ |
|||
path: "/app", |
|||
element: <AppShell />, |
|||
children: [ |
|||
{ |
|||
index: true, |
|||
element: <Navigate to="/app/files" replace />, |
|||
}, |
|||
{ |
|||
path: "files", |
|||
element: <FilesPage />, |
|||
}, |
|||
{ |
|||
path: "files/:directoryId", |
|||
element: <FilesPage />, |
|||
}, |
|||
{ |
|||
path: "recycle-bin", |
|||
element: <RecycleBinPage />, |
|||
}, |
|||
{ |
|||
path: "storage", |
|||
element: <StoragePage />, |
|||
}, |
|||
{ |
|||
path: "policies", |
|||
element: <PoliciesPage />, |
|||
}, |
|||
{ |
|||
path: "jobs", |
|||
element: <JobsPage />, |
|||
}, |
|||
], |
|||
}, |
|||
{ |
|||
path: "*", |
|||
element: <Navigate to="/app" replace />, |
|||
}, |
|||
]); |
|||
@ -1,32 +0,0 @@ |
|||
type DialogProps = { |
|||
title: string; |
|||
description?: string; |
|||
children: React.ReactNode; |
|||
onClose: () => void; |
|||
}; |
|||
|
|||
export function Dialog({ title, description, children, onClose }: DialogProps) { |
|||
return ( |
|||
<div className="dialog-backdrop" role="presentation" onClick={onClose}> |
|||
<div |
|||
className="dialog-card" |
|||
role="dialog" |
|||
aria-modal="true" |
|||
aria-label={title} |
|||
onClick={(event) => event.stopPropagation()} |
|||
> |
|||
<div className="dialog-header"> |
|||
<div> |
|||
<span className="eyebrow">Action</span> |
|||
<h3>{title}</h3> |
|||
{description ? <p className="item-muted">{description}</p> : null} |
|||
</div> |
|||
<button className="ghost-button" type="button" onClick={onClose}> |
|||
Close |
|||
</button> |
|||
</div> |
|||
{children} |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -1,55 +0,0 @@ |
|||
import { useEffect, useRef } from "react"; |
|||
|
|||
type MenuItem = { |
|||
label: string; |
|||
tone?: "default" | "danger"; |
|||
onSelect: () => void; |
|||
}; |
|||
|
|||
type MenuProps = { |
|||
items: MenuItem[]; |
|||
onClose: () => void; |
|||
}; |
|||
|
|||
export function Menu({ items, onClose }: MenuProps) { |
|||
const ref = useRef<HTMLDivElement | null>(null); |
|||
|
|||
useEffect(() => { |
|||
function handlePointer(event: MouseEvent) { |
|||
if (ref.current && !ref.current.contains(event.target as Node)) { |
|||
onClose(); |
|||
} |
|||
} |
|||
|
|||
function handleEscape(event: KeyboardEvent) { |
|||
if (event.key === "Escape") { |
|||
onClose(); |
|||
} |
|||
} |
|||
|
|||
window.addEventListener("mousedown", handlePointer); |
|||
window.addEventListener("keydown", handleEscape); |
|||
return () => { |
|||
window.removeEventListener("mousedown", handlePointer); |
|||
window.removeEventListener("keydown", handleEscape); |
|||
}; |
|||
}, [onClose]); |
|||
|
|||
return ( |
|||
<div ref={ref} className="menu-popover" role="menu"> |
|||
{items.map((item) => ( |
|||
<button |
|||
key={item.label} |
|||
type="button" |
|||
className={item.tone === "danger" ? "menu-item danger" : "menu-item"} |
|||
onClick={() => { |
|||
item.onSelect(); |
|||
onClose(); |
|||
}} |
|||
> |
|||
{item.label} |
|||
</button> |
|||
))} |
|||
</div> |
|||
); |
|||
} |
|||
@ -1,117 +0,0 @@ |
|||
type PaginationControlsProps = { |
|||
page: number; |
|||
pageSize: number; |
|||
totalItems: number; |
|||
totalPages: number; |
|||
onPageChange: (page: number) => void; |
|||
onPageSizeChange: (pageSize: number) => void; |
|||
pageSizeOptions?: number[]; |
|||
label: string; |
|||
}; |
|||
|
|||
export function PaginationControls({ |
|||
page, |
|||
pageSize, |
|||
totalItems, |
|||
totalPages, |
|||
onPageChange, |
|||
onPageSizeChange, |
|||
pageSizeOptions = [8, 16, 32, 64], |
|||
label, |
|||
}: PaginationControlsProps) { |
|||
const safeTotalPages = Math.max(1, totalPages); |
|||
const pageItems = getPaginationItems(page, safeTotalPages); |
|||
|
|||
return ( |
|||
<div className="pagination-bar pagination-bar-full" aria-label={label}> |
|||
<span className="pagination-copy"> |
|||
{totalItems} items |
|||
</span> |
|||
<div className="pagination-controls" role="group" aria-label={`${label} controls`}> |
|||
<button |
|||
className="pagination-page-button pagination-arrow-button" |
|||
type="button" |
|||
aria-label="Previous page" |
|||
disabled={page === 1} |
|||
onClick={() => onPageChange(Math.max(1, page - 1))} |
|||
> |
|||
‹ |
|||
</button> |
|||
<div className="pagination-page-list" aria-label={`Page ${page} of ${safeTotalPages}`}> |
|||
{pageItems.map((item, index) => |
|||
item === "ellipsis" ? ( |
|||
<span key={`ellipsis-${index}`} className="pagination-ellipsis" aria-hidden="true"> |
|||
... |
|||
</span> |
|||
) : ( |
|||
<button |
|||
key={item} |
|||
className={item === page ? "pagination-page-button active" : "pagination-page-button"} |
|||
type="button" |
|||
aria-current={item === page ? "page" : undefined} |
|||
onClick={() => onPageChange(item)} |
|||
> |
|||
{item} |
|||
</button> |
|||
), |
|||
)} |
|||
</div> |
|||
<button |
|||
className="pagination-page-button pagination-arrow-button" |
|||
type="button" |
|||
aria-label="Next page" |
|||
disabled={page === safeTotalPages} |
|||
onClick={() => onPageChange(Math.min(safeTotalPages, page + 1))} |
|||
> |
|||
› |
|||
</button> |
|||
<label className="pagination-size-control"> |
|||
<select |
|||
className="input-select pagination-select" |
|||
aria-label="Rows per page" |
|||
value={pageSize} |
|||
onChange={(event) => onPageSizeChange(Number(event.target.value))} |
|||
> |
|||
{pageSizeOptions.map((option) => ( |
|||
<option key={option} value={option}> |
|||
{option} / page |
|||
</option> |
|||
))} |
|||
</select> |
|||
</label> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
function getPaginationItems(page: number, totalPages: number): Array<number | "ellipsis"> { |
|||
if (totalPages <= 7) { |
|||
return Array.from({ length: totalPages }, (_, index) => index + 1); |
|||
} |
|||
|
|||
const pages = new Set<number>([1, totalPages, page - 1, page, page + 1]); |
|||
if (page <= 4) { |
|||
[2, 3, 4, 5].forEach((item) => pages.add(item)); |
|||
} |
|||
if (page >= totalPages - 3) { |
|||
[totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1].forEach((item) => pages.add(item)); |
|||
} |
|||
|
|||
const sortedPages = Array.from(pages) |
|||
.filter((item) => item >= 1 && item <= totalPages) |
|||
.sort((first, second) => first - second); |
|||
|
|||
return sortedPages.flatMap((item, index) => { |
|||
const previous = sortedPages[index - 1]; |
|||
if (!previous) { |
|||
return [item]; |
|||
} |
|||
if (item - previous === 2) { |
|||
return [previous + 1, item]; |
|||
} |
|||
if (item - previous > 2) { |
|||
return ["ellipsis", item]; |
|||
} |
|||
return [item]; |
|||
}); |
|||
} |
|||
@ -1,170 +0,0 @@ |
|||
import type { ChangeEvent } from "react"; |
|||
import { NavLink, useLocation, useNavigate } from "react-router-dom"; |
|||
import { useQuery } from "@tanstack/react-query"; |
|||
import { api } from "../../lib/api"; |
|||
import { clearAccessToken } from "../../lib/auth"; |
|||
import { useToast } from "../../features/toast/ToastProvider"; |
|||
|
|||
type AppLayoutProps = { |
|||
currentPath: string; |
|||
children: React.ReactNode; |
|||
}; |
|||
|
|||
const navItems = [ |
|||
{ to: "/app/files", label: "Files", icon: "files" }, |
|||
{ to: "/app/recycle-bin", label: "Deleted files", icon: "trash" }, |
|||
{ to: "/app/storage", label: "Storage", icon: "storage" }, |
|||
{ to: "/app/policies", label: "Policies", icon: "policies" }, |
|||
{ to: "/app/jobs", label: "Jobs", icon: "jobs" }, |
|||
]; |
|||
|
|||
export function AppLayout({ currentPath, children }: AppLayoutProps) { |
|||
const navigate = useNavigate(); |
|||
const location = useLocation(); |
|||
const { pushToast } = useToast(); |
|||
const isFilesSearch = location.pathname.startsWith("/app/files"); |
|||
const topbarSearchValue = isFilesSearch ? new URLSearchParams(location.search).get("q") ?? "" : ""; |
|||
const meQuery = useQuery({ |
|||
queryKey: ["auth", "me"], |
|||
queryFn: api.me, |
|||
retry: false, |
|||
}); |
|||
|
|||
async function handleLogout() { |
|||
try { |
|||
await api.logout(); |
|||
} catch { |
|||
// best effort
|
|||
} |
|||
clearAccessToken(); |
|||
pushToast("Signed out.", "info"); |
|||
navigate("/app/login", { replace: true }); |
|||
} |
|||
|
|||
function handleTopbarSearchChange(event: ChangeEvent<HTMLInputElement>) { |
|||
if (!isFilesSearch) { |
|||
return; |
|||
} |
|||
|
|||
const params = new URLSearchParams(location.search); |
|||
const value = event.target.value.trimStart(); |
|||
if (value) { |
|||
params.set("q", value); |
|||
} else { |
|||
params.delete("q"); |
|||
} |
|||
|
|||
navigate( |
|||
{ |
|||
pathname: location.pathname, |
|||
search: params.toString() ? `?${params.toString()}` : "", |
|||
}, |
|||
{ replace: true, state: location.state }, |
|||
); |
|||
} |
|||
|
|||
return ( |
|||
<div className="app-frame"> |
|||
<header className="oc-topbar" aria-label="Top bar"> |
|||
<div className="topbar-brand"> |
|||
<button className="app-switcher-button" type="button" aria-label="Application menu"> |
|||
<span /> |
|||
<span /> |
|||
<span /> |
|||
<span /> |
|||
<span /> |
|||
<span /> |
|||
<span /> |
|||
<span /> |
|||
<span /> |
|||
</button> |
|||
<div className="brand-lockup" aria-label="Iron Drive"> |
|||
<span className="brand-symbol">I</span> |
|||
<strong>Iron</strong> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="topbar-center"> |
|||
<div className="topbar-search" role="search"> |
|||
<input |
|||
aria-label="Search" |
|||
placeholder="Enter search term" |
|||
readOnly={!isFilesSearch} |
|||
value={topbarSearchValue} |
|||
onChange={handleTopbarSearchChange} |
|||
/> |
|||
<span className="topbar-search-scope">{getPageSearchScope(currentPath)}</span> |
|||
<span className="topbar-search-icon" aria-hidden="true" /> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="topbar-actions"> |
|||
<button className="user-chip" type="button" onClick={handleLogout}> |
|||
<span>{getInitial(meQuery.data?.user.username)}</span> |
|||
</button> |
|||
</div> |
|||
</header> |
|||
|
|||
<div className="app-shell"> |
|||
<aside className="sidebar"> |
|||
<div className="brand-block brand-block-compact"> |
|||
<span className="eyebrow">Personal</span> |
|||
<h1>Drive</h1> |
|||
</div> |
|||
|
|||
<nav className="main-nav" aria-label="Primary"> |
|||
{navItems.map((item) => ( |
|||
<NavLink |
|||
key={item.to} |
|||
to={item.to} |
|||
className={({ isActive }) => (isActive ? "nav-link active" : "nav-link")} |
|||
> |
|||
<span className={`nav-icon nav-icon-${item.icon}`} aria-hidden="true" /> |
|||
<span>{item.label}</span> |
|||
</NavLink> |
|||
))} |
|||
</nav> |
|||
|
|||
<div className="sidebar-footer"> |
|||
<span>Iron Web UI</span> |
|||
<span>Personal cloud drive</span> |
|||
</div> |
|||
</aside> |
|||
|
|||
<div className="workspace"> |
|||
<main className="workspace-content">{children}</main> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
function getInitial(username: string | undefined) { |
|||
return (username?.trim().charAt(0) || "I").toUpperCase(); |
|||
} |
|||
|
|||
function getPageSearchScope(pathname: string) { |
|||
if (pathname.startsWith("/app/files")) { |
|||
return "All files"; |
|||
} |
|||
return getPageTitle(pathname); |
|||
} |
|||
|
|||
function getPageTitle(pathname: string) { |
|||
if (pathname.startsWith("/app/files")) { |
|||
return "Files"; |
|||
} |
|||
if (pathname.startsWith("/app/recycle-bin")) { |
|||
return "Recycle Bin"; |
|||
} |
|||
if (pathname.startsWith("/app/storage")) { |
|||
return "Storage"; |
|||
} |
|||
if (pathname.startsWith("/app/policies")) { |
|||
return "Policies"; |
|||
} |
|||
if (pathname.startsWith("/app/jobs")) { |
|||
return "Jobs"; |
|||
} |
|||
return "Iron"; |
|||
} |
|||
@ -1,29 +0,0 @@ |
|||
import { useQuery } from "@tanstack/react-query"; |
|||
import { Navigate, useLocation } from "react-router-dom"; |
|||
import { api, ApiError } from "../../lib/api"; |
|||
import { getAccessToken } from "../../lib/auth"; |
|||
|
|||
export function RequireAuth({ children }: { children: React.ReactNode }) { |
|||
const location = useLocation(); |
|||
const token = getAccessToken(); |
|||
const query = useQuery({ |
|||
queryKey: ["auth", "me"], |
|||
queryFn: api.me, |
|||
enabled: Boolean(token), |
|||
retry: false, |
|||
}); |
|||
|
|||
if (!token) { |
|||
return <Navigate to="/app/login" replace state={{ from: `${location.pathname}${location.search}` }} />; |
|||
} |
|||
|
|||
if (query.isLoading) { |
|||
return <div className="page-loading">Loading your drive…</div>; |
|||
} |
|||
|
|||
if (query.error instanceof ApiError && query.error.status === 401) { |
|||
return <Navigate to="/app/login" replace state={{ from: `${location.pathname}${location.search}` }} />; |
|||
} |
|||
|
|||
return <>{children}</>; |
|||
} |
|||
@ -1,50 +0,0 @@ |
|||
import { createContext, useCallback, useContext, useMemo, useState } from "react"; |
|||
|
|||
type ToastTone = "info" | "success" | "error"; |
|||
|
|||
type ToastItem = { |
|||
id: string; |
|||
title: string; |
|||
tone: ToastTone; |
|||
}; |
|||
|
|||
type ToastContextValue = { |
|||
pushToast: (title: string, tone?: ToastTone) => void; |
|||
}; |
|||
|
|||
const ToastContext = createContext<ToastContextValue | null>(null); |
|||
|
|||
export function ToastProvider({ children }: { children: React.ReactNode }) { |
|||
const [toasts, setToasts] = useState<ToastItem[]>([]); |
|||
|
|||
const pushToast = useCallback((title: string, tone: ToastTone = "info") => { |
|||
const id = crypto.randomUUID(); |
|||
setToasts((current) => [...current.slice(-1), { id, title, tone }]); |
|||
window.setTimeout(() => { |
|||
setToasts((current) => current.filter((item) => item.id !== id)); |
|||
}, 2200); |
|||
}, []); |
|||
|
|||
const value = useMemo(() => ({ pushToast }), [pushToast]); |
|||
|
|||
return ( |
|||
<ToastContext.Provider value={value}> |
|||
{children} |
|||
<div className="toast-stack" aria-live="polite" aria-atomic="true"> |
|||
{toasts.map((toast) => ( |
|||
<div key={toast.id} className={`toast-item toast-${toast.tone}`}> |
|||
{toast.title} |
|||
</div> |
|||
))} |
|||
</div> |
|||
</ToastContext.Provider> |
|||
); |
|||
} |
|||
|
|||
export function useToast() { |
|||
const context = useContext(ToastContext); |
|||
if (!context) { |
|||
throw new Error("useToast must be used within ToastProvider."); |
|||
} |
|||
return context; |
|||
} |
|||
@ -0,0 +1,43 @@ |
|||
import { createApp } from "vue"; |
|||
import { createPinia } from "pinia"; |
|||
import DesignSystem from "@opencloud-eu/design-system"; |
|||
import "@opencloud-eu/design-system/tailwind"; |
|||
import "@opencloud-eu/design-system/dist/design-system.css"; |
|||
import "./styles/opencloud-app.css"; |
|||
import App from "./App.vue"; |
|||
|
|||
const app = createApp(App); |
|||
|
|||
app.use(createPinia()); |
|||
app.use(DesignSystem, { |
|||
iconUrlPrefix: "/assets/", |
|||
language: { |
|||
initGettext: true, |
|||
defaultLanguage: "en", |
|||
}, |
|||
tokens: { |
|||
roles: { |
|||
primary: "#00677f", |
|||
onPrimary: "#ffffff", |
|||
primaryContainer: "#b7eaff", |
|||
secondary: "#20434f", |
|||
onSecondary: "#ffffff", |
|||
secondaryContainer: "#cfe6f1", |
|||
background: "#ffffff", |
|||
onBackground: "#191c1d", |
|||
surface: "#ffffff", |
|||
onSurface: "#191c1d", |
|||
surfaceContainer: "#f6f8fa", |
|||
surfaceContainerHigh: "#f2f4f5", |
|||
surfaceContainerHighest: "#eceef0", |
|||
outline: "#70787c", |
|||
outlineVariant: "#bfc8cc", |
|||
chrome: "#20434f", |
|||
onChrome: "#ffffff", |
|||
error: "#ba1a1a", |
|||
onError: "#ffffff", |
|||
}, |
|||
}, |
|||
}); |
|||
|
|||
app.mount("#root"); |
|||
@ -1,10 +0,0 @@ |
|||
import React from "react"; |
|||
import ReactDOM from "react-dom/client"; |
|||
import { App } from "./app/App"; |
|||
import "./styles/app.css"; |
|||
|
|||
ReactDOM.createRoot(document.getElementById("root")!).render( |
|||
<React.StrictMode> |
|||
<App /> |
|||
</React.StrictMode>, |
|||
); |
|||
@ -1,215 +0,0 @@ |
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
|||
import { useEffect, useMemo, useState } from "react"; |
|||
import { PaginationControls } from "../components/PaginationControls"; |
|||
import { api, type JobSummary } from "../lib/api"; |
|||
import { formatDate, titleCase } from "../lib/format"; |
|||
|
|||
export function JobsPage() { |
|||
const queryClient = useQueryClient(); |
|||
const [page, setPage] = useState(1); |
|||
const [pageSize, setPageSize] = useState(8); |
|||
const [selectedJobId, setSelectedJobId] = useState<string | null>(null); |
|||
const jobsQuery = useQuery({ |
|||
queryKey: ["jobs"], |
|||
queryFn: api.listJobs, |
|||
}); |
|||
|
|||
const refreshJobs = async () => { |
|||
await queryClient.invalidateQueries({ queryKey: ["jobs"] }); |
|||
}; |
|||
|
|||
const retryMutation = useMutation({ |
|||
mutationFn: (jobId: string) => api.retryJob(jobId), |
|||
onSuccess: refreshJobs, |
|||
}); |
|||
const runMutation = useMutation({ |
|||
mutationFn: api.runPendingJobs, |
|||
onSuccess: refreshJobs, |
|||
}); |
|||
const healthMutation = useMutation({ |
|||
mutationFn: api.enqueueHealthChecks, |
|||
onSuccess: refreshJobs, |
|||
}); |
|||
const reconcileMutation = useMutation({ |
|||
mutationFn: api.enqueueFullReconcile, |
|||
onSuccess: refreshJobs, |
|||
}); |
|||
const items = jobsQuery.data?.items ?? []; |
|||
const pendingCount = items.filter((job) => job.status === "pending").length; |
|||
const completedCount = items.filter((job) => job.status === "completed").length; |
|||
const failedCount = items.filter((job) => job.status === "failed").length; |
|||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); |
|||
const pagedItems = useMemo( |
|||
() => items.slice((page - 1) * pageSize, page * pageSize), |
|||
[items, page, pageSize], |
|||
); |
|||
const selectedJob = items.find((job) => job.id === selectedJobId) ?? null; |
|||
useEffect(() => { |
|||
if (page > totalPages) { |
|||
setPage(totalPages); |
|||
} |
|||
}, [page, totalPages]); |
|||
useEffect(() => { |
|||
if (selectedJobId && !items.some((job) => job.id === selectedJobId)) { |
|||
setSelectedJobId(null); |
|||
} |
|||
}, [items, selectedJobId]); |
|||
|
|||
return ( |
|||
<div className="compact-page-shell compact-page-shell-wide"> |
|||
<div className="jobs-page-layout"> |
|||
<section className="panel main-panel dashboard-main-panel"> |
|||
<div className="panel-toolbar files-toolbar-top"> |
|||
<div className="files-header"> |
|||
<span className="eyebrow">Jobs</span> |
|||
<div className="directory-title-row"> |
|||
<h2>Background activity</h2> |
|||
</div> |
|||
<div className="inline-metrics"> |
|||
<span>{pendingCount} pending</span> |
|||
<span>{completedCount} completed</span> |
|||
<span>{failedCount} failed</span> |
|||
</div> |
|||
</div> |
|||
<div className="toolbar-group files-command-bar"> |
|||
<button className="ghost-button" type="button" onClick={() => runMutation.mutate()}> |
|||
Run Pending |
|||
</button> |
|||
<button className="ghost-button" type="button" onClick={() => healthMutation.mutate()}> |
|||
Enqueue Checks |
|||
</button> |
|||
<button className="primary-button" type="button" onClick={() => reconcileMutation.mutate()}> |
|||
Full Reconcile |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="jobs-list-shell list-surface"> |
|||
<div className="list-header jobs-list-header"> |
|||
<span>Job</span> |
|||
<span>Status</span> |
|||
<span>Attempts</span> |
|||
<span className="list-header-actions">Actions</span> |
|||
</div> |
|||
<div className="list-table"> |
|||
{items.length ? ( |
|||
pagedItems.map((job) => ( |
|||
<div |
|||
key={job.id} |
|||
className={selectedJobId === job.id ? "table-row jobs-table-row active" : "table-row jobs-table-row"} |
|||
role="button" |
|||
tabIndex={0} |
|||
onClick={() => setSelectedJobId(job.id)} |
|||
onKeyDown={(event) => { |
|||
if (event.key === "Enter") { |
|||
setSelectedJobId(job.id); |
|||
} |
|||
}} |
|||
> |
|||
<div> |
|||
<strong>{titleCase(job.kind)}</strong> |
|||
<span className="item-muted">Run after {formatDate(job.run_after)}</span> |
|||
{job.last_error ? <span className="error-text">{job.last_error}</span> : null} |
|||
</div> |
|||
<div className="table-meta jobs-row-meta"> |
|||
<span className="status-chip">{titleCase(job.status)}</span> |
|||
<span> |
|||
{job.attempt_count}/{job.max_attempts} |
|||
</span> |
|||
<button |
|||
className="ghost-button compact-button" |
|||
type="button" |
|||
onClick={(event) => { |
|||
event.stopPropagation(); |
|||
retryMutation.mutate(job.id); |
|||
}} |
|||
> |
|||
Retry |
|||
</button> |
|||
</div> |
|||
</div> |
|||
)) |
|||
) : ( |
|||
<div className="empty-state empty-state-rich"> |
|||
<strong>No background jobs recorded yet.</strong> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
<PaginationControls |
|||
label="Jobs pagination" |
|||
page={page} |
|||
pageSize={pageSize} |
|||
totalItems={items.length} |
|||
totalPages={totalPages} |
|||
onPageChange={setPage} |
|||
onPageSizeChange={(nextPageSize) => { |
|||
setPageSize(nextPageSize); |
|||
setPage(1); |
|||
}} |
|||
/> |
|||
</section> |
|||
<aside className="panel detail-panel jobs-detail-panel"> |
|||
{!selectedJob ? ( |
|||
<div className="detail-empty"> |
|||
<span className="eyebrow">Inspector</span> |
|||
<h3>Select a job</h3> |
|||
</div> |
|||
) : ( |
|||
<> |
|||
<div className="section-heading"> |
|||
<span className="eyebrow">Job Detail</span> |
|||
<h3>{titleCase(selectedJob.kind)}</h3> |
|||
</div> |
|||
<div className="inspector-storage-summary"> |
|||
<div> |
|||
<span>Status</span> |
|||
<strong>{titleCase(selectedJob.status)}</strong> |
|||
</div> |
|||
<span className="status-chip"> |
|||
{selectedJob.attempt_count}/{selectedJob.max_attempts} |
|||
</span> |
|||
</div> |
|||
<div className="meta-list inspector-card jobs-detail-meta"> |
|||
<div> |
|||
<span>Run after</span> |
|||
<strong>{formatDate(selectedJob.run_after)}</strong> |
|||
</div> |
|||
<div> |
|||
<span>Created</span> |
|||
<strong>{formatDate(selectedJob.created_at)}</strong> |
|||
</div> |
|||
<div> |
|||
<span>Updated</span> |
|||
<strong>{formatDate(selectedJob.updated_at)}</strong> |
|||
</div> |
|||
<div> |
|||
<span>ID</span> |
|||
<strong className="mono-text">{selectedJob.id}</strong> |
|||
</div> |
|||
</div> |
|||
{selectedJob.last_error ? ( |
|||
<div className="inspector-card replica-warning-card"> |
|||
<strong>Last error</strong> |
|||
<p className="item-muted">{selectedJob.last_error}</p> |
|||
</div> |
|||
) : null} |
|||
<section className="inspector-section"> |
|||
<span className="eyebrow">Payload</span> |
|||
<pre className="payload-preview">{formatJobPayload(selectedJob)}</pre> |
|||
</section> |
|||
</> |
|||
)} |
|||
</aside> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
function formatJobPayload(job: JobSummary) { |
|||
try { |
|||
return JSON.stringify(JSON.parse(job.payload_json), null, 2); |
|||
} catch { |
|||
return job.payload_json || "{}"; |
|||
} |
|||
} |
|||
@ -1,79 +0,0 @@ |
|||
import { FormEvent, useState } from "react"; |
|||
import { useLocation, useNavigate } from "react-router-dom"; |
|||
import { api } from "../lib/api"; |
|||
import { setAccessToken } from "../lib/auth"; |
|||
|
|||
export function LoginPage() { |
|||
const navigate = useNavigate(); |
|||
const location = useLocation(); |
|||
const [username, setUsername] = useState("admin"); |
|||
const [password, setPassword] = useState("changeme-iron"); |
|||
const [error, setError] = useState(""); |
|||
const [isSubmitting, setIsSubmitting] = useState(false); |
|||
|
|||
async function handleSubmit(event: FormEvent<HTMLFormElement>) { |
|||
event.preventDefault(); |
|||
setIsSubmitting(true); |
|||
setError(""); |
|||
|
|||
try { |
|||
const result = await api.login(username, password); |
|||
setAccessToken(result.access_token); |
|||
navigate((location.state as { from?: string } | null)?.from || "/app/files", { replace: true }); |
|||
} catch (submitError) { |
|||
setError(submitError instanceof Error ? submitError.message : "Unable to sign in."); |
|||
} finally { |
|||
setIsSubmitting(false); |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<div className="login-page"> |
|||
<section className="login-hero"> |
|||
<span className="eyebrow">Iron</span> |
|||
<h1>One personal drive, under your control.</h1> |
|||
<p> |
|||
Browse media, upload from the browser, and inspect storage health |
|||
without turning your daily file flow into an ops chore. |
|||
</p> |
|||
<div className="hero-metrics"> |
|||
<article> |
|||
<span className="metric-label">Product target</span> |
|||
<strong>Personal drive UI</strong> |
|||
</article> |
|||
<article> |
|||
<span className="metric-label">Runtime model</span> |
|||
<strong>Gateway + multi-backend storage</strong> |
|||
</article> |
|||
</div> |
|||
</section> |
|||
|
|||
<section className="login-card"> |
|||
<div className="section-heading"> |
|||
<span className="eyebrow">Local Login</span> |
|||
<h2>Enter your drive</h2> |
|||
</div> |
|||
|
|||
<form className="stack-form" onSubmit={handleSubmit}> |
|||
<label> |
|||
<span>Username</span> |
|||
<input value={username} onChange={(event) => setUsername(event.target.value)} required /> |
|||
</label> |
|||
<label> |
|||
<span>Password</span> |
|||
<input |
|||
type="password" |
|||
value={password} |
|||
onChange={(event) => setPassword(event.target.value)} |
|||
required |
|||
/> |
|||
</label> |
|||
<button className="primary-button" type="submit" disabled={isSubmitting}> |
|||
{isSubmitting ? "Signing in…" : "Sign in"} |
|||
</button> |
|||
{error ? <p className="error-text">{error}</p> : null} |
|||
</form> |
|||
</section> |
|||
</div> |
|||
); |
|||
} |
|||
@ -1,279 +0,0 @@ |
|||
import { FormEvent, useEffect, useMemo, useState } from "react"; |
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
|||
import { api, type PlacementPolicyPayload, type PlacementPolicySummary } from "../lib/api"; |
|||
import { useToast } from "../features/toast/ToastProvider"; |
|||
import { formatBackendType, formatBytes, titleCase } from "../lib/format"; |
|||
|
|||
const FILE_CLASSES: PlacementPolicySummary["file_class"][] = ["document", "image", "video", "other"]; |
|||
|
|||
const MIME_PRESETS: Record<PlacementPolicySummary["file_class"], string> = { |
|||
document: "application/pdf", |
|||
image: "image/png", |
|||
video: "video/mp4", |
|||
other: "application/octet-stream", |
|||
}; |
|||
|
|||
export function PoliciesPage() { |
|||
const queryClient = useQueryClient(); |
|||
const { pushToast } = useToast(); |
|||
const [selectedClass, setSelectedClass] = useState<PlacementPolicySummary["file_class"]>("document"); |
|||
const [requireLocal, setRequireLocal] = useState(true); |
|||
const [stableCount, setStableCount] = useState(1); |
|||
const [opportunisticCount, setOpportunisticCount] = useState(0); |
|||
const [preferredBackendIds, setPreferredBackendIds] = useState<string[]>([]); |
|||
const [excludedBackendIds, setExcludedBackendIds] = useState<string[]>([]); |
|||
const [maxNonLocalSize, setMaxNonLocalSize] = useState(""); |
|||
const [mimeType, setMimeType] = useState(MIME_PRESETS.document); |
|||
const [previewSize, setPreviewSize] = useState("1048576"); |
|||
const [formError, setFormError] = useState(""); |
|||
|
|||
const policiesQuery = useQuery({ |
|||
queryKey: ["placement-policies"], |
|||
queryFn: api.listPlacementPolicies, |
|||
}); |
|||
const backendQuery = useQuery({ |
|||
queryKey: ["backends"], |
|||
queryFn: api.listBackends, |
|||
}); |
|||
|
|||
const policies = policiesQuery.data?.items ?? []; |
|||
const backends = backendQuery.data?.items ?? []; |
|||
const selectedPolicy = policies.find((policy) => policy.file_class === selectedClass); |
|||
|
|||
useEffect(() => { |
|||
if (!selectedPolicy) { |
|||
return; |
|||
} |
|||
setRequireLocal(selectedPolicy.require_local); |
|||
setStableCount(selectedPolicy.stable_replica_count); |
|||
setOpportunisticCount(selectedPolicy.opportunistic_replica_count); |
|||
setPreferredBackendIds(selectedPolicy.preferred_backend_ids); |
|||
setExcludedBackendIds(selectedPolicy.excluded_backend_ids); |
|||
setMaxNonLocalSize( |
|||
selectedPolicy.max_non_local_size_bytes === null ? "" : String(selectedPolicy.max_non_local_size_bytes), |
|||
); |
|||
}, [selectedPolicy?.id, selectedPolicy?.updated_at]); |
|||
|
|||
useEffect(() => { |
|||
setMimeType(MIME_PRESETS[selectedClass]); |
|||
}, [selectedClass]); |
|||
|
|||
const previewSizeBytes = parseOptionalInt(previewSize); |
|||
const previewQuery = useQuery({ |
|||
queryKey: ["placement-preview", mimeType, previewSizeBytes], |
|||
queryFn: () => api.previewPlacementDecision(mimeType, previewSizeBytes), |
|||
}); |
|||
|
|||
const updateMutation = useMutation({ |
|||
mutationFn: (payload: PlacementPolicyPayload) => api.updatePlacementPolicy(selectedClass, payload), |
|||
onSuccess: async (result) => { |
|||
setFormError(""); |
|||
pushToast( |
|||
`Policy saved. Full reconcile queued ${result.reconcile_enqueued_jobs} job${result.reconcile_enqueued_jobs === 1 ? "" : "s"}.`, |
|||
"success", |
|||
); |
|||
await queryClient.invalidateQueries({ queryKey: ["placement-policies"] }); |
|||
await queryClient.invalidateQueries({ queryKey: ["placement-preview"] }); |
|||
await queryClient.invalidateQueries({ queryKey: ["jobs"] }); |
|||
}, |
|||
onError: (error) => { |
|||
setFormError(error instanceof Error ? error.message : "Policy update failed."); |
|||
pushToast(error instanceof Error ? error.message : "Policy update failed.", "error"); |
|||
}, |
|||
}); |
|||
|
|||
const localBackendCount = backends.filter((backend) => backend.type === "local_directory" && backend.is_enabled).length; |
|||
const stableBackendCount = backends.filter((backend) => backend.stability_class === "stable" && backend.is_enabled).length; |
|||
const opportunisticBackendCount = backends.filter( |
|||
(backend) => backend.stability_class === "opportunistic" && backend.is_enabled, |
|||
).length; |
|||
|
|||
function handleSubmit(event: FormEvent<HTMLFormElement>) { |
|||
event.preventDefault(); |
|||
const maxSize = parseOptionalInt(maxNonLocalSize); |
|||
if (maxNonLocalSize.trim() && maxSize === null) { |
|||
setFormError("Max non-local size must be a whole number."); |
|||
return; |
|||
} |
|||
updateMutation.mutate({ |
|||
require_local: requireLocal, |
|||
stable_replica_count: Math.max(0, stableCount), |
|||
opportunistic_replica_count: Math.max(0, opportunisticCount), |
|||
preferred_backend_ids: preferredBackendIds, |
|||
excluded_backend_ids: excludedBackendIds, |
|||
max_non_local_size_bytes: maxSize, |
|||
}); |
|||
} |
|||
|
|||
function toggleBackend(listName: "preferred" | "excluded", backendId: string) { |
|||
if (listName === "preferred") { |
|||
setPreferredBackendIds((current) => toggleId(current, backendId)); |
|||
setExcludedBackendIds((current) => current.filter((id) => id !== backendId)); |
|||
return; |
|||
} |
|||
setExcludedBackendIds((current) => toggleId(current, backendId)); |
|||
setPreferredBackendIds((current) => current.filter((id) => id !== backendId)); |
|||
} |
|||
|
|||
return ( |
|||
<div className="compact-page-shell compact-page-shell-wide"> |
|||
<div className="dashboard-page-grid"> |
|||
<section className="dashboard-main-column"> |
|||
<div className="panel-toolbar storage-toolbar"> |
|||
<div className="files-header"> |
|||
<span className="eyebrow">Placement</span> |
|||
<div className="directory-title-row"> |
|||
<h2>Replica policies</h2> |
|||
</div> |
|||
<div className="inline-metrics"> |
|||
<span>{policies.length} policies</span> |
|||
<span>{localBackendCount} local</span> |
|||
<span>{stableBackendCount} stable</span> |
|||
<span>{opportunisticBackendCount} opportunistic</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="policy-tabs" role="tablist" aria-label="File classes"> |
|||
{FILE_CLASSES.map((fileClass) => ( |
|||
<button |
|||
key={fileClass} |
|||
className={selectedClass === fileClass ? "policy-tab active" : "policy-tab"} |
|||
type="button" |
|||
onClick={() => setSelectedClass(fileClass)} |
|||
> |
|||
{titleCase(fileClass)} |
|||
</button> |
|||
))} |
|||
</div> |
|||
|
|||
<form className="panel policy-editor" onSubmit={handleSubmit}> |
|||
<div className="policy-editor-grid"> |
|||
<label className="checkbox-field"> |
|||
<input |
|||
type="checkbox" |
|||
checked={requireLocal} |
|||
onChange={(event) => setRequireLocal(event.target.checked)} |
|||
/> |
|||
<span>Require local replica</span> |
|||
</label> |
|||
<label> |
|||
<span>Stable replicas</span> |
|||
<input |
|||
type="number" |
|||
min={0} |
|||
value={stableCount} |
|||
onChange={(event) => setStableCount(Number(event.target.value))} |
|||
/> |
|||
</label> |
|||
<label> |
|||
<span>Opportunistic replicas</span> |
|||
<input |
|||
type="number" |
|||
min={0} |
|||
value={opportunisticCount} |
|||
onChange={(event) => setOpportunisticCount(Number(event.target.value))} |
|||
/> |
|||
</label> |
|||
<label> |
|||
<span>Max non-local bytes</span> |
|||
<input |
|||
value={maxNonLocalSize} |
|||
onChange={(event) => setMaxNonLocalSize(event.target.value)} |
|||
placeholder="No limit" |
|||
/> |
|||
</label> |
|||
</div> |
|||
|
|||
<div className="backend-policy-list"> |
|||
{backends.map((backend) => ( |
|||
<div key={backend.id} className="backend-policy-row"> |
|||
<div> |
|||
<strong>{backend.name}</strong> |
|||
<span className="item-muted"> |
|||
{formatBackendType(backend.type)} · {titleCase(backend.stability_class)} |
|||
</span> |
|||
</div> |
|||
<div className="backend-policy-actions"> |
|||
<label className="checkbox-field compact-checkbox"> |
|||
<input |
|||
type="checkbox" |
|||
checked={preferredBackendIds.includes(backend.id)} |
|||
onChange={() => toggleBackend("preferred", backend.id)} |
|||
/> |
|||
<span>Prefer</span> |
|||
</label> |
|||
<label className="checkbox-field compact-checkbox"> |
|||
<input |
|||
type="checkbox" |
|||
checked={excludedBackendIds.includes(backend.id)} |
|||
onChange={() => toggleBackend("excluded", backend.id)} |
|||
/> |
|||
<span>Exclude</span> |
|||
</label> |
|||
</div> |
|||
</div> |
|||
))} |
|||
</div> |
|||
|
|||
{formError ? <p className="error-text">{formError}</p> : null} |
|||
<div className="dialog-actions"> |
|||
<button className="primary-button" type="submit" disabled={updateMutation.isPending}> |
|||
Save Policy |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</section> |
|||
|
|||
<aside className="dashboard-side-column"> |
|||
<section className="panel dashboard-side-panel"> |
|||
<div className="section-heading"> |
|||
<span className="eyebrow">Preview</span> |
|||
<h3>Placement decision</h3> |
|||
</div> |
|||
<div className="dialog-form"> |
|||
<label> |
|||
<span>Media type</span> |
|||
<input value={mimeType} onChange={(event) => setMimeType(event.target.value)} /> |
|||
</label> |
|||
<label> |
|||
<span>Object size</span> |
|||
<input value={previewSize} onChange={(event) => setPreviewSize(event.target.value)} /> |
|||
</label> |
|||
</div> |
|||
<div className="decision-list"> |
|||
<span className="status-chip"> |
|||
{previewQuery.data?.non_local_allowed === false ? "Local only" : "Non-local allowed"} |
|||
</span> |
|||
<span className="item-muted"> |
|||
{previewSizeBytes === null ? "No size input" : formatBytes(previewSizeBytes)} |
|||
</span> |
|||
{(previewQuery.data?.selected_backends ?? []).map((backend) => ( |
|||
<div key={backend.id} className="decision-backend"> |
|||
<strong>{backend.name}</strong> |
|||
<span>{formatBackendType(backend.type)}</span> |
|||
</div> |
|||
))} |
|||
</div> |
|||
</section> |
|||
</aside> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
function toggleId(values: string[], id: string) { |
|||
return values.includes(id) ? values.filter((value) => value !== id) : [...values, id]; |
|||
} |
|||
|
|||
function parseOptionalInt(value: string) { |
|||
const cleaned = value.trim(); |
|||
if (!cleaned) { |
|||
return null; |
|||
} |
|||
const parsed = Number(cleaned); |
|||
if (!Number.isInteger(parsed) || parsed < 0) { |
|||
return null; |
|||
} |
|||
return parsed; |
|||
} |
|||
@ -1,97 +0,0 @@ |
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
|||
import { useEffect, useMemo, useState } from "react"; |
|||
import { PaginationControls } from "../components/PaginationControls"; |
|||
import { api } from "../lib/api"; |
|||
import { formatBytes, formatDate } from "../lib/format"; |
|||
|
|||
export function RecycleBinPage() { |
|||
const queryClient = useQueryClient(); |
|||
const [page, setPage] = useState(1); |
|||
const [pageSize, setPageSize] = useState(8); |
|||
const recycleQuery = useQuery({ |
|||
queryKey: ["recycle-bin"], |
|||
queryFn: api.listRecycleBin, |
|||
}); |
|||
|
|||
const restoreMutation = useMutation({ |
|||
mutationFn: (fileId: string) => api.restoreFile(fileId), |
|||
onSuccess: async () => { |
|||
await queryClient.invalidateQueries({ queryKey: ["recycle-bin"] }); |
|||
}, |
|||
}); |
|||
const items = recycleQuery.data?.items ?? []; |
|||
const totalBytes = items.reduce((sum, item) => sum + item.size_bytes, 0); |
|||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); |
|||
const pagedItems = useMemo( |
|||
() => items.slice((page - 1) * pageSize, page * pageSize), |
|||
[items, page, pageSize], |
|||
); |
|||
useEffect(() => { |
|||
if (page > totalPages) { |
|||
setPage(totalPages); |
|||
} |
|||
}, [page, totalPages]); |
|||
|
|||
return ( |
|||
<div className="compact-page-shell compact-page-shell-wide"> |
|||
<section className="panel dashboard-main-panel"> |
|||
<div className="panel-toolbar files-toolbar-top"> |
|||
<div className="files-header"> |
|||
<span className="eyebrow">Deleted files</span> |
|||
<div className="directory-title-row"> |
|||
<h2>Recycle Bin</h2> |
|||
</div> |
|||
<div className="inline-metrics"> |
|||
<span>{items.length} items</span> |
|||
<span>{formatBytes(totalBytes)}</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="list-surface"> |
|||
<div className="list-header recycle-list-header"> |
|||
<span>Name</span> |
|||
<span>Deleted</span> |
|||
<span>Size</span> |
|||
<span className="list-header-actions">Actions</span> |
|||
</div> |
|||
<div className="list-table"> |
|||
{items.length ? ( |
|||
pagedItems.map((item) => ( |
|||
<div key={item.id} className="table-row recycle-row"> |
|||
<div> |
|||
<strong>{item.name}</strong> |
|||
<span className="item-muted">Recoverable file</span> |
|||
</div> |
|||
<div className="table-meta recycle-row-meta"> |
|||
<span>{formatDate(item.deleted_at)}</span> |
|||
<span>{formatBytes(item.size_bytes)}</span> |
|||
<button className="ghost-button compact-button" type="button" onClick={() => restoreMutation.mutate(item.id)}> |
|||
Restore |
|||
</button> |
|||
</div> |
|||
</div> |
|||
)) |
|||
) : ( |
|||
<div className="empty-state empty-state-rich"> |
|||
<strong>Recycle bin is empty.</strong> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
<PaginationControls |
|||
label="Recycle bin pagination" |
|||
page={page} |
|||
pageSize={pageSize} |
|||
totalItems={items.length} |
|||
totalPages={totalPages} |
|||
onPageChange={setPage} |
|||
onPageSizeChange={(nextPageSize) => { |
|||
setPageSize(nextPageSize); |
|||
setPage(1); |
|||
}} |
|||
/> |
|||
</section> |
|||
</div> |
|||
); |
|||
} |
|||
@ -1,518 +0,0 @@ |
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
|||
import { FormEvent, useState } from "react"; |
|||
import { Dialog } from "../components/Dialog"; |
|||
import { useToast } from "../features/toast/ToastProvider"; |
|||
import { api } from "../lib/api"; |
|||
import type { BackendSummary } from "../lib/api"; |
|||
import { formatBackendType, formatDate, titleCase } from "../lib/format"; |
|||
|
|||
type BackendType = "local_directory" | "s3" | "webdav"; |
|||
type StabilityClass = "local" | "stable" | "opportunistic"; |
|||
|
|||
const CONFIG_TEMPLATES: Record<BackendType, string> = { |
|||
local_directory: JSON.stringify({ base_path: "./.iron-storage/archive" }, null, 2), |
|||
s3: JSON.stringify( |
|||
{ |
|||
bucket: "iron", |
|||
region: "us-east-1", |
|||
endpoint_url: "http://127.0.0.1:9000", |
|||
access_key_id: "", |
|||
secret_access_key: "", |
|||
prefix: "iron", |
|||
force_path_style: true, |
|||
}, |
|||
null, |
|||
2, |
|||
), |
|||
webdav: JSON.stringify( |
|||
{ |
|||
endpoint_url: "http://127.0.0.1:5244/dav", |
|||
username: "iron", |
|||
password: "", |
|||
root_path: "iron", |
|||
verify_ssl: true, |
|||
}, |
|||
null, |
|||
2, |
|||
), |
|||
}; |
|||
|
|||
export function StoragePage() { |
|||
const queryClient = useQueryClient(); |
|||
const { pushToast } = useToast(); |
|||
const [isCreateOpen, setIsCreateOpen] = useState(false); |
|||
const [backendType, setBackendType] = useState<BackendType>("s3"); |
|||
const [stabilityClass, setStabilityClass] = useState<StabilityClass>("stable"); |
|||
const [name, setName] = useState(""); |
|||
const [readPriority, setReadPriority] = useState(80); |
|||
const [writePriority, setWritePriority] = useState(80); |
|||
const [configText, setConfigText] = useState(CONFIG_TEMPLATES.s3); |
|||
const [formError, setFormError] = useState(""); |
|||
const [editingBackend, setEditingBackend] = useState<BackendSummary | null>(null); |
|||
const [editName, setEditName] = useState(""); |
|||
const [editStabilityClass, setEditStabilityClass] = useState<StabilityClass>("stable"); |
|||
const [editReadPriority, setEditReadPriority] = useState(80); |
|||
const [editWritePriority, setEditWritePriority] = useState(80); |
|||
const [editConfigText, setEditConfigText] = useState("{}"); |
|||
const [editSecretText, setEditSecretText] = useState("{}"); |
|||
const [editError, setEditError] = useState(""); |
|||
const [deleteTarget, setDeleteTarget] = useState<BackendSummary | null>(null); |
|||
const backendQuery = useQuery({ |
|||
queryKey: ["backends"], |
|||
queryFn: api.listBackends, |
|||
}); |
|||
|
|||
function handleTypeChange(nextType: BackendType) { |
|||
setBackendType(nextType); |
|||
setConfigText(CONFIG_TEMPLATES[nextType]); |
|||
setStabilityClass(nextType === "local_directory" ? "local" : "stable"); |
|||
if (!name.trim()) { |
|||
setName( |
|||
nextType === "local_directory" |
|||
? "local-archive" |
|||
: nextType === "s3" |
|||
? "s3-main" |
|||
: "openlist-main", |
|||
); |
|||
} |
|||
} |
|||
|
|||
const createMutation = useMutation({ |
|||
mutationFn: api.createBackend, |
|||
onSuccess: async () => { |
|||
setIsCreateOpen(false); |
|||
setFormError(""); |
|||
await queryClient.invalidateQueries({ queryKey: ["backends"] }); |
|||
}, |
|||
onError: (error) => { |
|||
setFormError(error instanceof Error ? error.message : "Backend creation failed."); |
|||
}, |
|||
}); |
|||
|
|||
const checkMutation = useMutation({ |
|||
mutationFn: (backendId: string) => api.checkBackend(backendId), |
|||
onSuccess: async () => { |
|||
pushToast("Backend check finished.", "success"); |
|||
await queryClient.invalidateQueries({ queryKey: ["backends"] }); |
|||
}, |
|||
onError: (error) => { |
|||
pushToast(error instanceof Error ? error.message : "Backend check failed.", "error"); |
|||
}, |
|||
}); |
|||
|
|||
const disableMutation = useMutation({ |
|||
mutationFn: (backendId: string) => api.disableBackend(backendId), |
|||
onSuccess: async () => { |
|||
pushToast("Backend disabled.", "success"); |
|||
await queryClient.invalidateQueries({ queryKey: ["backends"] }); |
|||
}, |
|||
onError: (error) => { |
|||
pushToast(error instanceof Error ? error.message : "Disable failed.", "error"); |
|||
}, |
|||
}); |
|||
|
|||
const enableMutation = useMutation({ |
|||
mutationFn: (backendId: string) => api.enableBackend(backendId), |
|||
onSuccess: async () => { |
|||
pushToast("Backend enabled.", "success"); |
|||
await queryClient.invalidateQueries({ queryKey: ["backends"] }); |
|||
}, |
|||
onError: (error) => { |
|||
pushToast(error instanceof Error ? error.message : "Enable failed.", "error"); |
|||
}, |
|||
}); |
|||
|
|||
const runAllChecksMutation = useMutation({ |
|||
mutationFn: api.enqueueHealthChecks, |
|||
onSuccess: async (result) => { |
|||
pushToast(`Queued ${result.enqueued_jobs} backend health check job${result.enqueued_jobs === 1 ? "" : "s"}.`, "success"); |
|||
await queryClient.invalidateQueries({ queryKey: ["jobs"] }); |
|||
}, |
|||
onError: (error) => { |
|||
pushToast(error instanceof Error ? error.message : "Could not queue health checks.", "error"); |
|||
}, |
|||
}); |
|||
|
|||
const updateMutation = useMutation({ |
|||
mutationFn: (payload: { backendId: string; form: ReturnType<typeof buildEditPayload> }) => |
|||
api.updateBackend(payload.backendId, payload.form), |
|||
onSuccess: async () => { |
|||
setEditingBackend(null); |
|||
setEditError(""); |
|||
pushToast("Backend updated.", "success"); |
|||
await queryClient.invalidateQueries({ queryKey: ["backends"] }); |
|||
}, |
|||
onError: (error) => { |
|||
setEditError(error instanceof Error ? error.message : "Backend update failed."); |
|||
}, |
|||
}); |
|||
|
|||
const deleteMutation = useMutation({ |
|||
mutationFn: (backendId: string) => api.deleteBackend(backendId), |
|||
onSuccess: async () => { |
|||
const deletedName = deleteTarget?.name; |
|||
setDeleteTarget(null); |
|||
pushToast(deletedName ? `Deleted ${deletedName}.` : "Backend deleted.", "success"); |
|||
await queryClient.invalidateQueries({ queryKey: ["backends"] }); |
|||
await queryClient.invalidateQueries({ queryKey: ["placement-policies"] }); |
|||
}, |
|||
onError: (error) => { |
|||
pushToast(error instanceof Error ? error.message : "Delete failed.", "error"); |
|||
}, |
|||
}); |
|||
const items = backendQuery.data?.items ?? []; |
|||
const healthyCount = items.filter((backend) => (backend.last_health_status ?? "unknown") === "healthy").length; |
|||
const enabledCount = items.filter((backend) => backend.is_enabled).length; |
|||
const disabledCount = items.filter((backend) => !backend.is_enabled).length; |
|||
|
|||
function handleCreateBackend(event: FormEvent<HTMLFormElement>) { |
|||
event.preventDefault(); |
|||
setFormError(""); |
|||
let config: Record<string, unknown>; |
|||
try { |
|||
config = JSON.parse(configText) as Record<string, unknown>; |
|||
} catch { |
|||
setFormError("Config must be valid JSON."); |
|||
return; |
|||
} |
|||
createMutation.mutate({ |
|||
name, |
|||
type: backendType, |
|||
stability_class: stabilityClass, |
|||
read_priority: readPriority, |
|||
write_priority: writePriority, |
|||
config, |
|||
}); |
|||
} |
|||
|
|||
function openEditBackend(backend: BackendSummary) { |
|||
setEditingBackend(backend); |
|||
setEditName(backend.name); |
|||
setEditStabilityClass(backend.stability_class as StabilityClass); |
|||
setEditReadPriority(backend.read_priority); |
|||
setEditWritePriority(backend.write_priority); |
|||
setEditConfigText(JSON.stringify(backend.config ?? {}, null, 2)); |
|||
setEditSecretText("{}"); |
|||
setEditError(""); |
|||
} |
|||
|
|||
function handleUpdateBackend(event: FormEvent<HTMLFormElement>) { |
|||
event.preventDefault(); |
|||
if (!editingBackend) { |
|||
return; |
|||
} |
|||
setEditError(""); |
|||
let form; |
|||
try { |
|||
form = buildEditPayload({ |
|||
name: editName, |
|||
stabilityClass: editStabilityClass, |
|||
readPriority: editReadPriority, |
|||
writePriority: editWritePriority, |
|||
configText: editConfigText, |
|||
secretText: editSecretText, |
|||
}); |
|||
} catch (error) { |
|||
setEditError(error instanceof Error ? error.message : "Config must be valid JSON."); |
|||
return; |
|||
} |
|||
updateMutation.mutate({ backendId: editingBackend.id, form }); |
|||
} |
|||
|
|||
return ( |
|||
<div className="compact-page-shell compact-page-shell-wide"> |
|||
<section className="panel dashboard-main-panel"> |
|||
<div className="panel-toolbar files-toolbar-top"> |
|||
<div className="files-header"> |
|||
<span className="eyebrow">Storage</span> |
|||
<div className="directory-title-row"> |
|||
<h2>Backends</h2> |
|||
</div> |
|||
<div className="inline-metrics"> |
|||
<span>{items.length} backends</span> |
|||
<span>{healthyCount} healthy</span> |
|||
<span>{enabledCount} enabled</span> |
|||
<span>{disabledCount} disabled</span> |
|||
</div> |
|||
</div> |
|||
<div className="toolbar-group files-command-bar"> |
|||
<button className="primary-button" type="button" onClick={() => setIsCreateOpen(true)}> |
|||
Add Backend |
|||
</button> |
|||
<button className="ghost-button" type="button" onClick={() => runAllChecksMutation.mutate()}> |
|||
Run All Checks |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="list-surface storage-list-surface"> |
|||
<div className="list-header storage-list-header"> |
|||
<span>Backend</span> |
|||
<span>Status</span> |
|||
<span>Class</span> |
|||
<span>Checked</span> |
|||
<span className="list-header-actions">Actions</span> |
|||
</div> |
|||
<div className="list-table"> |
|||
{items.map((backend) => ( |
|||
<div key={backend.id} className="table-row storage-table-row"> |
|||
<div> |
|||
<strong>{backend.name}</strong> |
|||
<span className="item-muted"> |
|||
{formatBackendType(backend.type)} · {formatBackendConfig(backend.config)} |
|||
{backend.secret_fields.length ? ` · ${backend.secret_fields.length} secrets` : ""} |
|||
</span> |
|||
</div> |
|||
<div className="table-meta storage-row-meta"> |
|||
<span className={backend.is_enabled ? "status-chip status-chip-success" : "status-chip"}> |
|||
{backend.is_enabled ? "Enabled" : "Disabled"} |
|||
</span> |
|||
<span className="status-chip">{titleCase(backend.last_health_status ?? "unknown")}</span> |
|||
<span>{titleCase(backend.stability_class)}</span> |
|||
<span>{formatDate(backend.last_health_checked_at)}</span> |
|||
<div className="toolbar-group storage-row-actions"> |
|||
<button className="ghost-button compact-button" type="button" onClick={() => checkMutation.mutate(backend.id)}> |
|||
Check |
|||
</button> |
|||
<button className="ghost-button compact-button" type="button" onClick={() => openEditBackend(backend)}> |
|||
Edit |
|||
</button> |
|||
{backend.is_enabled ? ( |
|||
<button className="danger-button compact-button" type="button" onClick={() => disableMutation.mutate(backend.id)}> |
|||
Disable |
|||
</button> |
|||
) : ( |
|||
<> |
|||
<button className="ghost-button compact-button" type="button" onClick={() => enableMutation.mutate(backend.id)}> |
|||
Enable |
|||
</button> |
|||
<button className="danger-button compact-button" type="button" onClick={() => setDeleteTarget(backend)}> |
|||
Delete |
|||
</button> |
|||
</> |
|||
)} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
))} |
|||
{!items.length ? ( |
|||
<div className="empty-state empty-state-rich"> |
|||
<strong>No storage backends configured.</strong> |
|||
</div> |
|||
) : null} |
|||
</div> |
|||
</div> |
|||
</section> |
|||
{isCreateOpen ? ( |
|||
<Dialog title="Add storage backend" onClose={() => setIsCreateOpen(false)}> |
|||
<form className="dialog-form" onSubmit={handleCreateBackend}> |
|||
<label> |
|||
<span>Type</span> |
|||
<select value={backendType} onChange={(event) => handleTypeChange(event.target.value as BackendType)}> |
|||
<option value="local_directory">Local directory</option> |
|||
<option value="s3">S3</option> |
|||
<option value="webdav">OpenList / WebDAV</option> |
|||
</select> |
|||
</label> |
|||
<label> |
|||
<span>Name</span> |
|||
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="storage-main" /> |
|||
</label> |
|||
<label> |
|||
<span>Policy class</span> |
|||
<select |
|||
value={stabilityClass} |
|||
onChange={(event) => setStabilityClass(event.target.value as StabilityClass)} |
|||
> |
|||
<option value="local">Local</option> |
|||
<option value="stable">Stable</option> |
|||
<option value="opportunistic">Opportunistic</option> |
|||
</select> |
|||
</label> |
|||
<div className="split-fields"> |
|||
<label> |
|||
<span>Read priority</span> |
|||
<input |
|||
type="number" |
|||
value={readPriority} |
|||
onChange={(event) => setReadPriority(Number(event.target.value))} |
|||
/> |
|||
</label> |
|||
<label> |
|||
<span>Write priority</span> |
|||
<input |
|||
type="number" |
|||
value={writePriority} |
|||
onChange={(event) => setWritePriority(Number(event.target.value))} |
|||
/> |
|||
</label> |
|||
</div> |
|||
<label> |
|||
<span>Config JSON</span> |
|||
<textarea |
|||
value={configText} |
|||
onChange={(event) => setConfigText(event.target.value)} |
|||
rows={backendType === "webdav" ? 8 : 9} |
|||
spellCheck={false} |
|||
/> |
|||
</label> |
|||
{formError ? <p className="error-text">{formError}</p> : null} |
|||
<div className="dialog-actions"> |
|||
<button className="ghost-button" type="button" onClick={() => setIsCreateOpen(false)}> |
|||
Cancel |
|||
</button> |
|||
<button className="primary-button" type="submit" disabled={createMutation.isPending}> |
|||
Create |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</Dialog> |
|||
) : null} |
|||
{editingBackend ? ( |
|||
<Dialog title="Edit storage backend" onClose={() => setEditingBackend(null)}> |
|||
<form className="dialog-form" onSubmit={handleUpdateBackend}> |
|||
<label> |
|||
<span>Type</span> |
|||
<input value={editingBackend.type} disabled /> |
|||
</label> |
|||
<label> |
|||
<span>Name</span> |
|||
<input value={editName} onChange={(event) => setEditName(event.target.value)} /> |
|||
</label> |
|||
<label> |
|||
<span>Policy class</span> |
|||
<select |
|||
value={editStabilityClass} |
|||
onChange={(event) => setEditStabilityClass(event.target.value as StabilityClass)} |
|||
> |
|||
<option value="local">Local</option> |
|||
<option value="stable">Stable</option> |
|||
<option value="opportunistic">Opportunistic</option> |
|||
</select> |
|||
</label> |
|||
<div className="split-fields"> |
|||
<label> |
|||
<span>Read priority</span> |
|||
<input |
|||
type="number" |
|||
value={editReadPriority} |
|||
onChange={(event) => setEditReadPriority(Number(event.target.value))} |
|||
/> |
|||
</label> |
|||
<label> |
|||
<span>Write priority</span> |
|||
<input |
|||
type="number" |
|||
value={editWritePriority} |
|||
onChange={(event) => setEditWritePriority(Number(event.target.value))} |
|||
/> |
|||
</label> |
|||
</div> |
|||
<label> |
|||
<span>Public config JSON</span> |
|||
<textarea |
|||
value={editConfigText} |
|||
onChange={(event) => setEditConfigText(event.target.value)} |
|||
rows={8} |
|||
spellCheck={false} |
|||
/> |
|||
</label> |
|||
<label> |
|||
<span>Secret override JSON</span> |
|||
<textarea |
|||
value={editSecretText} |
|||
onChange={(event) => setEditSecretText(event.target.value)} |
|||
rows={4} |
|||
spellCheck={false} |
|||
/> |
|||
</label> |
|||
<div className="meta-list compact-meta-list"> |
|||
<div> |
|||
<span>Stored secrets</span> |
|||
<strong>{editingBackend.secret_fields.length ? editingBackend.secret_fields.join(", ") : "None"}</strong> |
|||
</div> |
|||
</div> |
|||
{editError ? <p className="error-text">{editError}</p> : null} |
|||
<div className="dialog-actions"> |
|||
<button className="ghost-button" type="button" onClick={() => setEditingBackend(null)}> |
|||
Cancel |
|||
</button> |
|||
<button className="primary-button" type="submit" disabled={updateMutation.isPending}> |
|||
Save |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</Dialog> |
|||
) : null} |
|||
{deleteTarget ? ( |
|||
<Dialog title="Delete storage backend" onClose={() => setDeleteTarget(null)}> |
|||
<div className="dialog-form"> |
|||
<div className="empty-state"> |
|||
<strong>{deleteTarget.name}</strong> |
|||
<p className="item-muted"> |
|||
Delete only works for disabled backends with no stored replicas and no placement policy references. |
|||
</p> |
|||
</div> |
|||
<div className="dialog-actions"> |
|||
<button className="ghost-button" type="button" onClick={() => setDeleteTarget(null)}> |
|||
Cancel |
|||
</button> |
|||
<button |
|||
className="danger-button" |
|||
type="button" |
|||
disabled={deleteMutation.isPending} |
|||
onClick={() => deleteMutation.mutate(deleteTarget.id)} |
|||
> |
|||
Delete |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</Dialog> |
|||
) : null} |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
function buildEditPayload(input: { |
|||
name: string; |
|||
stabilityClass: StabilityClass; |
|||
readPriority: number; |
|||
writePriority: number; |
|||
configText: string; |
|||
secretText: string; |
|||
}) { |
|||
const publicConfig = JSON.parse(input.configText) as Record<string, unknown>; |
|||
const secretConfig = JSON.parse(input.secretText) as Record<string, unknown>; |
|||
if (!isObject(publicConfig) || !isObject(secretConfig)) { |
|||
throw new Error("Config must be a JSON object."); |
|||
} |
|||
return { |
|||
name: input.name, |
|||
stability_class: input.stabilityClass, |
|||
read_priority: input.readPriority, |
|||
write_priority: input.writePriority, |
|||
config: { ...publicConfig, ...secretConfig }, |
|||
}; |
|||
} |
|||
|
|||
function isObject(value: unknown): value is Record<string, unknown> { |
|||
return typeof value === "object" && value !== null && !Array.isArray(value); |
|||
} |
|||
|
|||
function formatBackendConfig(config: Record<string, unknown> | null) { |
|||
if (!config) { |
|||
return "Configured"; |
|||
} |
|||
if (typeof config.base_path === "string") { |
|||
return config.base_path; |
|||
} |
|||
if (typeof config.bucket === "string") { |
|||
const prefix = typeof config.prefix === "string" && config.prefix ? `/${config.prefix}` : ""; |
|||
return `${config.bucket}${prefix}`; |
|||
} |
|||
if (typeof config.endpoint_url === "string") { |
|||
const rootPath = typeof config.root_path === "string" ? config.root_path : "iron"; |
|||
return `${config.endpoint_url}/${rootPath}`; |
|||
} |
|||
return "Configured"; |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
/// <reference types="vite/client" />
|
|||
/// <reference types="@opencloud-eu/design-system/types" />
|
|||
|
|||
declare module "*.vue" { |
|||
import type { DefineComponent } from "vue"; |
|||
|
|||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>; |
|||
export default component; |
|||
} |
|||