Add lightweight analytics dashboard
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -17,6 +17,12 @@ const replayPrevBtn = document.getElementById("replayPrevBtn");
|
||||
const replayNextBtn = document.getElementById("replayNextBtn");
|
||||
const replaySpeedEl = document.getElementById("replaySpeed");
|
||||
const replaySeekEl = document.getElementById("replaySeek");
|
||||
const analyticsMetaEl = document.getElementById("analyticsMeta");
|
||||
const analyticsSummaryEl = document.getElementById("analyticsSummary");
|
||||
const analyticsCategorySummaryEl = document.getElementById("analyticsCategorySummary");
|
||||
const analyticsCategoriesEl = document.getElementById("analyticsCategories");
|
||||
const analyticsTrendSummaryEl = document.getElementById("analyticsTrendSummary");
|
||||
const analyticsTrendsEl = document.getElementById("analyticsTrends");
|
||||
|
||||
const state = {
|
||||
token: localStorage.getItem("screenjob_token") || "",
|
||||
@@ -35,6 +41,7 @@ const state = {
|
||||
}
|
||||
};
|
||||
const manuallyClosedSockets = new WeakSet();
|
||||
const analyticsRefreshEvents = new Set(["job_finished", "job_failed", "job_rejected"]);
|
||||
tokenInput.value = state.token;
|
||||
|
||||
function authHeaders() {
|
||||
@@ -66,6 +73,197 @@ function renderStats(stats) {
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (ch) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
})[ch]);
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 2) {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num.toFixed(digits) : "—";
|
||||
}
|
||||
|
||||
function formatCurrency(value, digits = 6) {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? `$${num.toFixed(digits)}` : "—";
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? `${num.toFixed(1)}%` : "—";
|
||||
}
|
||||
|
||||
function formatDateLabel(value) {
|
||||
const dt = new Date(value);
|
||||
if (Number.isNaN(dt.getTime())) return String(value || "—");
|
||||
return dt.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function renderMetricCard(label, value) {
|
||||
return `
|
||||
<div class="bg-slate-950 border border-slate-800 rounded-xl p-3">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-400">${escapeHtml(label)}</div>
|
||||
<div class="text-xl font-semibold mt-1">${escapeHtml(value)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLineChart(title, points, options = {}) {
|
||||
const color = options.color || "#22d3ee";
|
||||
const valueLabel = options.valueLabel || "";
|
||||
const sourcePoints = Array.isArray(points)
|
||||
? points.filter((point) => Number.isFinite(Number(point.value)))
|
||||
: [];
|
||||
|
||||
if (!sourcePoints.length) {
|
||||
return `
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-950/70 p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs text-slate-400">${escapeHtml(title)}</div>
|
||||
<div class="text-sm text-slate-200 font-semibold">No data yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const width = 640;
|
||||
const height = 220;
|
||||
const margin = { top: 20, right: 18, bottom: 34, left: 44 };
|
||||
const values = sourcePoints.map((point) => Number(point.value));
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
const span = maxValue - minValue || 1;
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
const xStep = sourcePoints.length > 1 ? chartWidth / (sourcePoints.length - 1) : 0;
|
||||
const coords = sourcePoints.map((point, index) => ({
|
||||
x: margin.left + (index * xStep),
|
||||
y: margin.top + ((maxValue - Number(point.value)) / span) * chartHeight,
|
||||
}));
|
||||
const linePath = coords.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
|
||||
const baseline = height - margin.bottom;
|
||||
const midIndex = Math.floor(sourcePoints.length / 2);
|
||||
const xLabels = [
|
||||
{ index: 0, label: sourcePoints[0].label },
|
||||
{ index: midIndex, label: sourcePoints[midIndex].label },
|
||||
{ index: sourcePoints.length - 1, label: sourcePoints[sourcePoints.length - 1].label },
|
||||
].filter((item, index, array) => item.label && array.findIndex((candidate) => candidate.index === item.index) === index);
|
||||
const minLabel = options.formatValue ? options.formatValue(minValue) : formatNumber(minValue, 2);
|
||||
const maxLabel = options.formatValue ? options.formatValue(maxValue) : formatNumber(maxValue, 2);
|
||||
const latest = sourcePoints[sourcePoints.length - 1];
|
||||
const latestValue = options.formatValue ? options.formatValue(latest.value) : formatNumber(latest.value, 2);
|
||||
|
||||
return `
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-950/70 p-3 space-y-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs text-slate-400">${escapeHtml(title)}</div>
|
||||
<div class="text-sm text-slate-200 font-semibold">${escapeHtml(latestValue)}${valueLabel ? ` <span class="text-slate-500 font-normal">${escapeHtml(valueLabel)}</span>` : ""}</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-400 text-right">
|
||||
<div>${escapeHtml(sourcePoints.length)} points</div>
|
||||
<div>${escapeHtml(minLabel)} - ${escapeHtml(maxLabel)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg viewBox="0 0 ${width} ${height}" class="w-full h-56">
|
||||
${Array.from({ length: 4 }, (_, idx) => {
|
||||
const y = margin.top + (chartHeight / 3) * idx;
|
||||
return `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="rgba(51, 65, 85, 0.7)" stroke-width="1" />`;
|
||||
}).join("")}
|
||||
<line x1="${margin.left}" y1="${baseline}" x2="${width - margin.right}" y2="${baseline}" stroke="rgba(71, 85, 105, 0.8)" stroke-width="1.5" />
|
||||
<path d="${linePath}" fill="none" stroke="${color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
${coords.map((point) => `
|
||||
<circle cx="${point.x}" cy="${point.y}" r="4.5" fill="${color}" />
|
||||
`).join("")}
|
||||
<text x="${margin.left - 8}" y="${margin.top + 4}" text-anchor="end" class="fill-slate-400 text-[10px]">${escapeHtml(maxLabel)}</text>
|
||||
<text x="${margin.left - 8}" y="${baseline}" text-anchor="end" class="fill-slate-400 text-[10px]">${escapeHtml(minLabel)}</text>
|
||||
${xLabels.map((item) => `
|
||||
<text x="${coords[item.index].x}" y="${height - 10}" text-anchor="middle" class="fill-slate-500 text-[10px]">${escapeHtml(formatDateLabel(item.label))}</text>
|
||||
`).join("")}
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAnalytics(payload) {
|
||||
const analytics = payload || {};
|
||||
const categories = Array.isArray(analytics.by_category) ? analytics.by_category : [];
|
||||
const timeline = Array.isArray(analytics.timeline) ? analytics.timeline : [];
|
||||
const finishedCategories = categories.filter((row) => Number(row.finished_jobs || 0) > 0);
|
||||
|
||||
if (analyticsMetaEl) {
|
||||
analyticsMetaEl.textContent = analytics.generated_at
|
||||
? `Updated ${new Date(analytics.generated_at).toLocaleString()}`
|
||||
: "Historical snapshot";
|
||||
}
|
||||
|
||||
analyticsSummaryEl.innerHTML = [
|
||||
renderMetricCard("Finished Jobs", analytics.finished_jobs || 0),
|
||||
renderMetricCard("Success Rate", formatPercent(analytics.success_rate)),
|
||||
renderMetricCard("Avg Steps", formatNumber(analytics.avg_steps, 1)),
|
||||
renderMetricCard("Avg Cost", formatCurrency(analytics.avg_cost_usd)),
|
||||
].join("");
|
||||
|
||||
analyticsCategorySummaryEl.textContent = finishedCategories.length
|
||||
? `${finishedCategories.length} categories`
|
||||
: "No finished jobs yet";
|
||||
|
||||
if (finishedCategories.length) {
|
||||
analyticsCategoriesEl.innerHTML = finishedCategories.map((row) => {
|
||||
const successRate = Number(row.success_rate || 0);
|
||||
const completed = Number(row.completed_jobs || 0);
|
||||
const finished = Number(row.finished_jobs || 0);
|
||||
const total = Number(row.total_jobs || 0);
|
||||
const avgSteps = row.avg_steps == null ? "—" : formatNumber(row.avg_steps, 1);
|
||||
const avgCost = row.avg_cost_usd == null ? "—" : formatCurrency(row.avg_cost_usd);
|
||||
return `
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-950/70 p-3 space-y-2">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">${escapeHtml(row.label || "Other")}</div>
|
||||
<div class="text-[11px] text-slate-400">${finished} finished · ${completed} completed · ${total} total</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-base font-semibold">${formatPercent(successRate)}</div>
|
||||
<div class="text-[11px] text-slate-500">success rate</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-2 rounded bg-slate-800 overflow-hidden">
|
||||
<div class="h-full rounded bg-cyan-400" style="width: ${Math.max(0, Math.min(successRate, 100))}%"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-[11px] text-slate-300">
|
||||
<div>Avg steps: ${escapeHtml(avgSteps)}</div>
|
||||
<div>Avg cost: ${escapeHtml(avgCost)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
} else {
|
||||
analyticsCategoriesEl.innerHTML = `
|
||||
<div class="rounded-lg border border-dashed border-slate-800 bg-slate-950/70 p-4 text-sm text-slate-400">
|
||||
No finished jobs yet.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
analyticsTrendSummaryEl.textContent = timeline.length ? `${timeline.length} days` : "No daily data yet";
|
||||
analyticsTrendsEl.innerHTML = [
|
||||
renderLineChart("Average steps per day", timeline.map((row) => ({ label: row.label, value: row.avg_steps })), { color: "#38bdf8" }),
|
||||
renderLineChart("Average cost per day", timeline.map((row) => ({ label: row.label, value: row.avg_cost_usd })), {
|
||||
color: "#34d399",
|
||||
valueLabel: "USD",
|
||||
formatValue: (value) => formatCurrency(value),
|
||||
}),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderJobs() {
|
||||
jobListEl.innerHTML = state.jobs.map((job) => {
|
||||
const active = job.job_id === state.selectedJobId;
|
||||
@@ -310,6 +508,11 @@ async function refreshStats() {
|
||||
renderStats(payload);
|
||||
}
|
||||
|
||||
async function refreshAnalytics() {
|
||||
const payload = await api("/api/analytics");
|
||||
renderAnalytics(payload);
|
||||
}
|
||||
|
||||
async function refreshJobDetail() {
|
||||
if (!state.selectedJobId) return;
|
||||
const [job, events, replay] = await Promise.all([
|
||||
@@ -345,6 +548,9 @@ function connectWs() {
|
||||
}
|
||||
await refreshJobs();
|
||||
await refreshStats();
|
||||
if (analyticsRefreshEvents.has(payload.event_type)) {
|
||||
await refreshAnalytics();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
@@ -362,6 +568,7 @@ function connectWs() {
|
||||
async function fullRefresh() {
|
||||
await refreshJobs();
|
||||
await refreshStats();
|
||||
await refreshAnalytics();
|
||||
await refreshJobDetail();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user