@gaia-ai/addon-gaia-ui 0.10.0 → 0.11.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 (34) hide show
  1. package/dist/src/Component/top-tabs/index.d.ts +49 -5
  2. package/dist/src/Component/top-tabs/index.js +62 -6
  3. package/dist/src/Entity/conductor/Data/conductors.d.ts +4 -3
  4. package/dist/src/Entity/conductor/Data/conductors.js +76 -10
  5. package/dist/src/Entity/conductor/Data/own-conductor.js +7 -1
  6. package/dist/src/Entity/statistics/Data/statistics.d.ts +83 -6
  7. package/dist/src/Entity/statistics/Data/statistics.js +85 -6
  8. package/dist/src/Entity/statistics/Screen/dashboard.js +17 -3
  9. package/dist/src/Entity/team/Data/members.d.ts +30 -0
  10. package/dist/src/Entity/team/Data/members.js +83 -0
  11. package/dist/src/Entity/team/Data/team.d.ts +22 -0
  12. package/dist/src/Entity/team/Data/team.js +38 -0
  13. package/dist/src/Entity/team/Data/teams.d.ts +223 -0
  14. package/dist/src/Entity/team/Data/teams.js +267 -0
  15. package/dist/src/Entity/team/Screen/team-users.d.ts +76 -0
  16. package/dist/src/Entity/team/Screen/team-users.js +215 -0
  17. package/dist/src/Entity/team/Screen/teams-screen.d.ts +116 -0
  18. package/dist/src/Entity/team/Screen/teams-screen.js +349 -0
  19. package/dist/src/Entity/ticket/Component/edit-form/index.d.ts +8 -3
  20. package/dist/src/Entity/ticket/Component/edit-form/index.js +4 -6
  21. package/dist/src/Entity/ticket/Data/detail.d.ts +7 -5
  22. package/dist/src/Entity/ticket/Data/detail.js +7 -5
  23. package/dist/src/Entity/ticket/Screen/edit-assignment-form.js +0 -1
  24. package/dist/src/Entity/transport.d.ts +14 -4
  25. package/dist/src/Entity/transport.js +2 -1
  26. package/dist/src/Entity/user/Data/users.d.ts +38 -0
  27. package/dist/src/Entity/user/Data/users.js +14 -0
  28. package/dist/src/Kernel/tui-kernel.js +0 -1
  29. package/dist/src/Routing/registry.js +19 -2
  30. package/dist/src/Routing/routes.js +13 -2
  31. package/dist/src/launcher.d.ts +18 -26
  32. package/dist/src/launcher.js +9 -11
  33. package/dist/src/types.d.ts +15 -12
  34. package/package.json +2 -2
@@ -14,8 +14,8 @@ export declare const ROOT_TABS: readonly [{
14
14
  readonly id: "conductors";
15
15
  readonly label: "Conductors";
16
16
  }, {
17
- readonly id: "users";
18
- readonly label: "Users";
17
+ readonly id: "teams";
18
+ readonly label: "Teams";
19
19
  }];
20
20
  /** The project level: the tabs of an opened project. */
21
21
  export declare const PROJECT_TABS: readonly [{
@@ -65,11 +65,36 @@ export declare const TICKET_TABS: readonly [{
65
65
  readonly id: "runs";
66
66
  readonly label: "Runs";
67
67
  }];
68
+ /**
69
+ * The team level: Overview · Users for one crew — GAIA-391 AC4, by operator
70
+ * ruling.
71
+ *
72
+ * The level the `users` root tab BECAME. It sits between root and user, so the
73
+ * navigation reads root → team → user: which crews are there, who is in this
74
+ * one, what has that person done. Destinations, not modes — each tab is its own
75
+ * screen, so Tab runs a real `Router.navigate` and `teamId` is preserved
76
+ * (`Routing/registry.ts` keeps the params for this scope for the same reason it
77
+ * keeps them for `project` and `user`).
78
+ *
79
+ * `overview` is also declared on the user level, which GAIA-256 D2 already made
80
+ * legal: ids are not globally unique, every function here is level-parameterised,
81
+ * and one bar is on screen at a time.
82
+ */
83
+ export declare const TEAM_TABS: readonly [{
84
+ readonly id: "overview";
85
+ readonly label: "Overview";
86
+ }, {
87
+ readonly id: "users";
88
+ readonly label: "Users";
89
+ }];
68
90
  /**
69
91
  * The user level: Overview · Tickets · Runs for one person (GAIA-377 D1).
70
92
  *
71
93
  * Destinations, not modes — each tab is its own screen in the slice that owns
72
94
  * its query (D2), so Tab runs a real `Router.navigate` and preserves `userId`.
95
+ *
96
+ * UNCHANGED by GAIA-391: the ruling moved this level's PARENT (it hangs off
97
+ * {@link TEAM_TABS} now instead of off a root `users` tab), not its contents.
73
98
  */
74
99
  export declare const USER_TABS: readonly [{
75
100
  readonly id: "overview";
@@ -81,7 +106,13 @@ export declare const USER_TABS: readonly [{
81
106
  readonly id: "runs";
82
107
  readonly label: "Runs";
83
108
  }];
84
- /** The tab bars, keyed by level — the one place a level is named. */
109
+ /**
110
+ * The tab bars, keyed by level — the one place a level is named.
111
+ *
112
+ * Declared in DEPTH order (root → project → ticket, then team → user), which is
113
+ * documentation rather than mechanism: nothing reads this object in key order
114
+ * since GAIA-256 D2 deleted `locateTab`, the one function that did.
115
+ */
85
116
  export declare const TAB_LEVELS: {
86
117
  readonly root: readonly [{
87
118
  readonly id: "dashboard";
@@ -93,8 +124,8 @@ export declare const TAB_LEVELS: {
93
124
  readonly id: "conductors";
94
125
  readonly label: "Conductors";
95
126
  }, {
96
- readonly id: "users";
97
- readonly label: "Users";
127
+ readonly id: "teams";
128
+ readonly label: "Teams";
98
129
  }];
99
130
  readonly project: readonly [{
100
131
  readonly id: "dashboard";
@@ -122,6 +153,13 @@ export declare const TAB_LEVELS: {
122
153
  readonly id: "runs";
123
154
  readonly label: "Runs";
124
155
  }];
156
+ readonly team: readonly [{
157
+ readonly id: "overview";
158
+ readonly label: "Overview";
159
+ }, {
160
+ readonly id: "users";
161
+ readonly label: "Users";
162
+ }];
125
163
  readonly user: readonly [{
126
164
  readonly id: "overview";
127
165
  readonly label: "Overview";
@@ -174,6 +212,12 @@ export type TabLocation = {
174
212
  level: 'ticket';
175
213
  tab: string;
176
214
  tabs?: readonly TopTab[];
215
+ } | {
216
+ level: 'team';
217
+ tab: TabIdOf<'team'>;
218
+ } | {
219
+ level: 'user';
220
+ tab: TabIdOf<'user'>;
177
221
  };
178
222
  /** The entries a location draws: its own, else its level's registry. */
179
223
  export declare const tabsOfLocation: (location: TabLocation) => readonly TopTab[];
@@ -1,13 +1,17 @@
1
1
  // GAIA-239: THE registry — the single place the set, the order and the labels of
2
- // the top-level tabs are written. Navigation has THREE levels, and every screen
2
+ // the top-level tabs are written. Navigation has FIVE levels, and every screen
3
3
  // shows exactly ONE tab bar: the one of its own level.
4
4
  //
5
- // root dashboard, projects, project-picker → Dashboard · Projects
5
+ // root dashboard, projects, project-picker → Dashboard · Projects ·
6
+ // Conductors · Teams
6
7
  // project dashboard, project, ticket-agents, → Dashboard · Tickets ·
7
8
  // project-runs Ticket-Agents · Runs
8
9
  // ticket overview, comments, related, runs → the sections of one open
9
10
  // ticket, in the bar row every
10
11
  // other level uses (GAIA-346)
12
+ // team one crew → Overview · Users (GAIA-391)
13
+ // user one person → Overview · Tickets · Runs
14
+ // (GAIA-377)
11
15
  //
12
16
  // `tickets` is the project/ticket drill-down; `ticket-agents` is the managed
13
17
  // create-agent list (GAIA-190); `runs` is the project-scoped run overview
@@ -31,7 +35,10 @@
31
35
  //
32
36
  // Only tabs that are actually navigable are listed. GAIA-377 added `Users` with
33
37
  // its screens; a tab drawn before its screen exists would advertise navigation
34
- // the operator cannot take, which is the very defect GAIA-239 removes.
38
+ // the operator cannot take, which is the very defect GAIA-239 removes. The same
39
+ // rule runs the other way, and GAIA-391 is where it did: a LEVEL is declared
40
+ // with the screens that occupy it, so `team` arrives with the crew overview and
41
+ // the crew's Users list, not before them.
35
42
  //
36
43
  // GAIA-232 added `conductors`, and put it on the ROOT level although the note
37
44
  // above once reserved it for the project level. A conductor is an operator
@@ -52,8 +59,28 @@ export const ROOT_TABS = [
52
59
  { id: 'dashboard', label: 'Dashboard' },
53
60
  { id: 'projects', label: 'Projects' },
54
61
  { id: 'conductors', label: 'Conductors' },
55
- // GAIA-377 D1arrives with its screens; see USER_TABS.
56
- { id: 'users', label: 'Users' },
62
+ // GAIA-391 AC4`teams` is the LAST root tab, and there is deliberately no
63
+ // `users` beside it any more.
64
+ //
65
+ // ROOT level for teams, by GAIA-232's argument for Conductors: a team is the
66
+ // level that cuts ACROSS customer projects (GAIA-321), so "which crews are
67
+ // there" has to be answerable without first choosing a project.
68
+ //
69
+ // THIS REVISES AN INFORMATION-ARCHITECTURE DECISION THAT BELONGS TO GAIA-377,
70
+ // NOT TO GAIA-391. GAIA-377 D1 shipped `users` as a root tab and declared
71
+ // {@link USER_TABS} as the level below it; GAIA-391 moved that whole level
72
+ // under the team level by OPERATOR RULING, so the root bar reads
73
+ // `Dashboard · Projects · Conductors · Teams` and a person is reached through
74
+ // the crew they are in: Teams → one crew → Users → one person. Recorded here
75
+ // because a silent removal would later read as a regression against 377, and
76
+ // because the previous comment on this list argued the OPPOSITE order
77
+ // ("people, then the crews they are in") — an argument this ruling overturns
78
+ // rather than an accident.
79
+ //
80
+ // Users did NOT become unreachable, which is what makes the move a move: the
81
+ // team level carries the tab ({@link TEAM_TABS}), and the Dashboard's PER USER
82
+ // rows still open one person directly.
83
+ { id: 'teams', label: 'Teams' },
57
84
  ];
58
85
  /** The project level: the tabs of an opened project. */
59
86
  export const PROJECT_TABS = [
@@ -89,22 +116,51 @@ export const TICKET_TABS = [
89
116
  { id: 'related', label: 'Related' },
90
117
  { id: 'runs', label: 'Runs' },
91
118
  ];
119
+ /**
120
+ * The team level: Overview · Users for one crew — GAIA-391 AC4, by operator
121
+ * ruling.
122
+ *
123
+ * The level the `users` root tab BECAME. It sits between root and user, so the
124
+ * navigation reads root → team → user: which crews are there, who is in this
125
+ * one, what has that person done. Destinations, not modes — each tab is its own
126
+ * screen, so Tab runs a real `Router.navigate` and `teamId` is preserved
127
+ * (`Routing/registry.ts` keeps the params for this scope for the same reason it
128
+ * keeps them for `project` and `user`).
129
+ *
130
+ * `overview` is also declared on the user level, which GAIA-256 D2 already made
131
+ * legal: ids are not globally unique, every function here is level-parameterised,
132
+ * and one bar is on screen at a time.
133
+ */
134
+ export const TEAM_TABS = [
135
+ { id: 'overview', label: 'Overview' },
136
+ { id: 'users', label: 'Users' },
137
+ ];
92
138
  /**
93
139
  * The user level: Overview · Tickets · Runs for one person (GAIA-377 D1).
94
140
  *
95
141
  * Destinations, not modes — each tab is its own screen in the slice that owns
96
142
  * its query (D2), so Tab runs a real `Router.navigate` and preserves `userId`.
143
+ *
144
+ * UNCHANGED by GAIA-391: the ruling moved this level's PARENT (it hangs off
145
+ * {@link TEAM_TABS} now instead of off a root `users` tab), not its contents.
97
146
  */
98
147
  export const USER_TABS = [
99
148
  { id: 'overview', label: 'Overview' },
100
149
  { id: 'tickets', label: 'Tickets' },
101
150
  { id: 'runs', label: 'Runs' },
102
151
  ];
103
- /** The tab bars, keyed by level — the one place a level is named. */
152
+ /**
153
+ * The tab bars, keyed by level — the one place a level is named.
154
+ *
155
+ * Declared in DEPTH order (root → project → ticket, then team → user), which is
156
+ * documentation rather than mechanism: nothing reads this object in key order
157
+ * since GAIA-256 D2 deleted `locateTab`, the one function that did.
158
+ */
104
159
  export const TAB_LEVELS = {
105
160
  root: ROOT_TABS,
106
161
  project: PROJECT_TABS,
107
162
  ticket: TICKET_TABS,
163
+ team: TEAM_TABS,
108
164
  user: USER_TABS,
109
165
  };
110
166
  /** The entries a location draws: its own, else its level's registry. */
@@ -223,9 +223,10 @@ export interface ConductorRowContext {
223
223
  /**
224
224
  * The conductor collection as cockpit rows, in server order.
225
225
  *
226
- * Swallows a read failure into an empty list on purpose: the Conductors screen
227
- * re-reads every 5 s, so one failed poll must leave the screen navigable rather
228
- * than replace it with an error.
226
+ * Sideloads `machine` so capacity (GAIA-353 D5/D10) comes from the host budget,
227
+ * not removed conductor fields. Swallows a read failure into an empty list on
228
+ * purpose: the Conductors screen re-reads every 5 s, so one failed poll must
229
+ * leave the screen navigable rather than replace it with an error.
229
230
  */
230
231
  export declare const listConductorRows: (client: DropshClient, ctx: ConductorRowContext) => Promise<ConductorRow[]>;
231
232
  /**
@@ -4,14 +4,19 @@
4
4
  // service that applied them would be unusable for any other purpose. The two read
5
5
  // strategies are both just reading, so both live here.
6
6
  import { toUnixSeconds } from '@gaia-ai/core';
7
+ import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
7
8
  import { humanizeAge } from '../../../lib/format.js';
8
9
  import { listOutcome, listPreferringWrite, } from '../../transport.js';
9
10
  import { deriveConductorStatus } from './liveness.js';
10
11
  const str = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
11
12
  const toConductor = (r) => ({
12
13
  id: r.id,
13
- label: str(r.attr('label')) ?? str(r.attr('machine_id')) ?? r.id,
14
- machineId: str(r.attr('machine_id')),
14
+ label: str(r.attr('label')) ??
15
+ str(r.attr('conductor_id')) ??
16
+ str(r.attr('machine_id')) ??
17
+ r.id,
18
+ // Registration key (GAIA-353 renamed machine_id → conductor_id).
19
+ machineId: str(r.attr('conductor_id')) ?? str(r.attr('machine_id')),
15
20
  status: str(r.attr('status')),
16
21
  projectId: r.rel('project_id'),
17
22
  });
@@ -242,19 +247,68 @@ export const CONDUCTOR_TABLE = {
242
247
  ],
243
248
  };
244
249
  const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
245
- const toConductorRow = (r, ctx) => {
250
+ /** Id of a single-valued relationship linkage (`data` object), or null. */
251
+ function singleRelId(rel) {
252
+ const data = rel?.data;
253
+ if (!data || Array.isArray(data))
254
+ return null;
255
+ return data.id ?? null;
256
+ }
257
+ /**
258
+ * Dropsh's collection path hands Resource accessors; the cockpit row path uses
259
+ * client.get so it can sideload `machine`. Map the raw JSON:API resource into
260
+ * the same attr/rel surface toConductor already speaks.
261
+ */
262
+ function asResource(raw) {
263
+ return {
264
+ id: raw.id ?? '',
265
+ type: raw.type ?? '',
266
+ attr: (key) => raw.attributes?.[key],
267
+ rel: (key) => singleRelId(raw.relationships?.[key]),
268
+ rels: (key) => {
269
+ const data = raw.relationships?.[key]?.data;
270
+ if (!data)
271
+ return [];
272
+ if (Array.isArray(data)) {
273
+ return data
274
+ .map((d) => d.id)
275
+ .filter((id) => typeof id === 'string');
276
+ }
277
+ return data.id ? [data.id] : [];
278
+ },
279
+ };
280
+ }
281
+ /** Host capacity from the sideloaded machine, with cutover fallbacks (GAIA-353). */
282
+ function capacityOf(raw, machinesById) {
283
+ const machineId = singleRelId(raw.relationships?.machine);
284
+ const machine = machineId ? machinesById.get(machineId) : undefined;
285
+ const attrs = raw.attributes ?? {};
286
+ return {
287
+ // Prefer machine.current_load; process telemetry / legacy conductor attrs
288
+ // only when the host row is missing during cutover.
289
+ currentLoad: num(machine?.attributes?.current_load) ??
290
+ num(attrs.reported_load) ??
291
+ num(attrs.current_load) ??
292
+ 0,
293
+ // max_parallel is machine-owned SoT; legacy conductor attr is last resort.
294
+ maxParallel: num(machine?.attributes?.max_parallel) ?? num(attrs.max_parallel) ?? 0,
295
+ };
296
+ }
297
+ const toConductorRow = (raw, ctx, machinesById) => {
298
+ const r = asResource(raw);
246
299
  const workspaceRoot = str(r.attr('workspace_root')) ?? '';
247
300
  // ISO-8601 over the wire, not the unix integer the field name implies — a
248
301
  // `typeof === 'number'` guard here is what made a live conductor read `stale`.
249
302
  const lastSeen = toUnixSeconds(r.attr('last_seen')) ?? 0;
250
303
  const leaseExpiresAt = toUnixSeconds(r.attr('lease_expires_at')) ?? 0;
304
+ const { currentLoad, maxParallel } = capacityOf(raw, machinesById);
251
305
  return {
252
306
  ...toConductor(r),
253
307
  workspaceRoot,
254
308
  lastSeen,
255
309
  leaseExpiresAt,
256
- currentLoad: num(r.attr('current_load')) ?? 0,
257
- maxParallel: num(r.attr('max_parallel')) ?? 0,
310
+ currentLoad,
311
+ maxParallel,
258
312
  lastPid: num(r.attr('last_pid')),
259
313
  conductorName: str(r.attr('conductor_name')),
260
314
  // `present: true` because the row IS a control-plane record — which is why
@@ -273,13 +327,25 @@ const toConductorRow = (r, ctx) => {
273
327
  /**
274
328
  * The conductor collection as cockpit rows, in server order.
275
329
  *
276
- * Swallows a read failure into an empty list on purpose: the Conductors screen
277
- * re-reads every 5 s, so one failed poll must leave the screen navigable rather
278
- * than replace it with an error.
330
+ * Sideloads `machine` so capacity (GAIA-353 D5/D10) comes from the host budget,
331
+ * not removed conductor fields. Swallows a read failure into an empty list on
332
+ * purpose: the Conductors screen re-reads every 5 s, so one failed poll must
333
+ * leave the screen navigable rather than replace it with an error.
279
334
  */
280
335
  export const listConductorRows = async (client, ctx) => {
281
- const outcome = await listOutcome(client, 'gaia_conductor', (r) => toConductorRow(r, ctx));
282
- return outcome.rows;
336
+ const params = new DrupalJsonApiParams().addInclude(['machine']);
337
+ try {
338
+ const doc = (await client.get('gaia_conductor/gaia_conductor', params));
339
+ const machinesById = new Map();
340
+ for (const inc of doc.included ?? []) {
341
+ if (inc.id)
342
+ machinesById.set(inc.id, inc);
343
+ }
344
+ return (doc.data ?? []).map((raw) => toConductorRow(raw, ctx, machinesById));
345
+ }
346
+ catch {
347
+ return [];
348
+ }
283
349
  };
284
350
  /**
285
351
  * Project uuid → name, for the Conductors screen's project column.
@@ -49,7 +49,13 @@ export function selectOwnConductor(doc, gaiaDir) {
49
49
  ? str((doc.included ?? []).find((inc) => inc.id === projectId)?.attributes
50
50
  ?.name)
51
51
  : undefined;
52
- return { machineId: str(own.attributes?.machine_id), project };
52
+ // GAIA-353 renamed the registration key machine_id conductor_id; accept
53
+ // the legacy attribute during cutover so a pre-upgrade control plane still
54
+ // preselects this checkout's conductor.
55
+ return {
56
+ machineId: str(own.attributes?.conductor_id) ?? str(own.attributes?.machine_id),
57
+ project,
58
+ };
53
59
  }
54
60
  /**
55
61
  * Read the conductor collection and resolve this checkout's own record.
@@ -51,6 +51,24 @@ export interface StatisticsBlock {
51
51
  total: StatisticsWindows;
52
52
  byProject?: StatisticsRow[];
53
53
  byUser?: StatisticsRow[];
54
+ /**
55
+ * GAIA-391 (AC4): the crew axis the server has served since GAIA-321.
56
+ *
57
+ * A team row is expanded from the per-USER counts onto every active team the
58
+ * assignee belongs to, so a dual-member's ticket is counted once per team and
59
+ * `sum(byTeam.*.window)` MAY EXCEED `total.window`. That is the server's own
60
+ * documented intent, not drift — which is why the summing invariant `byUser`
61
+ * carries (spec D8) is deliberately not claimed for this axis anywhere.
62
+ *
63
+ * Absent, never empty, when the read was team-scoped: the server omits it, the
64
+ * same way it omits `byProject` under `?project_id=` (spec D5).
65
+ *
66
+ * GAIA-319 D7 — a crew whose workspace the CALLER does not belong to is
67
+ * DROPPED, not folded, so this axis never carries an `id: null` row and the
68
+ * rows present may be fewer than the instance's crews. That is a disclosure
69
+ * boundary rather than a count: the axis reports what the caller may see.
70
+ */
71
+ byTeam?: StatisticsRow[];
54
72
  byDay?: StatisticsDayRow[];
55
73
  }
56
74
  /** The whole document, as the Dashboard consumes it. */
@@ -80,6 +98,41 @@ export type StatisticsResult = {
80
98
  };
81
99
  /** How an `id: null` breakdown row is named on screen (spec D8/D11). */
82
100
  export declare const UNATTRIBUTED_LABEL = "\u2014 unattributed";
101
+ /**
102
+ * `byTeam` with the part of the total no crew accounts for, as a real row.
103
+ *
104
+ * GAIA-391 round 4. The dashboard drew three breakdown axes identically while
105
+ * only two of them added up, and only `byUser` had a NAME for what it could not
106
+ * attribute — so a crew axis reporting 1 against a total of 2 was, at the
107
+ * surface, indistinguishable from a broken expansion. Measured on this branch's
108
+ * control plane as uid 4: `runs.total` 2, `byProject` 2, `byUser` 1 +
109
+ * unattributed 1, `byTeam` 1, because the second run's ticket carried no
110
+ * assignee. An operator read it as a defect within minutes.
111
+ *
112
+ * THE SERVER IS NOT THE THING BEING CHANGED. `by_team` still carries no
113
+ * `id: null` row and {@link rowsOf} still does not invent one — that is
114
+ * GAIA-321 D6, and the parse is asserted to leave it alone. This is the
115
+ * RENDERING's own row, derived here so both the interactive and the piped
116
+ * surface get it from one place, and labelled with the very same
117
+ * {@link UNATTRIBUTED_LABEL} `byUser` uses: the problem was three axes drawn
118
+ * alike, so a fourth visual language would not be a fix.
119
+ *
120
+ * The shortfall is computed PER WINDOW, because the axis can be exact in one
121
+ * window and short in another, and it is clamped at zero for the one documented
122
+ * case where the axis legitimately OVER-reports: a dual member's row is counted
123
+ * once per crew (GAIA-321 D6), and a negative shortfall is not a shortfall.
124
+ *
125
+ * `undefined` in, `undefined` out. A team-scoped read omits the axis (spec D5)
126
+ * and deriving a row there would draw a `PER TEAM` heading whose single row is
127
+ * the whole total, on the one screen already scoped to a single crew. An axis
128
+ * that is present but EMPTY is a different thing and does get the row: the
129
+ * server computed it and nothing landed in it.
130
+ *
131
+ * @param block One entity type's figures, as {@link loadStatistics} parsed them.
132
+ *
133
+ * @returns The crew rows to draw, or `undefined` when there is no crew axis.
134
+ */
135
+ export declare function teamRowsWithUnattributed(block: StatisticsBlock): StatisticsRow[] | undefined;
83
136
  /**
84
137
  * Closed tickets per run for each trailing window (GAIA-377 D4).
85
138
  *
@@ -98,15 +151,39 @@ export declare const formatClosedPerRun: (value: number | null) => string;
98
151
  * Read the control plane's ticket and run statistics — the ONE source of every
99
152
  * figure the Dashboard renders (AC-8).
100
153
  *
154
+ * NOT INSTANCE-WIDE since GAIA-319: the endpoint scopes every count to the
155
+ * requesting account's workspace memberships (`StatisticsCounter`,
156
+ * `TeamStatistics`, `StatisticsResource`), unless that account holds
157
+ * `administer gaia` and bypasses the scope. The document's SHAPE is unchanged —
158
+ * the same `total` plus `by_project` / `by_user` / `by_team` / `by_day`, with the
159
+ * same rows — so nothing in this parser moved, and that is exactly what the
160
+ * GAIA-319 cases in `tests/Entity/statistics/Data/statistics.test.ts` pin.
161
+ *
162
+ * Two of that scoping's effects are worth knowing HERE, because they look like
163
+ * client bugs and are not:
164
+ *
165
+ * - a person the caller may not name is FOLDED into the `by_user` row with
166
+ * `id: null` (D6), which this file renders as {@link UNATTRIBUTED_LABEL};
167
+ * - a crew outside the caller's workspaces is DROPPED from `by_team` (D7), so
168
+ * `sum(byTeam)` under-reports the instance rather than carrying a residual row.
169
+ *
170
+ * And one this client cannot fix: an account with no workspace memberships gets a
171
+ * document full of ZEROES, which the Dashboard reads as "no activity". Nothing in
172
+ * the payload distinguishes that from a quiet month, so the CLI does not claim to.
173
+ *
101
174
  * With `projectId` the read is scoped and the server drops `by_project` from
102
175
  * both blocks (spec D5). With `userId` (GAIA-377 D3) it drops `by_user` and
103
- * keeps `by_project`. The parameter names are `project_id` / `user_id` and NOT
104
- * bare letters: JSON:API requires an implementation-specific query parameter to
176
+ * keeps `by_project`. With `teamId` (GAIA-391 AC4, over the `?team_id=` GAIA-321
177
+ * already serves) it drops `by_team` and narrows the counts to the team's ACTIVE
178
+ * members. All three compose with AND, as the server's own resource documents.
179
+ * The parameter names are `project_id` / `user_id` / `team_id` and NOT bare
180
+ * letters: JSON:API requires an implementation-specific query parameter to
105
181
  * contain at least one non-`a-z` character, and Drupal enforces it.
106
182
  *
107
183
  * The client is the caller's, singular and used once: the TUI reads everything
108
- * through `services.client` (the `session` profile), and `pm` cannot read
109
- * `gaia_run` a loader that reached for another client would render zero runs
110
- * as a real measurement (spec R3).
184
+ * through `services.client`. Reaching for a second client would render zero runs
185
+ * as a real measurement (spec R3) the reason has changed with GAIA-391 (there
186
+ * is one identity now, not a read profile that can see runs and a write profile
187
+ * that cannot), but the rule has not: one figure, one source.
111
188
  */
112
- export declare function loadStatistics(client: DropshClient, projectId?: string, userId?: string): Promise<StatisticsResult>;
189
+ export declare function loadStatistics(client: DropshClient, projectId?: string, userId?: string, teamId?: string): Promise<StatisticsResult>;
@@ -41,11 +41,13 @@ function blockOf(value) {
41
41
  const b = (value ?? {});
42
42
  const byProject = rowsOf(b.by_project);
43
43
  const byUser = rowsOf(b.by_user);
44
+ const byTeam = rowsOf(b.by_team);
44
45
  const byDay = dayRowsOf(b.by_day);
45
46
  return {
46
47
  total: windowsOf(b.total),
47
48
  ...(byProject ? { byProject } : {}),
48
49
  ...(byUser ? { byUser } : {}),
50
+ ...(byTeam ? { byTeam } : {}),
49
51
  ...(byDay ? { byDay } : {}),
50
52
  };
51
53
  }
@@ -100,6 +102,56 @@ function jsonApiErrorDetail(caught) {
100
102
  // fall back to a non-empty marker rather than to `undefined`.
101
103
  return typeof detail === 'string' && detail !== '' ? detail : 'HTTP 404';
102
104
  }
105
+ /**
106
+ * `byTeam` with the part of the total no crew accounts for, as a real row.
107
+ *
108
+ * GAIA-391 round 4. The dashboard drew three breakdown axes identically while
109
+ * only two of them added up, and only `byUser` had a NAME for what it could not
110
+ * attribute — so a crew axis reporting 1 against a total of 2 was, at the
111
+ * surface, indistinguishable from a broken expansion. Measured on this branch's
112
+ * control plane as uid 4: `runs.total` 2, `byProject` 2, `byUser` 1 +
113
+ * unattributed 1, `byTeam` 1, because the second run's ticket carried no
114
+ * assignee. An operator read it as a defect within minutes.
115
+ *
116
+ * THE SERVER IS NOT THE THING BEING CHANGED. `by_team` still carries no
117
+ * `id: null` row and {@link rowsOf} still does not invent one — that is
118
+ * GAIA-321 D6, and the parse is asserted to leave it alone. This is the
119
+ * RENDERING's own row, derived here so both the interactive and the piped
120
+ * surface get it from one place, and labelled with the very same
121
+ * {@link UNATTRIBUTED_LABEL} `byUser` uses: the problem was three axes drawn
122
+ * alike, so a fourth visual language would not be a fix.
123
+ *
124
+ * The shortfall is computed PER WINDOW, because the axis can be exact in one
125
+ * window and short in another, and it is clamped at zero for the one documented
126
+ * case where the axis legitimately OVER-reports: a dual member's row is counted
127
+ * once per crew (GAIA-321 D6), and a negative shortfall is not a shortfall.
128
+ *
129
+ * `undefined` in, `undefined` out. A team-scoped read omits the axis (spec D5)
130
+ * and deriving a row there would draw a `PER TEAM` heading whose single row is
131
+ * the whole total, on the one screen already scoped to a single crew. An axis
132
+ * that is present but EMPTY is a different thing and does get the row: the
133
+ * server computed it and nothing landed in it.
134
+ *
135
+ * @param block One entity type's figures, as {@link loadStatistics} parsed them.
136
+ *
137
+ * @returns The crew rows to draw, or `undefined` when there is no crew axis.
138
+ */
139
+ export function teamRowsWithUnattributed(block) {
140
+ const rows = block.byTeam;
141
+ if (rows === undefined)
142
+ return undefined;
143
+ const shortfall = (window) => Math.max(0, block.total[window] - rows.reduce((sum, row) => sum + row[window], 0));
144
+ const residual = {
145
+ id: null,
146
+ label: UNATTRIBUTED_LABEL,
147
+ day: shortfall('day'),
148
+ week: shortfall('week'),
149
+ month: shortfall('month'),
150
+ };
151
+ if (residual.day === 0 && residual.week === 0 && residual.month === 0)
152
+ return rows;
153
+ return [...rows, residual];
154
+ }
103
155
  /**
104
156
  * Closed tickets per run for each trailing window (GAIA-377 D4).
105
157
  *
@@ -121,18 +173,42 @@ export const formatClosedPerRun = (value) => value === null ? '—' : value.toFi
121
173
  * Read the control plane's ticket and run statistics — the ONE source of every
122
174
  * figure the Dashboard renders (AC-8).
123
175
  *
176
+ * NOT INSTANCE-WIDE since GAIA-319: the endpoint scopes every count to the
177
+ * requesting account's workspace memberships (`StatisticsCounter`,
178
+ * `TeamStatistics`, `StatisticsResource`), unless that account holds
179
+ * `administer gaia` and bypasses the scope. The document's SHAPE is unchanged —
180
+ * the same `total` plus `by_project` / `by_user` / `by_team` / `by_day`, with the
181
+ * same rows — so nothing in this parser moved, and that is exactly what the
182
+ * GAIA-319 cases in `tests/Entity/statistics/Data/statistics.test.ts` pin.
183
+ *
184
+ * Two of that scoping's effects are worth knowing HERE, because they look like
185
+ * client bugs and are not:
186
+ *
187
+ * - a person the caller may not name is FOLDED into the `by_user` row with
188
+ * `id: null` (D6), which this file renders as {@link UNATTRIBUTED_LABEL};
189
+ * - a crew outside the caller's workspaces is DROPPED from `by_team` (D7), so
190
+ * `sum(byTeam)` under-reports the instance rather than carrying a residual row.
191
+ *
192
+ * And one this client cannot fix: an account with no workspace memberships gets a
193
+ * document full of ZEROES, which the Dashboard reads as "no activity". Nothing in
194
+ * the payload distinguishes that from a quiet month, so the CLI does not claim to.
195
+ *
124
196
  * With `projectId` the read is scoped and the server drops `by_project` from
125
197
  * both blocks (spec D5). With `userId` (GAIA-377 D3) it drops `by_user` and
126
- * keeps `by_project`. The parameter names are `project_id` / `user_id` and NOT
127
- * bare letters: JSON:API requires an implementation-specific query parameter to
198
+ * keeps `by_project`. With `teamId` (GAIA-391 AC4, over the `?team_id=` GAIA-321
199
+ * already serves) it drops `by_team` and narrows the counts to the team's ACTIVE
200
+ * members. All three compose with AND, as the server's own resource documents.
201
+ * The parameter names are `project_id` / `user_id` / `team_id` and NOT bare
202
+ * letters: JSON:API requires an implementation-specific query parameter to
128
203
  * contain at least one non-`a-z` character, and Drupal enforces it.
129
204
  *
130
205
  * The client is the caller's, singular and used once: the TUI reads everything
131
- * through `services.client` (the `session` profile), and `pm` cannot read
132
- * `gaia_run` a loader that reached for another client would render zero runs
133
- * as a real measurement (spec R3).
206
+ * through `services.client`. Reaching for a second client would render zero runs
207
+ * as a real measurement (spec R3) the reason has changed with GAIA-391 (there
208
+ * is one identity now, not a read profile that can see runs and a write profile
209
+ * that cannot), but the rule has not: one figure, one source.
134
210
  */
135
- export async function loadStatistics(client, projectId, userId) {
211
+ export async function loadStatistics(client, projectId, userId, teamId) {
136
212
  const params = new DrupalJsonApiParams();
137
213
  if (projectId !== undefined) {
138
214
  params.addCustomParam({ project_id: projectId });
@@ -140,6 +216,9 @@ export async function loadStatistics(client, projectId, userId) {
140
216
  if (userId !== undefined) {
141
217
  params.addCustomParam({ user_id: userId });
142
218
  }
219
+ if (teamId !== undefined) {
220
+ params.addCustomParam({ team_id: teamId });
221
+ }
143
222
  let doc;
144
223
  try {
145
224
  doc = (await client.get('gaia/statistics', params));
@@ -3,7 +3,7 @@ import { lineText, sectionRowNode, sectionRows, } from '../../../Component/secti
3
3
  import { statsPanelRows, } from '../../../Component/stats-panel/index.js';
4
4
  import { columnNode } from '../../../lib/vnode.js';
5
5
  import { AsyncScreen } from '../../../Screen/async-screen.js';
6
- import { closedPerRun, formatClosedPerRun, loadStatistics, } from '../Data/statistics.js';
6
+ import { closedPerRun, formatClosedPerRun, loadStatistics, teamRowsWithUnattributed, } from '../Data/statistics.js';
7
7
  /** Rows that are plain text, keyed under one name: a failure or empty state. */
8
8
  const plainRows = (key, lines) => lines.map((text, index) => ({ key: `${key}-${index}`, text, bold: false }));
9
9
  /** The Tab hint is a TERMINAL affordance; a pipe has no tabs (spec D9). */
@@ -219,10 +219,11 @@ const totalRows = (block) => [
219
219
  { label: WINDOWS[2] ?? '', numbers: [block.total.month] },
220
220
  ];
221
221
  /**
222
- * The breakdown groups of one axis (`by_project` or `by_user`), titled.
222
+ * The breakdown groups of one axis (`by_project`, `by_user` or `by_team`), titled.
223
223
  *
224
224
  * The title and the rows come from the SAME literal pair, so a third entity type
225
- * is added in one place and cannot be named one way and grouped another.
225
+ * is added in one place and cannot be named one way and grouped another — which
226
+ * is what made GAIA-391's `by_team` axis a one-line addition to the list below.
226
227
  */
227
228
  const groups = (statistics, pick) => [
228
229
  ['Tickets', pick(statistics.tickets)],
@@ -267,6 +268,19 @@ function statisticsSections(statistics) {
267
268
  for (const [heading, pick] of [
268
269
  ['PER PROJECT', (block) => block.byProject],
269
270
  ['PER USER', (block) => block.byUser],
271
+ // GAIA-391 (AC4): the crew axis, drawn LAST because it is the narrowest
272
+ // reading of the same figures — and because `PER USER` owns the selection
273
+ // cursor below, which keys its row ids off its own header offset. A team row
274
+ // is deliberately not selectable: the team detail is reached from the Teams
275
+ // list, its own route, and a second entrance here would put one destination
276
+ // behind two cursors on one screen.
277
+ //
278
+ // Round 4 — picked through `teamRowsWithUnattributed` rather than straight
279
+ // off the block, so the axis that carries no sum invariant SAYS SO on
280
+ // screen, in the row `PER USER` already uses for the same thing. Three axes
281
+ // drawn identically while one silently under-reported is what an operator
282
+ // read as a defect; the derivation is documented where it lives.
283
+ ['PER TEAM', (block) => teamRowsWithUnattributed(block)],
270
284
  ]) {
271
285
  const rowGroups = groups(statistics, pick);
272
286
  if (rowGroups.length === 0)