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:
+3
-1
@@ -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.
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user