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:
@@ -13,6 +13,7 @@ import type {
|
||||
SellabilityInfo,
|
||||
SpApiData,
|
||||
} from "./types.ts";
|
||||
|
||||
|
||||
type CategoryInfo = {
|
||||
id: number;
|
||||
@@ -36,7 +37,7 @@ type CategoryRunSummary = {
|
||||
fba: number;
|
||||
fbm: number;
|
||||
skip: number;
|
||||
status: "ok" | "empty" | "failed";
|
||||
status: "running" | "ok" | "empty" | "failed";
|
||||
error: string;
|
||||
runId?: number;
|
||||
results?: AnalysisResult[];
|
||||
@@ -158,6 +159,37 @@ export async function insertCategoryRunSummary(
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
export async function updateCategoryRunSummary(
|
||||
db: Database,
|
||||
runId: number,
|
||||
summary: Pick<CategoryRunSummary, "topAsinsChecked" | "availableAsins" | "fba" | "fbm" | "skip" | "status" | "error">,
|
||||
): Promise<void> {
|
||||
db.run(
|
||||
`
|
||||
UPDATE category_analysis_runs
|
||||
SET
|
||||
top_asins_checked = ?,
|
||||
available_asins = ?,
|
||||
fba_count = ?,
|
||||
fbm_count = ?,
|
||||
skip_count = ?,
|
||||
status = ?,
|
||||
error_message = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[
|
||||
summary.topAsinsChecked,
|
||||
summary.availableAsins,
|
||||
summary.fba,
|
||||
summary.fbm,
|
||||
summary.skip,
|
||||
summary.status,
|
||||
summary.error,
|
||||
runId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function insertProductAnalysisResults(
|
||||
db: Database,
|
||||
runId: number,
|
||||
@@ -817,16 +849,96 @@ function buildEnrichedProducts(
|
||||
});
|
||||
}
|
||||
|
||||
async function runLlmInBatches(
|
||||
products: EnrichedProduct[],
|
||||
): Promise<LlmVerdict[]> {
|
||||
const verdicts: LlmVerdict[] = [];
|
||||
export async function processCategory(
|
||||
db: Database,
|
||||
runId: number,
|
||||
category: CategoryInfo,
|
||||
perCategoryTop: number,
|
||||
): Promise<CategoryRunSummary> {
|
||||
log("info", `\nCategory ${category.label} (${category.id})`);
|
||||
|
||||
for (let i = 0; i < products.length; i += LLM_BATCH_SIZE) {
|
||||
const batch = products.slice(i, i + LLM_BATCH_SIZE);
|
||||
const topAsins = await fetchCategoryBestSellerAsins(category, perCategoryTop);
|
||||
if (topAsins.length === 0) {
|
||||
log("info", " Keepa returned no ASINs for this category.");
|
||||
await updateCategoryRunSummary(db, runId, {
|
||||
topAsinsChecked: 0,
|
||||
availableAsins: 0,
|
||||
fba: 0,
|
||||
fbm: 0,
|
||||
skip: 0,
|
||||
status: "empty",
|
||||
error: "No ASINs returned by Keepa",
|
||||
});
|
||||
return {
|
||||
categoryId: category.id,
|
||||
categoryLabel: category.label,
|
||||
topAsinsChecked: 0,
|
||||
availableAsins: 0,
|
||||
fba: 0,
|
||||
fbm: 0,
|
||||
skip: 0,
|
||||
status: "empty",
|
||||
error: "No ASINs returned by Keepa",
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
|
||||
const uniqueTopAsins = Array.from(new Set(topAsins));
|
||||
if (uniqueTopAsins.length !== topAsins.length) {
|
||||
log("warn", ` Removed ${topAsins.length - uniqueTopAsins.length} duplicate ASINs before analysis.`);
|
||||
}
|
||||
|
||||
log("info", ` Top ASINs fetched: ${uniqueTopAsins.length}`);
|
||||
const sellabilityMap = await fetchSellabilityMap(uniqueTopAsins);
|
||||
|
||||
const availableAsins = uniqueTopAsins.filter((asin) => {
|
||||
const info = sellabilityMap.get(asin);
|
||||
return info?.canSell === true && info.sellabilityStatus === "available";
|
||||
});
|
||||
|
||||
log("info", ` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`);
|
||||
if (availableAsins.length === 0) {
|
||||
await updateCategoryRunSummary(db, runId, {
|
||||
topAsinsChecked: uniqueTopAsins.length,
|
||||
availableAsins: 0,
|
||||
fba: 0,
|
||||
fbm: 0,
|
||||
skip: 0,
|
||||
status: "empty",
|
||||
error: "No sellable ASINs",
|
||||
});
|
||||
return {
|
||||
categoryId: category.id,
|
||||
categoryLabel: category.label,
|
||||
topAsinsChecked: uniqueTopAsins.length,
|
||||
availableAsins: 0,
|
||||
fba: 0,
|
||||
fbm: 0,
|
||||
skip: 0,
|
||||
status: "empty",
|
||||
error: "No sellable ASINs",
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
|
||||
const keepaEnrichment = await fetchKeepaEnrichmentMap(availableAsins);
|
||||
const spApiMap = await fetchSpApiMap(availableAsins, sellabilityMap);
|
||||
const enrichedProducts = buildEnrichedProducts(
|
||||
availableAsins,
|
||||
sellabilityMap,
|
||||
spApiMap,
|
||||
keepaEnrichment,
|
||||
);
|
||||
|
||||
const results: AnalysisResult[] = [];
|
||||
let fba = 0;
|
||||
let fbm = 0;
|
||||
let skip = 0;
|
||||
const totalBatches = Math.ceil(enrichedProducts.length / LLM_BATCH_SIZE);
|
||||
|
||||
for (let i = 0; i < enrichedProducts.length; i += LLM_BATCH_SIZE) {
|
||||
const batch = enrichedProducts.slice(i, i + LLM_BATCH_SIZE);
|
||||
const batchNum = Math.floor(i / LLM_BATCH_SIZE) + 1;
|
||||
const totalBatches = Math.ceil(products.length / LLM_BATCH_SIZE);
|
||||
|
||||
log("info", ` LLM batch ${batchNum}/${totalBatches}...`);
|
||||
|
||||
let batchVerdicts: LlmVerdict[];
|
||||
@@ -843,116 +955,64 @@ async function runLlmInBatches(
|
||||
}));
|
||||
}
|
||||
|
||||
verdicts.push(...batchVerdicts);
|
||||
const verdictByAsin = new Map(batchVerdicts.map((v) => [v.asin, v]));
|
||||
const batchResults: AnalysisResult[] = batch.map((product) => ({
|
||||
product,
|
||||
verdict: verdictByAsin.get(product.record.asin) ?? {
|
||||
asin: product.record.asin,
|
||||
verdict: "SKIP",
|
||||
confidence: 0,
|
||||
reasoning: "LLM returned no verdict",
|
||||
},
|
||||
}));
|
||||
|
||||
if (i + LLM_BATCH_SIZE < products.length) {
|
||||
await insertProductAnalysisResults(db, runId, batchResults);
|
||||
|
||||
for (const result of batchResults) {
|
||||
results.push(result);
|
||||
if (result.verdict.verdict === "FBA") {
|
||||
fba++;
|
||||
} else if (result.verdict.verdict === "FBM") {
|
||||
fbm++;
|
||||
} else {
|
||||
skip++;
|
||||
}
|
||||
}
|
||||
|
||||
await updateCategoryRunSummary(db, runId, {
|
||||
topAsinsChecked: uniqueTopAsins.length,
|
||||
availableAsins: availableAsins.length,
|
||||
fba,
|
||||
fbm,
|
||||
skip,
|
||||
status: "running",
|
||||
error: "",
|
||||
});
|
||||
|
||||
log(
|
||||
"info",
|
||||
` Persisted batch ${batchNum}/${totalBatches} (${batchResults.length} rows, totals FBA/FBM/SKIP=${fba}/${fbm}/${skip})`,
|
||||
);
|
||||
|
||||
if (i + LLM_BATCH_SIZE < enrichedProducts.length) {
|
||||
await sleep(1500);
|
||||
}
|
||||
}
|
||||
|
||||
return verdicts;
|
||||
}
|
||||
|
||||
export async function processCategory(
|
||||
db: Database,
|
||||
category: CategoryInfo,
|
||||
perCategoryTop: number,
|
||||
): Promise<CategoryRunSummary> {
|
||||
log("info", `\nCategory ${category.label} (${category.id})`);
|
||||
|
||||
const topAsins = await fetchCategoryBestSellerAsins(category, perCategoryTop);
|
||||
if (topAsins.length === 0) {
|
||||
log("info", " Keepa returned no ASINs for this category.");
|
||||
return {
|
||||
categoryId: category.id,
|
||||
categoryLabel: category.label,
|
||||
topAsinsChecked: 0,
|
||||
availableAsins: 0,
|
||||
fba: 0,
|
||||
fbm: 0,
|
||||
skip: 0,
|
||||
status: "empty",
|
||||
error: "No ASINs returned by Keepa",
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
|
||||
log("info", ` Top ASINs fetched: ${topAsins.length}`);
|
||||
const sellabilityMap = await fetchSellabilityMap(topAsins);
|
||||
|
||||
const availableAsins = topAsins.filter((asin) => {
|
||||
const info = sellabilityMap.get(asin);
|
||||
return info?.canSell === true && info.sellabilityStatus === "available";
|
||||
await updateCategoryRunSummary(db, runId, {
|
||||
topAsinsChecked: uniqueTopAsins.length,
|
||||
availableAsins: availableAsins.length,
|
||||
fba,
|
||||
fbm,
|
||||
skip,
|
||||
status: "ok",
|
||||
error: "",
|
||||
});
|
||||
|
||||
log("info", ` Sellable ASINs: ${availableAsins.length}/${topAsins.length}`);
|
||||
if (availableAsins.length === 0) {
|
||||
return {
|
||||
categoryId: category.id,
|
||||
categoryLabel: category.label,
|
||||
topAsinsChecked: topAsins.length,
|
||||
availableAsins: 0,
|
||||
fba: 0,
|
||||
fbm: 0,
|
||||
skip: 0,
|
||||
status: "empty",
|
||||
error: "No sellable ASINs",
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
|
||||
const keepaEnrichment = await fetchKeepaEnrichmentMap(availableAsins);
|
||||
const spApiMap = await fetchSpApiMap(availableAsins, sellabilityMap);
|
||||
const titleByAsin = new Map<string, string>();
|
||||
const keepaMap = new Map<string, KeepaData>();
|
||||
|
||||
for (const asin of availableAsins) {
|
||||
const enriched = keepaEnrichment.get(asin);
|
||||
if (enriched?.title) {
|
||||
titleByAsin.set(asin, enriched.title);
|
||||
}
|
||||
if (enriched?.keepa) {
|
||||
keepaMap.set(asin, enriched.keepa);
|
||||
}
|
||||
}
|
||||
|
||||
const enrichedProducts = buildEnrichedProducts(
|
||||
availableAsins,
|
||||
sellabilityMap,
|
||||
spApiMap,
|
||||
keepaEnrichment,
|
||||
);
|
||||
|
||||
const verdicts = await runLlmInBatches(enrichedProducts);
|
||||
const verdictByAsin = new Map(verdicts.map((v) => [v.asin, v]));
|
||||
|
||||
const results: AnalysisResult[] = enrichedProducts.map((product) => ({
|
||||
product,
|
||||
verdict: verdictByAsin.get(product.record.asin) ?? {
|
||||
asin: product.record.asin,
|
||||
verdict: "SKIP",
|
||||
confidence: 0,
|
||||
reasoning: "LLM returned no verdict",
|
||||
},
|
||||
}));
|
||||
|
||||
// No longer writing to XLSX, directly insert into DB
|
||||
// const outputName = `${sanitizeFileSegment(category.label)}_${category.id}.xlsx`;
|
||||
// const outputPath = path.join(outputDir, outputName);
|
||||
// writeCategoryResultsWorkbook(results, outputPath);
|
||||
|
||||
// The categoryRunId will be provided by the main function after inserting the summary
|
||||
// We need to pass it here or get it after inserting the summary in main.
|
||||
// For now, let's assume it's handled in main.
|
||||
|
||||
const fba = results.filter((r) => r.verdict.verdict === "FBA").length;
|
||||
const fbm = results.filter((r) => r.verdict.verdict === "FBM").length;
|
||||
const skip = results.filter((r) => r.verdict.verdict === "SKIP").length;
|
||||
|
||||
return {
|
||||
categoryId: category.id,
|
||||
categoryLabel: category.label,
|
||||
topAsinsChecked: topAsins.length,
|
||||
topAsinsChecked: uniqueTopAsins.length,
|
||||
availableAsins: availableAsins.length,
|
||||
fba,
|
||||
fbm,
|
||||
@@ -968,7 +1028,7 @@ export async function main(): Promise<void> {
|
||||
assertSpApiPrerequisites();
|
||||
|
||||
mkdirSync(args.outputDir, { recursive: true });
|
||||
const DB_PATH = path.join(args.outputDir, "analysis.sqlite");
|
||||
const DB_PATH = process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db");
|
||||
initDb(DB_PATH);
|
||||
const db = getDb(DB_PATH);
|
||||
|
||||
@@ -1001,23 +1061,33 @@ export async function main(): Promise<void> {
|
||||
|
||||
for (const category of allowedCategories) {
|
||||
let categorySummary: CategoryRunSummary;
|
||||
let runId: number | undefined;
|
||||
try {
|
||||
runId = await insertCategoryRunSummary(
|
||||
db,
|
||||
{
|
||||
categoryId: category.id,
|
||||
categoryLabel: category.label,
|
||||
topAsinsChecked: 0,
|
||||
availableAsins: 0,
|
||||
fba: 0,
|
||||
fbm: 0,
|
||||
skip: 0,
|
||||
status: "running",
|
||||
error: "",
|
||||
results: [],
|
||||
},
|
||||
runTimestamp,
|
||||
);
|
||||
|
||||
categorySummary = await processCategory(
|
||||
db,
|
||||
runId,
|
||||
category,
|
||||
args.perCategoryTop,
|
||||
);
|
||||
|
||||
const runId = await insertCategoryRunSummary(
|
||||
db,
|
||||
categorySummary,
|
||||
runTimestamp,
|
||||
);
|
||||
|
||||
if (categorySummary.results) {
|
||||
await insertProductAnalysisResults(db, runId, categorySummary.results);
|
||||
totalInsertedAsins += categorySummary.results.length;
|
||||
}
|
||||
totalInsertedAsins += categorySummary.results?.length ?? 0;
|
||||
|
||||
processedCategories++;
|
||||
allCategorySummaries.push({ ...categorySummary, runId });
|
||||
@@ -1039,8 +1109,19 @@ export async function main(): Promise<void> {
|
||||
error: message,
|
||||
results: [],
|
||||
};
|
||||
if (runId) {
|
||||
await updateCategoryRunSummary(db, runId, {
|
||||
topAsinsChecked: 0,
|
||||
availableAsins: 0,
|
||||
fba: 0,
|
||||
fbm: 0,
|
||||
skip: 0,
|
||||
status: "failed",
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
processedCategories++;
|
||||
allCategorySummaries.push(categorySummary);
|
||||
allCategorySummaries.push({ ...categorySummary, runId });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user