enhance job summary and dashboard styles with new analytics and logging components

This commit is contained in:
MOHAN 2026-04-14 13:26:54 +05:30
parent 3bd27d395c
commit a96d8ad3b0
2 changed files with 760 additions and 37 deletions

View File

@ -118,6 +118,219 @@ function FieldState({ fields }) {
);
}
function formatCounter(done, total) {
if (Number.isFinite(done) && Number.isFinite(total) && total > 0) {
return `${done} / ${total}`;
}
if (Number.isFinite(done)) {
return String(done);
}
return "-";
}
function getStageState(job, bucket) {
const activeStepMap = {
download: "downloadImages",
watermark: "watermarkImages",
upload: "uploadImagesToShopifyFiles",
convert: "convertToShopifyReady",
shopify: "upsertToShopify",
details: "fetchWebsiteData",
};
if (job?.status === "error") {
return "error";
}
if (job?.status === "done") {
return "done";
}
if (job?.step === activeStepMap[bucket]) {
return "active";
}
const stats = job?.liveStats || {};
if (stats[bucket]?.total || stats[bucket]?.done || stats.stageSummaries?.[activeStepMap[bucket]]) {
return "done";
}
return "pending";
}
const STEP_LABELS = {
queued: "Queued",
starting: "Preparing import",
fetchWebsiteData: "Collecting KYT catalog data",
downloadImages: "Downloading product images",
watermarkImages: "Applying watermarks",
uploadImagesToShopifyFiles: "Uploading images to Shopify",
convertToShopifyReady: "Preparing Shopify product data",
upsertToShopify: "Creating or updating products",
completed: "Import completed",
};
function getStepLabel(step) {
return STEP_LABELS[step] || step || "-";
}
function formatDateTime(value) {
if (!value) {
return "-";
}
return new Date(value).toLocaleString();
}
function formatElapsed(startedAt, updatedAt) {
if (!startedAt) {
return "-";
}
const startMs = new Date(startedAt).getTime();
const endMs = updatedAt ? new Date(updatedAt).getTime() : Date.now();
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) {
return "-";
}
const seconds = Math.floor((endMs - startMs) / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${remainingSeconds}s`;
}
if (minutes > 0) {
return `${minutes}m ${remainingSeconds}s`;
}
return `${remainingSeconds}s`;
}
function parseRecentActivity(entry) {
const rawLine = String(entry?.line || "");
const line = rawLine.replace(/^\[(log|info|warn|error)\]\s*/i, "").trim();
const watermarkMatch = line.match(/^\[WATERMARK\]\s+(\d+)\/(\d+)\s+processed/i);
if (watermarkMatch) {
return {
title: "Watermark progress updated",
detail: `${watermarkMatch[1]} of ${watermarkMatch[2]} images have been watermarked.`,
tone: "progress",
};
}
const imageDownloadMatch = line.match(/^\[IMAGES\]\s+(\d+)\/(\d+)\s+processed/i);
if (imageDownloadMatch) {
return {
title: "Image download progress updated",
detail: `${imageDownloadMatch[1]} of ${imageDownloadMatch[2]} product images have been downloaded.`,
tone: "progress",
};
}
const uploadProgressMatch = line.match(/^\[IMG-UPLOAD\]\s+(\d+)\/(\d+)\s+processed\s+\|\s+uploaded=(\d+)\s+skipped=(\d+)\s+failed=(\d+)/i);
if (uploadProgressMatch) {
return {
title: "Shopify image upload progress updated",
detail: `${uploadProgressMatch[3]} of ${uploadProgressMatch[2]} images are ready in Shopify files. ${uploadProgressMatch[4]} skipped and ${uploadProgressMatch[5]} failed so far.`,
tone: "progress",
};
}
const uploadOkMatch = line.match(/^\[IMG-UPLOAD-OK\]\s+(.+?)\s+\|\s+(.+?)\s+\|\s+fileId=(.+?)\s+\|\s+status=([A-Z]+)/i);
if (uploadOkMatch) {
return {
title: "Image uploaded to Shopify",
detail: `${uploadOkMatch[2]} is now in Shopify files with status ${uploadOkMatch[4]}.`,
tone: "success",
};
}
const uploadSkipMatch = line.match(/^\[IMG-UPLOAD-SKIP\]\s+(.+?)\s+\|\s+(.+?)\s+\|\s+(.+)/i);
if (uploadSkipMatch) {
return {
title: "Image reuse detected",
detail: `${uploadSkipMatch[2]} was already available, so upload was skipped.`,
tone: "neutral",
};
}
const uploadFailMatch = line.match(/^\[IMG-UPLOAD-FAIL\]\s+(.+?)\s+\|\s+(.+?)\s+->\s+(.+)/i);
if (uploadFailMatch) {
return {
title: "Image upload needs attention",
detail: `${uploadFailMatch[1]} could not be uploaded. ${uploadFailMatch[3]}`,
tone: "error",
};
}
const shopifyProgressMatch = line.match(/^\[SHOPIFY\]\s+(\d+)\/(\d+)\s+processed\s+\|\s+created=(\d+)\s+updated=(\d+)\s+failed=(\d+)/i);
if (shopifyProgressMatch) {
return {
title: "Product sync progress updated",
detail: `${shopifyProgressMatch[1]} of ${shopifyProgressMatch[2]} products processed. ${shopifyProgressMatch[3]} created, ${shopifyProgressMatch[4]} updated, ${shopifyProgressMatch[5]} failed.`,
tone: "progress",
};
}
const shopifyFailMatch = line.match(/^\[SHOPIFY-FAIL\]\s+(.+?)\s+->\s+(.+)/i);
if (shopifyFailMatch) {
return {
title: "Product sync needs attention",
detail: `${shopifyFailMatch[1]} could not be synced. ${shopifyFailMatch[2]}`,
tone: "error",
};
}
const detailProgressMatch = line.match(/^\[DETAIL\]\s+(\d+)\/(\d+)\s+completed/i);
if (detailProgressMatch) {
return {
title: "Product details collected",
detail: `${detailProgressMatch[1]} of ${detailProgressMatch[2]} products now have detailed catalog data.`,
tone: "progress",
};
}
const imageFailMatch = line.match(/^\[IMAGE-FAIL\]\s+(.+?)\s+\|\s+(.+?)\s+->\s+(.+)/i);
if (imageFailMatch) {
return {
title: "Image download needs attention",
detail: `${imageFailMatch[1]} has a download issue. ${imageFailMatch[3]}`,
tone: "error",
};
}
const watermarkFailMatch = line.match(/^\[WATERMARK-FAIL\]\s+(.+?)\s+->\s+(.+)/i);
if (watermarkFailMatch) {
return {
title: "Watermark step needs attention",
detail: `${watermarkFailMatch[1]} could not be watermarked. ${watermarkFailMatch[2]}`,
tone: "error",
};
}
const pipelineStepMatch = line.match(/^\[PIPELINE\s+(\d+)\/(\d+)\]\s+(.+)/i);
if (pipelineStepMatch) {
return {
title: `Pipeline step ${pipelineStepMatch[1]} of ${pipelineStepMatch[2]}`,
detail: pipelineStepMatch[3],
tone: "neutral",
};
}
return {
title: "Import activity updated",
detail: line,
tone: "neutral",
};
}
function JobSummary({ job }) {
if (!job) {
return (
@ -136,6 +349,154 @@ function JobSummary({ job }) {
Math.round(((job.stepIndex || 0) / (job.totalSteps || 6)) * 100),
),
);
const liveStats = job.liveStats || {};
const recentLogs = Array.isArray(job.logs)
? job.logs.slice(-8).reverse().map((entry) => ({
...entry,
parsed: parseRecentActivity(entry),
}))
: [];
const summarySteps = job.summary?.steps || {};
const downloadStats = liveStats.download || {};
const watermarkStats = liveStats.watermark || {};
const uploadStats = liveStats.upload || {};
const shopifyStats = liveStats.shopify || {};
const detailStats = liveStats.details || {};
const activeStepLabel = job.detail || "Import running";
const currentStepLabel = getStepLabel(job.step);
const analyticsCards = [
{
key: "download",
label: "Downloaded",
value: formatCounter(
downloadStats.done ?? summarySteps.downloadImages?.downloaded,
downloadStats.total ?? summarySteps.downloadImages?.totalImagesFound,
),
meta: `${downloadStats.skipped ?? summarySteps.downloadImages?.skipped ?? 0} skipped • ${downloadStats.failed ?? summarySteps.downloadImages?.failed ?? 0} failed`,
tone: "sunrise",
},
{
key: "watermark",
label: "Watermarked",
value: formatCounter(
watermarkStats.done ?? summarySteps.watermarkImages?.processed,
watermarkStats.total ?? summarySteps.watermarkImages?.totalImagesFound,
),
meta: `${watermarkStats.skipped ?? summarySteps.watermarkImages?.skipped ?? summarySteps.watermarkImages?.skippedCount ?? 0} skipped • ${watermarkStats.failed ?? summarySteps.watermarkImages?.failed ?? 0} failed`,
tone: "ice",
},
{
key: "upload",
label: "Uploaded",
value: formatCounter(
uploadStats.uploaded ?? summarySteps.uploadImagesToShopifyFiles?.uploaded,
uploadStats.total ?? summarySteps.uploadImagesToShopifyFiles?.totalTasks,
),
meta: `${uploadStats.skipped ?? summarySteps.uploadImagesToShopifyFiles?.skipped ?? 0} skipped • ${uploadStats.failed ?? summarySteps.uploadImagesToShopifyFiles?.failed ?? 0} failed`,
tone: "mint",
},
{
key: "shopify",
label: "Products synced",
value: formatCounter(
shopifyStats.done ?? summarySteps.upsertToShopify?.processed,
shopifyStats.total ?? summarySteps.upsertToShopify?.total,
),
meta: `${shopifyStats.created ?? summarySteps.upsertToShopify?.created ?? 0} created • ${shopifyStats.updated ?? summarySteps.upsertToShopify?.updated ?? 0} updated`,
tone: "navy",
},
].filter((card) => card.value !== "-");
const highlightCards = [
{
key: "activity",
label: "Current activity",
value: activeStepLabel,
meta: `Step ${job.stepIndex || 0} of ${job.totalSteps || 6}`,
},
{
key: "details",
label: "Product details",
value: formatCounter(
detailStats.done ?? summarySteps.fetchWebsiteData?.analysis?.detailFetchedNow,
detailStats.total ?? summarySteps.fetchWebsiteData?.analysis?.totalProductsUnique,
),
meta: `${summarySteps.fetchWebsiteData?.analysis?.cachedDetailReused ?? 0} cached reused`,
},
{
key: "ready",
label: "Images ready in Shopify",
value: String(
liveStats.uploadedOk ??
summarySteps.uploadImagesToShopifyFiles?.uploaded ??
0,
),
meta: `${uploadStats.failed ?? summarySteps.uploadImagesToShopifyFiles?.failed ?? 0} upload issues`,
},
{
key: "success",
label: "Sync success rate",
value: shopifyStats.successRate
? `${shopifyStats.successRate}%`
: summarySteps.upsertToShopify?.successRate
? `${summarySteps.upsertToShopify.successRate}%`
: job.status === "done"
? "100%"
: "Running",
meta: job.status === "error" ? "Needs attention" : "Based on product sync stage",
},
];
const stageItems = [
{
key: "details",
title: "Fetch product details",
state: getStageState(job, "details"),
value: formatCounter(
detailStats.done ?? summarySteps.fetchWebsiteData?.analysis?.detailFetchedNow,
detailStats.total ?? summarySteps.fetchWebsiteData?.analysis?.totalProductsUnique,
),
meta: `${summarySteps.fetchWebsiteData?.analysis?.cachedDetailReused ?? 0} reused from cache`,
},
{
key: "download",
title: "Download product images",
state: getStageState(job, "download"),
value: formatCounter(
downloadStats.done ?? summarySteps.downloadImages?.downloaded,
downloadStats.total ?? summarySteps.downloadImages?.totalImagesFound,
),
meta: `${downloadStats.failed ?? summarySteps.downloadImages?.failed ?? 0} failed`,
},
{
key: "watermark",
title: "Apply watermark",
state: getStageState(job, "watermark"),
value: formatCounter(
watermarkStats.done ?? summarySteps.watermarkImages?.processed,
watermarkStats.total ?? summarySteps.watermarkImages?.totalImagesFound,
),
meta: `${watermarkStats.failed ?? summarySteps.watermarkImages?.failed ?? 0} failed`,
},
{
key: "upload",
title: "Upload images to Shopify",
state: getStageState(job, "upload"),
value: formatCounter(
uploadStats.uploaded ?? summarySteps.uploadImagesToShopifyFiles?.uploaded,
uploadStats.total ?? summarySteps.uploadImagesToShopifyFiles?.totalTasks,
),
meta: `${uploadStats.skipped ?? summarySteps.uploadImagesToShopifyFiles?.skipped ?? 0} skipped • ${uploadStats.failed ?? summarySteps.uploadImagesToShopifyFiles?.failed ?? 0} failed`,
},
{
key: "shopify",
title: "Create or update products",
state: getStageState(job, "shopify"),
value: formatCounter(
shopifyStats.done ?? summarySteps.upsertToShopify?.processed,
shopifyStats.total ?? summarySteps.upsertToShopify?.total,
),
meta: `${shopifyStats.created ?? summarySteps.upsertToShopify?.created ?? 0} created • ${shopifyStats.updated ?? summarySteps.upsertToShopify?.updated ?? 0} updated`,
},
];
return (
<div className={styles.jobPanel}>
@ -158,7 +519,7 @@ function JobSummary({ job }) {
</div>
<div className={styles.progressMeta}>
<span>{job.step || "-"}</span>
<span>{currentStepLabel}</span>
<span>{progressPercent}% complete</span>
</div>
<div className={styles.progressTrack}>
@ -168,25 +529,115 @@ function JobSummary({ job }) {
/>
</div>
<div className={styles.progressOverviewGrid}>
<div className={styles.progressOverviewCard}>
<span className={styles.statLabel}>Current stage</span>
<strong className={styles.progressOverviewValue}>{currentStepLabel}</strong>
<span className={styles.progressOverviewMeta}>
Step {job.stepIndex || 0} of {job.totalSteps || 6}
</span>
</div>
<div className={styles.progressOverviewCard}>
<span className={styles.statLabel}>Started</span>
<strong className={styles.progressOverviewValue}>
{formatDateTime(job.startedAt)}
</strong>
<span className={styles.progressOverviewMeta}>Import start time</span>
</div>
<div className={styles.progressOverviewCard}>
<span className={styles.statLabel}>Last update</span>
<strong className={styles.progressOverviewValue}>
{formatDateTime(job.updatedAt)}
</strong>
<span className={styles.progressOverviewMeta}>Latest backend activity</span>
</div>
<div className={styles.progressOverviewCard}>
<span className={styles.statLabel}>Elapsed</span>
<strong className={styles.progressOverviewValue}>
{formatElapsed(job.startedAt, job.updatedAt)}
</strong>
<span className={styles.progressOverviewMeta}>Time spent on this run</span>
</div>
</div>
{analyticsCards.length ? (
<div className={styles.analyticsGrid}>
{analyticsCards.map((card) => (
<div
key={card.key}
className={`${styles.analyticsCard} ${styles[`analytics${card.tone[0].toUpperCase()}${card.tone.slice(1)}`]}`}
>
<span className={styles.analyticsLabel}>{card.label}</span>
<strong className={styles.analyticsValue}>{card.value}</strong>
<span className={styles.analyticsMeta}>{card.meta}</span>
</div>
))}
</div>
) : null}
<div className={styles.highlightGrid}>
{highlightCards.map((card) => (
<div key={card.key} className={styles.highlightCard}>
<span className={styles.highlightLabel}>{card.label}</span>
<strong className={styles.highlightValue}>{card.value}</strong>
<span className={styles.highlightMeta}>{card.meta}</span>
</div>
))}
</div>
<div className={styles.timelineCard}>
<div className={styles.timelineHeader}>
<span className={styles.statLabel}>Stage progress</span>
<strong className={styles.timelineTitle}>Live pipeline board</strong>
<span className={styles.timelineSubtitle}>
Each row shows the current count for that stage while the import is running.
</span>
</div>
<div className={styles.stageList}>
{stageItems.map((stage) => (
<div key={stage.key} className={styles.stageItem}>
<div className={styles.stageMain}>
<span
className={`${styles.stageDot} ${
stage.state === "done"
? styles.stageDone
: stage.state === "active"
? styles.stageActive
: stage.state === "error"
? styles.stageError
: styles.stagePending
}`}
/>
<div>
<strong className={styles.stageTitle}>{stage.title}</strong>
<p className={styles.stageMeta}>{stage.meta}</p>
</div>
</div>
<span className={styles.stageValue}>{stage.value}</span>
</div>
))}
</div>
</div>
<div className={styles.statGrid}>
<div className={styles.statCard}>
<span className={styles.statLabel}>Step</span>
<span className={styles.statLabel}>Job status</span>
<strong>
{job.stepIndex || 0}/{job.totalSteps || 6}
{job.status || "-"}
</strong>
</div>
<div className={styles.statCard}>
<span className={styles.statLabel}>Started</span>
<strong>{job.startedAt ? new Date(job.startedAt).toLocaleString() : "-"}</strong>
<span className={styles.statLabel}>Completion</span>
<strong>{progressPercent}%</strong>
</div>
<div className={styles.statCard}>
<span className={styles.statLabel}>Updated</span>
<strong>{job.updatedAt ? new Date(job.updatedAt).toLocaleString() : "-"}</strong>
<span className={styles.statLabel}>Job key</span>
<strong>{job.id}</strong>
</div>
</div>
<div className={styles.detailCard}>
<span className={styles.statLabel}>Detail</span>
<span className={styles.statLabel}>What is happening now</span>
<p>{job.detail || "-"}</p>
</div>
@ -205,6 +656,34 @@ function JobSummary({ job }) {
</pre>
</div>
) : null}
{recentLogs.length ? (
<div className={styles.logCard}>
<span className={styles.statLabel}>Recent activity</span>
<div className={styles.logList}>
{recentLogs.map((entry, index) => (
<div
key={`${entry.at}-${index}`}
className={`${styles.logLine} ${
entry.parsed?.tone === "success"
? styles.logSuccess
: entry.parsed?.tone === "error"
? styles.logError
: entry.parsed?.tone === "progress"
? styles.logProgress
: styles.logNeutral
}`}
>
<span className={styles.logTime}>
{entry.at ? new Date(entry.at).toLocaleTimeString() : ""}
</span>
<strong className={styles.logTitle}>{entry.parsed?.title || "Activity"}</strong>
<p className={styles.logDetail}>{entry.parsed?.detail || entry.line}</p>
</div>
))}
</div>
</div>
) : null}
</div>
);
}
@ -343,7 +822,7 @@ export default function Index() {
<s-section heading="Store Setup">
<div className={styles.panelBody}>
<div className={styles.inlineRow}>
<span className={styles.statLabel}>Store status</span>
<span className={styles.statLabel}>Connection</span>
<span
className={`${styles.statusPill} ${
connection?.status === 1 ? styles.success : styles.warning
@ -352,14 +831,9 @@ export default function Index() {
{connectionState}
</span>
</div>
<div className={styles.detailCard}>
<span className={styles.statLabel}>Message</span>
<p>{connectionMessage}</p>
</div>
{connection?.status !== 1 ? (
<div className={`${styles.detailCard} ${styles.warningCard}`}>
<span className={styles.statLabel}>What to do</span>
<p>Connect your store first, then start the import.</p>
<p>{connectionMessage}</p>
<div className={styles.actionRow}>
<s-button variant="primary" onClick={openSetup}>
Connect store
@ -367,27 +841,17 @@ export default function Index() {
</div>
</div>
) : (
<div className={`${styles.detailCard} ${styles.successCard}`}>
<span className={styles.statLabel}>Store connection</span>
<p>Your store is ready. You can start importing products now.</p>
</div>
<p>{connectionMessage}</p>
)}
</div>
</s-section>
<s-section
heading="Start Product Import"
description="Choose how much you want to import, then start the process."
description="Start the full Race Nation product import for this store."
>
<fetcher.Form method="post">
<div className={styles.panelBody}>
<s-text-field
label="Product limit"
name="limit"
type="number"
min="1"
details="Leave this empty to import all products, or enter a smaller number for a partial import."
/>
<div className={styles.actionRow}>
<s-button
type="submit"
@ -406,15 +870,6 @@ export default function Index() {
<s-section heading="Import Progress">
<JobSummary job={job} />
</s-section>
<s-section heading="What Happens Next">
<div className={styles.stepList}>
<div className={styles.stepItem}>1. Product information is collected.</div>
<div className={styles.stepItem}>2. Product images are prepared.</div>
<div className={styles.stepItem}>3. Images are uploaded to Shopify.</div>
<div className={styles.stepItem}>4. Products are created or updated in your store.</div>
</div>
</s-section>
</div>
</div>
</s-page>

View File

@ -150,6 +150,192 @@
gap: 1rem;
}
.analyticsGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.85rem;
}
.analyticsCard {
padding: 1rem;
border-radius: 20px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: linear-gradient(135deg, rgba(255, 247, 237, 0.92), rgba(255, 255, 255, 0.98));
display: grid;
gap: 0.35rem;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
}
.analyticsSunrise {
background: linear-gradient(135deg, rgba(255, 237, 213, 0.96), rgba(255, 251, 235, 0.98));
}
.analyticsIce {
background: linear-gradient(135deg, rgba(224, 242, 254, 0.96), rgba(248, 250, 252, 0.98));
}
.analyticsMint {
background: linear-gradient(135deg, rgba(220, 252, 231, 0.96), rgba(240, 253, 244, 0.98));
}
.analyticsNavy {
background: linear-gradient(135deg, rgba(224, 231, 255, 0.96), rgba(238, 242, 255, 0.98));
}
.analyticsLabel {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
}
.analyticsValue {
font-size: 1.45rem;
line-height: 1.1;
color: #0f172a;
}
.analyticsMeta {
font-size: 0.82rem;
color: #475569;
}
.highlightGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.highlightCard,
.timelineCard {
padding: 1rem;
border-radius: 20px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94));
}
.highlightCard {
display: grid;
gap: 0.35rem;
}
.highlightLabel,
.stageMeta {
font-size: 0.82rem;
color: #64748b;
}
.highlightValue {
font-size: 1.1rem;
line-height: 1.25;
color: #0f172a;
}
.highlightMeta {
font-size: 0.8rem;
color: #475569;
}
.timelineHeader {
display: grid;
gap: 0.2rem;
}
.timelineSubtitle,
.progressOverviewMeta {
font-size: 0.8rem;
color: #64748b;
}
.timelineTitle {
color: #0f172a;
font-size: 1rem;
}
.progressOverviewGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.progressOverviewCard {
padding: 1rem;
border-radius: 20px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94));
display: grid;
gap: 0.3rem;
}
.progressOverviewValue {
font-size: 1rem;
line-height: 1.35;
color: #0f172a;
}
.stageList {
display: grid;
gap: 0.75rem;
margin-top: 0.85rem;
}
.stageItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.85rem;
padding: 0.85rem 0.95rem;
border-radius: 16px;
background: rgba(248, 250, 252, 0.92);
border: 1px solid rgba(226, 232, 240, 0.95);
}
.stageMain {
display: flex;
align-items: flex-start;
gap: 0.75rem;
flex: 1;
}
.stageDot {
width: 0.85rem;
height: 0.85rem;
margin-top: 0.15rem;
border-radius: 999px;
flex: 0 0 auto;
}
.stagePending {
background: #cbd5e1;
}
.stageActive {
background: #f97316;
box-shadow: 0 0 0 6px rgba(249, 115, 22, 0.15);
}
.stageDone {
background: #16a34a;
box-shadow: 0 0 0 6px rgba(22, 163, 74, 0.12);
}
.stageError {
background: #dc2626;
box-shadow: 0 0 0 6px rgba(220, 38, 38, 0.12);
}
.stageTitle {
display: block;
color: #0f172a;
margin-bottom: 0.18rem;
}
.stageValue {
font-weight: 700;
color: #0f172a;
white-space: nowrap;
}
.panelTitle {
margin: 0.15rem 0 0;
font-size: 1.15rem;
@ -205,6 +391,68 @@
color: #e2e8f0;
}
.logCard {
padding: 1rem;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 20px;
background: #111827;
color: #e5e7eb;
}
.logList {
display: grid;
gap: 0.55rem;
margin-top: 0.75rem;
max-height: 18rem;
overflow: auto;
}
.logLine {
display: grid;
gap: 0.25rem;
padding: 0.7rem 0.8rem;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid transparent;
}
.logNeutral {
border-color: rgba(148, 163, 184, 0.16);
}
.logProgress {
border-color: rgba(251, 191, 36, 0.24);
background: rgba(251, 191, 36, 0.08);
}
.logSuccess {
border-color: rgba(74, 222, 128, 0.24);
background: rgba(34, 197, 94, 0.08);
}
.logError {
border-color: rgba(248, 113, 113, 0.24);
background: rgba(239, 68, 68, 0.08);
}
.logTitle {
font-size: 0.9rem;
color: #f8fafc;
}
.logDetail {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.82rem;
color: #dbe4f0;
}
.logTime {
font-size: 0.75rem;
color: #94a3b8;
}
.emptyPanel {
position: relative;
overflow: hidden;
@ -247,6 +495,24 @@
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94));
}
@media (max-width: 900px) {
.analyticsGrid,
.statGrid,
.highlightGrid,
.progressOverviewGrid {
grid-template-columns: 1fr;
}
.stageItem {
flex-direction: column;
align-items: flex-start;
}
.stageValue {
margin-left: 1.6rem;
}
}
@keyframes pulse {
0%,
100% {
@ -277,7 +543,9 @@
font-size: 1.55rem;
}
.statGrid {
.analyticsGrid,
.statGrid,
.progressOverviewGrid {
grid-template-columns: 1fr;
}
}