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:
619
src/server.ts
Normal file
619
src/server.ts
Normal 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}`);
|
||||
Reference in New Issue
Block a user