feat: add frontend dashboard for run results viewer

- Implemented main dashboard with run metrics and filtering options.
- Created detailed view for individual runs with results and anomalies.
- Added product listing page with filtering and pagination.
- Introduced utility functions for formatting dates and numbers.
- Styled components with CSS for a clean and responsive layout.
- Set up HTML entry point and linked to the main JavaScript file.
- Updated TypeScript configuration to include DOM types.
This commit is contained in:
Victor Noguera
2026-04-13 02:36:35 -04:00
parent a906f5ede3
commit 281bc7dcc9
14 changed files with 2484 additions and 567 deletions

619
src/server.ts Normal file
View File

@@ -0,0 +1,619 @@
import index from "./web/index.html";
import { getDb, initDb } from "./database.ts";
type ProcessType = "lead_analysis" | "category_analysis";
type RunRecord = {
processType: ProcessType;
runId: number;
timestamp: string;
status: string;
jobType: string;
source: string | null;
output: string | null;
totalProducts: number;
fbaCount: number;
fbmCount: number;
skipCount: number;
};
type ProductListRecord = {
processType: ProcessType;
runId: number;
asin: string;
product_name: string | null;
brand: string | null;
category: string | null;
verdict: "FBA" | "FBM" | "SKIP";
confidence: number | null;
sellability_status: string | null;
fetched_at: string;
};
const DB_PATH = process.env.RESULTS_DB_PATH || "./results.db";
const DEFAULT_PAGE_SIZE = 25;
const MAX_PAGE_SIZE = 200;
initDb(DB_PATH);
const db = getDb(DB_PATH);
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
function csv(text: string, filename: string): Response {
return new Response(text, {
status: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="${filename}"`,
},
});
}
function parseIntParam(value: string | null, fallback: number): number {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
return parsed;
}
function parseSort(sortParam: string | null, allowed: Set<string>, fallback: string): string {
if (!sortParam) return fallback;
const clauses = sortParam
.split(",")
.map((chunk) => chunk.trim())
.filter(Boolean)
.map((chunk) => {
const [fieldRaw, dirRaw] = chunk.split(":");
const field = fieldRaw?.trim();
const dir = dirRaw?.trim().toUpperCase() === "DESC" ? "DESC" : "ASC";
if (!field || !allowed.has(field)) return null;
return `${field} ${dir}`;
})
.filter((value): value is string => value !== null);
return clauses.length > 0 ? clauses.join(", ") : fallback;
}
function parseResultSort(sortParam: string | null, allowed: Set<string>, fallback: string): string {
if (!sortParam) return fallback;
const clauses = sortParam
.split(",")
.map((chunk) => chunk.trim())
.filter(Boolean)
.map((chunk) => {
const [fieldRaw, dirRaw] = chunk.split(":");
const field = fieldRaw?.trim();
const dir = dirRaw?.trim().toUpperCase() === "DESC" ? "DESC" : "ASC";
if (!field || !allowed.has(field)) return null;
if (field === "monthly_sold") return `CAST(COALESCE(monthly_sold, 0) AS INTEGER) ${dir}`;
return `${field} ${dir}`;
})
.filter((value): value is string => value !== null);
return clauses.length > 0 ? clauses.join(", ") : fallback;
}
function escapeCsvValue(value: unknown): string {
if (value === null || value === undefined) return "";
const text = String(value);
const escaped = text.replaceAll("\"", "\"\"");
return /[",\n]/.test(escaped) ? `"${escaped}"` : escaped;
}
function parseResultFilters(processType: ProcessType, runId: number, filters: URLSearchParams) {
const q = filters.get("q")?.trim() || "";
const verdict = filters.get("verdict")?.trim();
const sellabilityStatus = filters.get("sellabilityStatus")?.trim();
const minConfidence = filters.get("minConfidence")?.trim();
const maxConfidence = filters.get("maxConfidence")?.trim();
const conditions: string[] = ["run_id = ?"];
const params: Array<string | number> = [runId];
if (verdict) {
conditions.push("verdict = ?");
params.push(verdict);
}
if (sellabilityStatus) {
conditions.push("sellability_status = ?");
params.push(sellabilityStatus);
}
if (minConfidence) {
conditions.push("confidence >= ?");
params.push(Number(minConfidence));
}
if (maxConfidence) {
conditions.push("confidence <= ?");
params.push(Number(maxConfidence));
}
if (q) {
const wildcard = `%${q}%`;
if (processType === "lead_analysis") {
conditions.push("(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)");
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
} else {
conditions.push("(asin LIKE ? OR name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)");
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
}
}
return {
where: `WHERE ${conditions.join(" AND ")}`,
params,
};
}
function getRuns(filters: URLSearchParams) {
const q = filters.get("q")?.trim() || "";
const processType = filters.get("processType")?.trim();
const status = filters.get("status")?.trim();
const startDate = filters.get("startDate")?.trim();
const endDate = filters.get("endDate")?.trim();
const page = parseIntParam(filters.get("page"), 1);
const pageSize = Math.min(parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE);
const offset = (page - 1) * pageSize;
const allowedSort = new Set([
"timestamp",
"status",
"totalProducts",
"fbaCount",
"fbmCount",
"skipCount",
"runId",
"jobType",
]);
const orderBy = parseSort(filters.get("sort"), allowedSort, "timestamp DESC, runId DESC");
const conditions: string[] = [];
const params: Array<string | number> = [];
if (processType === "lead_analysis" || processType === "category_analysis") {
conditions.push("processType = ?");
params.push(processType);
}
if (status) {
conditions.push("status = ?");
params.push(status);
}
if (startDate) {
conditions.push("timestamp >= ?");
params.push(startDate);
}
if (endDate) {
conditions.push("timestamp <= ?");
params.push(endDate);
}
if (q) {
conditions.push("(jobType LIKE ? OR source LIKE ? OR output LIKE ? OR CAST(runId AS TEXT) LIKE ?)");
const wildcard = `%${q}%`;
params.push(wildcard, wildcard, wildcard, wildcard);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const baseUnion = `
SELECT
'lead_analysis' AS processType,
id AS runId,
timestamp,
'completed' AS status,
'lead_file_analysis' AS jobType,
input_file AS source,
output_file AS output,
COALESCE(total_products, 0) AS totalProducts,
COALESCE(fba_count, 0) AS fbaCount,
COALESCE(fbm_count, 0) AS fbmCount,
COALESCE(skip_count, 0) AS skipCount
FROM runs
UNION ALL
SELECT
'category_analysis' AS processType,
id AS runId,
run_timestamp AS timestamp,
status,
category_label AS jobType,
CAST(category_id AS TEXT) AS source,
NULL AS output,
COALESCE(top_asins_checked, 0) AS totalProducts,
COALESCE(fba_count, 0) AS fbaCount,
COALESCE(fbm_count, 0) AS fbmCount,
COALESCE(skip_count, 0) AS skipCount
FROM category_analysis_runs
`;
const totalRow = db
.query(`SELECT COUNT(*) as total FROM (${baseUnion}) all_runs ${where}`)
.get(...params) as { total: number };
const items = db
.query(`SELECT * FROM (${baseUnion}) all_runs ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`)
.all(...params, pageSize, offset) as RunRecord[];
return {
items,
page,
pageSize,
total: totalRow.total,
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
};
}
function getProductList(filters: URLSearchParams) {
const q = filters.get("q")?.trim() || "";
const verdict = filters.get("verdict")?.trim();
const page = parseIntParam(filters.get("page"), 1);
const pageSize = Math.min(parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE);
const offset = (page - 1) * pageSize;
const conditions: string[] = [];
const params: Array<string | number> = [];
if (verdict === "FBA" || verdict === "FBM" || verdict === "SKIP") {
conditions.push("verdict = ?");
params.push(verdict);
}
if (q) {
const wildcard = `%${q}%`;
conditions.push("(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ?)");
params.push(wildcard, wildcard, wildcard, wildcard);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const baseUnion = `
SELECT
'lead_analysis' AS processType,
run_id AS runId,
asin,
product_name,
brand,
category,
verdict,
confidence,
sellability_status,
fetched_at
FROM results
UNION ALL
SELECT
'category_analysis' AS processType,
run_id AS runId,
asin,
name AS product_name,
brand,
category,
verdict,
confidence,
sellability_status,
fetched_at
FROM product_analysis_results
`;
const totalRow = db
.query(`SELECT COUNT(*) as total FROM (${baseUnion}) all_products ${where}`)
.get(...params) as { total: number };
const items = db
.query(`SELECT * FROM (${baseUnion}) all_products ${where} ORDER BY fetched_at DESC LIMIT ? OFFSET ?`)
.all(...params, pageSize, offset) as ProductListRecord[];
return {
items,
page,
pageSize,
total: totalRow.total,
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
};
}
function getRun(processType: ProcessType, runId: number) {
if (processType === "lead_analysis") {
const run = db
.query(
`SELECT
id AS runId,
timestamp,
'completed' AS status,
'lead_file_analysis' AS jobType,
input_file AS source,
output_file AS output,
COALESCE(total_products, 0) AS totalProducts,
COALESCE(fba_count, 0) AS fbaCount,
COALESCE(fbm_count, 0) AS fbmCount,
COALESCE(skip_count, 0) AS skipCount
FROM runs WHERE id = ?`,
)
.get(runId);
return run ?? null;
}
const run = db
.query(
`SELECT
id AS runId,
run_timestamp AS timestamp,
status,
category_label AS jobType,
CAST(category_id AS TEXT) AS source,
NULL AS output,
COALESCE(top_asins_checked, 0) AS totalProducts,
COALESCE(fba_count, 0) AS fbaCount,
COALESCE(fbm_count, 0) AS fbmCount,
COALESCE(skip_count, 0) AS skipCount,
error_message AS errorMessage,
available_asins AS availableAsins
FROM category_analysis_runs WHERE id = ?`,
)
.get(runId);
return run ?? null;
}
function getRunResults(processType: ProcessType, runId: number, filters: URLSearchParams) {
const page = parseIntParam(filters.get("page"), 1);
const pageSize = Math.min(parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE);
const offset = (page - 1) * pageSize;
const tableName = processType === "lead_analysis" ? "results" : "product_analysis_results";
const productNameSelect = processType === "lead_analysis" ? "product_name" : "name AS product_name";
const sellerCountSelect = processType === "lead_analysis" ? "sellers AS seller_count" : "seller_count";
const salesRankAvgSelect = processType === "lead_analysis" ? "rank_avg_90d AS sales_rank_avg_90d" : "sales_rank_avg_90d";
const { where, params } = parseResultFilters(processType, runId, filters);
const allowedSort = new Set([
"asin",
"product_name",
"brand",
"category",
"current_price",
"avg_price_90d",
"sales_rank",
"seller_count",
"monthly_sold",
"verdict",
"confidence",
"fetched_at",
]);
const orderBy = parseResultSort(
filters.get("sort"),
allowedSort,
"CAST(COALESCE(monthly_sold, 0) AS INTEGER) DESC, asin ASC",
);
const totalRow = db
.query(`SELECT COUNT(*) as total FROM ${tableName} ${where}`)
.get(...params) as { total: number };
const items = db
.query(
`SELECT
id,
run_id,
asin,
${productNameSelect},
brand,
category,
unit_cost,
current_price,
avg_price_90d,
avg_price_90d_sheet,
selling_price_sheet,
sales_rank,
${salesRankAvgSelect},
${sellerCountSelect},
monthly_sold,
rank_drops_30d,
rank_drops_90d,
fba_fee,
fbm_fee,
referral_percent,
can_sell,
sellability_status,
sellability_reason,
verdict,
confidence,
reasoning,
fetched_at
FROM ${tableName}
${where}
ORDER BY ${orderBy}
LIMIT ? OFFSET ?`,
)
.all(...params, pageSize, offset);
return {
items,
page,
pageSize,
total: totalRow.total,
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
};
}
function deleteRun(processType: ProcessType, runId: number) {
if (processType === "lead_analysis") {
const resultRows = db.query("DELETE FROM results WHERE run_id = ?").run(runId);
const runRows = db.query("DELETE FROM runs WHERE id = ?").run(runId);
return {
deletedRun: runRows.changes > 0,
deletedResults: resultRows.changes,
};
}
const resultRows = db.query("DELETE FROM product_analysis_results WHERE run_id = ?").run(runId);
const runRows = db.query("DELETE FROM category_analysis_runs WHERE id = ?").run(runId);
return {
deletedRun: runRows.changes > 0,
deletedResults: resultRows.changes,
};
}
function exportRunResultsCsv(processType: ProcessType, runId: number, filters: URLSearchParams) {
const tableName = processType === "lead_analysis" ? "results" : "product_analysis_results";
const productNameSelect = processType === "lead_analysis" ? "product_name" : "name AS product_name";
const sellerCountSelect = processType === "lead_analysis" ? "sellers AS seller_count" : "seller_count";
const salesRankAvgSelect = processType === "lead_analysis" ? "rank_avg_90d AS sales_rank_avg_90d" : "sales_rank_avg_90d";
const { where, params } = parseResultFilters(processType, runId, filters);
const allowedSort = new Set([
"asin",
"product_name",
"brand",
"category",
"current_price",
"avg_price_90d",
"sales_rank",
"seller_count",
"monthly_sold",
"verdict",
"confidence",
"fetched_at",
]);
const orderBy = parseResultSort(
filters.get("sort"),
allowedSort,
"CAST(COALESCE(monthly_sold, 0) AS INTEGER) DESC, asin ASC",
);
const rows = db
.query(
`SELECT
run_id,
asin,
${productNameSelect},
brand,
category,
unit_cost,
current_price,
avg_price_90d,
${salesRankAvgSelect},
${sellerCountSelect},
monthly_sold,
sellability_status,
verdict,
confidence,
reasoning,
fetched_at
FROM ${tableName}
${where}
ORDER BY ${orderBy}`,
)
.all(...params) as Array<Record<string, unknown>>;
const headers = [
"run_id",
"asin",
"product_name",
"brand",
"category",
"unit_cost",
"current_price",
"avg_price_90d",
"sales_rank_avg_90d",
"seller_count",
"monthly_sold",
"sellability_status",
"verdict",
"confidence",
"reasoning",
"fetched_at",
];
const lines = [headers.join(",")];
for (const row of rows) {
lines.push(headers.map((h) => escapeCsvValue(row[h])).join(","));
}
return lines.join("\n");
}
const server = Bun.serve({
port: Number(process.env.PORT || "3000"),
routes: {
"/": index,
"/products": index,
"/runs/:processType/:runId": index,
"/api/runs": (req) => {
const url = new URL(req.url);
return json(getRuns(url.searchParams));
},
"/api/products": (req) => {
const url = new URL(req.url);
return json(getProductList(url.searchParams));
},
"/api/runs/:processType/:runId": (req) => {
const processType = req.params.processType as ProcessType;
const runId = Number(req.params.runId);
if (!(processType === "lead_analysis" || processType === "category_analysis") || !Number.isInteger(runId)) {
return json({ error: "Invalid run identifier" }, 400);
}
if (req.method === "DELETE") {
const deleted = deleteRun(processType, runId);
if (!deleted.deletedRun) return json({ error: "Run not found" }, 404);
return json(deleted);
}
const run = getRun(processType, runId);
if (!run) return json({ error: "Run not found" }, 404);
const summary = {
totalProducts: (run as { totalProducts: number }).totalProducts,
fbaCount: (run as { fbaCount: number }).fbaCount,
fbmCount: (run as { fbmCount: number }).fbmCount,
skipCount: (run as { skipCount: number }).skipCount,
};
return json({ processType, ...run, summary });
},
"/api/runs/:processType/:runId/results": (req) => {
const processType = req.params.processType as ProcessType;
const runId = Number(req.params.runId);
if (!(processType === "lead_analysis" || processType === "category_analysis") || !Number.isInteger(runId)) {
return json({ error: "Invalid run identifier" }, 400);
}
const url = new URL(req.url);
const payload = getRunResults(processType, runId, url.searchParams);
return json(payload);
},
"/api/runs/:processType/:runId/export.csv": (req) => {
const processType = req.params.processType as ProcessType;
const runId = Number(req.params.runId);
if (!(processType === "lead_analysis" || processType === "category_analysis") || !Number.isInteger(runId)) {
return json({ error: "Invalid run identifier" }, 400);
}
const url = new URL(req.url);
const csvText = exportRunResultsCsv(processType, runId, url.searchParams);
return csv(csvText, `run-${processType}-${runId}.csv`);
},
},
fetch() {
return json({ error: "Not found" }, 404);
},
development: {
hmr: true,
console: true,
},
});
console.log(`Results viewer running on http://localhost:${server.port}`);