Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions server/src/database/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,24 @@ export async function getTestHar(id) {
}
}

/**
* Most-recent failed test runs within the last 24 h, ordered newest-first.
* Bounded to the same 24 h window as the health pill so the table and the
* pill always agree on what counts as "recent" — unlike Bull's retained-
* failures list, which accumulates indefinitely and never decays.
*/
export async function getRecentFailures(limit) {
const select =
"SELECT id, location, url, scripting_name, label, failed_reason, finished_date FROM sitespeed_io_test_runs WHERE status = 'failed' AND finished_date >= NOW() - INTERVAL '24 hours' ORDER BY finished_date DESC LIMIT $1";
try {
const result = await DatabaseHelper.getInstance().query(select, [limit]);
return result.rows;
} catch (error) {
logError('Could not get recent failures', error);
return [];
}
}

// Cheap one-shot DB ping for the admin health banner. Unlike
// testConnection() (which retries with a 5s delay and is meant for
// startup), this returns false fast on any failure so it won't stall
Expand Down
66 changes: 22 additions & 44 deletions server/src/routes/html/admin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import {
getExistingQueueNames,
getQueueCounts,
getActiveJobs,
getFailedJobs,
retryFailedJob,
isRedisHealthy
} from '../../../queuehandler.js';
import { getTestRunners } from '../../../testrunners.js';
import { isDatabaseHealthy, getStatistics } from '../../../database/index.js';
import {
isDatabaseHealthy,
getStatistics,
getRecentFailures
} from '../../../database/index.js';

const require = createRequire(import.meta.url);
const serverVersion = require('../../../../package.json').version;
Expand Down Expand Up @@ -86,35 +88,23 @@ async function buildAdminView() {
}
activeJobs.sort((a, b) => (a.startedAt || 0) - (b.startedAt || 0));

// Recent failures across non-internal queues. Bull's `failedReason` is
// the first line of whatever the testrunner threw. Truncate it for the
// table so a 4-line stack trace doesn't blow up the layout; the full
// log is one click away on /result/<id>.
const failedJobs = [];
for (const queueName of queues) {
if (INTERNAL_QUEUES.has(queueName)) continue;
const jobs = await getFailedJobs(queueName, 20);
for (const job of jobs) {
const finishedAt = job.finishedOn;
let reason = (job.failedReason || '').split('\n')[0].trim();
if (reason.length > 160) reason = reason.slice(0, 157) + '…';
failedJobs.push({
id: String(job.id),
queue: queueName,
target:
job.data?.scriptingName || job.data?.url || job.data?.label || '',
reason,
attemptsMade: job.attemptsMade || 0,
finishedAt,
secondsAgo: finishedAt
? Math.max(0, Math.round((now - finishedAt) / 1000))
: undefined
});
}
}
failedJobs.sort((a, b) => (b.finishedAt || 0) - (a.finishedAt || 0));
// Show at most this many across all queues so the page stays scannable.
const recentFailures = failedJobs.slice(0, 25);
const failureRows = await getRecentFailures(25);
const recentFailures = failureRows.map(row => {
let reason = (row.failed_reason || '').split('\n')[0].trim();
if (reason.length > 160) reason = reason.slice(0, 157) + '…';
const finishedAt = row.finished_date
? new Date(row.finished_date).getTime()
: undefined;
return {
id: String(row.id),
target: row.scripting_name || row.url || row.label || '',
reason,
finishedAt,
secondsAgo: finishedAt
? Math.max(0, Math.round((now - finishedAt) / 1000))
: undefined
};
});

// Pull DB-backed activity stats first — the health banner uses the
// 24 h failed count from here. The helper itself caches for 60s so
Expand Down Expand Up @@ -207,15 +197,3 @@ admin.post('/', async function (request, response) {
response.set('Cache-Control', 'no-store');
renderAdmin(response, await buildAdminView());
});

// Re-enqueue a failed job. Bull's job.retry() pushes it back to the
// queue's wait list, so the next available testrunner on that queue
// picks it up and runs it again — same data, same attempt counter.
admin.post('/retry', async function (request, response) {
const queueName = request.body.queueName;
const jobId = request.body.jobId;
if (queueName && jobId) {
await retryFailedJob(queueName, jobId);
}
response.redirect('/admin/');
});
6 changes: 0 additions & 6 deletions server/views/admin/index.pug
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,6 @@ html(lang='en')
span.cell.fail-target(role='columnheader') URL / script
span.cell.fail-reason(role='columnheader') Reason
span.cell.fail-when(role='columnheader') When
span.cell.fail-action(role='columnheader' aria-label='Actions')
each job, failIndex in failedJobs
-
// Format the failure age into a readable label. Bull keeps
Expand Down Expand Up @@ -353,11 +352,6 @@ html(lang='en')
span.cell.fail-reason(role='cell' title=job.reason)
| #{job.reason || '(no reason recorded)'}
span.cell.fail-when(role='cell') #{whenLabel}
span.cell.fail-action(role='cell')
form(method='POST' action='/admin/retry' style='margin:0')
input(type='hidden' name='queueName' value=job.queue)
input(type='hidden' name='jobId' value=job.id)
button.retry-btn(type='submit' aria-label=`Retry job ${job.id}`) Retry
h2.section-heading Queues
.container(role='table' aria-label='Queue list')
.row.row-head(role='row')
Expand Down
Loading