nothingworry's picture
feat: Enhance admin rules with file upload, drag-and-drop, chunk processing, and improved UI
a477044
raw
history blame
22.8 kB
"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>
);
}