feat: Phase 4+5 — admin storage UI, backend proxy, CLAUDE.md enforcement

- backend/app/routers/storage_config.py: 5 admin-only endpoints proxying
  storage-service config + migration API (GET/PATCH/POST/DELETE)
- backend/app/main.py: register storage_config router
- frontend/src/api/client.ts: StorageStatus, MigrationStatus,
  StorageBackendConfig interfaces + 5 API functions
- frontend/src/pages/StorageAdminPage.tsx: full admin UI — backend health
  dot, driver selector (local/S3/WebDAV), conditional credential fields,
  Test & Migrate button, live 2s-poll migration progress bar, Cancel
- frontend/src/App.tsx: /admin/storage route (AdminRoute guard)
- CLAUDE.md: storage enforcement rule, updated Docker tables (6 services,
  3 volumes), §20 in merge checklist
- backend/CLAUDE.md, frontend/CLAUDE.md, doc-service/CLAUDE.md,
  ai-service/CLAUDE.md: updated to reflect storage-service integration
- tests/ALL_TESTS.md + tests/storage-service_tests.md: §20 (20 tests)
- backend/STATUS.md, frontend/STATUS.md: updated with new endpoints/routes
- changelog/2026-04-20_storage-service.md: full change log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-20 16:13:05 +02:00
parent 4c35d7a2a4
commit cfec3bb906
15 changed files with 746 additions and 22 deletions
+3 -1
View File
@@ -37,7 +37,8 @@ frontend/
│ │ └── ui/ ← shadcn/ui components (Button, Input, …)
│ ├── pages/ ← One file per route
│ │ ├── DocServiceSettingsPage.tsx ← Combined doc-service settings: upload limits + watch directory
│ │ ── PluginSettingsPage.tsx ← Generic plugin settings page driven by manifest
│ │ ── PluginSettingsPage.tsx ← Generic plugin settings page driven by manifest
│ │ └── StorageAdminPage.tsx ← Admin storage backend config + live migration progress
│ ├── lib/utils.ts ← cn() = clsx + tailwind-merge
│ └── styles/theme.css ← CSS custom properties, Tailwind setup
├── vite.config.ts ← /api/* proxied to backend:8000
@@ -66,6 +67,7 @@ frontend/
| `/admin/users` | `AdminUsersPage` | AdminRoute |
| `/admin/groups` | `AdminGroupsPage` | AdminRoute |
| `/admin/appearance` | `AdminAppearancePage` | AdminRoute |
| `/admin/storage` | `StorageAdminPage` | AdminRoute |
| `*` | redirect to `/` | — |
`PrivateRoute` — checks `token` from `useAuth`, redirects to `/login` if absent.
+6
View File
@@ -21,6 +21,7 @@ All API calls go through `src/api/client.ts` (single Axios instance, JWT injecte
| `/admin` | `AdminPage` (redirects to `/admin/users`) | Admin only |
| `/admin/users` | `AdminUsersPage` | Admin only |
| `/admin/groups` | `AdminGroupsPage` | Admin only |
| `/admin/storage` | `StorageAdminPage` | Admin only |
| `/profile` | `ProfilePage` | Required |
| `/settings` | `SettingsPage` (placeholder) | Required |
| `/settings/plugins/:id` | `PluginSettingsPage` | Required (per-plugin access control) |
@@ -114,6 +115,10 @@ Provider selector, per-provider fields, Test Connection, Save.
Upload limits + watch directory config.
### Admin — Storage page (`/admin/storage`)
Current backend status (green/red health dot). Driver selector (local/S3/WebDAV) with conditional credential fields. "Test & Migrate" button triggers an async migration that copies all objects to the new backend, verifies, then switches atomically. Live progress bar with 2s polling (states: validating → migrating → switching → cleaning → done). Cancel button during in-progress migrations.
### Admin — Users page (`/admin/users`)
User list, toggle active, create user, delete user.
@@ -202,6 +207,7 @@ Key document-related functions:
- [x] AI suggestion confirm/reject UI (folder + filename)
- [x] Groups admin UI
- [x] Replace Axios with native fetch; add global 401 → `/login` redirect for expired sessions
- [x] Admin storage page with live migration progress bar
- [ ] Toast notification system
- [ ] Loading skeletons
- [ ] Cmd+K global search (`CommandDialog`)
+2
View File
@@ -16,6 +16,7 @@ import DocServiceSettingsPage from "./pages/DocServiceSettingsPage";
import AIAdminSettingsPage from "./pages/AIAdminSettingsPage";
import SettingsPage from "./pages/SettingsPage";
import PluginSettingsPage from "./pages/PluginSettingsPage";
import StorageAdminPage from "./pages/StorageAdminPage";
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { token } = useAuth();
@@ -102,6 +103,7 @@ export default function App() {
<Route path="/admin/users" element={<AdminRoute><AdminUsersPage /></AdminRoute>} />
<Route path="/admin/groups" element={<AdminRoute><AdminGroupsPage /></AdminRoute>} />
<Route path="/admin/appearance" element={<AdminRoute><AdminAppearancePage /></AdminRoute>} />
<Route path="/admin/storage" element={<AdminRoute><StorageAdminPage /></AdminRoute>} />
{/* Catch-all */}
<Route path="*" element={<Navigate to="/" replace />} />
+43
View File
@@ -585,6 +585,49 @@ export interface PluginManifest {
};
}
// ── Storage admin ──────────────────────────────────────────────────────────────
export interface StorageStatus {
status: string;
backend: string;
}
export interface MigrationStatus {
state:
| "idle"
| "validating"
| "migrating"
| "switching"
| "cleaning"
| "done"
| "failed"
| "cancelled";
total: number;
done: number;
failed: number;
errors: string[];
}
export interface StorageBackendConfig {
driver: string;
config: Record<string, string>;
}
export const getStorageConfig = () => api.get<StorageStatus>("/admin/storage-config");
export const updateStorageConfig = (body: StorageBackendConfig) =>
api.patch<void>("/admin/storage-config", body);
export const startStorageMigration = (body: StorageBackendConfig) =>
api.post<{ status: string; driver: string }>("/admin/storage-config/migrate", body);
export const getMigrationStatus = () =>
api.get<MigrationStatus>("/admin/storage-config/migrate/status");
export const cancelMigration = () => api.delete<void>("/admin/storage-config/migrate");
// ── Plugins ────────────────────────────────────────────────────────────────────
export const getPlugins = () => api.get<PluginOut[]>("/plugins");
export const getPluginManifest = (id: string) =>
+382
View File
@@ -0,0 +1,382 @@
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getStorageConfig,
getMigrationStatus,
startStorageMigration,
cancelMigration,
updateStorageConfig,
type StorageBackendConfig,
type MigrationStatus,
} from "../api/client";
type Driver = "local" | "s3" | "webdav";
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section style={{ marginBottom: 36 }}>
<h2 style={{ fontSize: 18, marginBottom: 16 }}>{title}</h2>
{children}
</section>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "var(--color-text-muted)" }}>
{label}
</label>
{children}
</div>
);
}
function inputStyle(disabled = false): React.CSSProperties {
return {
width: "100%",
padding: "6px 10px",
border: "1px solid var(--color-border)",
borderRadius: 6,
fontSize: 14,
background: disabled ? "var(--color-surface)" : "var(--color-background)",
color: "var(--color-text-primary)",
opacity: disabled ? 0.7 : 1,
};
}
function MigrationProgressBar({ status }: { status: MigrationStatus }) {
const pct = status.total > 0 ? Math.round((status.done / status.total) * 100) : 0;
const isBusy = ["validating", "migrating", "switching", "cleaning"].includes(status.state);
const stateLabel: Record<string, string> = {
idle: "Idle",
validating: "Validating new backend…",
migrating: `Migrating — ${status.done} / ${status.total} objects`,
switching: "Switching active backend…",
cleaning: "Cleaning up old backend…",
done: "Migration complete",
failed: "Migration failed",
cancelled: "Migration cancelled",
};
return (
<div style={{ marginTop: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4, fontSize: 13 }}>
<span style={{ color: status.state === "failed" ? "#dc2626" : "var(--color-text-primary)" }}>
{stateLabel[status.state] ?? status.state}
</span>
{isBusy && <span style={{ color: "var(--color-text-muted)" }}>{pct}%</span>}
</div>
{(isBusy || status.state === "done") && (
<div
style={{
height: 8,
background: "var(--color-border)",
borderRadius: 4,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: status.state === "done" ? "100%" : `${pct}%`,
background: status.state === "done" ? "#16a34a" : "var(--color-primary)",
borderRadius: 4,
transition: "width 0.3s",
}}
/>
</div>
)}
{status.errors.length > 0 && (
<div
style={{
marginTop: 8,
padding: "8px 10px",
background: "#fef2f2",
borderRadius: 6,
fontSize: 12,
color: "#991b1b",
maxHeight: 120,
overflowY: "auto",
}}
>
{status.errors.slice(0, 10).map((e, i) => (
<div key={i} style={{ marginBottom: 2 }}>
{e}
</div>
))}
{status.errors.length > 10 && (
<div style={{ opacity: 0.7 }}>and {status.errors.length - 10} more</div>
)}
</div>
)}
</div>
);
}
export default function StorageAdminPage() {
const queryClient = useQueryClient();
const { data: storageStatus } = useQuery({
queryKey: ["storage-config"],
queryFn: getStorageConfig,
refetchInterval: 10_000,
});
const { data: migStatus, refetch: refetchMig } = useQuery({
queryKey: ["migration-status"],
queryFn: getMigrationStatus,
refetchInterval: (query) => {
const state = query.state.data?.state;
return state && ["validating", "migrating", "switching", "cleaning"].includes(state)
? 2_000
: false;
},
});
const isMigrating =
migStatus &&
["validating", "migrating", "switching", "cleaning"].includes(migStatus.state);
// ── New backend form ─────────────────────────────────────────────────────────
const [driver, setDriver] = useState<Driver>("local");
const [s3EndpointUrl, setS3EndpointUrl] = useState("");
const [s3AccessKey, setS3AccessKey] = useState("");
const [s3SecretKey, setS3SecretKey] = useState("");
const [s3Region, setS3Region] = useState("us-east-1");
const [webdavUrl, setWebdavUrl] = useState("");
const [webdavUsername, setWebdavUsername] = useState("");
const [webdavPassword, setWebdavPassword] = useState("");
const [webdavRootPath, setWebdavRootPath] = useState("/");
const [error, setError] = useState("");
function buildConfig(): StorageBackendConfig {
if (driver === "s3") {
return {
driver,
config: {
endpoint_url: s3EndpointUrl,
access_key: s3AccessKey,
secret_key: s3SecretKey,
region: s3Region,
},
};
}
if (driver === "webdav") {
return {
driver,
config: {
url: webdavUrl,
username: webdavUsername,
password: webdavPassword,
root_path: webdavRootPath,
},
};
}
return { driver: "local", config: {} };
}
const migrateMutation = useMutation({
mutationFn: startStorageMigration,
onSuccess: () => {
setError("");
refetchMig();
},
onError: (e: Error) => setError(e.message),
});
const cancelMutation = useMutation({
mutationFn: cancelMigration,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["migration-status"] });
queryClient.invalidateQueries({ queryKey: ["storage-config"] });
},
onError: (e: Error) => setError(e.message),
});
const currentDriver = storageStatus?.backend ?? "—";
return (
<div style={{ maxWidth: 680, margin: "0 auto", padding: "32px 16px" }}>
<h1 style={{ fontSize: 24, marginBottom: 4 }}>Storage</h1>
<p style={{ color: "var(--color-text-muted)", marginBottom: 32, fontSize: 14 }}>
All uploaded files are stored through the storage-service. Switch between local filesystem,
S3-compatible cloud storage, or WebDAV (Nextcloud).
</p>
<Section title="Current backend">
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: 6,
fontSize: 14,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: storageStatus?.status === "ok" ? "#16a34a" : "#dc2626",
flexShrink: 0,
}}
/>
<strong>{currentDriver}</strong>
{storageStatus?.status === "ok" ? " — healthy" : " — unreachable"}
</div>
</Section>
<Section title="Switch backend">
<p style={{ fontSize: 13, color: "var(--color-text-muted)", marginBottom: 16 }}>
When you click <strong>Test &amp; Migrate</strong>, all existing files will be copied to the
new backend, verified, and the switch will happen only after every file is confirmed. The
old backend is cleaned up automatically.
</p>
<Field label="Backend driver">
<select
value={driver}
onChange={(e) => setDriver(e.target.value as Driver)}
disabled={!!isMigrating}
style={inputStyle(!!isMigrating)}
>
<option value="local">Local filesystem (default)</option>
<option value="s3">S3-compatible (MinIO / AWS S3 / Backblaze / Cloudflare R2)</option>
<option value="webdav">WebDAV (Nextcloud / )</option>
</select>
</Field>
{driver === "s3" && (
<>
<Field label="Endpoint URL (leave empty for real AWS S3)">
<input
value={s3EndpointUrl}
onChange={(e) => setS3EndpointUrl(e.target.value)}
placeholder="http://minio:9000"
disabled={!!isMigrating}
style={inputStyle(!!isMigrating)}
/>
</Field>
<Field label="Access key">
<input
value={s3AccessKey}
onChange={(e) => setS3AccessKey(e.target.value)}
disabled={!!isMigrating}
style={inputStyle(!!isMigrating)}
/>
</Field>
<Field label="Secret key">
<input
type="password"
value={s3SecretKey}
onChange={(e) => setS3SecretKey(e.target.value)}
disabled={!!isMigrating}
style={inputStyle(!!isMigrating)}
/>
</Field>
<Field label="Region">
<input
value={s3Region}
onChange={(e) => setS3Region(e.target.value)}
placeholder="us-east-1"
disabled={!!isMigrating}
style={inputStyle(!!isMigrating)}
/>
</Field>
</>
)}
{driver === "webdav" && (
<>
<Field label="Server URL">
<input
value={webdavUrl}
onChange={(e) => setWebdavUrl(e.target.value)}
placeholder="https://nextcloud.example.com"
disabled={!!isMigrating}
style={inputStyle(!!isMigrating)}
/>
</Field>
<Field label="Username">
<input
value={webdavUsername}
onChange={(e) => setWebdavUsername(e.target.value)}
disabled={!!isMigrating}
style={inputStyle(!!isMigrating)}
/>
</Field>
<Field label="Password">
<input
type="password"
value={webdavPassword}
onChange={(e) => setWebdavPassword(e.target.value)}
disabled={!!isMigrating}
style={inputStyle(!!isMigrating)}
/>
</Field>
<Field label="WebDAV root path">
<input
value={webdavRootPath}
onChange={(e) => setWebdavRootPath(e.target.value)}
placeholder="/remote.php/dav/files/username"
disabled={!!isMigrating}
style={inputStyle(!!isMigrating)}
/>
</Field>
</>
)}
{error && (
<p style={{ color: "#dc2626", fontSize: 13, marginBottom: 8 }}>{error}</p>
)}
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
<button
onClick={() => migrateMutation.mutate(buildConfig())}
disabled={!!isMigrating || migrateMutation.isPending}
style={{
padding: "8px 18px",
background: "var(--color-primary)",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 14,
cursor: isMigrating || migrateMutation.isPending ? "not-allowed" : "pointer",
opacity: isMigrating || migrateMutation.isPending ? 0.6 : 1,
}}
>
{migrateMutation.isPending ? "Starting…" : "Test & Migrate"}
</button>
{isMigrating && (
<button
onClick={() => cancelMutation.mutate()}
disabled={cancelMutation.isPending}
style={{
padding: "8px 18px",
background: "transparent",
color: "#dc2626",
border: "1px solid #dc2626",
borderRadius: 6,
fontSize: 14,
cursor: "pointer",
}}
>
Cancel
</button>
)}
</div>
{migStatus && migStatus.state !== "idle" && (
<MigrationProgressBar status={migStatus} />
)}
</Section>
</div>
);
}