Spaces:
Sleeping
Sleeping
feat: Enhance admin rules with file upload, drag-and-drop, chunk processing, and improved UI
a477044
| "use client"; | |
| import { useCallback, useMemo, useState, useRef, useEffect } from "react"; | |
| import Link from "next/link"; | |
| import { AdminRulesPanel } from "@/components/admin-rules-panel"; | |
| import { Footer } from "@/components/footer"; | |
| import { useTenant } from "@/contexts/TenantContext"; | |
| import { TenantSelector } from "@/components/tenant-selector"; | |
| const BACKEND_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_BASE_URL ?? "http://localhost:8000"; | |
| type StatusState = { tone: "info" | "success" | "error"; message: string } | null; | |
| export default function AdminRulesPage() { | |
| const { tenantId } = useTenant(); | |
| const [rulesInput, setRulesInput] = useState(""); | |
| const [deleteInput, setDeleteInput] = useState(""); | |
| const [rules, setRules] = useState<string[]>([]); | |
| const [loading, setLoading] = useState(false); | |
| const [status, setStatus] = useState<StatusState>(null); | |
| const [isDragging, setIsDragging] = useState(false); | |
| const [lastUpdated, setLastUpdated] = useState<string>(""); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| // Set initial time only on client side to avoid hydration mismatch | |
| useEffect(() => { | |
| setLastUpdated(new Date().toLocaleTimeString()); | |
| }, []); | |
| const headers = useMemo(() => { | |
| if (!tenantId.trim()) return undefined; | |
| return { | |
| "Content-Type": "application/json", | |
| "x-tenant-id": tenantId.trim(), | |
| }; | |
| }, [tenantId]); | |
| const requireTenant = useCallback(() => { | |
| if (!tenantId.trim()) { | |
| setStatus({ tone: "error", message: "Enter a tenant ID in the navbar first." }); | |
| return false; | |
| } | |
| return true; | |
| }, [tenantId]); | |
| const handleRefresh = useCallback(async () => { | |
| if (!requireTenant()) return; | |
| try { | |
| setLoading(true); | |
| setStatus({ tone: "info", message: "Loading rules..." }); | |
| const response = await fetch(`${BACKEND_BASE_URL}/admin/rules`, { | |
| method: "GET", | |
| headers, | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Backend error ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| setRules(data.rules ?? []); | |
| setLastUpdated(new Date().toLocaleTimeString()); | |
| setStatus({ tone: "success", message: "Rules synced." }); | |
| } catch (error: any) { | |
| setStatus({ tone: "error", message: error.message || "Failed to fetch rules" }); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [headers, requireTenant]); | |
| const handleUpload = useCallback(async () => { | |
| if (!requireTenant()) return; | |
| const lines = rulesInput | |
| .split("\n") | |
| .map((line) => line.trim()) | |
| .filter((line) => line && !line.startsWith("#")); // Filter out comments and empty lines | |
| if (!lines.length) { | |
| setStatus({ tone: "error", message: "Add at least one rule to upload. (Comment lines starting with # are ignored)" }); | |
| return; | |
| } | |
| try { | |
| setLoading(true); | |
| setStatus({ tone: "info", message: `Uploading ${lines.length} rule(s)...` }); | |
| const response = await fetch(`${BACKEND_BASE_URL}/admin/rules/bulk?enhance=true`, { | |
| method: "POST", | |
| headers, | |
| body: JSON.stringify({ rules: lines }), | |
| }); | |
| if (!response.ok) { | |
| const details = await response.text(); | |
| throw new Error(details || `Backend error ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| await handleRefresh(); | |
| setRulesInput(""); | |
| const enhancedMsg = data.enhanced ? " (enhanced by LLM)" : ""; | |
| setStatus({ tone: "success", message: `Uploaded ${data.added_rules?.length || lines.length} rule(s)${enhancedMsg}.` }); | |
| } catch (error: any) { | |
| setStatus({ tone: "error", message: error.message || "Failed to upload rules" }); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [handleRefresh, headers, requireTenant, rulesInput]); | |
| const processFile = useCallback(async (file: File) => { | |
| if (!requireTenant()) return; | |
| const fileExt = file.name.split('.').pop()?.toLowerCase(); | |
| if (!fileExt || !['txt', 'pdf', 'doc', 'docx', 'md'].includes(fileExt)) { | |
| setStatus({ tone: "error", message: "Unsupported file type. Supported: TXT, PDF, DOC, DOCX, MD" }); | |
| return; | |
| } | |
| try { | |
| setLoading(true); | |
| setStatus({ tone: "info", message: `Uploading and processing ${file.name}...` }); | |
| // For TXT files, read client-side for faster processing | |
| if (fileExt === 'txt' || fileExt === 'md') { | |
| const fileContent = await file.text(); | |
| const lines = fileContent | |
| .split("\n") | |
| .map((line) => line.trim()) | |
| .filter((line) => line && !line.startsWith("#")); | |
| if (!lines.length) { | |
| setStatus({ tone: "error", message: "No valid rules found in file (after filtering comments)." }); | |
| setLoading(false); | |
| return; | |
| } | |
| // Upload rules via bulk endpoint | |
| setStatus({ tone: "info", message: `Uploading ${lines.length} rule(s)...` }); | |
| const response = await fetch(`${BACKEND_BASE_URL}/admin/rules/bulk?enhance=true`, { | |
| method: "POST", | |
| headers, | |
| body: JSON.stringify({ rules: lines }), | |
| }); | |
| if (!response.ok) { | |
| const details = await response.text(); | |
| throw new Error(details || `Backend error ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| await handleRefresh(); | |
| const enhancedMsg = data.enhanced ? " (enhanced by LLM)" : ""; | |
| setStatus({ tone: "success", message: `Uploaded ${data.added_rules?.length || lines.length} rule(s) from ${file.name}${enhancedMsg}.` }); | |
| return; | |
| } | |
| // For PDF, DOC, DOCX - use backend file upload endpoint | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| setStatus({ tone: "info", message: `Extracting text from ${file.name}...` }); | |
| const response = await fetch(`${BACKEND_BASE_URL}/admin/rules/upload-file?enhance=true`, { | |
| method: "POST", | |
| headers: { | |
| "x-tenant-id": tenantId.trim(), | |
| }, | |
| body: formData, | |
| }); | |
| if (!response.ok) { | |
| const details = await response.text(); | |
| throw new Error(details || `Backend error ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| await handleRefresh(); | |
| const enhancedMsg = data.enhanced ? " (enhanced by LLM)" : ""; | |
| setStatus({ | |
| tone: "success", | |
| message: `Uploaded ${data.added_rules?.length || data.total_extracted || 0} rule(s) from ${file.name}${enhancedMsg}.` | |
| }); | |
| } catch (error: any) { | |
| setStatus({ tone: "error", message: error.message || "Failed to upload rules from file" }); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [handleRefresh, headers, requireTenant, tenantId]); | |
| const handleFileUpload = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = event.target.files?.[0]; | |
| if (!file) return; | |
| await processFile(file); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ""; | |
| } | |
| }, [processFile]); | |
| const handleDragOver = useCallback((e: React.DragEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setIsDragging(true); | |
| }, []); | |
| const handleDragLeave = useCallback((e: React.DragEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setIsDragging(false); | |
| }, []); | |
| const handleDrop = useCallback(async (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setIsDragging(false); | |
| const file = e.dataTransfer.files?.[0]; | |
| if (!file) return; | |
| const fileExt = file.name.split('.').pop()?.toLowerCase(); | |
| if (!fileExt || !['txt', 'pdf', 'doc', 'docx', 'md'].includes(fileExt)) { | |
| setStatus({ tone: "error", message: "Unsupported file type. Supported: TXT, PDF, DOC, DOCX, MD" }); | |
| return; | |
| } | |
| await processFile(file); | |
| }, [processFile]); | |
| const handleDelete = useCallback(async () => { | |
| if (!requireTenant()) return; | |
| if (!deleteInput.trim()) { | |
| setStatus({ tone: "error", message: "Enter the rule text you want to delete." }); | |
| return; | |
| } | |
| try { | |
| setLoading(true); | |
| setStatus({ tone: "info", message: "Deleting rule..." }); | |
| const response = await fetch( | |
| `${BACKEND_BASE_URL}/admin/rules/${encodeURIComponent(deleteInput.trim())}`, | |
| { | |
| method: "DELETE", | |
| headers, | |
| } | |
| ); | |
| if (!response.ok) { | |
| const details = await response.text(); | |
| throw new Error(details || `Backend error ${response.status}`); | |
| } | |
| await handleRefresh(); | |
| setDeleteInput(""); | |
| setStatus({ tone: "success", message: "Rule deleted." }); | |
| } catch (error: any) { | |
| setStatus({ tone: "error", message: error.message || "Failed to delete rule" }); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [deleteInput, handleRefresh, headers, requireTenant]); | |
| return ( | |
| <main className="mx-auto flex min-h-screen max-w-5xl flex-col gap-10 px-4 pb-16 pt-12 sm:px-6 lg:px-8"> | |
| <header className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-6 text-slate-100 shadow-lg shadow-slate-950/40"> | |
| <div className="flex items-center justify-between gap-3"> | |
| <div className="flex items-center gap-3 text-base font-semibold"> | |
| <span className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-slate-950"> | |
| IC | |
| </span> | |
| IntegraChat Β· Admin Rule Ingestion | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| <TenantSelector /> | |
| <Link href="/" className="text-xs font-semibold uppercase tracking-[0.3em] text-cyan-300 hover:text-white"> | |
| β Back Home | |
| </Link> | |
| </div> | |
| </div> | |
| <div className="space-y-2"> | |
| <p className="text-sm text-slate-300"> | |
| Upload governance policies, compliance workflows, and red-flag patterns. Rules are automatically enhanced by LLM and stored in the backend. | |
| </p> | |
| <div className="flex flex-wrap gap-2 text-xs text-slate-400"> | |
| <span className="flex items-center gap-1 rounded-full bg-white/5 px-3 py-1"> | |
| <span>β¨</span> | |
| <span>LLM Enhanced</span> | |
| </span> | |
| <span className="flex items-center gap-1 rounded-full bg-white/5 px-3 py-1"> | |
| <span>π</span> | |
| <span>File Upload</span> | |
| </span> | |
| <span className="flex items-center gap-1 rounded-full bg-white/5 px-3 py-1"> | |
| <span>π</span> | |
| <span>Chunk Processing</span> | |
| </span> | |
| </div> | |
| </div> | |
| </header> | |
| <AdminRulesPanel /> | |
| <section className="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-2xl shadow-slate-950/40"> | |
| <div className="flex flex-col gap-6"> | |
| <div className="flex items-center justify-between gap-3"> | |
| {lastUpdated && ( | |
| <div className="flex items-center gap-2 text-sm text-slate-400"> | |
| <span>π</span> | |
| <span>Last updated: {lastUpdated}</span> | |
| </div> | |
| )} | |
| {!lastUpdated && <div></div>} | |
| <button | |
| onClick={handleRefresh} | |
| disabled={loading} | |
| className="flex items-center gap-2 rounded-xl bg-gradient-to-r from-cyan-400 to-blue-500 px-6 py-3 text-sm font-semibold text-slate-950 shadow-lg shadow-cyan-500/30 transition hover:shadow-cyan-500/50 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| {loading ? ( | |
| <> | |
| <span className="animate-spin">β³</span> | |
| <span>Refreshing...</span> | |
| </> | |
| ) : ( | |
| <> | |
| <span>π</span> | |
| <span>Refresh Rules</span> | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| <div className="grid gap-6 lg:grid-cols-2"> | |
| {/* Left Column: Upload Rules */} | |
| <div className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-slate-900/30 p-6"> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-lg">π</span> | |
| <h3 className="text-lg font-semibold text-slate-200">Add Rules</h3> | |
| </div> | |
| <label className="flex flex-col gap-2 text-sm font-semibold text-slate-200"> | |
| <span>Bulk Upload Rules (one per line)</span> | |
| <textarea | |
| value={rulesInput} | |
| onChange={(e) => setRulesInput(e.target.value)} | |
| placeholder="Block password disclosure requests\nPrevent sharing of API keys\nNo sharing of credit card information" | |
| rows={8} | |
| className="rounded-xl border border-white/10 bg-slate-900/50 px-4 py-3 text-sm text-white placeholder:text-slate-500 outline-none ring-0 transition focus:border-cyan-400 focus:ring-2 focus:ring-cyan-400/20" | |
| /> | |
| <span className="text-xs text-slate-400"> | |
| π‘ Tip: Comment lines (starting with #) are automatically ignored | |
| </span> | |
| </label> | |
| <button | |
| onClick={handleUpload} | |
| disabled={loading || !rulesInput.trim()} | |
| className="rounded-xl bg-gradient-to-r from-emerald-400 to-lime-400 px-6 py-3 text-sm font-semibold text-slate-900 shadow-lg shadow-emerald-500/30 transition hover:shadow-emerald-500/50 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| {loading ? "β³ Uploading..." : "β Upload / Append Rules"} | |
| </button> | |
| <div className="flex items-center gap-3 py-2"> | |
| <span className="h-px flex-1 bg-white/10"></span> | |
| <span className="text-xs font-semibold uppercase tracking-wider text-slate-400">OR</span> | |
| <span className="h-px flex-1 bg-white/10"></span> | |
| </div> | |
| <label className="flex flex-col gap-2 text-sm font-semibold text-slate-200"> | |
| <span>π Upload Rules from File</span> | |
| <div | |
| onDragOver={handleDragOver} | |
| onDragLeave={handleDragLeave} | |
| onDrop={handleDrop} | |
| className={`relative rounded-xl border-2 border-dashed transition-all ${ | |
| isDragging | |
| ? "border-cyan-400 bg-cyan-500/10 scale-[1.02]" | |
| : "border-white/20 bg-slate-900/50 hover:border-cyan-400/50 hover:bg-slate-900/70" | |
| }`} | |
| > | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept=".txt,.pdf,.doc,.docx,.md" | |
| onChange={handleFileUpload} | |
| disabled={loading} | |
| className="hidden" | |
| id="file-upload-input" | |
| /> | |
| <label | |
| htmlFor="file-upload-input" | |
| className="flex flex-col items-center justify-center gap-3 p-8 cursor-pointer" | |
| > | |
| {isDragging ? ( | |
| <> | |
| <span className="text-4xl animate-bounce">π₯</span> | |
| <span className="text-sm font-semibold text-cyan-300">Drop file here</span> | |
| </> | |
| ) : ( | |
| <> | |
| <span className="text-4xl">π</span> | |
| <div className="text-center"> | |
| <span className="text-sm font-semibold text-slate-200"> | |
| Drag & drop file here | |
| </span> | |
| <span className="text-xs text-slate-400 block mt-1">or click to browse</span> | |
| </div> | |
| <button | |
| type="button" | |
| disabled={loading} | |
| className="mt-2 rounded-lg bg-gradient-to-r from-cyan-500 to-blue-500 px-4 py-2 text-xs font-semibold text-slate-900 transition hover:from-cyan-400 hover:to-blue-400 disabled:opacity-50" | |
| > | |
| Choose File | |
| </button> | |
| </> | |
| )} | |
| </label> | |
| </div> | |
| <span className="text-xs text-slate-400"> | |
| Supported: TXT, PDF, DOC, DOCX, MD β’ Files processed server-side with LLM enhancement | |
| </span> | |
| </label> | |
| </div> | |
| {/* Right Column: Delete Rules */} | |
| <div className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-slate-900/30 p-6"> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-lg">ποΈ</span> | |
| <h3 className="text-lg font-semibold text-slate-200">Delete Rule</h3> | |
| </div> | |
| <label className="flex flex-col gap-2 text-sm font-semibold text-slate-200"> | |
| <span>Enter exact rule text to remove</span> | |
| <textarea | |
| value={deleteInput} | |
| onChange={(e) => setDeleteInput(e.target.value)} | |
| placeholder="Paste the exact rule text here to delete it..." | |
| rows={8} | |
| className="rounded-xl border border-white/10 bg-slate-900/50 px-4 py-3 text-sm text-white placeholder:text-slate-500 outline-none ring-0 transition focus:border-rose-400 focus:ring-2 focus:ring-rose-400/20" | |
| /> | |
| <span className="text-xs text-slate-400"> | |
| β οΈ This action cannot be undone. Make sure the text matches exactly. | |
| </span> | |
| </label> | |
| <button | |
| onClick={handleDelete} | |
| disabled={loading || !deleteInput.trim()} | |
| className="rounded-xl border-2 border-rose-500 bg-rose-500/10 px-6 py-3 text-sm font-semibold text-rose-300 transition hover:bg-rose-500/20 hover:border-rose-400 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| {loading ? "β³ Deleting..." : "ποΈ Delete Rule"} | |
| </button> | |
| </div> | |
| </div> | |
| {status && ( | |
| <div | |
| className={`rounded-xl border-2 px-5 py-4 text-sm font-medium shadow-lg ${ | |
| status.tone === "error" | |
| ? "border-rose-500/50 bg-rose-500/10 text-rose-200 shadow-rose-500/20" | |
| : status.tone === "success" | |
| ? "border-emerald-500/50 bg-emerald-500/10 text-emerald-200 shadow-emerald-500/20" | |
| : "border-cyan-500/50 bg-cyan-500/10 text-cyan-200 shadow-cyan-500/20" | |
| }`} | |
| > | |
| <div className="flex items-start gap-3"> | |
| <span className="text-lg"> | |
| {status.tone === "error" ? "β" : status.tone === "success" ? "β " : "βΉοΈ"} | |
| </span> | |
| <span className="flex-1">{status.message}</span> | |
| </div> | |
| </div> | |
| )} | |
| <div className="rounded-2xl border border-white/10 bg-gradient-to-br from-slate-900/60 to-slate-950/60 shadow-xl"> | |
| <div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-6 py-4"> | |
| <div className="flex items-center gap-3"> | |
| <span className="text-xl">π</span> | |
| <h3 className="text-base font-semibold uppercase tracking-[0.2em] text-slate-300">Rule Set</h3> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <button | |
| onClick={handleRefresh} | |
| disabled={loading} | |
| className="flex items-center gap-2 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-4 py-2 text-xs font-semibold text-cyan-300 transition hover:bg-cyan-500/20 hover:border-cyan-400/50 disabled:opacity-50 disabled:cursor-not-allowed" | |
| title="Refresh rules from database" | |
| > | |
| {loading ? ( | |
| <> | |
| <span className="animate-spin">β³</span> | |
| <span>Refreshing...</span> | |
| </> | |
| ) : ( | |
| <> | |
| <span>π</span> | |
| <span>Refresh</span> | |
| </> | |
| )} | |
| </button> | |
| <div className="flex items-center gap-2 rounded-full bg-cyan-500/20 px-4 py-2"> | |
| <span className="text-sm font-semibold text-cyan-300">{rules.length}</span> | |
| <span className="text-xs text-slate-400">entries</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="overflow-x-auto max-h-[500px] overflow-y-auto"> | |
| <table className="w-full text-left text-sm"> | |
| <thead className="sticky top-0 bg-slate-900/95 backdrop-blur-sm text-xs uppercase tracking-[0.2em] text-slate-400"> | |
| <tr> | |
| <th className="px-6 py-4 font-semibold">#</th> | |
| <th className="px-6 py-4 font-semibold">Rule</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {rules.length === 0 && ( | |
| <tr> | |
| <td colSpan={2} className="px-6 py-12 text-center"> | |
| <div className="flex flex-col items-center gap-3"> | |
| <span className="text-4xl">π</span> | |
| <p className="text-slate-400">No rules loaded</p> | |
| <p className="text-xs text-slate-500">Use the refresh button above to load rules</p> | |
| </div> | |
| </td> | |
| </tr> | |
| )} | |
| {rules.map((rule, idx) => ( | |
| <tr | |
| key={`${rule}-${idx}`} | |
| className="border-t border-white/5 transition hover:bg-white/5" | |
| > | |
| <td className="px-6 py-4 text-slate-400 font-mono">{idx + 1}</td> | |
| <td className="px-6 py-4 text-slate-200">{rule}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <Footer /> | |
| </main> | |
| ); | |
| } | |