@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.
Files changed (45) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +275 -0
  3. package/build/configure.d.ts +8 -0
  4. package/build/configure.js +38 -0
  5. package/build/index.d.ts +10 -0
  6. package/build/index.js +9 -0
  7. package/build/providers/orbit_provider.d.ts +13 -0
  8. package/build/providers/orbit_provider.js +35 -0
  9. package/build/src/auth/basic_auth.d.ts +12 -0
  10. package/build/src/auth/basic_auth.js +36 -0
  11. package/build/src/controllers/dashboard_controller.d.ts +51 -0
  12. package/build/src/controllers/dashboard_controller.js +341 -0
  13. package/build/src/define_config.d.ts +5 -0
  14. package/build/src/define_config.js +6 -0
  15. package/build/src/jobs/prune_job_runs.d.ts +20 -0
  16. package/build/src/jobs/prune_job_runs.js +27 -0
  17. package/build/src/logo.d.ts +4 -0
  18. package/build/src/logo.js +12 -0
  19. package/build/src/middleware/dashboard_auth.d.ts +10 -0
  20. package/build/src/middleware/dashboard_auth.js +16 -0
  21. package/build/src/models/job_run.d.ts +35 -0
  22. package/build/src/models/job_run.js +71 -0
  23. package/build/src/models/silenced_job.d.ts +14 -0
  24. package/build/src/models/silenced_job.js +27 -0
  25. package/build/src/recorder.d.ts +14 -0
  26. package/build/src/recorder.js +102 -0
  27. package/build/src/retry.d.ts +9 -0
  28. package/build/src/retry.js +18 -0
  29. package/build/src/routes.d.ts +8 -0
  30. package/build/src/routes.js +20 -0
  31. package/build/src/sinks/database_sink.d.ts +16 -0
  32. package/build/src/sinks/database_sink.js +88 -0
  33. package/build/src/types.d.ts +92 -0
  34. package/build/src/types.js +1 -0
  35. package/build/stubs/config/orbit.stub +31 -0
  36. package/build/stubs/main.d.ts +1 -0
  37. package/build/stubs/main.js +1 -0
  38. package/build/stubs/migrations/create_job_runs_table.stub +62 -0
  39. package/build/stubs/migrations/create_silenced_jobs_table.stub +29 -0
  40. package/build/templates/components/layout.edge +174 -0
  41. package/build/templates/index.edge +111 -0
  42. package/build/templates/metrics.edge +59 -0
  43. package/build/templates/orbit.svg +35 -0
  44. package/build/templates/silenced.edge +35 -0
  45. 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>