feat: Add OCR Processing Layers Viewer UI and log proxy middleware

This commit is contained in:
Rafhan Mazaya Fathurrahman committed 2026-07-01 08:45:45 +07:00
1 parent 54b46fc35f
commit aa3233e411
11 files changed
+964 -30

No files matched your search

+1 -1
View File
@@ -57,7 +57,7 @@ services:
- PIPELINE_PORT=8090
- PIPELINE_DEVICE=gpu:0
- CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}
- VLLM_SERVER_URL=http://paddleocr-vllm-server:8118/v1
- VLLM_SERVER_URL=http://paddleocr-pfm-web-app:3000/api/vllm-proxy/v1
- PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True
# No exposed ports; internal only
deploy:
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../../db";
export const dynamic = "force-dynamic";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> | { id: string } }
) {
try {
// Handle both Promise and synchronous params for Next.js version compatibility
const resolvedParams = await params;
const { id } = resolvedParams;
const docId = parseInt(id, 10);
if (isNaN(docId)) {
return NextResponse.json({ error: "Invalid document ID" }, { status: 400 });
}
const res = await query(
"SELECT filename, processing_logs FROM documents WHERE id = $1",
[docId]
);
if (res.rowCount === 0 || !res.rows[0]) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
return NextResponse.json({
filename: res.rows[0].filename,
processing_logs: res.rows[0].processing_logs || null
});
} catch (error: any) {
console.error("Error fetching document logs:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
@@ -6,13 +6,14 @@ import { query, cleanupAndReindexItems, resolveStoreFromText } from "../../../db
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
export async function POST(req: NextRequest) {
let safeFile = "";
try {
const { filename } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
}
const safeFile = path.basename(filename);
safeFile = path.basename(filename);
// 1. Resolve path to the public assets folder or fallback to /uploads
let filePath = path.join(process.cwd(), "public", "do-pfm", safeFile);
@@ -3,7 +3,8 @@ import fs from "fs";
import path from "path";
import crypto from "crypto";
import { query, resolveStoreFromText } from "../../../db";
import { parseDOMetadata } from "../../../utils/parser";
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
import { startActiveLog, getActiveLog, clearActiveLog } from "../../../utils/active-log";
const UPLOADS_DIR = "/uploads";
@@ -75,6 +76,7 @@ export async function POST(req: NextRequest) {
const pipelineUrl = process.env.PIPELINE_URL || "http://paddleocr-pipeline-api:8090/layout-parsing";
console.log(`Forwarding uploaded file ${filename} to pipeline: ${pipelineUrl}`);
startActiveLog(filename);
const response = await fetch(pipelineUrl, {
method: "POST",
@@ -85,6 +87,7 @@ export async function POST(req: NextRequest) {
});
if (!response.ok) {
clearActiveLog();
const errText = await response.text();
return NextResponse.json({ error: `Pipeline API error: ${errText}` }, { status: response.status });
}
@@ -131,19 +134,46 @@ export async function POST(req: NextRequest) {
(docMetadata as any).orderUntuk = resolvedStore.orderUntuk;
(docMetadata as any).alamat = resolvedStore.alamat;
// Stage 2 Filtering: Sanitize parsed metadata
const sanitizedMetadata = sanitizeParsedMetadata(docMetadata as any);
// Construct client response representation
const wrappedResult = {
errorCode: 0,
errorMsg: "Success",
result: pipelineResult
};
const clientResponse = {
filename,
result: wrappedResult
};
// Retrieve and finalize active log data
const activeLog = getActiveLog();
let logsPayload: any = null;
if (activeLog && activeLog.filename === filename) {
activeLog.ocr_raw = pipelineResult;
activeLog.stage_1_output = docMetadata;
activeLog.stage_2_output = sanitizedMetadata;
activeLog.frontend_response = clientResponse;
logsPayload = activeLog;
}
clearActiveLog();
const insertDocRes = await query(`
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`, [
filename,
new Date(),
buffer.length,
true,
JSON.stringify(docMetadata),
JSON.stringify(sanitizedMetadata),
JSON.stringify(pipelineResult),
false,
fileHash
fileHash,
logsPayload ? JSON.stringify(logsPayload) : null
]);
const docId = insertDocRes.rows[0].id;
@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from "next/server";
import { logVllmCall } from "../../../../utils/active-log";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
return handleProxy(req);
}
export async function GET(req: NextRequest) {
return handleProxy(req);
}
export async function PUT(req: NextRequest) {
return handleProxy(req);
}
export async function DELETE(req: NextRequest) {
return handleProxy(req);
}
async function handleProxy(req: NextRequest) {
try {
const pathname = req.nextUrl.pathname;
// Extract everything after /api/vllm-proxy
const relPath = pathname.replace(/^\/api\/vllm-proxy/, "");
// The real vLLM server is at paddleocr-vllm-server:8118 inside docker compose
const realBaseUrl = process.env.VLLM_SERVER_REAL_URL || "http://paddleocr-vllm-server:8118";
// Construct the destination URL
const destUrl = `${realBaseUrl}${relPath}${req.nextUrl.search}`;
console.log(`[vllm-proxy] Routing request from ${pathname} to ${destUrl}`);
// Read the request body if present
let reqBody: any = null;
let reqBodyBuffer: Buffer | null = null;
if (req.body) {
const arrayBuffer = await req.arrayBuffer();
reqBodyBuffer = Buffer.from(arrayBuffer);
const contentType = req.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
try {
reqBody = JSON.parse(reqBodyBuffer.toString("utf-8"));
} catch (e) {
console.warn("[vllm-proxy] Failed to parse request body as JSON:", e);
}
}
}
// Reconstruct headers, filtering out headers that might cause issues (e.g. Host)
const headers = new Headers();
req.headers.forEach((value, key) => {
if (key.toLowerCase() !== "host" && key.toLowerCase() !== "content-length") {
headers.set(key, value);
}
});
// Make the actual call to the real vLLM server
const forwardResponse = await fetch(destUrl, {
method: req.method,
headers: headers,
body: reqBodyBuffer ? new Uint8Array(reqBodyBuffer) : null,
// @ts-ignore
duplex: "half"
});
// Read the response content
const resBodyBuffer = Buffer.from(await forwardResponse.arrayBuffer());
let resBody: any = null;
const resContentType = forwardResponse.headers.get("content-type") || "";
if (resContentType.includes("application/json")) {
try {
resBody = JSON.parse(resBodyBuffer.toString("utf-8"));
} catch (e) {
console.warn("[vllm-proxy] Failed to parse response body as JSON:", e);
}
} else {
resBody = resBodyBuffer.toString("utf-8");
}
// Log the interaction if it looks like a completion call
if (pathname.includes("/chat/completions") || pathname.includes("/completions")) {
// Make a clean copy of the request to log (hiding huge base64 images if they clutter logs)
const cleanReq = sanitizeLogPayload(reqBody);
logVllmCall(cleanReq, resBody);
}
// Return the response back to pipeline-api
const responseHeaders = new Headers();
forwardResponse.headers.forEach((value, key) => {
responseHeaders.set(key, value);
});
return new Response(resBodyBuffer, {
status: forwardResponse.status,
statusText: forwardResponse.statusText,
headers: responseHeaders
});
} catch (error) {
console.error("[vllm-proxy] Error forwarding request:", error);
return NextResponse.json({ error: "Failed to proxy request to vLLM server" }, { status: 500 });
}
}
// Helper function to keep log sizes reasonable by truncating huge base64 image strings
function sanitizeLogPayload(payload: any): any {
if (!payload) return payload;
try {
const copy = JSON.parse(JSON.stringify(payload));
if (copy.messages && Array.isArray(copy.messages)) {
for (const msg of copy.messages) {
if (msg.content && Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "image_url" && part.image_url && part.image_url.url) {
const url = part.image_url.url;
if (url.startsWith("data:") && url.length > 200) {
part.image_url.url = url.substring(0, 100) + "...[TRUNCATED BASE64]..." + url.substring(url.length - 50);
}
}
}
}
}
}
return copy;
} catch (e) {
return payload;
}
}
+4 -2
View File
@@ -1,6 +1,8 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #0f172a;
--foreground: #f8fafc;
}
@media (prefers-color-scheme: dark) {
+3 -13
View File
@@ -1,17 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "AI OCR Delivery Order",
description: "Generated by create next app",
@@ -25,9 +14,10 @@ export default function RootLayout({
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className="h-full antialiased text-slate-100 bg-slate-950"
suppressHydrationWarning
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col font-sans">{children}</body>
</html>
);
}
+691 -4
View File
@@ -1,10 +1,697 @@
export const dynamic = "force-dynamic";
"use client";
import React, { useState, useEffect, useRef } from "react";
interface HistoryItem {
id: number;
filename: string;
uploadTime: string;
size: number;
parsed: boolean;
isSample: boolean;
metadata: any;
totalItems: number;
flaggedItems: number;
}
export default function Home() {
const [history, setHistory] = useState<HistoryItem[]>([]);
const [selectedItem, setSelectedItem] = useState<HistoryItem | null>(null);
const [selectedDetails, setSelectedDetails] = useState<any>(null);
const [selectedLogs, setSelectedLogs] = useState<any>(null);
const [activeStep, setActiveStep] = useState<number>(0);
const [uploading, setUploading] = useState<boolean>(false);
const [dragActive, setDragActive] = useState<boolean>(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Fetch history list
const fetchHistory = async () => {
try {
const res = await fetch("/api/history?all=true");
if (res.ok) {
const data = await res.json();
setHistory(data.history || []);
}
} catch (err) {
console.error("Failed to fetch history:", err);
}
};
useEffect(() => {
fetchHistory();
}, []);
// Fetch details and logs for a selected document
const handleSelectItem = async (item: HistoryItem) => {
setSelectedItem(item);
setSelectedDetails(null);
setSelectedLogs(null);
setErrorMsg(null);
setActiveStep(0);
try {
// 1. Fetch items and doc details
const detailsRes = await fetch(`/api/history?file=${encodeURIComponent(item.filename)}`);
if (detailsRes.ok) {
const detailsData = await detailsRes.json();
setSelectedDetails(detailsData);
} else {
throw new Error("Failed to load document details");
}
// 2. Fetch processing logs
const logsRes = await fetch(`/api/documents/${item.id}/logs`);
if (logsRes.ok) {
const logsData = await logsRes.json();
setSelectedLogs(logsData.processing_logs);
}
} catch (err: any) {
console.error("Error loading document:", err);
setErrorMsg(err.message || "Error loading document details");
}
};
// Handle Drag & Drop events
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
await handleUploadFile(e.dataTransfer.files[0]);
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
await handleUploadFile(e.target.files[0]);
}
};
const handleUploadFile = async (file: File) => {
setUploading(true);
setErrorMsg(null);
try {
const formData = new FormData();
formData.append("file", file);
console.log("Uploading file to gateway...");
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to process image");
}
const data = await res.json();
console.log("Upload response received:", data);
// Refresh list and select new document
await fetchHistory();
// Attempt to find the newly uploaded item in history list
// Next.js history is sorted descending, so it will likely be first
const updatedRes = await fetch("/api/history?all=true");
if (updatedRes.ok) {
const updatedData = await updatedRes.json();
const newHistory = updatedData.history || [];
setHistory(newHistory);
const match = newHistory.find((h: HistoryItem) => h.filename === data.filename);
if (match) {
handleSelectItem(match);
}
}
} catch (err: any) {
console.error("Upload failed:", err);
setErrorMsg(err.message || "Failed to upload and process file.");
} finally {
setUploading(false);
}
};
const triggerFileInput = () => {
fileInputRef.current?.click();
};
const handleDeleteItem = async (e: React.MouseEvent, item: HistoryItem) => {
e.stopPropagation();
if (!confirm(`Are you sure you want to delete ${item.filename}?`)) return;
try {
const res = await fetch("/api/history", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: item.filename })
});
if (res.ok) {
if (selectedItem?.id === item.id) {
setSelectedItem(null);
setSelectedDetails(null);
setSelectedLogs(null);
}
fetchHistory();
}
} catch (err) {
console.error("Failed to delete document:", err);
}
};
// Format bytes to human readable format
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// Setup Stepper Steps
const getSteps = () => {
const vllmCallsCount = selectedLogs?.vllm_calls?.length || 0;
return [
{
title: "1. Upload & Preprocess",
description: "Verify document metadata & files",
content: (
<div className="space-y-4">
<h4 className="text-sm font-semibold text-teal-400">Uploaded Document File Info</h4>
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs space-y-2 text-slate-300">
<p><span className="text-slate-500">Filename:</span> {selectedItem?.filename}</p>
<p><span className="text-slate-500">File Size:</span> {formatBytes(selectedItem?.size || 0)}</p>
<p><span className="text-slate-500">Upload Date:</span> {selectedItem?.uploadTime ? new Date(selectedItem.uploadTime).toLocaleString() : "N/A"}</p>
<p><span className="text-slate-500">File Hash:</span> {selectedItem?.metadata?.fileHash || "Processed content hash saved"}</p>
<p><span className="text-slate-500">Is Sample DO:</span> {selectedItem?.isSample ? "True" : "False"}</p>
</div>
{selectedItem?.filename && (
<div>
<h4 className="text-sm font-semibold text-teal-400 mb-2">Original Document Source View</h4>
<div className="border border-slate-700 rounded-lg overflow-hidden max-h-96 flex justify-center bg-slate-950">
<img
src={selectedItem.isSample ? `/do-pfm/${selectedItem.filename}` : `/api/history?file=${encodeURIComponent(selectedItem.filename)}`}
alt="Original Document"
className="object-contain max-h-96 w-full"
onError={(e) => {
// Fallback if direct image access requires alternative endpoint
(e.target as HTMLImageElement).src = `https://placehold.co/600x800/1e293b/f8fafc?text=${encodeURIComponent(selectedItem.filename)}`;
}}
/>
</div>
</div>
)}
</div>
)
},
{
title: `2. vLLM Prompt ("Before")`,
description: `Inspect prompt parameters & system role (${vllmCallsCount} call${vllmCallsCount > 1 ? "s" : ""})`,
content: (
<div className="space-y-4">
<h4 className="text-sm font-semibold text-teal-400">Payload Sent to vLLM Server</h4>
{selectedLogs?.vllm_calls && selectedLogs.vllm_calls.length > 0 ? (
selectedLogs.vllm_calls.map((call: any, idx: number) => (
<div key={idx} className="space-y-2 border-b border-slate-700/40 pb-4 last:border-0 last:pb-0">
<div className="flex justify-between items-center text-xs text-slate-400">
<span className="bg-slate-800 text-teal-300 px-2 py-0.5 rounded-full font-semibold">Call #{idx + 1}</span>
<span>{new Date(call.timestamp).toLocaleTimeString()}</span>
</div>
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
<pre>{JSON.stringify(call.request, null, 2)}</pre>
</div>
</div>
))
) : (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
No vLLM API calls logged. (If this is a sample/re-indexed document, logs may not be recorded yet).
</div>
)}
</div>
)
},
{
title: `3. vLLM Response ("After")`,
description: "Inspect raw output returned by VLM",
content: (
<div className="space-y-4">
<h4 className="text-sm font-semibold text-teal-400">Raw Text Generated by vLLM (PaddleOCR-VL)</h4>
{selectedLogs?.vllm_calls && selectedLogs.vllm_calls.length > 0 ? (
selectedLogs.vllm_calls.map((call: any, idx: number) => (
<div key={idx} className="space-y-2 border-b border-slate-700/40 pb-4 last:border-0 last:pb-0">
<div className="flex justify-between items-center text-xs text-slate-400">
<span className="bg-slate-800 text-teal-300 px-2 py-0.5 rounded-full font-semibold">Response #{idx + 1}</span>
<span>{new Date(call.timestamp).toLocaleTimeString()}</span>
</div>
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
<pre>{JSON.stringify(call.response, null, 2)}</pre>
</div>
</div>
))
) : (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
No vLLM API responses logged.
</div>
)}
</div>
)
},
{
title: "4. Raw OCR Layout Markdown",
description: "View parsed layout markdown structure",
content: (
<div className="space-y-4">
<h4 className="text-sm font-semibold text-teal-400">Document Layout Parsing Markdown Output</h4>
{selectedDetails?.result || selectedLogs?.ocr_raw ? (
<div className="space-y-2">
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
<pre className="whitespace-pre-wrap">
{(() => {
const res = selectedDetails?.result || selectedLogs?.ocr_raw;
const page0 = res?.layoutParsingResults?.[0] || {};
return page0?.markdown?.text || "No markdown text output found in results.";
})()}
</pre>
</div>
</div>
) : (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
No raw layout results parsed.
</div>
)}
</div>
)
},
{
title: "5. Stage 1 Extracted Fields",
description: "View output of regex schema matching",
content: (
<div className="space-y-4">
<h4 className="text-sm font-semibold text-teal-400">Regex Extracted Fields (Metadata & Items)</h4>
{selectedLogs?.stage_1_output ? (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
<pre>{JSON.stringify(selectedLogs.stage_1_output, null, 2)}</pre>
</div>
) : (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
No Stage 1 log output saved.
</div>
)}
</div>
)
},
{
title: "6. Stage 2 Sanitized Output",
description: "View results after validation checks",
content: (
<div className="space-y-4">
<h4 className="text-sm font-semibold text-teal-400">Sanitized & Validated Metadata (Final database fields)</h4>
{selectedLogs?.stage_2_output ? (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
<pre>{JSON.stringify(selectedLogs.stage_2_output, null, 2)}</pre>
</div>
) : (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
No Stage 2 log output saved.
</div>
)}
</div>
)
},
{
title: "7. Client Gateway Response",
description: "Inspect JSON payload sent to frontends",
content: (
<div className="space-y-4">
<h4 className="text-sm font-semibold text-teal-400">Final Gateway JSON Response</h4>
{selectedLogs?.frontend_response || selectedDetails ? (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
<pre>
{JSON.stringify(
selectedLogs?.frontend_response || {
filename: selectedItem?.filename,
result: selectedDetails
},
null,
2
)}
</pre>
</div>
) : (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
No response payload logged.
</div>
)}
</div>
)
}
];
};
const steps = getSteps();
return (
<div style={{ fontFamily: 'system-ui, sans-serif', padding: '2rem', textAlign: 'center' }}>
<h1>Prima Fresh Mart OCR API Gateway</h1>
<p>Status: Running</p>
<div className="flex-1 flex flex-col bg-slate-950 text-slate-100 min-h-screen">
{/* Sleek Top Navigation Bar */}
<header className="border-b border-slate-800 bg-slate-900/80 backdrop-blur-md px-6 py-4 flex justify-between items-center sticky top-0 z-50">
<div className="flex items-center space-x-3">
<div className="h-8 w-8 bg-gradient-to-tr from-teal-500 to-indigo-600 rounded-lg flex items-center justify-center font-bold text-white shadow-lg shadow-teal-500/20">
P
</div>
<div>
<h1 className="text-md font-bold bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-200 to-slate-400">
Prima Fresh Mart AI OCR Gateway
</h1>
<p className="text-[10px] text-teal-400 font-mono tracking-wider uppercase">
Pipeline Process Layers Dashboard
</p>
</div>
</div>
{/* Status Indicators */}
<div className="flex items-center space-x-4 text-xs">
<div className="flex items-center space-x-2 bg-slate-800/80 px-3 py-1 rounded-full border border-slate-700/40">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse"></span>
<span className="text-slate-400 font-mono">DB: Online</span>
</div>
<div className="flex items-center space-x-2 bg-slate-800/80 px-3 py-1 rounded-full border border-slate-700/40">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse"></span>
<span className="text-slate-400 font-mono">vLLM: Connected</span>
</div>
</div>
</header>
{/* Main Layout Grid */}
<main className="flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6 p-6 overflow-hidden">
{/* LEFT COLUMN: Upload Panel & History List (4 Cols) */}
<section className="lg:col-span-4 flex flex-col space-y-6 overflow-y-auto max-h-[calc(100vh-120px)] pr-2">
{/* Upload Zone Card */}
<div
className={`border-2 border-dashed rounded-2xl p-6 transition-all duration-300 cursor-pointer flex flex-col items-center justify-center ${
dragActive
? "border-teal-400 bg-teal-950/20 shadow-md shadow-teal-500/10"
: "border-slate-800 hover:border-slate-700 bg-slate-900/40 hover:bg-slate-900/60"
}`}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
onClick={triggerFileInput}
>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
/>
{uploading ? (
<div className="flex flex-col items-center py-4 space-y-3">
<div className="h-8 w-8 border-4 border-t-teal-400 border-slate-700 rounded-full animate-spin"></div>
<p className="text-xs text-teal-400 font-mono">Running OCR Pipeline...</p>
</div>
) : (
<div className="text-center py-4 space-y-3">
<div className="text-3xl text-slate-400">📤</div>
<div>
<p className="text-sm font-semibold text-slate-200">Upload Delivery Order Image</p>
<p className="text-xs text-slate-500 mt-1">Drag and drop file, or click to browse</p>
</div>
</div>
)}
</div>
{/* History List Card */}
<div className="bg-slate-900/30 border border-slate-850 rounded-2xl p-5 flex flex-col flex-grow min-h-[350px]">
<h3 className="text-sm font-bold text-slate-400 font-mono uppercase tracking-wider mb-4">
Upload History
</h3>
<div className="space-y-3 overflow-y-auto max-h-[450px] flex-grow pr-1">
{history.length === 0 ? (
<div className="text-center py-8 text-xs text-slate-500 italic">
No uploads processed yet.
</div>
) : (
history.map((item) => {
const isSelected = selectedItem?.id === item.id;
const dateFormatted = new Date(item.uploadTime).toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit"
});
return (
<div
key={item.id}
className={`p-3.5 rounded-xl border transition-all duration-200 cursor-pointer flex justify-between items-start ${
isSelected
? "bg-slate-900 border-teal-500/60 shadow-lg shadow-teal-500/5"
: "bg-slate-900/50 border-slate-800 hover:border-slate-700"
}`}
onClick={() => handleSelectItem(item)}
>
<div className="space-y-1.5 min-w-0 flex-1">
<p className="text-xs font-semibold text-slate-200 truncate pr-2" title={item.filename}>
{item.filename.substring(item.filename.indexOf("-") + 1)}
</p>
<div className="flex items-center space-x-2 text-[10px] text-slate-500 font-mono">
<span>{dateFormatted}</span>
<span>•</span>
<span>{formatBytes(item.size)}</span>
</div>
{item.metadata && (
<div className="flex flex-wrap gap-1.5 mt-2">
{item.metadata.noPO && item.metadata.noPO !== "Not Found" && (
<span className="bg-teal-950/50 text-teal-400 border border-teal-900/60 px-1.5 py-0.5 rounded text-[9px] font-mono font-semibold">
{item.metadata.noPO}
</span>
)}
{item.totalItems > 0 && (
<span className="bg-slate-800 text-slate-300 px-1.5 py-0.5 rounded text-[9px] font-mono">
{item.totalItems} Items
</span>
)}
{item.flaggedItems > 0 && (
<span className="bg-rose-950/30 text-rose-400 border border-rose-900/40 px-1.5 py-0.5 rounded text-[9px] font-mono font-semibold">
{item.flaggedItems} Warning
</span>
)}
</div>
)}
</div>
<button
onClick={(e) => handleDeleteItem(e, item)}
className="text-slate-600 hover:text-rose-400 p-1.5 rounded-lg hover:bg-rose-950/20 transition-colors"
title="Delete document"
>
🗑️
</button>
</div>
);
})
)}
</div>
</div>
</section>
{/* RIGHT COLUMN: Interactive Stepper & Document Viewer (8 Cols) */}
<section className="lg:col-span-8 flex flex-col space-y-6 overflow-y-auto max-h-[calc(100vh-120px)]">
{errorMsg && (
<div className="bg-rose-950/30 border border-rose-800/40 p-4 rounded-xl text-xs text-rose-400 flex items-start space-x-2">
<span className="text-base leading-none">⚠️</span>
<span>{errorMsg}</span>
</div>
)}
{!selectedItem ? (
<div className="flex-grow bg-slate-900/20 border border-slate-900/60 rounded-2xl p-12 flex flex-col justify-center items-center text-center">
<div className="text-5xl mb-4 text-slate-700 animate-pulse">📁</div>
<h3 className="text-md font-bold text-slate-400">No Document Selected</h3>
<p className="text-xs text-slate-500 max-w-sm mt-2">
Upload a new Delivery Order image, or select one from the history panel to inspect its processing stages.
</p>
</div>
) : (
<div className="space-y-6">
{/* Document Overview Metadata Card */}
<div className="bg-slate-900/40 border border-slate-850 rounded-2xl p-6 shadow-xl">
<div className="flex justify-between items-start border-b border-slate-800 pb-4 mb-4">
<div>
<h3 className="text-md font-bold text-slate-100 flex items-center space-x-2">
<span>📄</span>
<span className="truncate max-w-md">{selectedItem.filename.substring(selectedItem.filename.indexOf("-") + 1)}</span>
</h3>
<p className="text-xs text-slate-400 mt-1">Parsed Overview results from Postgres Database</p>
</div>
{selectedItem.isSample && (
<span className="bg-blue-950/40 text-blue-400 border border-blue-900/40 px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wider font-mono">
Sample DO
</span>
)}
</div>
{selectedDetails?.result?.metadata || selectedItem.metadata ? (
(() => {
const meta = selectedDetails?.result?.metadata || selectedItem.metadata;
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs font-mono">
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">PO Number</p>
<p className="text-slate-200 font-semibold">{meta.noPO || "Not Found"}</p>
</div>
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">SO Number</p>
<p className="text-slate-200 font-semibold">{meta.noSO || "Not Found"}</p>
</div>
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">DO Number</p>
<p className="text-slate-200 font-semibold">{meta.noDO || "Not Found"}</p>
</div>
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850 md:col-span-3">
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">Order Untuk (Store)</p>
<p className="text-slate-200 font-semibold text-sm">{meta.orderUntuk || "Not Found"}</p>
<p className="text-slate-400 mt-1 text-[10px] truncate">{meta.alamat || ""}</p>
</div>
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">Date</p>
<p className="text-slate-200 font-semibold">{meta.tanggal || "Not Found"}</p>
</div>
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">Truck Plate</p>
<p className="text-slate-200 font-semibold">{meta.platTruk || "Not Found"}</p>
</div>
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">Total SKUs</p>
<p className="text-slate-200 font-semibold">{selectedDetails?.items?.length || 0} SKU Items</p>
</div>
</div>
);
})()
) : (
<p className="text-xs text-slate-500 italic">No metadata available</p>
)}
{/* Parsed Items List */}
{selectedDetails?.items && selectedDetails.items.length > 0 && (
<div className="mt-5 border-t border-slate-800 pt-4">
<h4 className="text-xs font-bold font-mono text-slate-400 uppercase mb-2">Parsed SKU Table Rows</h4>
<div className="overflow-x-auto border border-slate-850 rounded-xl">
<table className="min-w-full text-xs text-left text-slate-300">
<thead className="bg-slate-950/60 font-mono text-[9px] uppercase text-slate-500 border-b border-slate-800">
<tr>
<th className="px-4 py-2">No</th>
<th className="px-4 py-2">SKU Code</th>
<th className="px-4 py-2">Item Description</th>
<th className="px-4 py-2 text-right">Qty</th>
<th className="px-4 py-2 text-right">Price/Total</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/40 bg-slate-950/10">
{selectedDetails.items.map((item: any, idx: number) => {
const isFlagged = selectedDetails.flagged?.[idx];
const remark = selectedDetails.remarks?.[idx];
return (
<tr key={idx} className={isFlagged ? "bg-rose-950/10" : "hover:bg-slate-900/20"}>
<td className="px-4 py-2 text-slate-500">{idx + 1}</td>
<td className={`px-4 py-2 font-mono font-semibold ${isFlagged ? "text-rose-400" : "text-teal-400"}`}>
{item.kodeBarang}
</td>
<td className="px-4 py-2 pr-6">
<div>
<p className="font-semibold text-slate-200">{item.namaBarang}</p>
{remark && <p className="text-[10px] text-rose-400 italic mt-0.5">{remark}</p>}
</div>
</td>
<td className="px-4 py-2 text-right font-mono font-semibold">{item.banyak}</td>
<td className="px-4 py-2 text-right font-mono text-slate-400">{item.jumlah}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* TIMELINE / STEPPER LAYERS CARD */}
<div className="bg-slate-900/40 border border-slate-850 rounded-2xl p-6 shadow-xl flex flex-col space-y-6">
<div className="border-b border-slate-800 pb-3">
<h3 className="text-md font-bold text-slate-100 flex items-center space-x-2">
<span>🔬</span>
<span>AI OCR Pipeline Process Layers</span>
</h3>
<p className="text-xs text-slate-500 mt-1">Interactive vertical timeline inspecting how data flows from original image to clean JSON response</p>
</div>
{/* Process Timeline Stepper Container */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
{/* Stepper Steps (4 Cols) */}
<div className="md:col-span-4 flex flex-col space-y-2 border-r border-slate-800/60 pr-4">
{steps.map((step, idx) => {
const isActive = activeStep === idx;
return (
<div
key={idx}
className={`p-3 rounded-xl cursor-pointer transition-all duration-200 border text-left ${
isActive
? "bg-slate-900 border-teal-500 text-slate-200 shadow-md shadow-teal-500/5"
: "bg-slate-950/20 border-transparent text-slate-500 hover:text-slate-300"
}`}
onClick={() => setActiveStep(idx)}
>
<p className="text-xs font-bold font-mono tracking-wide">{step.title}</p>
<p className="text-[10px] font-normal truncate mt-0.5">{step.description}</p>
</div>
);
})}
</div>
{/* Step Detail Content Panel (8 Cols) */}
<div className="md:col-span-8 bg-slate-950/40 border border-slate-850 rounded-xl p-5 min-h-[350px]">
<div className="flex justify-between items-center mb-4 border-b border-slate-800/80 pb-2">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 font-mono">
Layer Detail view
</h4>
<span className="text-[10px] bg-slate-800 text-slate-400 px-2 py-0.5 rounded-full font-mono font-semibold">
Layer {activeStep + 1} of 7
</span>
</div>
<div className="text-xs text-slate-200">
{steps[activeStep].content}
</div>
</div>
</div>
</div>
</div>
)}
</section>
</main>
</div>
);
}
+5 -3
View File
@@ -19,15 +19,17 @@ export async function initDb(pool: Pool) {
metadata JSONB,
layout_parsing_result JSONB,
is_sample BOOLEAN NOT NULL DEFAULT FALSE,
file_hash VARCHAR(64)
file_hash VARCHAR(64),
processing_logs JSONB
);
`);
// Ensure file_hash exists on existing tables
// Ensure file_hash and processing_logs exist on existing tables
try {
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_hash VARCHAR(64);");
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS processing_logs JSONB;");
} catch (alterErr) {
console.error("Failed to alter documents table for file_hash:", alterErr);
console.error("Failed to alter documents table for schema upgrade:", alterErr);
}
await pool.query(`
@@ -0,0 +1,51 @@
// Active Log Tracker for OCR Processing Layers
export interface VllmCall {
request: any;
response: any;
timestamp: string;
}
export interface ActiveUploadLog {
filename: string;
vllm_calls: VllmCall[];
ocr_raw?: any;
stage_1_output?: any;
stage_2_output?: any;
frontend_response?: any;
}
// Store active log in global context to persist across Next.js hot-reloads
const globalForActiveLog = global as unknown as {
activeLog: ActiveUploadLog | null;
};
export function startActiveLog(filename: string) {
globalForActiveLog.activeLog = {
filename,
vllm_calls: []
};
console.log(`[ActiveLog] Started tracking log for ${filename}`);
}
export function logVllmCall(request: any, response: any) {
if (globalForActiveLog.activeLog) {
globalForActiveLog.activeLog.vllm_calls.push({
request,
response,
timestamp: new Date().toISOString()
});
console.log(`[ActiveLog] Logged vLLM call for ${globalForActiveLog.activeLog.filename} (total calls: ${globalForActiveLog.activeLog.vllm_calls.length})`);
} else {
console.log("[ActiveLog] Warning: Attempted to log vLLM call but no active log session is running.");
}
}
export function getActiveLog(): ActiveUploadLog | null {
return globalForActiveLog.activeLog;
}
export function clearActiveLog() {
globalForActiveLog.activeLog = null;
console.log("[ActiveLog] Cleared active log tracking context");
}
+1 -1
View File
@@ -55,7 +55,7 @@ services:
- PIPELINE_PORT=8090
- PIPELINE_DEVICE=gpu:0
- CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}
- VLLM_SERVER_URL=http://paddleocr-vllm-server:8118/v1
- VLLM_SERVER_URL=http://paddleocr-pfm-web-app:3000/api/vllm-proxy/v1
- PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True
deploy:
resources: