@gaia-ai/addon-gaia-ui 0.14.0 → 0.14.2
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/dist/src/Entity/collection.d.ts +15 -0
- package/dist/src/Entity/collection.js +26 -5
- package/dist/src/Entity/conductor/Data/detail.d.ts +39 -0
- package/dist/src/Entity/conductor/Data/detail.js +69 -0
- package/dist/src/Entity/conductor/Data/log-block.d.ts +18 -0
- package/dist/src/Entity/conductor/Data/log-block.js +32 -0
- package/dist/src/Entity/conductor/Screen/conductor-log.d.ts +36 -0
- package/dist/src/Entity/conductor/Screen/conductor-log.js +46 -0
- package/dist/src/Entity/conductor/Screen/conductor-overview.d.ts +40 -0
- package/dist/src/Entity/conductor/Screen/conductor-overview.js +61 -0
- package/dist/src/Entity/conductor/Screen/conductor-runs.d.ts +90 -0
- package/dist/src/Entity/conductor/Screen/conductor-runs.js +128 -0
- package/dist/src/Entity/conductor/Screen/conductors.d.ts +6 -13
- package/dist/src/Entity/conductor/Screen/conductors.js +9 -60
- package/dist/src/Entity/conductor/Screen/project-conductors.d.ts +6 -13
- package/dist/src/Entity/conductor/Screen/project-conductors.js +9 -57
- package/dist/src/Entity/project/Data/with-tickets.js +1 -0
- package/dist/src/Entity/run/Data/query.d.ts +12 -0
- package/dist/src/Entity/run/Data/query.js +22 -1
- package/dist/src/Entity/run/Data/runs.d.ts +99 -0
- package/dist/src/Entity/run/Data/runs.js +37 -0
- package/dist/src/Entity/run/Form/edit.d.ts +29 -0
- package/dist/src/Entity/run/Form/edit.js +23 -0
- package/dist/src/Entity/run/Screen/edit-run-form.d.ts +26 -0
- package/dist/src/Entity/run/Screen/edit-run-form.js +117 -0
- package/dist/src/Entity/run/Screen/run-detail.d.ts +24 -3
- package/dist/src/Entity/run/Screen/run-detail.js +55 -3
- package/dist/src/Entity/ticket/Component/ticket-content/model.js +9 -1
- package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +6 -3
- package/dist/src/Entity/ticket/Component/ticket-detail/index.js +8 -3
- package/dist/src/Entity/ticket/Data/parents.js +2 -0
- package/dist/src/Entity/ticket/Data/query.d.ts +17 -0
- package/dist/src/Entity/ticket/Data/query.js +29 -1
- package/dist/src/Entity/ticket/Data/store.js +16 -0
- package/dist/src/Entity/ticket/Data/tickets.d.ts +115 -1
- package/dist/src/Entity/ticket/Data/tickets.js +38 -0
- package/dist/src/Entity/ticket/Form/who-filter.d.ts +8 -0
- package/dist/src/Entity/ticket/Screen/conductor-tickets.d.ts +85 -0
- package/dist/src/Entity/ticket/Screen/conductor-tickets.js +111 -0
- package/dist/src/Entity/ticket/Screen/ticket-runs.js +9 -0
- package/dist/src/Entity/ticket/Screen/tickets.d.ts +8 -0
- package/dist/src/Entity/ticket/Screen/tickets.js +27 -0
- package/dist/src/Entity/ticket/Screen/user-tickets.d.ts +8 -0
- package/dist/src/Entity/ticket/Screen/who-filter-form.d.ts +7 -0
- package/dist/src/Entity/ticket/Screen/who-filter-form.js +20 -6
- package/dist/src/Kernel/braid.d.ts +4 -0
- package/dist/src/Kernel/braid.js +10 -0
- package/dist/src/Kernel/chrome.d.ts +1 -1
- package/dist/src/Kernel/chrome.js +4 -4
- package/dist/src/Kernel/splash.js +10 -3
- package/dist/src/Routing/registry.js +1 -0
- package/dist/src/Routing/routes.js +10 -0
- package/dist/src/Screen/async-react-screen.d.ts +4 -2
- package/dist/src/Screen/async-react-screen.js +26 -1
- package/dist/src/Statistics/figures.d.ts +1 -2
- package/dist/src/Statistics/figures.js +72 -29
- package/dist/src/Statistics/presentation.d.ts +8 -0
- package/dist/src/Statistics/presentation.js +23 -0
- package/dist/src/Statistics/screen.d.ts +21 -14
- package/dist/src/Statistics/screen.js +126 -27
- package/dist/src/Vendor/termcn/ui/dither-chart.js +1 -2
- package/dist/src/lib/theme.d.ts +5 -0
- package/dist/src/lib/theme.js +5 -0
- package/package.json +2 -2
|
@@ -162,6 +162,21 @@ export interface ListColumn<Row, Ctx = void> {
|
|
|
162
162
|
readonly min?: number;
|
|
163
163
|
readonly max?: number;
|
|
164
164
|
readonly grow?: number;
|
|
165
|
+
/**
|
|
166
|
+
* Size this column to the longest value in the rows being rendered, never
|
|
167
|
+
* below `width` and never below the header's own label.
|
|
168
|
+
*
|
|
169
|
+
* The other columns of this tree are DECLARED widths and deliberately so — a
|
|
170
|
+
* domain constant like STATE's 13 is the length of `qualification`. An
|
|
171
|
+
* identifier has no such constant: its length is the project's prefix plus a
|
|
172
|
+
* number, so a fixed column cuts exactly the number that distinguishes one
|
|
173
|
+
* ticket from the next (GAIA-549). One width per render, shared by the header
|
|
174
|
+
* and every row, is what keeps the column aligned.
|
|
175
|
+
*
|
|
176
|
+
* Deliberately uncapped: a cap puts the ellipsis back on the number. The
|
|
177
|
+
* surplus demand is paid by the row's grower, which is the title.
|
|
178
|
+
*/
|
|
179
|
+
readonly fit?: boolean;
|
|
165
180
|
get(row: Row, ctx: Ctx): unknown;
|
|
166
181
|
}
|
|
167
182
|
/**
|
|
@@ -96,9 +96,11 @@ const BLANKS = ' '.repeat(GROWN_CELL_FILL);
|
|
|
96
96
|
* argument for it: the target is a number the COLUMN DECLARES, never a width
|
|
97
97
|
* measured off the page (spec D4), and the cell resolves to exactly the columns
|
|
98
98
|
* it resolved to before — Yoga is handed the same budget either way. `cellText`
|
|
99
|
-
* and `elideLeft` are
|
|
100
|
-
*
|
|
101
|
-
*
|
|
99
|
+
* and `elideLeft` are sanctioned `displayWidth` callers because they mark a cut
|
|
100
|
+
* against a declared width; this is the same shape, one step further: it FILLS
|
|
101
|
+
* to a declared width. {@link fitColumns} is the one caller that measures a
|
|
102
|
+
* value instead, and it does so to PRODUCE the declared width before any cell
|
|
103
|
+
* exists, so the rule this paragraph states still holds below it.
|
|
102
104
|
*
|
|
103
105
|
* The two branches are not a style choice, they are what the renderer allows:
|
|
104
106
|
*
|
|
@@ -261,6 +263,24 @@ const tableRow = (ids, columns, value, { selected, bold }) => seamlessRowNode(id
|
|
|
261
263
|
* `rowNodeId`. Beside {@link listTableTree}, which keys the rows under it, and
|
|
262
264
|
* reading the SAME `noun` off the same configuration (GAIA-345 D2). */
|
|
263
265
|
export const listRowIds = (table, rows) => rows.map((row) => rowNodeId(table.noun, row.id));
|
|
266
|
+
/**
|
|
267
|
+
* The column set as RENDERED: a `fit` column resolved against the rows this
|
|
268
|
+
* frame carries, every other column untouched.
|
|
269
|
+
*
|
|
270
|
+
* Measured with `displayWidth`, like every other measurement in this tree, so a
|
|
271
|
+
* wide glyph costs the columns it paints rather than the characters it has.
|
|
272
|
+
* That makes this the one caller that measures a VALUE rather than marking a cut
|
|
273
|
+
* against a width already declared (see {@link tableRow}) — which is the point:
|
|
274
|
+
* it turns the longest value of this frame INTO the declared width, once, before
|
|
275
|
+
* a cell is built, so every cell below still resolves against a number the
|
|
276
|
+
* column declares.
|
|
277
|
+
*/
|
|
278
|
+
const fitColumns = (columns, rows, ctx) => columns.map((column) => column.fit !== true
|
|
279
|
+
? column
|
|
280
|
+
: {
|
|
281
|
+
...column,
|
|
282
|
+
width: rows.reduce((widest, row) => Math.max(widest, displayWidth(String(column.get(row, ctx) ?? ''))), Math.max(column.width, displayWidth(column.label))),
|
|
283
|
+
});
|
|
264
284
|
/**
|
|
265
285
|
* GAIA-340 D1 — a column-driven list as the renderable tree a screen hands over.
|
|
266
286
|
*
|
|
@@ -301,6 +321,7 @@ export const listRowIds = (table, rows) => rows.map((row) => rowNodeId(table.nou
|
|
|
301
321
|
*/
|
|
302
322
|
export function listTableTree(table, rows, ctx, { selectedIndex }) {
|
|
303
323
|
const { noun, columns, emptyMessage, header = true } = table;
|
|
324
|
+
const rendered = fitColumns(columns, rows, ctx);
|
|
304
325
|
return columnNode(`${noun}-table`,
|
|
305
326
|
// GAIA-345 D5/D6 — the header is BOLD, which is what distinguishes it from a
|
|
306
327
|
// data row, and it is optional because one list is not a record set: the
|
|
@@ -310,14 +331,14 @@ export function listTableTree(table, rows, ctx, { selectedIndex }) {
|
|
|
310
331
|
tableRow({
|
|
311
332
|
row: `${noun}-header`,
|
|
312
333
|
cell: (key) => `${noun}-header-${key}`,
|
|
313
|
-
},
|
|
334
|
+
}, rendered, (column) => column.label, { selected: false, bold: true }),
|
|
314
335
|
]
|
|
315
336
|
: []), ...(rows.length === 0
|
|
316
337
|
? [textNode({ id: `${noun}-empty`, content: emptyMessage })]
|
|
317
338
|
: rows.map((row, index) => tableRow({
|
|
318
339
|
row: rowNodeId(noun, row.id),
|
|
319
340
|
cell: (key) => `${noun}-cell-${row.id}-${key}`,
|
|
320
|
-
},
|
|
341
|
+
}, rendered, (column) => column.get(row, ctx), { selected: index === selectedIndex, bold: false }))));
|
|
321
342
|
}
|
|
322
343
|
/**
|
|
323
344
|
* GAIA-345 D7 — the SAME list for the surface that has no layout:
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type SessionStore } from '../../../Data/session-store.js';
|
|
2
|
+
import type { DropshClient } from '../../../types.js';
|
|
3
|
+
import { type ConductorStatus } from './liveness.js';
|
|
4
|
+
/** The conductor detail scope's shared header data. */
|
|
5
|
+
export interface ConductorDetail {
|
|
6
|
+
id: string;
|
|
7
|
+
/** Never empty — the uuid is the last fallback. */
|
|
8
|
+
label: string;
|
|
9
|
+
/** The `--conductor <name>` stem it was started from, if reported. */
|
|
10
|
+
conductorName: string | undefined;
|
|
11
|
+
/** The sideloaded machine's own label, or its host_key, or ''. */
|
|
12
|
+
machineLabel: string;
|
|
13
|
+
/** The linked project's uuid, or '' when unlinked — no project read here. */
|
|
14
|
+
projectLabel: string;
|
|
15
|
+
/** The `.gaia` dir it last registered from; '' when it never reported one. */
|
|
16
|
+
workspaceRoot: string;
|
|
17
|
+
lastPid: number | undefined;
|
|
18
|
+
status: ConductorStatus;
|
|
19
|
+
lastSeen: number;
|
|
20
|
+
currentLoad: number;
|
|
21
|
+
maxParallel: number;
|
|
22
|
+
/** THIS record's machine host_key, for the `mine` derivation. */
|
|
23
|
+
hostKey: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
/** Read one conductor with its sideloaded machine, mapped to {@link ConductorDetail}. */
|
|
26
|
+
export declare function loadConductorDetail(client: DropshClient, conductorId: string, nowSeconds: number): Promise<ConductorDetail>;
|
|
27
|
+
/** What the store-cached read needs from the outside world. */
|
|
28
|
+
interface ConductorDetailContext {
|
|
29
|
+
readonly store?: SessionStore;
|
|
30
|
+
readonly services?: {
|
|
31
|
+
client: DropshClient;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The store-cached single-entity read every tab of the conductor scope
|
|
36
|
+
* shares — one request per conductor per session, not one per tab.
|
|
37
|
+
*/
|
|
38
|
+
export declare function readConductorDetail(context: ConductorDetailContext, conductorId: string, nowSeconds?: number): Promise<ConductorDetail>;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The shared conductor read for the conductor detail scope. ONE cached
|
|
2
|
+
// single-entity read (`include=machine`), so all four tabs share exactly one
|
|
3
|
+
// header instead of each re-deriving it.
|
|
4
|
+
import { toUnixSeconds } from '@gaia-ai/core';
|
|
5
|
+
import { dataKey } from '../../../Data/session-store.js';
|
|
6
|
+
import { deriveConductorStatus } from './liveness.js';
|
|
7
|
+
const str = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
8
|
+
const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
9
|
+
function singleRelId(raw, key) {
|
|
10
|
+
const data = raw.relationships?.[key]?.data;
|
|
11
|
+
if (!data || Array.isArray(data))
|
|
12
|
+
return null;
|
|
13
|
+
return data.id ?? null;
|
|
14
|
+
}
|
|
15
|
+
/** Read one conductor with its sideloaded machine, mapped to {@link ConductorDetail}. */
|
|
16
|
+
export async function loadConductorDetail(client, conductorId, nowSeconds) {
|
|
17
|
+
const doc = (await client.get(`gaia_conductor/gaia_conductor/${conductorId}?include=machine`));
|
|
18
|
+
const raw = doc?.data ?? { id: conductorId };
|
|
19
|
+
const attrs = raw.attributes ?? {};
|
|
20
|
+
const machineId = singleRelId(raw, 'machine');
|
|
21
|
+
const machine = machineId
|
|
22
|
+
? (doc.included ?? []).find((r) => r.id === machineId)
|
|
23
|
+
: undefined;
|
|
24
|
+
const machineAttrs = machine?.attributes ?? {};
|
|
25
|
+
const lastSeen = toUnixSeconds(attrs.last_seen) ?? 0;
|
|
26
|
+
const leaseExpiresAt = toUnixSeconds(attrs.lease_expires_at) ?? 0;
|
|
27
|
+
const hostKey = str(machineAttrs.host_key);
|
|
28
|
+
return {
|
|
29
|
+
id: raw.id ?? conductorId,
|
|
30
|
+
label: str(attrs.label) ??
|
|
31
|
+
str(attrs.conductor_id) ??
|
|
32
|
+
str(attrs.machine_id) ??
|
|
33
|
+
raw.id ??
|
|
34
|
+
conductorId,
|
|
35
|
+
conductorName: str(attrs.conductor_name),
|
|
36
|
+
machineLabel: str(machineAttrs.label) ?? hostKey ?? '',
|
|
37
|
+
projectLabel: singleRelId(raw, 'project_id') ?? '',
|
|
38
|
+
workspaceRoot: str(attrs.workspace_root) ?? '',
|
|
39
|
+
lastPid: num(attrs.last_pid),
|
|
40
|
+
status: deriveConductorStatus({
|
|
41
|
+
present: true,
|
|
42
|
+
status: str(attrs.status) ?? null,
|
|
43
|
+
lastSeen: lastSeen === 0 ? null : lastSeen,
|
|
44
|
+
leaseExpiresAt: leaseExpiresAt === 0 ? null : leaseExpiresAt,
|
|
45
|
+
}, nowSeconds),
|
|
46
|
+
lastSeen,
|
|
47
|
+
currentLoad: num(machineAttrs.current_load) ?? num(attrs.current_load) ?? 0,
|
|
48
|
+
maxParallel: num(machineAttrs.max_parallel) ?? num(attrs.max_parallel) ?? 0,
|
|
49
|
+
hostKey,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const conductorDetailKey = (id) => dataKey(`conductor/${id}`);
|
|
53
|
+
/**
|
|
54
|
+
* The store-cached single-entity read every tab of the conductor scope
|
|
55
|
+
* shares — one request per conductor per session, not one per tab.
|
|
56
|
+
*/
|
|
57
|
+
export async function readConductorDetail(context, conductorId, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
58
|
+
if (!context.services)
|
|
59
|
+
throw new Error('Conductor detail requires services');
|
|
60
|
+
const key = conductorDetailKey(conductorId);
|
|
61
|
+
if (!context.store)
|
|
62
|
+
return loadConductorDetail(context.services.client, conductorId, nowSeconds);
|
|
63
|
+
const cached = context.store.get(key);
|
|
64
|
+
if (cached)
|
|
65
|
+
return cached;
|
|
66
|
+
const detail = await loadConductorDetail(context.services.client, conductorId, nowSeconds);
|
|
67
|
+
context.store.set(key, detail, ['detail']);
|
|
68
|
+
return detail;
|
|
69
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ConductorDetail } from './detail.js';
|
|
2
|
+
export interface ConductorLog {
|
|
3
|
+
readonly heading: string;
|
|
4
|
+
readonly lines: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Whether the log block should tail a real file — true only when the
|
|
8
|
+
* detail's own machine host_key matches THIS process's. An absent key on
|
|
9
|
+
* either side is never waved through as a match: an unproven identity is
|
|
10
|
+
* refused, the same rule the Conductors list's `mine` uses.
|
|
11
|
+
*/
|
|
12
|
+
export declare function isMine(detail: ConductorDetail, thisHostKey: string | undefined): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* The open row's log block: where it is, and its tail. A conductor on
|
|
15
|
+
* another machine has a workspace this machine cannot read, so the block
|
|
16
|
+
* states that instead of showing an empty tail.
|
|
17
|
+
*/
|
|
18
|
+
export declare function logBlockFor(detail: ConductorDetail, mine: boolean): ConductorLog;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The conductor log block: where it is, and its tail — the single home for
|
|
2
|
+
// what was duplicated character for character in both Conductors screens.
|
|
3
|
+
import { configHomeOf } from '@gaia-ai/core';
|
|
4
|
+
import { tailConductorLog } from './log-tail.js';
|
|
5
|
+
/**
|
|
6
|
+
* Whether the log block should tail a real file — true only when the
|
|
7
|
+
* detail's own machine host_key matches THIS process's. An absent key on
|
|
8
|
+
* either side is never waved through as a match: an unproven identity is
|
|
9
|
+
* refused, the same rule the Conductors list's `mine` uses.
|
|
10
|
+
*/
|
|
11
|
+
export function isMine(detail, thisHostKey) {
|
|
12
|
+
return (detail.hostKey !== undefined &&
|
|
13
|
+
thisHostKey !== undefined &&
|
|
14
|
+
detail.hostKey === thisHostKey);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The open row's log block: where it is, and its tail. A conductor on
|
|
18
|
+
* another machine has a workspace this machine cannot read, so the block
|
|
19
|
+
* states that instead of showing an empty tail.
|
|
20
|
+
*/
|
|
21
|
+
export function logBlockFor(detail, mine) {
|
|
22
|
+
const logPath = `${configHomeOf(detail.workspaceRoot)}/log.txt`;
|
|
23
|
+
return mine
|
|
24
|
+
? {
|
|
25
|
+
heading: `log — ${logPath}`,
|
|
26
|
+
lines: tailConductorLog(logPath, { maxLines: 40 }).lines,
|
|
27
|
+
}
|
|
28
|
+
: {
|
|
29
|
+
heading: `log — ${detail.label} runs on another machine; no log here`,
|
|
30
|
+
lines: [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { VNode } from '@opentui/core';
|
|
2
|
+
import { AsyncScreen } from '../../../Screen/async-screen.js';
|
|
3
|
+
import type { FullScreenChrome } from '../../../Screen/full-screen.js';
|
|
4
|
+
import type { ScreenContext } from '../../../Screen/screen.js';
|
|
5
|
+
interface LogView {
|
|
6
|
+
readonly label: string;
|
|
7
|
+
readonly heading: string;
|
|
8
|
+
readonly lines: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
/** The tail of the selected conductor's log, or why there is none here. */
|
|
11
|
+
export declare class ConductorLogScreen extends AsyncScreen<LogView> {
|
|
12
|
+
static readonly route: {
|
|
13
|
+
id: string;
|
|
14
|
+
path: string;
|
|
15
|
+
parent: string;
|
|
16
|
+
tab: {
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
scope: string;
|
|
20
|
+
weight: number;
|
|
21
|
+
};
|
|
22
|
+
fallback: {
|
|
23
|
+
routeId: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly frameId = "conductor";
|
|
28
|
+
protected load(context: ScreenContext): Promise<LogView>;
|
|
29
|
+
protected renderLoading(): string[];
|
|
30
|
+
protected renderReady(view: LogView): VNode;
|
|
31
|
+
protected chrome(): FullScreenChrome;
|
|
32
|
+
/** Nothing on this tab to act on. */
|
|
33
|
+
protected actions(): never[];
|
|
34
|
+
renderNonInteractive(): string[];
|
|
35
|
+
}
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { columnNode, textNode } from '../../../lib/vnode.js';
|
|
2
|
+
import { AsyncScreen } from '../../../Screen/async-screen.js';
|
|
3
|
+
import { readConductorDetail } from '../Data/detail.js';
|
|
4
|
+
import { isMine, logBlockFor } from '../Data/log-block.js';
|
|
5
|
+
import { createConductorProcessHost } from '../Data/process.js';
|
|
6
|
+
import { conductorTitle } from './conductor-overview.js';
|
|
7
|
+
/** The tail of the selected conductor's log, or why there is none here. */
|
|
8
|
+
export class ConductorLogScreen extends AsyncScreen {
|
|
9
|
+
static route = {
|
|
10
|
+
id: 'conductor.log',
|
|
11
|
+
path: '/conductors/:conductorId/log',
|
|
12
|
+
parent: 'conductors',
|
|
13
|
+
tab: { id: 'log', label: 'Log', scope: 'conductor', weight: 40 },
|
|
14
|
+
fallback: { routeId: 'conductors' },
|
|
15
|
+
};
|
|
16
|
+
id = ConductorLogScreen.route.id;
|
|
17
|
+
frameId = 'conductor';
|
|
18
|
+
async load(context) {
|
|
19
|
+
const conductorId = context.params?.conductorId ?? '';
|
|
20
|
+
const detail = await readConductorDetail(context, conductorId);
|
|
21
|
+
const host = context.services?.conductorProcess ?? createConductorProcessHost();
|
|
22
|
+
const thisHostKey = await host.thisHostKey();
|
|
23
|
+
const block = logBlockFor(detail, isMine(detail, thisHostKey));
|
|
24
|
+
return { label: detail.label, ...block };
|
|
25
|
+
}
|
|
26
|
+
renderLoading() {
|
|
27
|
+
return ['Loading log…'];
|
|
28
|
+
}
|
|
29
|
+
renderReady(view) {
|
|
30
|
+
return columnNode('conductor-log', textNode({ id: 'conductor-log-heading', content: view.heading }), ...view.lines.map((line, index) => textNode({ id: `conductor-log-${index}`, content: line })));
|
|
31
|
+
}
|
|
32
|
+
chrome() {
|
|
33
|
+
return {
|
|
34
|
+
title: conductorTitle(this.readyValue ? { label: this.readyValue.label } : undefined),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Nothing on this tab to act on. */
|
|
38
|
+
actions() {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
renderNonInteractive() {
|
|
42
|
+
return this.readyValue
|
|
43
|
+
? [this.readyValue.heading, ...this.readyValue.lines]
|
|
44
|
+
: [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { VNode } from '@opentui/core';
|
|
2
|
+
import { AsyncScreen } from '../../../Screen/async-screen.js';
|
|
3
|
+
import type { FullScreenChrome } from '../../../Screen/full-screen.js';
|
|
4
|
+
import type { ScreenContext } from '../../../Screen/screen.js';
|
|
5
|
+
import { type ConductorDetail } from '../Data/detail.js';
|
|
6
|
+
/** `GAIA conductor — <label>` — the tab bar title every tab of the scope shares. */
|
|
7
|
+
export declare function conductorTitle(detail: Pick<ConductorDetail, 'label'> | undefined): string;
|
|
8
|
+
/**
|
|
9
|
+
* The label/value rows of the Overview tab — the single source both
|
|
10
|
+
* `renderReady` and `renderNonInteractive` draw from, so the piped output
|
|
11
|
+
* cannot drift from the drawn tree.
|
|
12
|
+
*/
|
|
13
|
+
export declare function conductorOverviewRows(detail: ConductorDetail, nowSeconds?: number): string[];
|
|
14
|
+
/** Overview — the conductor detail scope's landing tab. */
|
|
15
|
+
export declare class ConductorOverviewScreen extends AsyncScreen<ConductorDetail> {
|
|
16
|
+
static readonly route: {
|
|
17
|
+
id: string;
|
|
18
|
+
path: string;
|
|
19
|
+
pipe: "scope";
|
|
20
|
+
parent: string;
|
|
21
|
+
tab: {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
scope: string;
|
|
25
|
+
weight: number;
|
|
26
|
+
};
|
|
27
|
+
fallback: {
|
|
28
|
+
routeId: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly frameId = "conductor";
|
|
33
|
+
protected load(context: ScreenContext): Promise<ConductorDetail>;
|
|
34
|
+
protected renderLoading(): string[];
|
|
35
|
+
protected renderReady(detail: ConductorDetail, context: ScreenContext): VNode;
|
|
36
|
+
protected chrome(): FullScreenChrome;
|
|
37
|
+
/** Read-only — nothing on this tab to act on. */
|
|
38
|
+
protected actions(): never[];
|
|
39
|
+
renderNonInteractive(context: ScreenContext): string[];
|
|
40
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { humanizeAge } from '../../../lib/format.js';
|
|
2
|
+
import { columnNode, textNode } from '../../../lib/vnode.js';
|
|
3
|
+
import { AsyncScreen } from '../../../Screen/async-screen.js';
|
|
4
|
+
import { readConductorDetail } from '../Data/detail.js';
|
|
5
|
+
/** `GAIA conductor — <label>` — the tab bar title every tab of the scope shares. */
|
|
6
|
+
export function conductorTitle(detail) {
|
|
7
|
+
return `GAIA conductor — ${detail?.label ?? ''}`;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The label/value rows of the Overview tab — the single source both
|
|
11
|
+
* `renderReady` and `renderNonInteractive` draw from, so the piped output
|
|
12
|
+
* cannot drift from the drawn tree.
|
|
13
|
+
*/
|
|
14
|
+
export function conductorOverviewRows(detail, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
15
|
+
return [
|
|
16
|
+
`Status: ${detail.status} (${humanizeAge(detail.lastSeen || null, nowSeconds)})`,
|
|
17
|
+
`Machine: ${detail.machineLabel || '—'}`,
|
|
18
|
+
`Project: ${detail.projectLabel || '—'}`,
|
|
19
|
+
`Workspace: ${detail.workspaceRoot || '—'}`,
|
|
20
|
+
`PID: ${detail.lastPid ?? '-'}`,
|
|
21
|
+
// Stated explicitly as the machine's own budget, not the conductor's:
|
|
22
|
+
// several conductors can share one host's capacity.
|
|
23
|
+
`Load: ${detail.currentLoad}/${detail.maxParallel} (machine budget)`,
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
/** Overview — the conductor detail scope's landing tab. */
|
|
27
|
+
export class ConductorOverviewScreen extends AsyncScreen {
|
|
28
|
+
static route = {
|
|
29
|
+
id: 'conductor.overview',
|
|
30
|
+
path: '/conductors/:conductorId',
|
|
31
|
+
pipe: 'scope',
|
|
32
|
+
parent: 'conductors',
|
|
33
|
+
tab: { id: 'overview', label: 'Overview', scope: 'conductor', weight: 10 },
|
|
34
|
+
fallback: { routeId: 'conductors' },
|
|
35
|
+
};
|
|
36
|
+
id = ConductorOverviewScreen.route.id;
|
|
37
|
+
frameId = 'conductor';
|
|
38
|
+
load(context) {
|
|
39
|
+
return readConductorDetail(context, context.params?.conductorId ?? '', context.nowSeconds?.() ?? Math.floor(Date.now() / 1000));
|
|
40
|
+
}
|
|
41
|
+
renderLoading() {
|
|
42
|
+
return ['Loading conductor…'];
|
|
43
|
+
}
|
|
44
|
+
renderReady(detail, context) {
|
|
45
|
+
const nowSeconds = context.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
|
|
46
|
+
return columnNode('conductor-overview', ...conductorOverviewRows(detail, nowSeconds).map((line, index) => textNode({ id: `conductor-overview-${index}`, content: line })));
|
|
47
|
+
}
|
|
48
|
+
chrome() {
|
|
49
|
+
return { title: conductorTitle(this.readyValue) };
|
|
50
|
+
}
|
|
51
|
+
/** Read-only — nothing on this tab to act on. */
|
|
52
|
+
actions() {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
renderNonInteractive(context) {
|
|
56
|
+
if (!this.readyValue)
|
|
57
|
+
return [];
|
|
58
|
+
const nowSeconds = context.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
|
|
59
|
+
return conductorOverviewRows(this.readyValue, nowSeconds);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { FullScreenChrome } from '../../../Screen/full-screen.js';
|
|
2
|
+
import { ListScreen } from '../../../Screen/list-screen.js';
|
|
3
|
+
import type { ScreenContext } from '../../../Screen/screen.js';
|
|
4
|
+
import { type Page } from '../../collection.js';
|
|
5
|
+
import { type RunRow } from '../../run/Data/runs.js';
|
|
6
|
+
/** Held work — the runs one conductor currently holds (claimed/running). */
|
|
7
|
+
export declare class ConductorRunsScreen extends ListScreen<RunRow, number> {
|
|
8
|
+
static readonly route: {
|
|
9
|
+
id: string;
|
|
10
|
+
path: string;
|
|
11
|
+
parent: string;
|
|
12
|
+
tab: {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
scope: string;
|
|
16
|
+
weight: number;
|
|
17
|
+
};
|
|
18
|
+
fallback: {
|
|
19
|
+
routeId: string;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly frameId = "conductor";
|
|
24
|
+
protected readonly table: {
|
|
25
|
+
readonly noun: "run";
|
|
26
|
+
readonly columns: readonly [{
|
|
27
|
+
readonly key: "run";
|
|
28
|
+
readonly label: "RUN";
|
|
29
|
+
readonly width: 8;
|
|
30
|
+
readonly fields: readonly ["drupal_internal__id"];
|
|
31
|
+
readonly get: (r: RunRow) => string;
|
|
32
|
+
}, {
|
|
33
|
+
readonly key: "ticket";
|
|
34
|
+
readonly label: "TICKET";
|
|
35
|
+
readonly width: 10;
|
|
36
|
+
readonly fields: readonly ["ticket_id"];
|
|
37
|
+
readonly get: (r: RunRow) => string;
|
|
38
|
+
}, {
|
|
39
|
+
readonly key: "title";
|
|
40
|
+
readonly label: "TITLE";
|
|
41
|
+
readonly width: 0;
|
|
42
|
+
readonly grow: 2;
|
|
43
|
+
readonly min: 24;
|
|
44
|
+
readonly fields: readonly ["ticket_id"];
|
|
45
|
+
readonly get: (r: RunRow) => string;
|
|
46
|
+
}, {
|
|
47
|
+
readonly key: "state";
|
|
48
|
+
readonly label: "STATE";
|
|
49
|
+
readonly width: 8;
|
|
50
|
+
readonly fields: readonly ["state", "heartbeat", "claim_expires_at"];
|
|
51
|
+
readonly get: (r: RunRow, nowSeconds: number) => string;
|
|
52
|
+
}, {
|
|
53
|
+
readonly key: "phase";
|
|
54
|
+
readonly label: "PHASE";
|
|
55
|
+
readonly width: 13;
|
|
56
|
+
readonly fields: readonly ["state_at_start"];
|
|
57
|
+
readonly get: (r: RunRow) => string;
|
|
58
|
+
}, {
|
|
59
|
+
readonly key: "started";
|
|
60
|
+
readonly label: "STARTED";
|
|
61
|
+
readonly width: 0;
|
|
62
|
+
readonly grow: 1;
|
|
63
|
+
readonly fields: readonly ["started_at", "created"];
|
|
64
|
+
readonly get: (r: RunRow, nowSeconds: number) => string;
|
|
65
|
+
}];
|
|
66
|
+
readonly emptyMessage: "This conductor holds no run.";
|
|
67
|
+
};
|
|
68
|
+
protected tableContext(context: ScreenContext): number;
|
|
69
|
+
protected loadRows(context: ScreenContext, page: number): Promise<Page<RunRow>>;
|
|
70
|
+
protected renderLoading(): string[];
|
|
71
|
+
protected chrome(context: ScreenContext): FullScreenChrome;
|
|
72
|
+
protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
|
|
73
|
+
activate(index: number, context: ScreenContext): boolean;
|
|
74
|
+
select(index: number, context: ScreenContext): boolean;
|
|
75
|
+
handleInput(input: string, context: ScreenContext): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* The header title needs the shared conductor label. Loaded once here (the
|
|
78
|
+
* store-cached read is cheap on a repeat) rather than threading it through
|
|
79
|
+
* `loadRows`'s row type.
|
|
80
|
+
*/
|
|
81
|
+
private headerDetail;
|
|
82
|
+
protected didLoad(_data: Page<RunRow>, context: ScreenContext): void;
|
|
83
|
+
reset(): void;
|
|
84
|
+
/**
|
|
85
|
+
* The pipe's own text — the SAME table + rows the drawn tree uses, through
|
|
86
|
+
* `listTableRows` rather than a second description (Task 8's anti-drift
|
|
87
|
+
* pattern).
|
|
88
|
+
*/
|
|
89
|
+
renderNonInteractive(context: ScreenContext): string[];
|
|
90
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { actionItem } from '../../../Component/footer-actions/index.js';
|
|
2
|
+
import { flattenSections } from '../../../Component/section-list/index.js';
|
|
3
|
+
import { ListScreen } from '../../../Screen/list-screen.js';
|
|
4
|
+
import { listTableRows, loadPage } from '../../collection.js';
|
|
5
|
+
import { LIST_PAGE_SIZE } from '../../cycle.js';
|
|
6
|
+
import { buildConductorRunQueryParams } from '../../run/Data/query.js';
|
|
7
|
+
import { CONDUCTOR_RUN_TABLE, mapRunData, } from '../../run/Data/runs.js';
|
|
8
|
+
import { deriveRunStatus } from '../../run/Data/status.js';
|
|
9
|
+
import { readConductorDetail } from '../Data/detail.js';
|
|
10
|
+
import { conductorTitle } from './conductor-overview.js';
|
|
11
|
+
/** Held work — the runs one conductor currently holds (claimed/running). */
|
|
12
|
+
export class ConductorRunsScreen extends ListScreen {
|
|
13
|
+
static route = {
|
|
14
|
+
id: 'conductor.runs',
|
|
15
|
+
path: '/conductors/:conductorId/runs',
|
|
16
|
+
parent: 'conductors',
|
|
17
|
+
tab: { id: 'runs', label: 'Held work', scope: 'conductor', weight: 20 },
|
|
18
|
+
fallback: { routeId: 'conductors' },
|
|
19
|
+
};
|
|
20
|
+
id = ConductorRunsScreen.route.id;
|
|
21
|
+
frameId = 'conductor';
|
|
22
|
+
table = CONDUCTOR_RUN_TABLE;
|
|
23
|
+
tableContext(context) {
|
|
24
|
+
return context.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
|
|
25
|
+
}
|
|
26
|
+
loadRows(context, page) {
|
|
27
|
+
if (!context.services)
|
|
28
|
+
throw new Error('Held work requires services');
|
|
29
|
+
const conductorId = context.params?.conductorId ?? '';
|
|
30
|
+
const window = { offset: page * LIST_PAGE_SIZE, limit: LIST_PAGE_SIZE };
|
|
31
|
+
const params = buildConductorRunQueryParams(conductorId, {
|
|
32
|
+
size: window.limit,
|
|
33
|
+
index: page,
|
|
34
|
+
});
|
|
35
|
+
return loadPage(context.services.client, 'gaia_run/gaia_run', params, (data, included) => {
|
|
36
|
+
const ticketsById = new Map(included.filter((r) => r.id).map((r) => [r.id, r]));
|
|
37
|
+
return data.map((item) => mapRunData(item, ticketsById));
|
|
38
|
+
}, page, window);
|
|
39
|
+
}
|
|
40
|
+
renderLoading() {
|
|
41
|
+
return ['Loading held work…'];
|
|
42
|
+
}
|
|
43
|
+
chrome(context) {
|
|
44
|
+
const nowSeconds = this.tableContext(context);
|
|
45
|
+
const stale = this.rows.filter((row) => deriveRunStatus(row, nowSeconds) === 'stale').length;
|
|
46
|
+
return {
|
|
47
|
+
selectable: this.rows.length,
|
|
48
|
+
selectedIndex: this.selectedIndex,
|
|
49
|
+
rowIds: this.rowNodeIds,
|
|
50
|
+
title: conductorTitle(this.headerDetail),
|
|
51
|
+
meta: [
|
|
52
|
+
this.countLabel(),
|
|
53
|
+
...(stale > 0 ? [`${stale} stale`] : []),
|
|
54
|
+
...this.pagingSegments(),
|
|
55
|
+
]
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.join(' · '),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
actions() {
|
|
61
|
+
return [
|
|
62
|
+
...(this.rows.length > 0
|
|
63
|
+
? [actionItem('conductor-runs-enter', 'Enter', 'ticket')]
|
|
64
|
+
: []),
|
|
65
|
+
actionItem('conductor-runs-tab', 'Tab', 'tabs'),
|
|
66
|
+
...this.pagingActionItems(),
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
activate(index, context) {
|
|
70
|
+
const ticketId = this.rows[index]?.ticketId;
|
|
71
|
+
if (!ticketId || !context.navigate)
|
|
72
|
+
return false;
|
|
73
|
+
context.navigate('ticket.runs', { ticketId });
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
select(index, context) {
|
|
77
|
+
if (index < 0 || index >= this.rows.length)
|
|
78
|
+
return false;
|
|
79
|
+
this.selectedIndex = index;
|
|
80
|
+
context.requestDraw();
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
handleInput(input, context) {
|
|
84
|
+
if (super.handleInput(input, context))
|
|
85
|
+
return true;
|
|
86
|
+
if (this.handlePagingInput(input, context))
|
|
87
|
+
return true;
|
|
88
|
+
if (this.handleCursorInput(input, context))
|
|
89
|
+
return true;
|
|
90
|
+
if (input === 'enter')
|
|
91
|
+
return this.activate(this.selectedIndex, context);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The header title needs the shared conductor label. Loaded once here (the
|
|
96
|
+
* store-cached read is cheap on a repeat) rather than threading it through
|
|
97
|
+
* `loadRows`'s row type.
|
|
98
|
+
*/
|
|
99
|
+
headerDetail;
|
|
100
|
+
didLoad(_data, context) {
|
|
101
|
+
const conductorId = context.params?.conductorId ?? '';
|
|
102
|
+
if (!conductorId || !context.services || this.headerDetail)
|
|
103
|
+
return;
|
|
104
|
+
void readConductorDetail(context, conductorId).then((detail) => {
|
|
105
|
+
this.headerDetail = detail;
|
|
106
|
+
context.requestDraw();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
reset() {
|
|
110
|
+
this.resetList();
|
|
111
|
+
this.headerDetail = undefined;
|
|
112
|
+
super.reset();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The pipe's own text — the SAME table + rows the drawn tree uses, through
|
|
116
|
+
* `listTableRows` rather than a second description (Task 8's anti-drift
|
|
117
|
+
* pattern).
|
|
118
|
+
*/
|
|
119
|
+
renderNonInteractive(context) {
|
|
120
|
+
const nowSeconds = this.tableContext(context);
|
|
121
|
+
return flattenSections([
|
|
122
|
+
{
|
|
123
|
+
heading: 'Held work',
|
|
124
|
+
lines: listTableRows(CONDUCTOR_RUN_TABLE, this.rows, nowSeconds),
|
|
125
|
+
},
|
|
126
|
+
]).lines;
|
|
127
|
+
}
|
|
128
|
+
}
|