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; |
||||
|
} |
||||