@artian-techs/adonis-orbit 0.1.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/LICENSE.md +21 -0
- package/README.md +275 -0
- package/build/configure.d.ts +8 -0
- package/build/configure.js +38 -0
- package/build/index.d.ts +10 -0
- package/build/index.js +9 -0
- package/build/providers/orbit_provider.d.ts +13 -0
- package/build/providers/orbit_provider.js +35 -0
- package/build/src/auth/basic_auth.d.ts +12 -0
- package/build/src/auth/basic_auth.js +36 -0
- package/build/src/controllers/dashboard_controller.d.ts +51 -0
- package/build/src/controllers/dashboard_controller.js +341 -0
- package/build/src/define_config.d.ts +5 -0
- package/build/src/define_config.js +6 -0
- package/build/src/jobs/prune_job_runs.d.ts +20 -0
- package/build/src/jobs/prune_job_runs.js +27 -0
- package/build/src/logo.d.ts +4 -0
- package/build/src/logo.js +12 -0
- package/build/src/middleware/dashboard_auth.d.ts +10 -0
- package/build/src/middleware/dashboard_auth.js +16 -0
- package/build/src/models/job_run.d.ts +35 -0
- package/build/src/models/job_run.js +71 -0
- package/build/src/models/silenced_job.d.ts +14 -0
- package/build/src/models/silenced_job.js +27 -0
- package/build/src/recorder.d.ts +14 -0
- package/build/src/recorder.js +102 -0
- package/build/src/retry.d.ts +9 -0
- package/build/src/retry.js +18 -0
- package/build/src/routes.d.ts +8 -0
- package/build/src/routes.js +20 -0
- package/build/src/sinks/database_sink.d.ts +16 -0
- package/build/src/sinks/database_sink.js +88 -0
- package/build/src/types.d.ts +92 -0
- package/build/src/types.js +1 -0
- package/build/stubs/config/orbit.stub +31 -0
- package/build/stubs/main.d.ts +1 -0
- package/build/stubs/main.js +1 -0
- package/build/stubs/migrations/create_job_runs_table.stub +62 -0
- package/build/stubs/migrations/create_silenced_jobs_table.stub +29 -0
- package/build/templates/components/layout.edge +174 -0
- package/build/templates/index.edge +111 -0
- package/build/templates/metrics.edge +59 -0
- package/build/templates/orbit.svg +35 -0
- package/build/templates/silenced.edge +35 -0
- package/package.json +130 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import logger from '@adonisjs/core/services/logger';
|
|
2
|
+
import { tracingChannels } from '@adonisjs/queue';
|
|
3
|
+
/** The steps of a job that this package does not read. */
|
|
4
|
+
const IGNORED = {
|
|
5
|
+
end: () => { },
|
|
6
|
+
asyncStart: () => { },
|
|
7
|
+
error: () => { },
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* What the reader of the trace needs to understand the failure.
|
|
11
|
+
*
|
|
12
|
+
* A job that reached its last try fails with an error that names the count,
|
|
13
|
+
* and carries the real one as its cause.
|
|
14
|
+
*/
|
|
15
|
+
function describe(error) {
|
|
16
|
+
if (!error) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const cause = error.cause instanceof Error ? `\n${error.cause.stack ?? error.cause.message}` : '';
|
|
20
|
+
return `${error.stack ?? error.message}${cause}`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* What one job carries, as much of it as the configuration keeps.
|
|
24
|
+
*
|
|
25
|
+
* A payload holds what a person wrote: an address, a name, a number. An
|
|
26
|
+
* application that sends its trace to another machine says here what leaves
|
|
27
|
+
* its own.
|
|
28
|
+
*/
|
|
29
|
+
function keep(rule, job, payload) {
|
|
30
|
+
const values = (payload ?? {});
|
|
31
|
+
if (rule === 'none') {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
if (typeof rule === 'function') {
|
|
35
|
+
return rule(values, job);
|
|
36
|
+
}
|
|
37
|
+
return values;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Keep what the queue does with its jobs.
|
|
41
|
+
*
|
|
42
|
+
* The queue holds its jobs in Redis and removes them when they end. The trace
|
|
43
|
+
* holds what a job carried, how many tries it took, and the error that stopped
|
|
44
|
+
* it.
|
|
45
|
+
*
|
|
46
|
+
* The channels of the queue answer at once, thus the events are written apart.
|
|
47
|
+
* A sink that refuses an event must never stop the job that runs.
|
|
48
|
+
*
|
|
49
|
+
* A configuration that names no sink keeps nothing.
|
|
50
|
+
*/
|
|
51
|
+
export function recordJobRuns(config) {
|
|
52
|
+
const sinks = config.sinks ?? [];
|
|
53
|
+
const rule = config.payload ?? 'full';
|
|
54
|
+
function write(events) {
|
|
55
|
+
for (const sink of sinks) {
|
|
56
|
+
void sink.write(events).catch((error) => {
|
|
57
|
+
logger.error({ err: error }, 'The trace of a job could not be written');
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function identify(job, queue) {
|
|
62
|
+
const identity = { id: job.id, name: job.name, queue, attempts: job.attempts ?? 0 };
|
|
63
|
+
return { ...identity, payload: keep(rule, identity, job.payload) };
|
|
64
|
+
}
|
|
65
|
+
tracingChannels.dispatchChannel.subscribe({
|
|
66
|
+
...IGNORED,
|
|
67
|
+
start: () => { },
|
|
68
|
+
asyncEnd(message) {
|
|
69
|
+
/**
|
|
70
|
+
* A dispatch that the window of `dedup` held opens no job, thus it gives
|
|
71
|
+
* no event either.
|
|
72
|
+
*/
|
|
73
|
+
if (message.error || (message.dedupOutcome && message.dedupOutcome !== 'added')) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const at = new Date();
|
|
77
|
+
write(message.jobs.map((job) => ({
|
|
78
|
+
type: 'dispatched',
|
|
79
|
+
at,
|
|
80
|
+
job: identify(job, message.queue),
|
|
81
|
+
})));
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
tracingChannels.executeChannel.subscribe({
|
|
85
|
+
...IGNORED,
|
|
86
|
+
start(message) {
|
|
87
|
+
write([{ type: 'started', at: new Date(), job: identify(message.job, message.queue) }]);
|
|
88
|
+
},
|
|
89
|
+
asyncEnd(message) {
|
|
90
|
+
write([
|
|
91
|
+
{
|
|
92
|
+
type: 'finished',
|
|
93
|
+
at: new Date(),
|
|
94
|
+
job: identify(message.job, message.queue),
|
|
95
|
+
outcome: message.status ?? 'retrying',
|
|
96
|
+
duration: message.duration ?? null,
|
|
97
|
+
error: describe(message.error),
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type JobRun from './models/job_run.js';
|
|
2
|
+
/**
|
|
3
|
+
* Ask the queue for a job that already ran.
|
|
4
|
+
*
|
|
5
|
+
* The row is never replaced: the new job takes a new identifier and opens its
|
|
6
|
+
* own row, and the old one names it. A job asked for twice is thus read twice
|
|
7
|
+
* in the table, with the two answers it gave.
|
|
8
|
+
*/
|
|
9
|
+
export declare function retryRun(run: JobRun): Promise<string>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Locator } from '@adonisjs/queue';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
3
|
+
/**
|
|
4
|
+
* Ask the queue for a job that already ran.
|
|
5
|
+
*
|
|
6
|
+
* The row is never replaced: the new job takes a new identifier and opens its
|
|
7
|
+
* own row, and the old one names it. A job asked for twice is thus read twice
|
|
8
|
+
* in the table, with the two answers it gave.
|
|
9
|
+
*/
|
|
10
|
+
export async function retryRun(run) {
|
|
11
|
+
const JobClass = Locator.get(run.name);
|
|
12
|
+
if (!JobClass) {
|
|
13
|
+
throw new Error(`The job "${run.name}" is not registered`);
|
|
14
|
+
}
|
|
15
|
+
const { jobId } = await JobClass.dispatch(run.payload).toQueue(run.queue).run();
|
|
16
|
+
await run.merge({ retriedAt: DateTime.now(), retriedJobId: jobId }).save();
|
|
17
|
+
return jobId;
|
|
18
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HttpRouterService } from '@adonisjs/core/types';
|
|
2
|
+
import type { OrbitConfig } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The addresses of the page: the lists, the button that asks for a job again,
|
|
5
|
+
* the button that silences a class, and the mode that a person reads in. They
|
|
6
|
+
* live under the path that the application chose.
|
|
7
|
+
*/
|
|
8
|
+
export declare function registerRoutes(router: HttpRouterService, config: OrbitConfig): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { dashboardAuth } from './middleware/dashboard_auth.js';
|
|
2
|
+
const DashboardController = () => import('./controllers/dashboard_controller.js');
|
|
3
|
+
/**
|
|
4
|
+
* The addresses of the page: the lists, the button that asks for a job again,
|
|
5
|
+
* the button that silences a class, and the mode that a person reads in. They
|
|
6
|
+
* live under the path that the application chose.
|
|
7
|
+
*/
|
|
8
|
+
export function registerRoutes(router, config) {
|
|
9
|
+
router
|
|
10
|
+
.group(() => {
|
|
11
|
+
router.get('/', [DashboardController, 'index']).as('orbit.index');
|
|
12
|
+
router.get('/silenced', [DashboardController, 'silenced']).as('orbit.silenced');
|
|
13
|
+
router.get('/metrics', [DashboardController, 'metrics']).as('orbit.metrics');
|
|
14
|
+
router.post('/runs/:id/retry', [DashboardController, 'retry']).as('orbit.retry');
|
|
15
|
+
router.post('/jobs/:name/silence', [DashboardController, 'silence']).as('orbit.silence');
|
|
16
|
+
router.post('/theme/:theme', [DashboardController, 'theme']).as('orbit.theme');
|
|
17
|
+
})
|
|
18
|
+
.prefix(config.path)
|
|
19
|
+
.use(dashboardAuth(config));
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { JobEvent, Sink } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Keep the trace of the jobs in the table of the application.
|
|
4
|
+
*
|
|
5
|
+
* The two inserts are written in SQL because they need `on conflict`, which
|
|
6
|
+
* Lucid does not carry. The model would read the row before writing it, and
|
|
7
|
+
* two processes that write together would then undo each other.
|
|
8
|
+
*
|
|
9
|
+
* The row that the dispatch writes and the row that the worker writes are the
|
|
10
|
+
* same row. Either of the two can arrive first, thus the first one writes and
|
|
11
|
+
* the second one completes.
|
|
12
|
+
*/
|
|
13
|
+
export default class DatabaseSink implements Sink {
|
|
14
|
+
#private;
|
|
15
|
+
write(events: JobEvent[]): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import db from '@adonisjs/lucid/services/db';
|
|
2
|
+
import JobRun, { JobRunStatus } from '../models/job_run.js';
|
|
3
|
+
/** What one status of the queue is called in the table. */
|
|
4
|
+
const STATUS = {
|
|
5
|
+
completed: JobRunStatus.COMPLETED,
|
|
6
|
+
failed: JobRunStatus.FAILED,
|
|
7
|
+
retrying: JobRunStatus.RETRYING,
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Keep the trace of the jobs in the table of the application.
|
|
11
|
+
*
|
|
12
|
+
* The two inserts are written in SQL because they need `on conflict`, which
|
|
13
|
+
* Lucid does not carry. The model would read the row before writing it, and
|
|
14
|
+
* two processes that write together would then undo each other.
|
|
15
|
+
*
|
|
16
|
+
* The row that the dispatch writes and the row that the worker writes are the
|
|
17
|
+
* same row. Either of the two can arrive first, thus the first one writes and
|
|
18
|
+
* the second one completes.
|
|
19
|
+
*/
|
|
20
|
+
export default class DatabaseSink {
|
|
21
|
+
async write(events) {
|
|
22
|
+
const dispatched = events.filter((event) => event.type === 'dispatched');
|
|
23
|
+
if (dispatched.length > 0) {
|
|
24
|
+
await this.#open(dispatched);
|
|
25
|
+
}
|
|
26
|
+
for (const event of events) {
|
|
27
|
+
if (event.type === 'started') {
|
|
28
|
+
await this.#take(event);
|
|
29
|
+
}
|
|
30
|
+
if (event.type === 'finished') {
|
|
31
|
+
await this.#close(event);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Write the row of every job that waits for a worker. */
|
|
36
|
+
#open(events) {
|
|
37
|
+
const rows = events.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ');
|
|
38
|
+
const values = events.flatMap(({ job, at }) => [
|
|
39
|
+
job.id,
|
|
40
|
+
job.name,
|
|
41
|
+
job.queue,
|
|
42
|
+
JobRunStatus.PENDING,
|
|
43
|
+
JSON.stringify(job.payload),
|
|
44
|
+
job.attempts,
|
|
45
|
+
at,
|
|
46
|
+
at,
|
|
47
|
+
at,
|
|
48
|
+
]);
|
|
49
|
+
return db.rawQuery(`insert into ${JobRun.table}
|
|
50
|
+
(job_id, name, queue, status, payload, attempts, dispatched_at, created_at, updated_at)
|
|
51
|
+
values ${rows}
|
|
52
|
+
on conflict (job_id) do nothing`, values);
|
|
53
|
+
}
|
|
54
|
+
/** Write the row of the job that a worker holds, or complete it. */
|
|
55
|
+
#take({ job, at }) {
|
|
56
|
+
return db.rawQuery(`insert into ${JobRun.table}
|
|
57
|
+
(job_id, name, queue, status, payload, attempts, started_at, created_at, updated_at)
|
|
58
|
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
59
|
+
on conflict (job_id) do update set
|
|
60
|
+
status = excluded.status,
|
|
61
|
+
attempts = excluded.attempts,
|
|
62
|
+
started_at = excluded.started_at,
|
|
63
|
+
updated_at = excluded.updated_at`, [
|
|
64
|
+
job.id,
|
|
65
|
+
job.name,
|
|
66
|
+
job.queue,
|
|
67
|
+
JobRunStatus.RUNNING,
|
|
68
|
+
JSON.stringify(job.payload),
|
|
69
|
+
job.attempts,
|
|
70
|
+
at,
|
|
71
|
+
at,
|
|
72
|
+
at,
|
|
73
|
+
]);
|
|
74
|
+
}
|
|
75
|
+
/** Say how the try of a job ended. */
|
|
76
|
+
#close({ job, at, outcome, duration, error }) {
|
|
77
|
+
const ended = outcome === 'completed' || outcome === 'failed';
|
|
78
|
+
return JobRun.query()
|
|
79
|
+
.where('jobId', job.id)
|
|
80
|
+
.update({
|
|
81
|
+
status: STATUS[outcome],
|
|
82
|
+
duration,
|
|
83
|
+
error,
|
|
84
|
+
finishedAt: ended ? at : null,
|
|
85
|
+
updatedAt: at,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
/**
|
|
3
|
+
* Who may read the page.
|
|
4
|
+
*
|
|
5
|
+
* The answer is `true`, and the request goes on. The answer is `false`, and
|
|
6
|
+
* the page says "not found" — a guard that wants to say something else writes
|
|
7
|
+
* its own answer before it says `false`.
|
|
8
|
+
*/
|
|
9
|
+
export type Authorize = (ctx: HttpContext) => boolean | Promise<boolean>;
|
|
10
|
+
/**
|
|
11
|
+
* What names one job, in every event that it gives.
|
|
12
|
+
*
|
|
13
|
+
* The three events carry the same identity. A sink that reads one event alone
|
|
14
|
+
* thus knows the job, and needs none of the events that came before.
|
|
15
|
+
*/
|
|
16
|
+
export interface JobIdentity {
|
|
17
|
+
/** The identifier that the queue gives to the job. */
|
|
18
|
+
id: string;
|
|
19
|
+
/** The name of the class that does the work. */
|
|
20
|
+
name: string;
|
|
21
|
+
/** The queue that holds the job. */
|
|
22
|
+
queue: string;
|
|
23
|
+
/** What the job carries. The configuration says how much of it is kept. */
|
|
24
|
+
payload: Record<string, unknown>;
|
|
25
|
+
/** How many times a worker took the job. */
|
|
26
|
+
attempts: number;
|
|
27
|
+
}
|
|
28
|
+
/** How a try of a job ended. */
|
|
29
|
+
export type JobOutcome = 'completed' | 'failed' | 'retrying';
|
|
30
|
+
/**
|
|
31
|
+
* One step of one job.
|
|
32
|
+
*
|
|
33
|
+
* The words are those of a queue, and of no framework: a sink writes them in a
|
|
34
|
+
* table, or sends them to a machine that reads them.
|
|
35
|
+
*/
|
|
36
|
+
export type JobEvent = {
|
|
37
|
+
type: 'dispatched';
|
|
38
|
+
at: Date;
|
|
39
|
+
job: JobIdentity;
|
|
40
|
+
} | {
|
|
41
|
+
type: 'started';
|
|
42
|
+
at: Date;
|
|
43
|
+
job: JobIdentity;
|
|
44
|
+
} | {
|
|
45
|
+
type: 'finished';
|
|
46
|
+
at: Date;
|
|
47
|
+
job: JobIdentity;
|
|
48
|
+
outcome: JobOutcome;
|
|
49
|
+
/** How long the try took, in milliseconds. */
|
|
50
|
+
duration: number | null;
|
|
51
|
+
/** The error that stopped the job, and the error that caused it. */
|
|
52
|
+
error: string | null;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Where the trace of the jobs goes.
|
|
56
|
+
*
|
|
57
|
+
* The recorder gives the events of one step together. A sink that writes
|
|
58
|
+
* several rows at once thus writes them in one call.
|
|
59
|
+
*/
|
|
60
|
+
export interface Sink {
|
|
61
|
+
write(events: JobEvent[]): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* How much of what a job carries is kept.
|
|
65
|
+
*
|
|
66
|
+
* `full` keeps everything. `none` keeps nothing. A function keeps what it
|
|
67
|
+
* gives back, thus an application takes out the values that it does not want
|
|
68
|
+
* to read again.
|
|
69
|
+
*/
|
|
70
|
+
export type PayloadRule = 'full' | 'none' | ((payload: Record<string, unknown>, job: Omit<JobIdentity, 'payload'>) => Record<string, unknown>);
|
|
71
|
+
/**
|
|
72
|
+
* What the application tells Orbit.
|
|
73
|
+
*/
|
|
74
|
+
export interface OrbitConfig {
|
|
75
|
+
/** Where the page answers. */
|
|
76
|
+
path: string;
|
|
77
|
+
/**
|
|
78
|
+
* Who may read the page. The package carries `basicAuth`, and an application
|
|
79
|
+
* writes its own guard when it reads a name of a person instead.
|
|
80
|
+
*/
|
|
81
|
+
authorize: Authorize;
|
|
82
|
+
/**
|
|
83
|
+
* Where the trace of the jobs goes.
|
|
84
|
+
*
|
|
85
|
+
* Nothing is kept that this list does not name. An application that wants
|
|
86
|
+
* the table of `job_runs` and the page that reads it thus names
|
|
87
|
+
* `DatabaseSink` here.
|
|
88
|
+
*/
|
|
89
|
+
sinks: Sink[];
|
|
90
|
+
/** How much of what a job carries is kept. Without a rule, everything. */
|
|
91
|
+
payload?: PayloadRule;
|
|
92
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{{{
|
|
2
|
+
exports({ to: app.configPath('orbit.ts') })
|
|
3
|
+
}}}
|
|
4
|
+
import { DatabaseSink, basicAuth, defineConfig } from '@artian-techs/adonis-orbit'
|
|
5
|
+
|
|
6
|
+
import env from '#start/env'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The page that shows what the queue does.
|
|
10
|
+
*
|
|
11
|
+
* It is read by the team that runs the machine, not by the users of the
|
|
12
|
+
* application, thus it holds no session.
|
|
13
|
+
*/
|
|
14
|
+
export default defineConfig({
|
|
15
|
+
path: '/queue',
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Who may read the page. `basicAuth` asks for a name and a word, and answers
|
|
19
|
+
* "not found" while one of the two is missing.
|
|
20
|
+
*/
|
|
21
|
+
authorize: basicAuth({
|
|
22
|
+
user: env.get('ORBIT_DASHBOARD_USER'),
|
|
23
|
+
password: env.get('ORBIT_DASHBOARD_PASSWORD'),
|
|
24
|
+
}),
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where the trace of the jobs goes. Nothing is kept that this list does not
|
|
28
|
+
* name: take `DatabaseSink` away, and the table stays empty.
|
|
29
|
+
*/
|
|
30
|
+
sinks: [new DatabaseSink()],
|
|
31
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const stubsRoot: string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const stubsRoot = import.meta.dirname;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{{{
|
|
2
|
+
exports({ to: app.makePath(migration.folder, migration.fileName) })
|
|
3
|
+
}}}
|
|
4
|
+
import { BaseSchema } from '@adonisjs/lucid/schema'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the queue did with each job.
|
|
8
|
+
*
|
|
9
|
+
* The queue keeps its jobs in Redis and removes them when they end. This table
|
|
10
|
+
* holds the trace: a job that failed stays here, with the values it carried,
|
|
11
|
+
* and a person can ask for it again.
|
|
12
|
+
*
|
|
13
|
+
* One row for one job. A job that is tried again keeps its row, and the row
|
|
14
|
+
* counts the tries.
|
|
15
|
+
*/
|
|
16
|
+
export default class extends BaseSchema {
|
|
17
|
+
protected tableName = 'job_runs'
|
|
18
|
+
|
|
19
|
+
async up() {
|
|
20
|
+
this.schema.createTable(this.tableName, (table) => {
|
|
21
|
+
table.bigIncrements('id')
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The identifier that the queue gives to a job. It names the same job in
|
|
25
|
+
* the journal of the worker and in Redis.
|
|
26
|
+
*/
|
|
27
|
+
table.uuid('job_id').notNullable().unique()
|
|
28
|
+
|
|
29
|
+
table.string('name').notNullable()
|
|
30
|
+
table.string('queue').notNullable()
|
|
31
|
+
table.string('status').notNullable()
|
|
32
|
+
|
|
33
|
+
table.jsonb('payload').notNullable().defaultTo('{}')
|
|
34
|
+
|
|
35
|
+
table.integer('attempts').notNullable().defaultTo(0)
|
|
36
|
+
table.text('error').nullable()
|
|
37
|
+
table.decimal('duration', 12, 2).nullable()
|
|
38
|
+
|
|
39
|
+
table.timestamp('dispatched_at').nullable()
|
|
40
|
+
table.timestamp('started_at').nullable()
|
|
41
|
+
table.timestamp('finished_at').nullable()
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The day a person asked for this job again, and the job that answered
|
|
45
|
+
* that request. A row keeps its own history: it is never replaced.
|
|
46
|
+
*/
|
|
47
|
+
table.timestamp('retried_at').nullable()
|
|
48
|
+
table.uuid('retried_job_id').nullable()
|
|
49
|
+
|
|
50
|
+
table.timestamp('created_at')
|
|
51
|
+
table.timestamp('updated_at')
|
|
52
|
+
|
|
53
|
+
table.index('status')
|
|
54
|
+
table.index('name')
|
|
55
|
+
table.index(['status', 'id'])
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async down() {
|
|
60
|
+
this.schema.dropTable(this.tableName)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{{{
|
|
2
|
+
exports({ to: app.makePath(migration.folder, migration.fileName) })
|
|
3
|
+
}}}
|
|
4
|
+
import { BaseSchema } from '@adonisjs/lucid/schema'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The jobs that the page does not show.
|
|
8
|
+
*
|
|
9
|
+
* A row holds the name of a class, not a job. Nothing is removed and nothing
|
|
10
|
+
* stops: the worker keeps its work, the table keeps its trace, and only the
|
|
11
|
+
* lists of the page leave the name out. Take the row away, and everything the
|
|
12
|
+
* job did comes back, including what it did while it was silent.
|
|
13
|
+
*/
|
|
14
|
+
export default class extends BaseSchema {
|
|
15
|
+
protected tableName = 'silenced_jobs'
|
|
16
|
+
|
|
17
|
+
async up() {
|
|
18
|
+
this.schema.createTable(this.tableName, (table) => {
|
|
19
|
+
table.bigIncrements('id')
|
|
20
|
+
table.string('name').notNullable().unique()
|
|
21
|
+
table.timestamp('created_at')
|
|
22
|
+
table.timestamp('updated_at')
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async down() {
|
|
27
|
+
this.schema.dropTable(this.tableName)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en"{{{ theme ? ` data-theme="${theme}"` : '' }}}>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{ title }} — Orbit</title>
|
|
7
|
+
<link rel="icon" href="{{ favicon }}">
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: light dark;
|
|
11
|
+
--bg: #f6f7f9;
|
|
12
|
+
--card: #ffffff;
|
|
13
|
+
--side: #ffffff;
|
|
14
|
+
--line: #e3e6ea;
|
|
15
|
+
--text: #1b1f23;
|
|
16
|
+
--muted: #6b7280;
|
|
17
|
+
--accent: #4338ca;
|
|
18
|
+
--accent-soft: #eef2ff;
|
|
19
|
+
}
|
|
20
|
+
@media (prefers-color-scheme: dark) {
|
|
21
|
+
:root {
|
|
22
|
+
--bg: #14171a; --card: #1c2024; --side: #191d21; --line: #2c3238;
|
|
23
|
+
--text: #e8eaed; --muted: #9aa3ad; --accent: #a5b4fc; --accent-soft: #312e81;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/* A person who chose reads what they chose, whatever the machine says. */
|
|
27
|
+
:root[data-theme="light"] {
|
|
28
|
+
color-scheme: light;
|
|
29
|
+
--bg: #f6f7f9; --card: #ffffff; --side: #ffffff; --line: #e3e6ea;
|
|
30
|
+
--text: #1b1f23; --muted: #6b7280; --accent: #4338ca; --accent-soft: #eef2ff;
|
|
31
|
+
}
|
|
32
|
+
:root[data-theme="dark"] {
|
|
33
|
+
color-scheme: dark;
|
|
34
|
+
--bg: #14171a; --card: #1c2024; --side: #191d21; --line: #2c3238;
|
|
35
|
+
--text: #e8eaed; --muted: #9aa3ad; --accent: #a5b4fc; --accent-soft: #312e81;
|
|
36
|
+
}
|
|
37
|
+
* { box-sizing: border-box; }
|
|
38
|
+
body {
|
|
39
|
+
margin: 0; display: flex; min-height: 100vh; background: var(--bg); color: var(--text);
|
|
40
|
+
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
41
|
+
}
|
|
42
|
+
aside {
|
|
43
|
+
width: 220px; flex: 0 0 220px; background: var(--side);
|
|
44
|
+
border-right: 1px solid var(--line); padding: 20px 12px;
|
|
45
|
+
display: flex; flex-direction: column;
|
|
46
|
+
position: sticky; top: 0; height: 100vh; overflow-y: auto;
|
|
47
|
+
}
|
|
48
|
+
.theme { margin-top: auto; padding-top: 18px; }
|
|
49
|
+
.theme button {
|
|
50
|
+
display: flex; align-items: center; gap: 10px; width: 100%;
|
|
51
|
+
padding: 8px 10px; border: 0; border-radius: 6px; cursor: pointer;
|
|
52
|
+
background: none; color: var(--muted); font: inherit; text-align: left;
|
|
53
|
+
}
|
|
54
|
+
.theme button:hover { background: var(--bg); color: var(--text); }
|
|
55
|
+
/*
|
|
56
|
+
* The page shows the mode that the person does not read now. The machine
|
|
57
|
+
* says which one that is while nobody chose, thus the page asks it here
|
|
58
|
+
* and no script asks it later.
|
|
59
|
+
*/
|
|
60
|
+
.to-light { display: none; }
|
|
61
|
+
@media (prefers-color-scheme: dark) {
|
|
62
|
+
:root:not([data-theme]) .to-light { display: flex; }
|
|
63
|
+
:root:not([data-theme]) .to-dark { display: none; }
|
|
64
|
+
}
|
|
65
|
+
:root[data-theme="dark"] .to-light { display: flex; }
|
|
66
|
+
:root[data-theme="dark"] .to-dark { display: none; }
|
|
67
|
+
:root[data-theme="light"] .to-light { display: none; }
|
|
68
|
+
:root[data-theme="light"] .to-dark { display: flex; }
|
|
69
|
+
.brand {
|
|
70
|
+
font-weight: 600; font-size: 15px; padding: 0 10px 18px;
|
|
71
|
+
display: flex; align-items: center; gap: 8px;
|
|
72
|
+
}
|
|
73
|
+
.brand span { color: var(--muted); font-weight: 400; }
|
|
74
|
+
.brand svg { width: 30px; height: 30px; flex: none; }
|
|
75
|
+
nav a {
|
|
76
|
+
display: flex; align-items: center; gap: 10px; padding: 8px 10px; margin-bottom: 2px;
|
|
77
|
+
border-radius: 6px; color: inherit; text-decoration: none;
|
|
78
|
+
}
|
|
79
|
+
nav a:hover { background: var(--bg); }
|
|
80
|
+
nav a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
|
81
|
+
nav a svg { flex: 0 0 16px; }
|
|
82
|
+
nav a .count {
|
|
83
|
+
margin-left: auto; font-size: 12px; color: var(--muted);
|
|
84
|
+
background: var(--bg); border-radius: 999px; padding: 1px 8px;
|
|
85
|
+
}
|
|
86
|
+
nav a.active .count { background: var(--card); color: var(--accent); }
|
|
87
|
+
main { flex: 1; padding: 24px 28px; min-width: 0; }
|
|
88
|
+
h1 { font-size: 20px; margin: 0 0 20px; }
|
|
89
|
+
h2 { font-size: 15px; margin: 28px 0 12px; }
|
|
90
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; }
|
|
91
|
+
.card {
|
|
92
|
+
background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 16px;
|
|
93
|
+
}
|
|
94
|
+
.card .value { font-size: 26px; font-weight: 600; letter-spacing: -0.5px; }
|
|
95
|
+
.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; margin-top: 4px; }
|
|
96
|
+
.panel { background: var(--card); border: 1px solid var(--line); border-radius: 8px; overflow-x: auto; }
|
|
97
|
+
table { border-collapse: collapse; width: 100%; min-width: 860px; }
|
|
98
|
+
th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
99
|
+
th { font-weight: 600; color: var(--muted); font-size: 12px; text-transform: uppercase; }
|
|
100
|
+
tr:last-child td { border-bottom: none; }
|
|
101
|
+
.status { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
|
102
|
+
.pending { background: #e5e7eb; color: #374151; }
|
|
103
|
+
.running { background: #dbeafe; color: #1e40af; }
|
|
104
|
+
.retrying { background: #fef3c7; color: #92400e; }
|
|
105
|
+
.completed { background: #d1fae5; color: #065f46; }
|
|
106
|
+
.failed { background: #fee2e2; color: #991b1b; }
|
|
107
|
+
.muted { color: var(--muted); font-size: 12px; }
|
|
108
|
+
.actions { display: flex; gap: 6px; align-items: center; }
|
|
109
|
+
.actions a { color: inherit; white-space: nowrap; }
|
|
110
|
+
button {
|
|
111
|
+
font: inherit; font-size: 13px; padding: 5px 10px; border-radius: 6px; cursor: pointer;
|
|
112
|
+
border: 1px solid var(--line); background: var(--card); color: inherit;
|
|
113
|
+
}
|
|
114
|
+
button:hover { border-color: var(--accent); color: var(--accent); }
|
|
115
|
+
pre {
|
|
116
|
+
margin: 8px 0 0; padding: 10px; background: var(--bg); border-radius: 6px;
|
|
117
|
+
overflow-x: auto; font-size: 12px; max-width: 520px; white-space: pre-wrap;
|
|
118
|
+
}
|
|
119
|
+
details summary { cursor: pointer; color: var(--muted); font-size: 12px; }
|
|
120
|
+
.pager { display: flex; gap: 14px; align-items: center; margin-top: 16px; }
|
|
121
|
+
.pager a { color: inherit; }
|
|
122
|
+
.empty { padding: 28px; text-align: center; color: var(--muted); }
|
|
123
|
+
</style>
|
|
124
|
+
</head>
|
|
125
|
+
<body>
|
|
126
|
+
<aside>
|
|
127
|
+
<div class="brand">{{{ logo }}}Orbit <span>for AdonisJS</span></div>
|
|
128
|
+
<nav>
|
|
129
|
+
@each(item in menu)
|
|
130
|
+
<a href="{{ item.href }}" class="{{ item.key === active ? 'active' : '' }}">
|
|
131
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
132
|
+
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">{{{ item.icon }}}</svg>
|
|
133
|
+
{{ item.label }}
|
|
134
|
+
@if(item.count !== null)
|
|
135
|
+
<span class="count">{{ item.count }}</span>
|
|
136
|
+
@end
|
|
137
|
+
</a>
|
|
138
|
+
@end
|
|
139
|
+
</nav>
|
|
140
|
+
<div class="theme">
|
|
141
|
+
<form method="post" action="{{ path }}/theme/dark">
|
|
142
|
+
@if(csrfToken)
|
|
143
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
144
|
+
@end
|
|
145
|
+
<button type="submit" class="to-dark">
|
|
146
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
147
|
+
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
148
|
+
<path d="M21 13a8.5 8.5 0 0 1-10-10 8.5 8.5 0 1 0 10 10z"/>
|
|
149
|
+
</svg>
|
|
150
|
+
Dark
|
|
151
|
+
</button>
|
|
152
|
+
</form>
|
|
153
|
+
<form method="post" action="{{ path }}/theme/light">
|
|
154
|
+
@if(csrfToken)
|
|
155
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
156
|
+
@end
|
|
157
|
+
<button type="submit" class="to-light">
|
|
158
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
159
|
+
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
160
|
+
<circle cx="12" cy="12" r="4.5"/>
|
|
161
|
+
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/>
|
|
162
|
+
</svg>
|
|
163
|
+
Light
|
|
164
|
+
</button>
|
|
165
|
+
</form>
|
|
166
|
+
</div>
|
|
167
|
+
</aside>
|
|
168
|
+
|
|
169
|
+
<main>
|
|
170
|
+
<h1>{{ title }}</h1>
|
|
171
|
+
{{{ await $slots.main() }}}
|
|
172
|
+
</main>
|
|
173
|
+
</body>
|
|
174
|
+
</html>
|