@artian-techs/adonis-orbit 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -3
- package/build/index.d.ts +2 -0
- package/build/index.js +2 -0
- package/build/providers/orbit_provider.js +11 -1
- package/build/src/controllers/dashboard_controller.d.ts +11 -0
- package/build/src/controllers/dashboard_controller.js +136 -25
- package/build/src/jobs/reconcile_job_runs.d.ts +18 -0
- package/build/src/jobs/reconcile_job_runs.js +26 -0
- package/build/src/models/job_run.d.ts +4 -0
- package/build/src/models/job_run.js +4 -0
- package/build/src/reconcile.d.ts +42 -0
- package/build/src/reconcile.js +81 -0
- package/build/src/recorder.d.ts +6 -1
- package/build/src/recorder.js +22 -1
- package/build/src/routes.js +1 -0
- package/build/src/sinks/database_sink.js +2 -1
- package/build/src/types.d.ts +8 -2
- package/build/templates/components/layout.edge +17 -1
- package/build/templates/components/pager.edge +39 -0
- package/build/templates/index.edge +3 -9
- package/build/templates/metrics.edge +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,10 +45,16 @@ Under it, one button reads the page in the light or in the dark. The page reads
|
|
|
45
45
|
the mode of the machine while nobody chose; a choice is held in a cookie. The
|
|
46
46
|
page holds no JavaScript.
|
|
47
47
|
|
|
48
|
+
The list carries a pager over the table and under it: the number of every page
|
|
49
|
+
that stands near the one that is read, the first, the last, and a gap between
|
|
50
|
+
them. **Rows** says how many jobs one page holds — 25, 50 or 100. The choice is
|
|
51
|
+
held in a cookie, thus the menu carries it from one status to the next. Without
|
|
52
|
+
one, fifty.
|
|
53
|
+
|
|
48
54
|
| Entry | What it shows |
|
|
49
55
|
| --- | --- |
|
|
50
56
|
| Overview | four numbers, then the last jobs of every status |
|
|
51
|
-
| Pending, Running, Retrying, Completed, Failed | the same list, of one status |
|
|
57
|
+
| Pending, Running, Retrying, Completed, Failed, Lost | the same list, of one status |
|
|
52
58
|
| Silenced | the classes that the lists leave out |
|
|
53
59
|
| Metrics | what each class and each queue cost over the last 24 hours |
|
|
54
60
|
|
|
@@ -203,6 +209,7 @@ One row for one job, completed at each step.
|
|
|
203
209
|
| `retrying` | a try failed, the next one is set |
|
|
204
210
|
| `completed` | done |
|
|
205
211
|
| `failed` | every try fell |
|
|
212
|
+
| `lost` | the queue let the job go, and no worker said how it ended |
|
|
206
213
|
|
|
207
214
|
The row that the dispatch writes and the row that the worker writes are the
|
|
208
215
|
same row. Either of the two can arrive first, thus the first one writes and the
|
|
@@ -210,8 +217,8 @@ second one completes.
|
|
|
210
217
|
|
|
211
218
|
## Ask a job again
|
|
212
219
|
|
|
213
|
-
From the page, with the button on a row that failed
|
|
214
|
-
package carries:
|
|
220
|
+
From the page, with the button on a row that failed or that the queue lost. Or
|
|
221
|
+
with the job that the package carries:
|
|
215
222
|
|
|
216
223
|
```ts
|
|
217
224
|
import { JobRun, JobRunStatus, retryRun } from '@artian-techs/adonis-orbit'
|
|
@@ -261,6 +268,44 @@ them away at the end.
|
|
|
261
268
|
every status to read and the name `orbit` and the word `orbit`. It reads a
|
|
262
269
|
database of its own, `orbit_preview`, and makes it if it is not there.
|
|
263
270
|
|
|
271
|
+
## Close the rows that wait for nothing
|
|
272
|
+
|
|
273
|
+
A worker publishes what it does, and Orbit reads it. A worker that refuses a
|
|
274
|
+
job **before it starts** publishes nothing: it fails the job in the queue and
|
|
275
|
+
takes the next one. This happens at every deployment where the worker restarts
|
|
276
|
+
after the API, because the worker of the old build does not carry the class
|
|
277
|
+
that the new API asks for.
|
|
278
|
+
|
|
279
|
+
The row of that job stays `pending`, and the page shows a job that waits for a
|
|
280
|
+
worker that will never take it.
|
|
281
|
+
|
|
282
|
+
`ReconcileJobRuns` asks the queue about every row that stayed open too long:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
import { ReconcileJobRuns } from '@artian-techs/adonis-orbit'
|
|
286
|
+
|
|
287
|
+
await ReconcileJobRuns.schedule({ minutes: 5 }).cron('* * * * *').run()
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
- the queue holds the job, thus the row is right and stays as it is;
|
|
291
|
+
- the queue holds the answer, thus the row takes it, with its error;
|
|
292
|
+
- the queue holds nothing, thus the row becomes `lost`.
|
|
293
|
+
|
|
294
|
+
`minutes` says how long a row stays open before the queue is asked about it.
|
|
295
|
+
It leaves the events of the worker the time to land: a row that a worker closed
|
|
296
|
+
a second ago is never read. Without it, five minutes.
|
|
297
|
+
|
|
298
|
+
The rows are read from the table, thus this needs `DatabaseSink`. The answers
|
|
299
|
+
go to every sink of the configuration, as the events of the worker do.
|
|
300
|
+
|
|
301
|
+
A queue that keeps its failed jobs gives the error that stopped them. Ask for
|
|
302
|
+
it in `config/queue.ts`, else the queue removes a job as soon as it fails and
|
|
303
|
+
the row can only say `lost`:
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
defaultJobOptions: { removeOnFail: false },
|
|
307
|
+
```
|
|
308
|
+
|
|
264
309
|
## Take out the traces that ended well
|
|
265
310
|
|
|
266
311
|
The package carries `PruneJobRuns` and names it to the queue itself. Give it a
|
package/build/index.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export type { Authorize, JobEvent, JobIdentity, JobOutcome, OrbitConfig, Payload
|
|
|
5
5
|
export { default as JobRun, JobRunStatus, type JobRunStatusValue } from './src/models/job_run.js';
|
|
6
6
|
export { default as SilencedJob } from './src/models/silenced_job.js';
|
|
7
7
|
export { default as PruneJobRuns } from './src/jobs/prune_job_runs.js';
|
|
8
|
+
export { default as ReconcileJobRuns } from './src/jobs/reconcile_job_runs.js';
|
|
9
|
+
export { reconcileJobRuns, type QueueLookup, type ReconcileOptions } from './src/reconcile.js';
|
|
8
10
|
export { retryRun } from './src/retry.js';
|
|
9
11
|
export { stubsRoot } from './stubs/main.js';
|
|
10
12
|
export { configure } from './configure.js';
|
package/build/index.js
CHANGED
|
@@ -4,6 +4,8 @@ export { default as DatabaseSink } from './src/sinks/database_sink.js';
|
|
|
4
4
|
export { default as JobRun, JobRunStatus } from './src/models/job_run.js';
|
|
5
5
|
export { default as SilencedJob } from './src/models/silenced_job.js';
|
|
6
6
|
export { default as PruneJobRuns } from './src/jobs/prune_job_runs.js';
|
|
7
|
+
export { default as ReconcileJobRuns } from './src/jobs/reconcile_job_runs.js';
|
|
8
|
+
export { reconcileJobRuns } from './src/reconcile.js';
|
|
7
9
|
export { retryRun } from './src/retry.js';
|
|
8
10
|
export { stubsRoot } from './stubs/main.js';
|
|
9
11
|
export { configure } from './configure.js';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Locator } from '@adonisjs/queue';
|
|
2
2
|
import edge from 'edge.js';
|
|
3
3
|
import PruneJobRuns from '../src/jobs/prune_job_runs.js';
|
|
4
|
+
import ReconcileJobRuns from '../src/jobs/reconcile_job_runs.js';
|
|
4
5
|
const DEFAULTS = { path: '/queue', authorize: () => false, sinks: [] };
|
|
5
6
|
/**
|
|
6
7
|
* Watch what the queue does, and keep it.
|
|
@@ -26,8 +27,17 @@ export default class OrbitProvider {
|
|
|
26
27
|
* lives here. It names itself to the queue.
|
|
27
28
|
*/
|
|
28
29
|
Locator.register(PruneJobRuns.name, PruneJobRuns);
|
|
30
|
+
Locator.register(ReconcileJobRuns.name, ReconcileJobRuns);
|
|
29
31
|
const { recordJobRuns } = await import('../src/recorder.js');
|
|
30
|
-
recordJobRuns(config);
|
|
32
|
+
const recorder = recordJobRuns(config);
|
|
33
|
+
/**
|
|
34
|
+
* A sink answers after the job does, and the application closes its
|
|
35
|
+
* database when it stops. The writes that are open are waited for first.
|
|
36
|
+
*
|
|
37
|
+
* This hook runs before the providers stop, thus the database of the
|
|
38
|
+
* application is still open.
|
|
39
|
+
*/
|
|
40
|
+
this.app.terminating(() => recorder.flush());
|
|
31
41
|
const router = await this.app.container.make('router');
|
|
32
42
|
const { registerRoutes } = await import('../src/routes.js');
|
|
33
43
|
registerRoutes(router, config);
|
|
@@ -5,6 +5,10 @@ import type { HttpContext } from '@adonisjs/core/http';
|
|
|
5
5
|
* It reads the table, not Redis: a job that ended left Redis, and the table
|
|
6
6
|
* keeps it. The page is served by the API itself, outside the front, thus it
|
|
7
7
|
* answers even when the front is down.
|
|
8
|
+
*
|
|
9
|
+
* A button answers with the page that carried it, and that page keeps the
|
|
10
|
+
* values of its address: a list of one status stays on that status, and a page
|
|
11
|
+
* of a pager stays on its number.
|
|
8
12
|
*/
|
|
9
13
|
export default class DashboardController {
|
|
10
14
|
#private;
|
|
@@ -16,6 +20,13 @@ export default class DashboardController {
|
|
|
16
20
|
* the other, the page shows the numbers and the last jobs of every status.
|
|
17
21
|
*/
|
|
18
22
|
index(ctx: HttpContext): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Read more rows on one page, or fewer.
|
|
25
|
+
*
|
|
26
|
+
* The choice is held in a cookie, as the mode is. A page that stands past
|
|
27
|
+
* the last one after the change answers with the last one.
|
|
28
|
+
*/
|
|
29
|
+
perPage({ params, response }: HttpContext): Promise<void>;
|
|
19
30
|
/**
|
|
20
31
|
* The classes that the lists leave out.
|
|
21
32
|
*
|
|
@@ -7,11 +7,20 @@ import JobRun, { JobRunStatus } from '../models/job_run.js';
|
|
|
7
7
|
import SilencedJob from '../models/silenced_job.js';
|
|
8
8
|
import { retryRun } from '../retry.js';
|
|
9
9
|
const PER_PAGE = 50;
|
|
10
|
+
/** The sizes of a page that a person chooses between. */
|
|
11
|
+
const PER_PAGE_SIZES = [25, 50, 100];
|
|
12
|
+
/** Where the size of a page is held, and for how long. */
|
|
13
|
+
const PER_PAGE_COOKIE = 'orbit_per_page';
|
|
14
|
+
const PER_PAGE_AGE = '1y';
|
|
15
|
+
/** How many numbers of pages stand on each side of the one that is read. */
|
|
16
|
+
const PAGER_WINDOW = 2;
|
|
10
17
|
/** The two modes that a person chooses between. */
|
|
11
18
|
const THEMES = ['light', 'dark'];
|
|
12
19
|
/** Where the choice of the mode is held, and for how long. */
|
|
13
20
|
const THEME_COOKIE = 'orbit_theme';
|
|
14
21
|
const THEME_AGE = '1y';
|
|
22
|
+
/** The statuses of a job that a person may ask for again. */
|
|
23
|
+
const ENDED_BADLY = [JobRunStatus.FAILED, JobRunStatus.LOST];
|
|
15
24
|
/** How far back the metrics read. */
|
|
16
25
|
const METRICS_WINDOW = { hours: 24 };
|
|
17
26
|
const STATUS_LABELS = {
|
|
@@ -20,6 +29,7 @@ const STATUS_LABELS = {
|
|
|
20
29
|
[JobRunStatus.RETRYING]: 'Retrying',
|
|
21
30
|
[JobRunStatus.COMPLETED]: 'Completed',
|
|
22
31
|
[JobRunStatus.FAILED]: 'Failed',
|
|
32
|
+
[JobRunStatus.LOST]: 'Lost',
|
|
23
33
|
};
|
|
24
34
|
/**
|
|
25
35
|
* The drawings of the side menu. They are written here, and the page prints
|
|
@@ -32,6 +42,7 @@ const ICONS = {
|
|
|
32
42
|
retrying: '<path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/>',
|
|
33
43
|
completed: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>',
|
|
34
44
|
failed: '<circle cx="12" cy="12" r="9"/><path d="M12 7v6"/><path d="M12 16.5v.5"/>',
|
|
45
|
+
lost: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9.5l5 5"/><path d="M14.5 9.5l-5 5"/>',
|
|
35
46
|
silenced: '<path d="M18 8a6 6 0 0 0-9.3-5"/><path d="M6 9v5l-2 3h13"/><path d="M10.5 20a2 2 0 0 0 3 0"/><path d="M3 3l18 18"/>',
|
|
36
47
|
metrics: '<path d="M4 20V10"/><path d="M10 20V4"/><path d="M16 20v-7"/><path d="M22 20H2"/>',
|
|
37
48
|
};
|
|
@@ -41,6 +52,10 @@ const ICONS = {
|
|
|
41
52
|
* It reads the table, not Redis: a job that ended left Redis, and the table
|
|
42
53
|
* keeps it. The page is served by the API itself, outside the front, thus it
|
|
43
54
|
* answers even when the front is down.
|
|
55
|
+
*
|
|
56
|
+
* A button answers with the page that carried it, and that page keeps the
|
|
57
|
+
* values of its address: a list of one status stays on that status, and a page
|
|
58
|
+
* of a pager stays on its number.
|
|
44
59
|
*/
|
|
45
60
|
export default class DashboardController {
|
|
46
61
|
#silenced;
|
|
@@ -57,21 +72,32 @@ export default class DashboardController {
|
|
|
57
72
|
const job = request.input('job');
|
|
58
73
|
const page = Number(request.input('page', 1)) || 1;
|
|
59
74
|
const silenced = await this.#silencedNames();
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
query.
|
|
63
|
-
|
|
75
|
+
const perPage = this.#perPage(request);
|
|
76
|
+
const build = () => {
|
|
77
|
+
const query = JobRun.query().orderBy('id', 'desc');
|
|
78
|
+
if (status) {
|
|
79
|
+
query.where('status', status);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A person who asks for one job asks for that job, thus a class that is
|
|
83
|
+
* silenced answers here as any other class.
|
|
84
|
+
*/
|
|
85
|
+
if (job) {
|
|
86
|
+
query.where('jobId', job);
|
|
87
|
+
}
|
|
88
|
+
else if (silenced.length > 0) {
|
|
89
|
+
query.whereNotIn('name', silenced);
|
|
90
|
+
}
|
|
91
|
+
return query;
|
|
92
|
+
};
|
|
93
|
+
let runs = await build().paginate(page, perPage);
|
|
64
94
|
/**
|
|
65
|
-
* A
|
|
66
|
-
*
|
|
95
|
+
* A page that stands past the last one holds nothing. The last page holds
|
|
96
|
+
* the rows that the person asked for.
|
|
67
97
|
*/
|
|
68
|
-
if (
|
|
69
|
-
|
|
98
|
+
if (page > runs.lastPage && runs.lastPage > 0) {
|
|
99
|
+
runs = await build().paginate(runs.lastPage, perPage);
|
|
70
100
|
}
|
|
71
|
-
else if (silenced.length > 0) {
|
|
72
|
-
query.whereNotIn('name', silenced);
|
|
73
|
-
}
|
|
74
|
-
const runs = await query.paginate(page, PER_PAGE);
|
|
75
101
|
const answers = await this.#answers(runs.all());
|
|
76
102
|
const alone = Boolean(status || job);
|
|
77
103
|
return this.#render(ctx, 'index', {
|
|
@@ -81,10 +107,89 @@ export default class DashboardController {
|
|
|
81
107
|
stats: alone ? null : await this.#stats(),
|
|
82
108
|
status: status ?? '',
|
|
83
109
|
rows: runs.all().map((run) => this.#row(run, silenced, answers)),
|
|
84
|
-
|
|
85
|
-
|
|
110
|
+
pager: this.#pager(runs, { status, job }),
|
|
111
|
+
perPage: { current: perPage, sizes: PER_PAGE_SIZES },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* How many rows one page holds.
|
|
116
|
+
*
|
|
117
|
+
* The choice is held in a cookie, thus the menu carries it from one status
|
|
118
|
+
* to the next and no address holds it.
|
|
119
|
+
*/
|
|
120
|
+
#perPage(request) {
|
|
121
|
+
const size = Number(request.plainCookie(PER_PAGE_COOKIE));
|
|
122
|
+
return PER_PAGE_SIZES.includes(size) ? size : PER_PAGE;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Every address that the pager leads to. The page reads them, it builds
|
|
126
|
+
* none.
|
|
127
|
+
*
|
|
128
|
+
* The numbers stand around the page that is read, and the first and the last
|
|
129
|
+
* stand at the ends. A hole between two of them is a step that the page
|
|
130
|
+
* writes as a gap.
|
|
131
|
+
*/
|
|
132
|
+
#pager(runs, filters) {
|
|
133
|
+
const current = runs.currentPage;
|
|
134
|
+
const last = runs.lastPage;
|
|
135
|
+
const href = (page) => this.#href({ ...filters, page: page > 1 ? page : undefined });
|
|
136
|
+
const pager = {
|
|
137
|
+
page: current,
|
|
138
|
+
lastPage: last,
|
|
86
139
|
total: runs.total,
|
|
140
|
+
previous: current > 1 ? href(current - 1) : null,
|
|
141
|
+
next: current < last ? href(current + 1) : null,
|
|
142
|
+
pages: [],
|
|
143
|
+
};
|
|
144
|
+
if (last < 2) {
|
|
145
|
+
return pager;
|
|
146
|
+
}
|
|
147
|
+
const numbers = new Set([1, last]);
|
|
148
|
+
for (let number = current - PAGER_WINDOW; number <= current + PAGER_WINDOW; number++) {
|
|
149
|
+
if (number >= 1 && number <= last) {
|
|
150
|
+
numbers.add(number);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
let previous = 0;
|
|
154
|
+
for (const number of [...numbers].sort((a, b) => a - b)) {
|
|
155
|
+
if (previous > 0 && number - previous > 1) {
|
|
156
|
+
pager.pages.push({ number: null, href: null, current: false });
|
|
157
|
+
}
|
|
158
|
+
pager.pages.push({ number, href: href(number), current: number === current });
|
|
159
|
+
previous = number;
|
|
160
|
+
}
|
|
161
|
+
return pager;
|
|
162
|
+
}
|
|
163
|
+
/** One address of the page, with the values that name what it shows. */
|
|
164
|
+
#href(params) {
|
|
165
|
+
const query = new URLSearchParams();
|
|
166
|
+
for (const [key, value] of Object.entries(params)) {
|
|
167
|
+
if (value !== undefined && value !== '') {
|
|
168
|
+
query.set(key, String(value));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const search = query.toString();
|
|
172
|
+
const { path } = this.#config();
|
|
173
|
+
return search ? `${path}?${search}` : path;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Read more rows on one page, or fewer.
|
|
177
|
+
*
|
|
178
|
+
* The choice is held in a cookie, as the mode is. A page that stands past
|
|
179
|
+
* the last one after the change answers with the last one.
|
|
180
|
+
*/
|
|
181
|
+
async perPage({ params, response }) {
|
|
182
|
+
const size = Number(params.count);
|
|
183
|
+
if (!PER_PAGE_SIZES.includes(size)) {
|
|
184
|
+
return response.notFound({ success: false, error: 'Not found' });
|
|
185
|
+
}
|
|
186
|
+
response.plainCookie(PER_PAGE_COOKIE, String(size), {
|
|
187
|
+
path: this.#config().path,
|
|
188
|
+
maxAge: PER_PAGE_AGE,
|
|
189
|
+
httpOnly: false,
|
|
190
|
+
sameSite: 'lax',
|
|
87
191
|
});
|
|
192
|
+
return response.redirect().withQs().back();
|
|
88
193
|
}
|
|
89
194
|
/** What the page is called. */
|
|
90
195
|
#title(status, job) {
|
|
@@ -134,8 +239,8 @@ export default class DashboardController {
|
|
|
134
239
|
active: 'metrics',
|
|
135
240
|
title: 'Metrics',
|
|
136
241
|
hours: METRICS_WINDOW.hours,
|
|
137
|
-
byJob: await this.#metricsBy('name', since),
|
|
138
|
-
byQueue: await this.#metricsBy('queue', since),
|
|
242
|
+
byJob: await this.#metricsBy(['name', 'queue'], since),
|
|
243
|
+
byQueue: await this.#metricsBy(['queue'], since),
|
|
139
244
|
});
|
|
140
245
|
}
|
|
141
246
|
/**
|
|
@@ -147,7 +252,7 @@ export default class DashboardController {
|
|
|
147
252
|
async retry({ params, response }) {
|
|
148
253
|
const run = await JobRun.findOrFail(params.id);
|
|
149
254
|
await retryRun(run);
|
|
150
|
-
return response.redirect().back();
|
|
255
|
+
return response.redirect().withQs().back();
|
|
151
256
|
}
|
|
152
257
|
/**
|
|
153
258
|
* Read the page in the light, or in the dark.
|
|
@@ -167,7 +272,7 @@ export default class DashboardController {
|
|
|
167
272
|
httpOnly: false,
|
|
168
273
|
sameSite: 'lax',
|
|
169
274
|
});
|
|
170
|
-
return response.redirect().back();
|
|
275
|
+
return response.redirect().withQs().back();
|
|
171
276
|
}
|
|
172
277
|
/** Take a class out of the lists, or put it back. */
|
|
173
278
|
async silence({ params, response }) {
|
|
@@ -179,7 +284,7 @@ export default class DashboardController {
|
|
|
179
284
|
else {
|
|
180
285
|
await SilencedJob.create({ name });
|
|
181
286
|
}
|
|
182
|
-
return response.redirect().back();
|
|
287
|
+
return response.redirect().withQs().back();
|
|
183
288
|
}
|
|
184
289
|
/** The four numbers of the overview. */
|
|
185
290
|
async #stats() {
|
|
@@ -201,23 +306,29 @@ export default class DashboardController {
|
|
|
201
306
|
return Number(row.total);
|
|
202
307
|
}
|
|
203
308
|
/**
|
|
204
|
-
* What one column cost over the window.
|
|
309
|
+
* What one column, or two, cost over the window.
|
|
205
310
|
*
|
|
206
311
|
* The count of the failures rides with the count of the rows: one reading of
|
|
207
312
|
* the table answers both.
|
|
313
|
+
*
|
|
314
|
+
* A class that runs on two queues is read twice, once for each queue: the
|
|
315
|
+
* duration of a job is the duration of the queue that carries it, and one
|
|
316
|
+
* average over the two says nothing about either.
|
|
208
317
|
*/
|
|
209
|
-
async #metricsBy(
|
|
210
|
-
const
|
|
318
|
+
async #metricsBy(columns, since) {
|
|
319
|
+
const group = columns.join(', ');
|
|
320
|
+
const result = await db.rawQuery(`select ${group},
|
|
211
321
|
count(*) as total,
|
|
212
322
|
count(*) filter (where status = ?) as failed,
|
|
213
323
|
round(avg(duration)::numeric, 1) as average,
|
|
214
324
|
round(max(duration)::numeric, 1) as longest
|
|
215
325
|
from ${JobRun.table}
|
|
216
326
|
where finished_at > ?
|
|
217
|
-
group by ${
|
|
327
|
+
group by ${group}
|
|
218
328
|
order by count(*) desc`, [JobRunStatus.FAILED, since]);
|
|
219
329
|
return result.rows.map((row) => ({
|
|
220
|
-
label: row
|
|
330
|
+
label: row[columns[0]],
|
|
331
|
+
queue: columns.length > 1 ? row[columns[1]] : null,
|
|
221
332
|
total: Number(row.total),
|
|
222
333
|
failed: Number(row.failed),
|
|
223
334
|
average: row.average === null ? null : Number(row.average),
|
|
@@ -279,7 +390,7 @@ export default class DashboardController {
|
|
|
279
390
|
retriedAt: run.retriedAt?.toFormat('dd LLL yyyy HH:mm:ss') ?? null,
|
|
280
391
|
retriedJobId: run.retriedJobId,
|
|
281
392
|
retriedStatus: answer ? STATUS_LABELS[answer] : null,
|
|
282
|
-
canRetry: run.status
|
|
393
|
+
canRetry: ENDED_BADLY.includes(run.status) && !run.retriedJobId,
|
|
283
394
|
isSilenced: silenced.includes(run.name),
|
|
284
395
|
};
|
|
285
396
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Job } from '@adonisjs/queue';
|
|
2
|
+
import type { JobOptions } from '@adonisjs/queue/types';
|
|
3
|
+
interface ReconcileJobRunsPayload {
|
|
4
|
+
/** How long a row stays open before the queue is asked about it, in minutes. */
|
|
5
|
+
minutes?: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Close the rows that wait for an answer that never comes.
|
|
9
|
+
*
|
|
10
|
+
* A worker that refuses a job before it starts publishes nothing. The row of
|
|
11
|
+
* that job stays open, and the page shows it as a job that waits. This asks
|
|
12
|
+
* the queue about every row that stayed open, and writes what the queue says.
|
|
13
|
+
*/
|
|
14
|
+
export default class ReconcileJobRuns extends Job<ReconcileJobRunsPayload> {
|
|
15
|
+
static options: JobOptions;
|
|
16
|
+
execute(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import app from '@adonisjs/core/services/app';
|
|
2
|
+
import logger from '@adonisjs/core/services/logger';
|
|
3
|
+
import { Job } from '@adonisjs/queue';
|
|
4
|
+
import { reconcileJobRuns } from '../reconcile.js';
|
|
5
|
+
/**
|
|
6
|
+
* Close the rows that wait for an answer that never comes.
|
|
7
|
+
*
|
|
8
|
+
* A worker that refuses a job before it starts publishes nothing. The row of
|
|
9
|
+
* that job stays open, and the page shows it as a job that waits. This asks
|
|
10
|
+
* the queue about every row that stayed open, and writes what the queue says.
|
|
11
|
+
*/
|
|
12
|
+
export default class ReconcileJobRuns extends Job {
|
|
13
|
+
static options = {
|
|
14
|
+
queue: 'default',
|
|
15
|
+
maxRetries: 1,
|
|
16
|
+
};
|
|
17
|
+
async execute() {
|
|
18
|
+
const config = app.config.get('orbit', {
|
|
19
|
+
path: '/queue',
|
|
20
|
+
authorize: () => false,
|
|
21
|
+
sinks: [],
|
|
22
|
+
});
|
|
23
|
+
const events = await reconcileJobRuns(config, { minutes: this.payload.minutes });
|
|
24
|
+
logger.info(`${events.length} trace(s) of jobs closed.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -6,6 +6,9 @@ import type { DateTime } from 'luxon';
|
|
|
6
6
|
* `pending` and `running` are what the worker is about to do or does now.
|
|
7
7
|
* `completed` and `failed` are what it did. `retrying` names a job that failed
|
|
8
8
|
* a try and waits for the next one.
|
|
9
|
+
*
|
|
10
|
+
* `lost` names a job that the queue does not hold any more, and that no worker
|
|
11
|
+
* said anything about.
|
|
9
12
|
*/
|
|
10
13
|
export declare const JobRunStatus: {
|
|
11
14
|
readonly PENDING: "pending";
|
|
@@ -13,6 +16,7 @@ export declare const JobRunStatus: {
|
|
|
13
16
|
readonly RETRYING: "retrying";
|
|
14
17
|
readonly COMPLETED: "completed";
|
|
15
18
|
readonly FAILED: "failed";
|
|
19
|
+
readonly LOST: "lost";
|
|
16
20
|
};
|
|
17
21
|
export type JobRunStatusValue = (typeof JobRunStatus)[keyof typeof JobRunStatus];
|
|
18
22
|
export default class JobRun extends BaseModel {
|
|
@@ -11,6 +11,9 @@ import { BaseModel, column } from '@adonisjs/lucid/orm';
|
|
|
11
11
|
* `pending` and `running` are what the worker is about to do or does now.
|
|
12
12
|
* `completed` and `failed` are what it did. `retrying` names a job that failed
|
|
13
13
|
* a try and waits for the next one.
|
|
14
|
+
*
|
|
15
|
+
* `lost` names a job that the queue does not hold any more, and that no worker
|
|
16
|
+
* said anything about.
|
|
14
17
|
*/
|
|
15
18
|
export const JobRunStatus = {
|
|
16
19
|
PENDING: 'pending',
|
|
@@ -18,6 +21,7 @@ export const JobRunStatus = {
|
|
|
18
21
|
RETRYING: 'retrying',
|
|
19
22
|
COMPLETED: 'completed',
|
|
20
23
|
FAILED: 'failed',
|
|
24
|
+
LOST: 'lost',
|
|
21
25
|
};
|
|
22
26
|
export default class JobRun extends BaseModel {
|
|
23
27
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { JobEvent, OrbitConfig } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Who answers about one job.
|
|
4
|
+
*
|
|
5
|
+
* The queue of the application answers this, and so does a queue of a test.
|
|
6
|
+
* The words are those of a queue, and of no framework.
|
|
7
|
+
*/
|
|
8
|
+
export interface QueueLookup {
|
|
9
|
+
getJob(id: string, queue: string): Promise<{
|
|
10
|
+
status: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
} | null>;
|
|
13
|
+
}
|
|
14
|
+
export interface ReconcileOptions {
|
|
15
|
+
/** How long a row stays open before the queue is asked about it, in minutes. */
|
|
16
|
+
minutes?: number;
|
|
17
|
+
/** How many rows one call reads. */
|
|
18
|
+
limit?: number;
|
|
19
|
+
/** Who answers about a job. Without one, the queue of the application. */
|
|
20
|
+
queue?: QueueLookup;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Ask the queue about the jobs that the table still calls open.
|
|
24
|
+
*
|
|
25
|
+
* A worker publishes what it does, and the recorder reads it. A worker that
|
|
26
|
+
* refuses a job before it starts publishes nothing: it fails the job in the
|
|
27
|
+
* queue and takes the next one. The row of that job then waits for an answer
|
|
28
|
+
* that never comes.
|
|
29
|
+
*
|
|
30
|
+
* This reads the rows that stayed open, and asks the queue about each one:
|
|
31
|
+
*
|
|
32
|
+
* - the queue holds the job, thus the row is right and stays as it is;
|
|
33
|
+
* - the queue holds the answer, thus the row takes it;
|
|
34
|
+
* - the queue holds nothing, thus the job is `lost`.
|
|
35
|
+
*
|
|
36
|
+
* The grace leaves the events of the worker the time to land. A row that a
|
|
37
|
+
* worker closed a second ago is not read here.
|
|
38
|
+
*
|
|
39
|
+
* The rows are read from the table, thus this needs `DatabaseSink`. The
|
|
40
|
+
* answers go to every sink of the configuration.
|
|
41
|
+
*/
|
|
42
|
+
export declare function reconcileJobRuns(config: OrbitConfig, options?: ReconcileOptions): Promise<JobEvent[]>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import logger from '@adonisjs/core/services/logger';
|
|
2
|
+
import { QueueManager } from '@adonisjs/queue';
|
|
3
|
+
import { DateTime } from 'luxon';
|
|
4
|
+
import JobRun, { JobRunStatus } from './models/job_run.js';
|
|
5
|
+
/** The statuses of a row that waits for an answer. */
|
|
6
|
+
const OPEN = [JobRunStatus.PENDING, JobRunStatus.RUNNING, JobRunStatus.RETRYING];
|
|
7
|
+
/** The statuses that the queue gives to a job that it still holds. */
|
|
8
|
+
const HELD = ['pending', 'active', 'delayed'];
|
|
9
|
+
/** What a row carries when the queue lets the job go and says nothing. */
|
|
10
|
+
const LOST = 'The queue does not hold this job any more. No worker said how it ended.';
|
|
11
|
+
/** How long a row stays open before the queue is asked about it, in minutes. */
|
|
12
|
+
const GRACE = 5;
|
|
13
|
+
/** How many rows one call reads. */
|
|
14
|
+
const LIMIT = 500;
|
|
15
|
+
/**
|
|
16
|
+
* Ask the queue about the jobs that the table still calls open.
|
|
17
|
+
*
|
|
18
|
+
* A worker publishes what it does, and the recorder reads it. A worker that
|
|
19
|
+
* refuses a job before it starts publishes nothing: it fails the job in the
|
|
20
|
+
* queue and takes the next one. The row of that job then waits for an answer
|
|
21
|
+
* that never comes.
|
|
22
|
+
*
|
|
23
|
+
* This reads the rows that stayed open, and asks the queue about each one:
|
|
24
|
+
*
|
|
25
|
+
* - the queue holds the job, thus the row is right and stays as it is;
|
|
26
|
+
* - the queue holds the answer, thus the row takes it;
|
|
27
|
+
* - the queue holds nothing, thus the job is `lost`.
|
|
28
|
+
*
|
|
29
|
+
* The grace leaves the events of the worker the time to land. A row that a
|
|
30
|
+
* worker closed a second ago is not read here.
|
|
31
|
+
*
|
|
32
|
+
* The rows are read from the table, thus this needs `DatabaseSink`. The
|
|
33
|
+
* answers go to every sink of the configuration.
|
|
34
|
+
*/
|
|
35
|
+
export async function reconcileJobRuns(config, options = {}) {
|
|
36
|
+
const before = DateTime.now().minus({ minutes: options.minutes ?? GRACE });
|
|
37
|
+
const open = await JobRun.query()
|
|
38
|
+
.whereIn('status', [...OPEN])
|
|
39
|
+
.where('updatedAt', '<', before.toJSDate())
|
|
40
|
+
.orderBy('id', 'asc')
|
|
41
|
+
.limit(options.limit ?? LIMIT);
|
|
42
|
+
if (open.length === 0) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const queue = options.queue ?? QueueManager.use();
|
|
46
|
+
const at = new Date();
|
|
47
|
+
const events = [];
|
|
48
|
+
for (const run of open) {
|
|
49
|
+
const record = await queue.getJob(run.jobId, run.queue);
|
|
50
|
+
if (record && HELD.includes(record.status)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const outcome = record ? record.status : 'lost';
|
|
54
|
+
events.push({
|
|
55
|
+
type: 'finished',
|
|
56
|
+
at,
|
|
57
|
+
job: {
|
|
58
|
+
id: run.jobId,
|
|
59
|
+
name: run.name,
|
|
60
|
+
queue: run.queue,
|
|
61
|
+
payload: run.payload ?? {},
|
|
62
|
+
attempts: run.attempts,
|
|
63
|
+
},
|
|
64
|
+
outcome,
|
|
65
|
+
duration: null,
|
|
66
|
+
error: record ? record.error || null : LOST,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (events.length === 0) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
for (const sink of config.sinks ?? []) {
|
|
73
|
+
try {
|
|
74
|
+
await sink.write(events);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
logger.error({ err: error }, 'The trace of a job could not be written');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return events;
|
|
81
|
+
}
|
package/build/src/recorder.d.ts
CHANGED
|
@@ -10,5 +10,10 @@ import type { OrbitConfig } from './types.js';
|
|
|
10
10
|
* A sink that refuses an event must never stop the job that runs.
|
|
11
11
|
*
|
|
12
12
|
* A configuration that names no sink keeps nothing.
|
|
13
|
+
*
|
|
14
|
+
* The recorder gives back a `flush`. It waits for the writes that are open,
|
|
15
|
+
* and the application calls it before it closes what the sinks use.
|
|
13
16
|
*/
|
|
14
|
-
export declare function recordJobRuns(config: OrbitConfig):
|
|
17
|
+
export declare function recordJobRuns(config: OrbitConfig): {
|
|
18
|
+
flush: () => Promise<void>;
|
|
19
|
+
};
|
package/build/src/recorder.js
CHANGED
|
@@ -47,15 +47,35 @@ function keep(rule, job, payload) {
|
|
|
47
47
|
* A sink that refuses an event must never stop the job that runs.
|
|
48
48
|
*
|
|
49
49
|
* A configuration that names no sink keeps nothing.
|
|
50
|
+
*
|
|
51
|
+
* The recorder gives back a `flush`. It waits for the writes that are open,
|
|
52
|
+
* and the application calls it before it closes what the sinks use.
|
|
50
53
|
*/
|
|
51
54
|
export function recordJobRuns(config) {
|
|
52
55
|
const sinks = config.sinks ?? [];
|
|
53
56
|
const rule = config.payload ?? 'full';
|
|
57
|
+
/** The writes that are started, and that no sink finished yet. */
|
|
58
|
+
const pending = new Set();
|
|
54
59
|
function write(events) {
|
|
55
60
|
for (const sink of sinks) {
|
|
56
|
-
|
|
61
|
+
const done = sink.write(events).catch((error) => {
|
|
57
62
|
logger.error({ err: error }, 'The trace of a job could not be written');
|
|
58
63
|
});
|
|
64
|
+
pending.add(done);
|
|
65
|
+
void done.finally(() => pending.delete(done));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Wait for every write that is open.
|
|
70
|
+
*
|
|
71
|
+
* A command puts a job in the queue and stops at once. The writes of the
|
|
72
|
+
* sinks are still open at that moment, and the application closes its
|
|
73
|
+
* database after this call. A write that a sink starts while this one waits
|
|
74
|
+
* is waited for too.
|
|
75
|
+
*/
|
|
76
|
+
async function flush() {
|
|
77
|
+
while (pending.size > 0) {
|
|
78
|
+
await Promise.all(pending);
|
|
59
79
|
}
|
|
60
80
|
}
|
|
61
81
|
function identify(job, queue) {
|
|
@@ -99,4 +119,5 @@ export function recordJobRuns(config) {
|
|
|
99
119
|
]);
|
|
100
120
|
},
|
|
101
121
|
});
|
|
122
|
+
return { flush };
|
|
102
123
|
}
|
package/build/src/routes.js
CHANGED
|
@@ -14,6 +14,7 @@ export function registerRoutes(router, config) {
|
|
|
14
14
|
router.post('/runs/:id/retry', [DashboardController, 'retry']).as('orbit.retry');
|
|
15
15
|
router.post('/jobs/:name/silence', [DashboardController, 'silence']).as('orbit.silence');
|
|
16
16
|
router.post('/theme/:theme', [DashboardController, 'theme']).as('orbit.theme');
|
|
17
|
+
router.post('/per-page/:count', [DashboardController, 'perPage']).as('orbit.perPage');
|
|
17
18
|
})
|
|
18
19
|
.prefix(config.path)
|
|
19
20
|
.use(dashboardAuth(config));
|
|
@@ -5,6 +5,7 @@ const STATUS = {
|
|
|
5
5
|
completed: JobRunStatus.COMPLETED,
|
|
6
6
|
failed: JobRunStatus.FAILED,
|
|
7
7
|
retrying: JobRunStatus.RETRYING,
|
|
8
|
+
lost: JobRunStatus.LOST,
|
|
8
9
|
};
|
|
9
10
|
/**
|
|
10
11
|
* Keep the trace of the jobs in the table of the application.
|
|
@@ -74,7 +75,7 @@ export default class DatabaseSink {
|
|
|
74
75
|
}
|
|
75
76
|
/** Say how the try of a job ended. */
|
|
76
77
|
#close({ job, at, outcome, duration, error }) {
|
|
77
|
-
const ended = outcome
|
|
78
|
+
const ended = outcome !== 'retrying';
|
|
78
79
|
return JobRun.query()
|
|
79
80
|
.where('jobId', job.id)
|
|
80
81
|
.update({
|
package/build/src/types.d.ts
CHANGED
|
@@ -25,8 +25,14 @@ export interface JobIdentity {
|
|
|
25
25
|
/** How many times a worker took the job. */
|
|
26
26
|
attempts: number;
|
|
27
27
|
}
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* How a try of a job ended.
|
|
30
|
+
*
|
|
31
|
+
* `lost` names a job that the queue does not hold any more, and that no worker
|
|
32
|
+
* said anything about. A worker that stops between the two says nothing, and
|
|
33
|
+
* the job would wait in the trace for ever.
|
|
34
|
+
*/
|
|
35
|
+
export type JobOutcome = 'completed' | 'failed' | 'retrying' | 'lost';
|
|
30
36
|
/**
|
|
31
37
|
* One step of one job.
|
|
32
38
|
*
|
|
@@ -104,6 +104,7 @@
|
|
|
104
104
|
.retrying { background: #fef3c7; color: #92400e; }
|
|
105
105
|
.completed { background: #d1fae5; color: #065f46; }
|
|
106
106
|
.failed { background: #fee2e2; color: #991b1b; }
|
|
107
|
+
.lost { background: #ede9fe; color: #5b21b6; }
|
|
107
108
|
.muted { color: var(--muted); font-size: 12px; }
|
|
108
109
|
.actions { display: flex; gap: 6px; align-items: center; }
|
|
109
110
|
.actions a { color: inherit; white-space: nowrap; }
|
|
@@ -117,8 +118,23 @@
|
|
|
117
118
|
overflow-x: auto; font-size: 12px; max-width: 520px; white-space: pre-wrap;
|
|
118
119
|
}
|
|
119
120
|
details summary { cursor: pointer; color: var(--muted); font-size: 12px; }
|
|
120
|
-
.pager {
|
|
121
|
+
.pager {
|
|
122
|
+
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
|
|
123
|
+
margin: 16px 0; font-size: 13px;
|
|
124
|
+
}
|
|
121
125
|
.pager a { color: inherit; }
|
|
126
|
+
.pager form { display: inline; }
|
|
127
|
+
.page {
|
|
128
|
+
display: inline-block; min-width: 30px; padding: 4px 8px; border-radius: 6px;
|
|
129
|
+
border: 1px solid var(--line); background: var(--card); text-align: center;
|
|
130
|
+
text-decoration: none; font: inherit; font-size: 13px; color: inherit; cursor: pointer;
|
|
131
|
+
}
|
|
132
|
+
a.page:hover, button.page:hover { border-color: var(--accent); color: var(--accent); }
|
|
133
|
+
.page.current {
|
|
134
|
+
border-color: var(--accent); background: var(--accent-soft);
|
|
135
|
+
color: var(--accent); font-weight: 600; cursor: default;
|
|
136
|
+
}
|
|
137
|
+
.rows { display: flex; gap: 6px; align-items: center; margin-left: auto; }
|
|
122
138
|
.empty { padding: 28px; text-align: center; color: var(--muted); }
|
|
123
139
|
</style>
|
|
124
140
|
</head>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<div class="pager">
|
|
2
|
+
<span class="muted">{{ pager.total }} job(s)</span>
|
|
3
|
+
|
|
4
|
+
@if(pager.previous)
|
|
5
|
+
<a href="{{ pager.previous }}">← Previous</a>
|
|
6
|
+
@end
|
|
7
|
+
|
|
8
|
+
@each(entry in pager.pages)
|
|
9
|
+
@if(entry.number === null)
|
|
10
|
+
<span class="muted">…</span>
|
|
11
|
+
@elseif(entry.current)
|
|
12
|
+
<span class="page current">{{ entry.number }}</span>
|
|
13
|
+
@else
|
|
14
|
+
<a class="page" href="{{ entry.href }}">{{ entry.number }}</a>
|
|
15
|
+
@end
|
|
16
|
+
@end
|
|
17
|
+
|
|
18
|
+
@if(pager.next)
|
|
19
|
+
<a href="{{ pager.next }}">Next →</a>
|
|
20
|
+
@end
|
|
21
|
+
|
|
22
|
+
@if(rows)
|
|
23
|
+
<span class="rows">
|
|
24
|
+
<span class="muted">Rows</span>
|
|
25
|
+
@each(size in perPage.sizes)
|
|
26
|
+
@if(size === perPage.current)
|
|
27
|
+
<span class="page current">{{ size }}</span>
|
|
28
|
+
@else
|
|
29
|
+
<form method="post" action="{{ path }}/per-page/{{ size }}">
|
|
30
|
+
@if(csrfToken)
|
|
31
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
32
|
+
@end
|
|
33
|
+
<button type="submit" class="page">{{ size }}</button>
|
|
34
|
+
</form>
|
|
35
|
+
@end
|
|
36
|
+
@end
|
|
37
|
+
</span>
|
|
38
|
+
@end
|
|
39
|
+
</div>
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
<h2>Recent jobs</h2>
|
|
23
23
|
@end
|
|
24
24
|
|
|
25
|
+
@!component('orbit::components/pager', { pager, path, csrfToken, perPage, rows: true })
|
|
26
|
+
|
|
25
27
|
<div class="panel">
|
|
26
28
|
<table>
|
|
27
29
|
<thead>
|
|
@@ -99,13 +101,5 @@
|
|
|
99
101
|
</table>
|
|
100
102
|
</div>
|
|
101
103
|
|
|
102
|
-
|
|
103
|
-
@if(page > 1)
|
|
104
|
-
<a href="{{ path }}?page={{ page - 1 }}&status={{ status }}">← Previous</a>
|
|
105
|
-
@end
|
|
106
|
-
<span class="muted">Page {{ page }} of {{ lastPage }} — {{ total }} job(s)</span>
|
|
107
|
-
@if(page < lastPage)
|
|
108
|
-
<a href="{{ path }}?page={{ page + 1 }}&status={{ status }}">Next →</a>
|
|
109
|
-
@end
|
|
110
|
-
</div>
|
|
104
|
+
@!component('orbit::components/pager', { pager, path, csrfToken, perPage, rows: true })
|
|
111
105
|
@end
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
<thead>
|
|
8
8
|
<tr>
|
|
9
9
|
<th>Job</th>
|
|
10
|
+
<th>Queue</th>
|
|
10
11
|
<th>Ended</th>
|
|
11
12
|
<th>Failed</th>
|
|
12
13
|
<th>Average</th>
|
|
@@ -17,13 +18,14 @@
|
|
|
17
18
|
@each(row in byJob)
|
|
18
19
|
<tr>
|
|
19
20
|
<td><b>{{ row.label }}</b></td>
|
|
21
|
+
<td>{{ row.queue }}</td>
|
|
20
22
|
<td>{{ row.total }}</td>
|
|
21
23
|
<td>{{ row.failed }}</td>
|
|
22
24
|
<td>{{ row.average === null ? '—' : row.average + ' ms' }}</td>
|
|
23
25
|
<td>{{ row.longest === null ? '—' : row.longest + ' ms' }}</td>
|
|
24
26
|
</tr>
|
|
25
27
|
@else
|
|
26
|
-
<tr><td colspan="
|
|
28
|
+
<tr><td colspan="6" class="empty">No job ended in this window.</td></tr>
|
|
27
29
|
@end
|
|
28
30
|
</tbody>
|
|
29
31
|
</table>
|