@meshmakers/octo-meshboard 3.4.1490 → 3.5.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.
@@ -1,7 +1,7 @@
1
- import { FieldFilterOperatorsDto, CkTypeSelectorService, RuntimeEntitySelectDataSource, RuntimeEntityDialogDataSource, DeleteStrategiesDto, AssociationModOptionsDto, CkModelService, TENANT_ID_PROVIDER, QueryModeDto, GraphDirectionDto, AttributeSelectorService, GraphQL, GetCkTypeAvailableQueryColumnsDtoGQL, SortOrdersDto, SeriesResolutionSignalDto, HealthService, AssetRepoService, JobManagementService, SystemCommunicationDeploymentStateDto, SystemCommunicationConfigurationStateDto, CONFIGURATION_SERVICE } from '@meshmakers/octo-services';
1
+ import { FieldFilterOperatorsDto, CkTypeSelectorService, RuntimeEntitySelectDataSource, RuntimeEntityDialogDataSource, DeleteStrategiesDto, AssociationModOptionsDto, CkModelService, TENANT_ID_PROVIDER, QueryModeDto, GraphDirectionDto, AttributeSelectorService, GraphQL, GetCkTypeAvailableQueryColumnsDtoGQL, SortOrdersDto, SeriesResolutionSignalDto, HealthService, AssetRepoService, JobManagementService, CONFIGURATION_SERVICE, SystemCommunicationDeploymentStateDto, SystemCommunicationConfigurationStateDto, HealthStatus } from '@meshmakers/octo-services';
2
2
  export { RuntimeEntityDialogDataSource, RuntimeEntitySelectDataSource, TENANT_ID_PROVIDER } from '@meshmakers/octo-services';
3
3
  import * as i0 from '@angular/core';
4
- import { Injectable, inject, EventEmitter, forwardRef, Output, Input, ViewChild, ChangeDetectionStrategy, Component, Injector, EnvironmentInjector, ApplicationRef, signal, computed, effect, untracked, Directive, NgZone, DestroyRef, ElementRef, input, createEnvironmentInjector, ViewContainerRef, makeEnvironmentProviders, provideAppInitializer, InjectionToken, output, isSignal, viewChild } from '@angular/core';
4
+ import { Injectable, inject, EventEmitter, forwardRef, Output, Input, ViewChild, ChangeDetectionStrategy, Component, Injector, EnvironmentInjector, ApplicationRef, signal, computed, effect, untracked, Directive, InjectionToken, isSignal, NgZone, DestroyRef, ElementRef, input, createEnvironmentInjector, ViewContainerRef, makeEnvironmentProviders, provideAppInitializer, output, viewChild } from '@angular/core';
5
5
  import * as i1$1 from '@angular/forms';
6
6
  import { NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';
7
7
  import { firstValueFrom, from, map, Observable, of, switchMap as switchMap$1, catchError, interval, filter, combineLatest, defer, EMPTY } from 'rxjs';
@@ -25,7 +25,7 @@ import * as i1$2 from '@progress/kendo-angular-indicators';
25
25
  import { LoaderModule } from '@progress/kendo-angular-indicators';
26
26
  import * as i1$4 from '@progress/kendo-angular-icons';
27
27
  import { SVGIconModule } from '@progress/kendo-angular-icons';
28
- import { arrowUpIcon, arrowDownIcon, minusIcon, arrowRightIcon, arrowLeftIcon, linkIcon, chevronDownIcon, chevronRightIcon, circleIcon, questionCircleIcon, minusCircleIcon, warningTriangleIcon, exclamationCircleIcon, xCircleIcon, checkCircleIcon, columnsIcon, sortAscIcon, filterIcon, searchIcon, chartPieIcon, infoCircleIcon, plusIcon, pencilIcon, chartLineIcon, clockArrowRotateIcon, chartAreaRangeIcon, dataIcon, connectorIcon, gearsIcon, clipboardMarkdownIcon, copyIcon, gridIcon, heartIcon, gridLayoutIcon, chartLineMarkersIcon, chartColumnStackedIcon, chartDoughnutIcon, tableIcon, shareIcon, fileTxtIcon, checkIcon, xIcon, uploadIcon, pinIcon, unpinIcon, gearIcon, saveIcon, arrowRotateCwIcon, undoIcon } from '@progress/kendo-svg-icons';
28
+ import { arrowUpIcon, arrowDownIcon, minusIcon, arrowRightIcon, arrowLeftIcon, linkIcon, chevronDownIcon, chevronRightIcon, circleIcon, questionCircleIcon, minusCircleIcon, warningTriangleIcon, exclamationCircleIcon, xCircleIcon, checkCircleIcon, columnsIcon, sortAscIcon, filterIcon, searchIcon, plusIcon, trashIcon, chartPieIcon, infoCircleIcon, pencilIcon, chartLineIcon, clockArrowRotateIcon, dataIcon, connectorIcon, chartAreaRangeIcon, gearsIcon, clipboardMarkdownIcon, copyIcon, gridIcon, heartIcon, gridLayoutIcon, chartLineMarkersIcon, chartColumnStackedIcon, chartDoughnutIcon, tableIcon, shareIcon, fileTxtIcon, checkIcon, xIcon, uploadIcon, pinIcon, unpinIcon, gearIcon, saveIcon, arrowRotateCwIcon, undoIcon } from '@progress/kendo-svg-icons';
29
29
  import { ExpressionEvaluatorService, GetProcessDiagramDtoGQL, GetProcessDiagramsDtoGQL, CreateProcessDiagramDtoGQL, UpdateProcessDiagramDtoGQL, SymbolLibraryService, renderAnimations, getFlowParticlesAnimation, renderFlowParticles } from '@meshmakers/octo-process-diagrams';
30
30
  import { NotificationService } from '@progress/kendo-angular-notification';
31
31
  import * as i1$6 from '@progress/kendo-angular-gauges';
@@ -48,7 +48,10 @@ import { BreadCrumbService } from '@meshmakers/shared-services';
48
48
  * Defines the interfaces for MeshBoard widgets and their data sources
49
49
  */
50
50
  /** The cockpit widget types (e.g. to tell whether a board already shows them). */
51
- const COCKPIT_WIDGET_TYPES = ['attentionList', 'adapterStatus', 'ckModelState', 'pipelineExecutions'];
51
+ const COCKPIT_WIDGET_TYPES = [
52
+ 'attentionList', 'adapterStatus', 'ckModelState', 'pipelineExecutions',
53
+ 'tenantCount', 'blueprintUpdates', 'servicesHealth', 'versionInfo'
54
+ ];
52
55
  /** True for the cockpit widget types. */
53
56
  function isCockpitWidgetType(type) {
54
57
  return COCKPIT_WIDGET_TYPES.includes(type ?? '');
@@ -255,6 +258,72 @@ function chartThemeColorsFrom(style) {
255
258
  function chartThemeColors(doc = typeof document !== 'undefined' ? document : null) {
256
259
  return chartThemeColorsFrom(computedRootStyle(doc));
257
260
  }
261
+ /** Fixed series colours, the last resort when the host theme defines no chart palette (or no CSS). */
262
+ const FALLBACK_SERIES_COLORS = Object.freeze(['#3b9eff', '#2fb37a', '#e0a43a', '#c861d6', '#64ceb9', '#da9162', '#6c4da8', '#8a94a0']);
263
+ const SERIES_TOKEN_PREFIXES = ['--theme-chart-', '--kendo-chart-series-'];
264
+ const KENDO_SERIES_LETTERS = ['a', 'b', 'c', 'd', 'e', 'f'];
265
+ const MAX_SERIES_TOKENS = 12;
266
+ /** A resolved custom property value usable as an SVG fill (non-empty, no unresolved `var()`, a valid colour where checkable). */
267
+ function isUsableColor(value) {
268
+ if (!value || value.includes('var(')) {
269
+ return false;
270
+ }
271
+ return typeof CSS === 'undefined' || typeof CSS.supports !== 'function' || CSS.supports('color', value);
272
+ }
273
+ function chartSeriesColorsFrom(style) {
274
+ for (const prefix of SERIES_TOKEN_PREFIXES) {
275
+ const colors = [];
276
+ for (let i = 1; i <= MAX_SERIES_TOKENS; i++) {
277
+ const value = style?.getPropertyValue(`${prefix}${i}`).trim();
278
+ if (isUsableColor(value)) {
279
+ colors.push(value);
280
+ }
281
+ }
282
+ if (colors.length) {
283
+ return colors;
284
+ }
285
+ }
286
+ const kendo = KENDO_SERIES_LETTERS.map(letter => style?.getPropertyValue(`--kendo-color-series-${letter}`).trim()).filter(isUsableColor);
287
+ return kendo.length ? kendo : [...FALLBACK_SERIES_COLORS];
288
+ }
289
+ /**
290
+ * The categorical chart palette of the current theme (AB#5622): the host's `--theme-chart-1…12`
291
+ * (Studio tokens), else the Kendo theme's `--kendo-chart-series-1…12`, else
292
+ * `--kendo-color-series-a…f`, else {@link FALLBACK_SERIES_COLORS}. Values that are empty, still
293
+ * contain `var()` or are no valid colour are skipped — Kendo resolves its series colours once per
294
+ * page load, and a token it could not resolve rendered a slice black in dark mode. The charts set
295
+ * these colours explicitly on every data item, so the palette also follows a live theme switch.
296
+ */
297
+ function chartSeriesColors(doc = typeof document !== 'undefined' ? document : null) {
298
+ return chartSeriesColorsFrom(computedRootStyle(doc));
299
+ }
300
+ /**
301
+ * Resolves a configured colour (per-category colour of a chart, AB#5622): a status name
302
+ * (`success`, `warning`, `error`, `info` → theme status colour), a custom property (`--brand-paid`
303
+ * or `var(--brand-paid, #2fb37a)`) read from the document root, or any CSS colour (`#2fb37a`,
304
+ * `rgb(…)`) used as it is. Returns `undefined` when it cannot be resolved (the default colour applies).
305
+ */
306
+ function resolveChartColor(token, doc = typeof document !== 'undefined' ? document : null) {
307
+ // Board JSON is not type-checked: a non-string value (number, object) is no colour.
308
+ const text = typeof token === 'string' ? token.trim() : '';
309
+ if (!text) {
310
+ return undefined;
311
+ }
312
+ if (text === 'success' || text === 'warning' || text === 'error' || text === 'info') {
313
+ return statusColor(text, doc);
314
+ }
315
+ const variable = /^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\)$/.exec(text);
316
+ const name = text.startsWith('--') ? text : variable?.[1];
317
+ if (name) {
318
+ const value = computedRootStyle(doc)?.getPropertyValue(name).trim();
319
+ if (isUsableColor(value)) {
320
+ return value;
321
+ }
322
+ const fallback = variable?.[2]?.trim();
323
+ return isUsableColor(fallback) ? fallback : undefined;
324
+ }
325
+ return isUsableColor(text) ? text : undefined;
326
+ }
258
327
  /**
259
328
  * What the charts' resolved colours depend on: the `data-theme` attribute, the OS colour scheme,
260
329
  * the resolved status colours and the chart text / line colours. `class` / `style` of `<html>` are deliberately not part of it —
@@ -265,7 +334,7 @@ function themeSignature(doc = typeof document !== 'undefined' ? document : null)
265
334
  // One style computation per signature (it runs on every <html> mutation).
266
335
  const style = computedRootStyle(doc);
267
336
  const text = chartThemeColorsFrom(style);
268
- const colors = [...['success', 'warning', 'error', 'info'].map(status => statusColorFrom(status, style)), text.text, text.muted, text.grid].join(',');
337
+ const colors = [...['success', 'warning', 'error', 'info'].map(status => statusColorFrom(status, style)), text.text, text.muted, text.grid, ...chartSeriesColorsFrom(style)].join(',');
269
338
  return `${doc?.documentElement?.getAttribute('data-theme') ?? ''}|${dark ? 'dark' : 'light'}|${colors}`;
270
339
  }
271
340
  /**
@@ -10150,6 +10219,63 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
10150
10219
  type: Input
10151
10220
  }] } });
10152
10221
 
10222
+ /** Default texts of {@link MeshBoardMessages} (the list texts default inside `mm-list-view`). */
10223
+ const DEFAULT_MESHBOARD_MESSAGES = {
10224
+ tableNotConfigured: 'Table not configured',
10225
+ };
10226
+ /**
10227
+ * Optional: the host's translations of the MeshBoard widget texts (English without it). Set it
10228
+ * once app-wide, either directly or via `provideMeshBoard({ messages })` /
10229
+ * `provideMeshBoardMessages(messages)`.
10230
+ *
10231
+ * ```ts
10232
+ * providers: [provideMeshBoardMessages({ tableList: { noRecords: 'Keine Einträge vorhanden.', pagerItemsPerPage: 'Einträge pro Seite' } })]
10233
+ * ```
10234
+ */
10235
+ const MESHBOARD_MESSAGES = new InjectionToken('MESHBOARD_MESSAGES');
10236
+ /** Reads a {@link MeshBoardMessagesSource} (fixed or signal) as the current host layer. */
10237
+ function readMeshBoardMessagesSource(source) {
10238
+ if (!source) {
10239
+ return null;
10240
+ }
10241
+ return isSignal(source) ? source() ?? null : source;
10242
+ }
10243
+ /** The resolved MeshBoard texts: defaults with the host layer on top (missing / null members keep the default). */
10244
+ function resolveMeshBoardMessages(messages) {
10245
+ return {
10246
+ ...DEFAULT_MESHBOARD_MESSAGES,
10247
+ ...(messages?.tableList ? { tableList: messages.tableList } : {}),
10248
+ ...(messages?.tableNotConfigured ? { tableNotConfigured: messages.tableNotConfigured } : {}),
10249
+ };
10250
+ }
10251
+ /** The resolved MeshBoard messages of {@link MESHBOARD_MESSAGES} as a signal. Call in an injection context. */
10252
+ function injectMeshBoardMessages() {
10253
+ const source = inject(MESHBOARD_MESSAGES, { optional: true });
10254
+ return computed(() => resolveMeshBoardMessages(readMeshBoardMessagesSource(source)));
10255
+ }
10256
+
10257
+ /** App-wide {@link MeshBoardLabelResolver}. Not provided = today's texts. */
10258
+ const MESHBOARD_LABEL_RESOLVER = new InjectionToken('MESHBOARD_LABEL_RESOLVER');
10259
+ const failedResolvers = new WeakSet();
10260
+ /** Resolves one label; the default text when there is no resolver, no translation, or the resolver throws. */
10261
+ function resolveMeshBoardLabel(resolver, request) {
10262
+ if (!resolver) {
10263
+ return request.defaultText;
10264
+ }
10265
+ let text;
10266
+ try {
10267
+ text = resolver(request);
10268
+ }
10269
+ catch (error) {
10270
+ if (!failedResolvers.has(resolver)) {
10271
+ failedResolvers.add(resolver);
10272
+ console.warn('octo-meshboard: the label resolver threw; showing the default texts', error);
10273
+ }
10274
+ return request.defaultText;
10275
+ }
10276
+ return typeof text === 'string' && text !== '' ? text : request.defaultText;
10277
+ }
10278
+
10153
10279
  const ICON_MAP = {
10154
10280
  'check-circle': checkCircleIcon,
10155
10281
  'x-circle': xCircleIcon,
@@ -10169,6 +10295,13 @@ class TableWidgetComponent {
10169
10295
  config;
10170
10296
  dataSource;
10171
10297
  stateService = inject(MeshBoardStateService);
10298
+ /** Host texts (`MESHBOARD_MESSAGES`, AB#5622): list texts (empty state, pager, toolbar) and the placeholder. */
10299
+ texts = injectMeshBoardMessages();
10300
+ /** Host translation of enum cell values (`MESHBOARD_LABEL_RESOLVER`, AB#5622); absent = raw text. */
10301
+ labelResolver = inject(MESHBOARD_LABEL_RESOLVER, { optional: true });
10302
+ /** CK value type per derived column field (persistent queries), for the label resolver. */
10303
+ _queryColumnValueTypes = signal(new Map(), /* @ts-ignore */
10304
+ ...(ngDevMode ? [{ debugName: "_queryColumnValueTypes" }] : /* istanbul ignore next */ []));
10172
10305
  // Widget state signals
10173
10306
  _isLoading = signal(false, /* @ts-ignore */
10174
10307
  ...(ngDevMode ? [{ debugName: "_isLoading" }] : /* istanbul ignore next */ []));
@@ -10188,6 +10321,7 @@ class TableWidgetComponent {
10188
10321
  */
10189
10322
  onQueryColumnsLoaded(columns) {
10190
10323
  if (columns.length > 0) {
10324
+ this._queryColumnValueTypes.set(new Map(columns.map(col => [col.attributePath, col.attributeValueType])));
10191
10325
  this._queryColumnsForView.set(columns.map(col => ({
10192
10326
  field: col.attributePath,
10193
10327
  displayName: this.formatColumnTitle(col.attributePath),
@@ -10209,12 +10343,41 @@ class TableWidgetComponent {
10209
10343
  // explicitly typed ('date', 'numeric', 'iso8601', …) keep their own
10210
10344
  // rendering, and any value that is not an ISO-8601 date-time passes through
10211
10345
  // unchanged. Derived stream-data columns are all 'text', so they're covered.
10346
+ //
10347
+ // With a host label resolver (AB#5622) string cells of those columns are offered to it as
10348
+ // well (enum values such as `PAID` → "Bezahlt"); columns whose query value type is known and
10349
+ // not an enum are skipped. Without a resolver the formatter is unchanged.
10350
+ const resolver = this.labelResolver;
10351
+ const dataSource = this.config?.dataSource;
10352
+ const ckTypeId = dataSource?.type === 'runtimeEntity' ? dataSource.ckTypeId || undefined : undefined;
10353
+ const valueTypes = this._queryColumnValueTypes();
10212
10354
  const withDateFormatter = (column) => {
10213
10355
  const isPlainText = !column.dataType || column.dataType === 'text';
10214
10356
  if (!isPlainText || column.formatter) {
10215
10357
  return column;
10216
10358
  }
10217
- return { ...column, formatter: (value) => formatTableCellValue(value, mode) };
10359
+ const valueType = valueTypes.get(column.field) || undefined;
10360
+ const translate = !!resolver && (!valueType || /ENUM/i.test(valueType));
10361
+ if (!translate) {
10362
+ return { ...column, formatter: (value) => formatTableCellValue(value, mode) };
10363
+ }
10364
+ return {
10365
+ ...column,
10366
+ formatter: (value) => {
10367
+ const text = formatTableCellValue(value, mode);
10368
+ if (typeof value !== 'string' || value === '') {
10369
+ return text;
10370
+ }
10371
+ return resolveMeshBoardLabel(resolver, {
10372
+ kind: 'tableCell',
10373
+ ...(ckTypeId ? { ckTypeId } : {}),
10374
+ attribute: column.field,
10375
+ value,
10376
+ ...(valueType ? { valueType } : {}),
10377
+ defaultText: text
10378
+ });
10379
+ }
10380
+ };
10218
10381
  };
10219
10382
  // If explicit columns are configured, use them (works for both data source types)
10220
10383
  if (this.config?.columns && this.config.columns.length > 0) {
@@ -10281,6 +10444,7 @@ class TableWidgetComponent {
10281
10444
  // Clear cached query columns when config changes (for persistent queries)
10282
10445
  if (this.config?.dataSource?.type === 'persistentQuery') {
10283
10446
  this._queryColumnsForView.set([]);
10447
+ this._queryColumnValueTypes.set(new Map());
10284
10448
  }
10285
10449
  // Defer refresh to next microtask to allow Angular to propagate config to directive
10286
10450
  Promise.resolve().then(() => this.refresh());
@@ -10310,15 +10474,16 @@ class TableWidgetComponent {
10310
10474
  [showRowCheckBoxes]="false"
10311
10475
  [showRowSelectAllCheckBox]="false"
10312
10476
  [rowIsClickable]="false"
10477
+ [messages]="texts().tableList"
10313
10478
  (queryColumnsLoaded)="onQueryColumnsLoaded($event)">
10314
10479
  </mm-list-view>
10315
10480
  } @else {
10316
10481
  <div class="no-config-overlay">
10317
- <span>Table not configured</span>
10482
+ <span>{{ texts().tableNotConfigured }}</span>
10318
10483
  </div>
10319
10484
  }
10320
10485
  </div>
10321
- `, isInline: true, styles: [".table-widget{height:100%;width:100%;display:flex;flex-direction:column}.no-config-overlay{display:flex;align-items:center;justify-content:center;height:100%;color:var(--kendo-color-subtle, #6c757d);font-style:italic}:host ::ng-deep mm-list-view{height:100%}:host ::ng-deep .k-grid{height:100%}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: ListViewComponent, selector: "mm-list-view", inputs: ["pageSize", "skip", "autoPageSize", "rowIsClickable", "showRowCheckBoxes", "showRowSelectAllCheckBox", "contextMenuType", "leftToolbarActions", "rightToolbarActions", "actionCommandItems", "contextMenuCommandItems", "rowActions", "maxInlineRowActions", "excelExportFileName", "pdfExportFileName", "pageable", "sortable", "rowFilterEnabled", "searchTextBoxEnabled", "persistListState", "listStateKey", "resizable", "actionsColumnWidth", "hideCheckboxesBelow", "cardModeBelow", "collapseCommandsBelow", "hasExternalFilters", "rowClass", "messages", "selectable", "columns", "rowLabelField"], outputs: ["rowClicked", "rowAction", "resetFilters"] }, { kind: "directive", type: TableWidgetDataSourceDirective, selector: "[mmTableWidgetDataSource]", inputs: ["config"], outputs: ["queryColumnsLoaded"], exportAs: ["mmTableWidgetDataSource"] }], changeDetection: i0.ChangeDetectionStrategy.Eager });
10486
+ `, isInline: true, styles: [".table-widget{height:100%;width:100%;display:flex;flex-direction:column}.no-config-overlay{display:flex;align-items:center;justify-content:center;height:100%;color:var(--kendo-color-subtle, #6c757d);font-style:italic}:host ::ng-deep mm-list-view{height:100%}:host ::ng-deep .k-grid{height:100%}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: ListViewComponent, selector: "mm-list-view", inputs: ["pageSize", "skip", "autoPageSize", "rowIsClickable", "showRowCheckBoxes", "showRowSelectAllCheckBox", "contextMenuType", "leftToolbarActions", "rightToolbarActions", "actionCommandItems", "contextMenuCommandItems", "rowActions", "maxInlineRowActions", "excelExportFileName", "pdfExportFileName", "pageable", "sortable", "rowFilterEnabled", "searchTextBoxEnabled", "persistListState", "listStateKey", "resizable", "actionsColumnWidth", "hideCheckboxesBelow", "cardModeBelow", "collapseCommandsBelow", "hasExternalFilters", "emptyState", "emptyTemplate", "rowClass", "messages", "selectable", "columns", "rowLabelField"], outputs: ["rowClicked", "rowAction", "resetFilters"] }, { kind: "directive", type: TableWidgetDataSourceDirective, selector: "[mmTableWidgetDataSource]", inputs: ["config"], outputs: ["queryColumnsLoaded"], exportAs: ["mmTableWidgetDataSource"] }], changeDetection: i0.ChangeDetectionStrategy.Eager });
10322
10487
  }
10323
10488
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: TableWidgetComponent, decorators: [{
10324
10489
  type: Component,
@@ -10342,11 +10507,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
10342
10507
  [showRowCheckBoxes]="false"
10343
10508
  [showRowSelectAllCheckBox]="false"
10344
10509
  [rowIsClickable]="false"
10510
+ [messages]="texts().tableList"
10345
10511
  (queryColumnsLoaded)="onQueryColumnsLoaded($event)">
10346
10512
  </mm-list-view>
10347
10513
  } @else {
10348
10514
  <div class="no-config-overlay">
10349
- <span>Table not configured</span>
10515
+ <span>{{ texts().tableNotConfigured }}</span>
10350
10516
  </div>
10351
10517
  }
10352
10518
  </div>
@@ -13128,6 +13294,8 @@ function injectChartTheme() {
13128
13294
  return theme.asReadonly();
13129
13295
  }
13130
13296
 
13297
+ /** Kendo label position per configured {@link PieChartWidgetConfig.labelPosition}. */
13298
+ const KENDO_LABEL_POSITION = { inside: 'insideEnd', outside: 'outsideEnd' };
13131
13299
  const PLOT_AREA_WITH_LABELS = Object.freeze({ background: 'transparent', margin: Object.freeze({ top: 30, right: 30, bottom: 30, left: 30 }) });
13132
13300
  const PLOT_AREA_WITHOUT_LABELS = Object.freeze({ background: 'transparent', margin: Object.freeze({ top: 4, right: 4, bottom: 4, left: 4 }) });
13133
13301
  class PieChartWidgetComponent {
@@ -13147,6 +13315,8 @@ class PieChartWidgetComponent {
13147
13315
  dataService = inject(MeshBoardDataService);
13148
13316
  stateService = inject(MeshBoardStateService);
13149
13317
  variableService = inject(MeshBoardVariableService);
13318
+ /** Host translation of category names (`MESHBOARD_LABEL_RESOLVER`, AB#5622): slices, legend and tooltips. */
13319
+ labelResolver = inject(MESHBOARD_LABEL_RESOLVER, { optional: true });
13150
13320
  config;
13151
13321
  // Widget state signals
13152
13322
  _isLoading = signal(false, /* @ts-ignore */
@@ -13165,7 +13335,8 @@ class PieChartWidgetComponent {
13165
13335
  /** Keeps its reference while categories, values and colours are unchanged (AB#5568). */
13166
13336
  chartData = computed(() => {
13167
13337
  this.chartTheme();
13168
- return this._rawData().map(item => this.toItem(item.category, item.value));
13338
+ const palette = chartSeriesColors();
13339
+ return this._rawData().map((item, index) => this.toItem(item.category, item.value, index, palette));
13169
13340
  }, { ...(ngDevMode ? { debugName: "chartData" } : /* istanbul ignore next */ {}), equal: sameChartItems });
13170
13341
  error = this._error.asReadonly();
13171
13342
  data = computed(() => this.chartData(), /* @ts-ignore */
@@ -13195,7 +13366,8 @@ class PieChartWidgetComponent {
13195
13366
  * chart on every hover (AB#5568).
13196
13367
  */
13197
13368
  plotArea() {
13198
- return this.config?.showLabels === true ? PLOT_AREA_WITH_LABELS : PLOT_AREA_WITHOUT_LABELS;
13369
+ const outsideLabels = this.config?.showLabels === true && this.config?.labelPosition !== 'inside' && this.config?.labelPosition !== 'none';
13370
+ return outsideLabels ? PLOT_AREA_WITH_LABELS : PLOT_AREA_WITHOUT_LABELS;
13199
13371
  }
13200
13372
  /** Margin of the plot area (see `plotArea`). */
13201
13373
  plotAreaMargin() {
@@ -13224,10 +13396,42 @@ class PieChartWidgetComponent {
13224
13396
  ngOnDestroy() {
13225
13397
  this.resizeObserver?.disconnect();
13226
13398
  }
13227
- /** Human label and status colour for a raw category value (exposed for tests). */
13228
- toItem(rawCategory, value) {
13399
+ /**
13400
+ * Human label and status colour for a raw category value (exposed for tests). The label is the
13401
+ * host resolver's text (AB#5622), else the humanized category; the colour still follows the raw value.
13402
+ */
13403
+ toItem(rawCategory, value, index = 0, palette = chartSeriesColors()) {
13404
+ const category = this.categoryLabel(rawCategory);
13229
13405
  const status = categoryStatus(rawCategory);
13230
- return { category: humanizeCategory(rawCategory), value, ...(status ? { color: statusColor(status) } : {}) };
13406
+ const color = this.configuredColor(rawCategory, category)
13407
+ ?? (status ? statusColor(status) : undefined)
13408
+ ?? (palette.length ? palette[index % palette.length] : undefined);
13409
+ return { category, value, ...(color ? { color } : {}) };
13410
+ }
13411
+ /** The `categoryColors` entry of a category (raw value, case-insensitive, then the label), resolved. */
13412
+ configuredColor(rawCategory, label) {
13413
+ const colors = this.config?.categoryColors;
13414
+ if (!colors) {
13415
+ return undefined;
13416
+ }
13417
+ const lower = rawCategory.toLowerCase();
13418
+ // Own keys only: `in` would also match inherited members (`constructor`, `toString`, ...).
13419
+ const own = (k) => Object.prototype.hasOwnProperty.call(colors, k);
13420
+ const key = own(rawCategory) ? rawCategory
13421
+ : Object.keys(colors).find(k => k.toLowerCase() === lower) ?? (own(label) ? label : undefined);
13422
+ return key !== undefined ? resolveChartColor(colors[key]) : undefined;
13423
+ }
13424
+ /** Display name of a raw category: the host label resolver, else `humanizeCategory`. */
13425
+ categoryLabel(rawCategory) {
13426
+ const defaultText = humanizeCategory(rawCategory);
13427
+ if (!this.labelResolver) {
13428
+ return defaultText;
13429
+ }
13430
+ const dataSource = this.config?.dataSource;
13431
+ const attribute = dataSource?.type === 'constructionKitQuery'
13432
+ ? dataSource.groupBy ?? this.config?.categoryField ?? ''
13433
+ : this.config?.categoryField ?? '';
13434
+ return resolveMeshBoardLabel(this.labelResolver, { kind: 'chartCategory', attribute, value: rawCategory, defaultText });
13231
13435
  }
13232
13436
  _labelSettings = signal({
13233
13437
  visible: false,
@@ -13237,10 +13441,24 @@ class PieChartWidgetComponent {
13237
13441
  /** Series label options incl. the theme text colour (new object only when either changes). */
13238
13442
  labelSettings = computed(() => ({ ...this._labelSettings(), color: this.chartTheme().text }), /* @ts-ignore */
13239
13443
  ...(ngDevMode ? [{ debugName: "labelSettings" }] : /* istanbul ignore next */ []));
13444
+ /** Connector lines of outside labels: theme coloured when `labelPosition: 'outside'` is set, else the chart default (AB#5622). */
13445
+ _explicitOutsideLabels = signal(false, /* @ts-ignore */
13446
+ ...(ngDevMode ? [{ debugName: "_explicitOutsideLabels" }] : /* istanbul ignore next */ []));
13447
+ connectors = computed(() => this._explicitOutsideLabels()
13448
+ ? { color: this.chartTheme().muted, width: 1, padding: 4 }
13449
+ : undefined, /* @ts-ignore */
13450
+ ...(ngDevMode ? [{ debugName: "connectors" }] : /* istanbul ignore next */ []));
13240
13451
  updateLabelSettings() {
13452
+ const position = this.config?.labelPosition;
13453
+ const minPercent = Math.max(0, Number(this.config?.hideLabelsBelowPercent) || 0);
13454
+ this._explicitOutsideLabels.set(this.config?.showLabels === true && position === 'outside');
13241
13455
  this._labelSettings.set({
13242
- visible: this.config?.showLabels === true,
13456
+ visible: this.config?.showLabels === true && position !== 'none',
13457
+ ...(position === 'inside' || position === 'outside' ? { position: KENDO_LABEL_POSITION[position] } : {}),
13243
13458
  content: (e) => {
13459
+ if (minPercent > 0 && typeof e.percentage === 'number' && e.percentage * 100 < minPercent) {
13460
+ return '';
13461
+ }
13244
13462
  const maxLen = 20;
13245
13463
  const name = e.category.length > maxLen ? e.category.substring(0, maxLen) + '...' : e.category;
13246
13464
  return `${name}: ${this.formatValue(e.value)}`;
@@ -13398,14 +13616,27 @@ class PieChartWidgetComponent {
13398
13616
  <kendo-chart class="chart-container" [plotArea]="plotArea()">
13399
13617
  <kendo-chart-area [background]="'transparent'"></kendo-chart-area>
13400
13618
  <kendo-chart-series>
13401
- <kendo-chart-series-item
13402
- [type]="config.chartType"
13403
- [data]="chartData()"
13404
- field="value"
13405
- categoryField="category"
13406
- colorField="color"
13407
- [labels]="labelSettings()">
13408
- </kendo-chart-series-item>
13619
+ <!-- Connector lines only bound when outside labels are configured (AB#5622); otherwise the Kendo default stays untouched. -->
13620
+ @if (connectors(); as labelConnectors) {
13621
+ <kendo-chart-series-item
13622
+ [type]="config.chartType"
13623
+ [data]="chartData()"
13624
+ field="value"
13625
+ categoryField="category"
13626
+ colorField="color"
13627
+ [labels]="labelSettings()"
13628
+ [connectors]="labelConnectors">
13629
+ </kendo-chart-series-item>
13630
+ } @else {
13631
+ <kendo-chart-series-item
13632
+ [type]="config.chartType"
13633
+ [data]="chartData()"
13634
+ field="value"
13635
+ categoryField="category"
13636
+ colorField="color"
13637
+ [labels]="labelSettings()">
13638
+ </kendo-chart-series-item>
13639
+ }
13409
13640
  </kendo-chart-series>
13410
13641
  <kendo-chart-legend
13411
13642
  [visible]="config.showLegend !== false"
@@ -13446,14 +13677,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
13446
13677
  <kendo-chart class="chart-container" [plotArea]="plotArea()">
13447
13678
  <kendo-chart-area [background]="'transparent'"></kendo-chart-area>
13448
13679
  <kendo-chart-series>
13449
- <kendo-chart-series-item
13450
- [type]="config.chartType"
13451
- [data]="chartData()"
13452
- field="value"
13453
- categoryField="category"
13454
- colorField="color"
13455
- [labels]="labelSettings()">
13456
- </kendo-chart-series-item>
13680
+ <!-- Connector lines only bound when outside labels are configured (AB#5622); otherwise the Kendo default stays untouched. -->
13681
+ @if (connectors(); as labelConnectors) {
13682
+ <kendo-chart-series-item
13683
+ [type]="config.chartType"
13684
+ [data]="chartData()"
13685
+ field="value"
13686
+ categoryField="category"
13687
+ colorField="color"
13688
+ [labels]="labelSettings()"
13689
+ [connectors]="labelConnectors">
13690
+ </kendo-chart-series-item>
13691
+ } @else {
13692
+ <kendo-chart-series-item
13693
+ [type]="config.chartType"
13694
+ [data]="chartData()"
13695
+ field="value"
13696
+ categoryField="category"
13697
+ colorField="color"
13698
+ [labels]="labelSettings()">
13699
+ </kendo-chart-series-item>
13700
+ }
13457
13701
  </kendo-chart-series>
13458
13702
  <kendo-chart-legend
13459
13703
  [visible]="config.showLegend !== false"
@@ -13500,6 +13744,20 @@ class PieChartConfigDialogComponent {
13500
13744
  initialCkQueryTarget;
13501
13745
  initialCkGroupBy;
13502
13746
  initialFilters;
13747
+ initialCategoryColors;
13748
+ initialLabelPosition;
13749
+ initialHideLabelsBelowPercent;
13750
+ addIcon = plusIcon;
13751
+ removeIcon = trashIcon;
13752
+ /** Rows of the category colour editor (AB#5622). */
13753
+ categoryColorRows = [];
13754
+ nextCategoryColorId = 1;
13755
+ labelPositions = [
13756
+ { value: undefined, label: 'Default' },
13757
+ { value: 'outside', label: 'Outside (with connector lines)' },
13758
+ { value: 'inside', label: 'Inside' },
13759
+ { value: 'none', label: 'None' }
13760
+ ];
13503
13761
  searchIcon = searchIcon;
13504
13762
  chartPieIcon = chartPieIcon;
13505
13763
  // State
@@ -13546,7 +13804,9 @@ class PieChartConfigDialogComponent {
13546
13804
  displayMode: 'legend',
13547
13805
  legendPosition: 'right',
13548
13806
  ckQueryTarget: 'models',
13549
- ckGroupBy: 'modelState'
13807
+ ckGroupBy: 'modelState',
13808
+ labelPosition: undefined,
13809
+ hideLabelsBelowPercent: 0
13550
13810
  };
13551
13811
  get isValid() {
13552
13812
  if (this.form.dataSourceType === 'persistentQuery') {
@@ -13584,6 +13844,9 @@ class PieChartConfigDialogComponent {
13584
13844
  this.form.legendPosition = this.initialLegendPosition ?? 'right';
13585
13845
  this.form.ckQueryTarget = this.initialCkQueryTarget ?? 'models';
13586
13846
  this.form.ckGroupBy = this.initialCkGroupBy ?? 'modelState';
13847
+ this.form.labelPosition = this.initialLabelPosition;
13848
+ this.form.hideLabelsBelowPercent = this.initialHideLabelsBelowPercent ?? 0;
13849
+ this.categoryColorRows = Object.entries(this.initialCategoryColors ?? {}).map(([category, color]) => ({ id: this.nextCategoryColorId++, category, color }));
13587
13850
  this.ignoreTimeFilter = this.initialIgnoreTimeFilter ?? false;
13588
13851
  this.entitySelectorId = this.initialEntitySelectorId;
13589
13852
  // Initialize filters
@@ -13717,6 +13980,23 @@ class PieChartConfigDialogComponent {
13717
13980
  aggregationType: c.aggregationType ?? null
13718
13981
  }));
13719
13982
  }
13983
+ addCategoryColor() {
13984
+ this.categoryColorRows = [...this.categoryColorRows, { id: this.nextCategoryColorId++, category: '', color: '' }];
13985
+ }
13986
+ removeCategoryColor(id) {
13987
+ this.categoryColorRows = this.categoryColorRows.filter(row => row.id !== id);
13988
+ }
13989
+ /** Value of the native colour picker: the row's hex colour, else black (the picker needs a #rrggbb value). */
13990
+ swatchValue(color) {
13991
+ return /^#[0-9a-f]{6}$/i.test(color.trim()) ? color.trim() : '#000000';
13992
+ }
13993
+ /** The edited category colours; rows without category or colour are dropped; none = undefined. */
13994
+ categoryColorsResult() {
13995
+ const entries = this.categoryColorRows
13996
+ .map(row => [row.category.trim(), row.color.trim()])
13997
+ .filter(([category, color]) => category !== '' && color !== '');
13998
+ return entries.length ? Object.fromEntries(entries) : undefined;
13999
+ }
13720
14000
  onFiltersChange(updatedFilters) {
13721
14001
  this.filters = updatedFilters;
13722
14002
  }
@@ -13741,6 +14021,18 @@ class PieChartConfigDialogComponent {
13741
14021
  legendPosition: this.form.legendPosition,
13742
14022
  filters: filtersDto
13743
14023
  };
14024
+ const categoryColors = this.categoryColorsResult();
14025
+ if (categoryColors) {
14026
+ result.categoryColors = categoryColors;
14027
+ }
14028
+ if (this.form.displayMode === 'labels') {
14029
+ if (this.form.labelPosition) {
14030
+ result.labelPosition = this.form.labelPosition;
14031
+ }
14032
+ if (this.form.hideLabelsBelowPercent && this.form.hideLabelsBelowPercent > 0) {
14033
+ result.hideLabelsBelowPercent = this.form.hideLabelsBelowPercent;
14034
+ }
14035
+ }
13744
14036
  if (this.form.dataSourceType === 'persistentQuery') {
13745
14037
  if (!this.selectedPersistentQuery)
13746
14038
  return;
@@ -13760,7 +14052,7 @@ class PieChartConfigDialogComponent {
13760
14052
  this.windowRef.close();
13761
14053
  }
13762
14054
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: PieChartConfigDialogComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
13763
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: PieChartConfigDialogComponent, isStandalone: true, selector: "mm-pie-chart-config-dialog", inputs: { initialDataSourceType: "initialDataSourceType", initialQueryRtId: "initialQueryRtId", initialQueryName: "initialQueryName", initialQueryFamily: "initialQueryFamily", initialIgnoreTimeFilter: "initialIgnoreTimeFilter", initialEntitySelectorId: "initialEntitySelectorId", initialChartType: "initialChartType", initialCategoryField: "initialCategoryField", initialValueField: "initialValueField", initialShowLabels: "initialShowLabels", initialShowLegend: "initialShowLegend", initialLegendPosition: "initialLegendPosition", initialCkQueryTarget: "initialCkQueryTarget", initialCkGroupBy: "initialCkGroupBy", initialFilters: "initialFilters" }, viewQueries: [{ propertyName: "querySelector", first: true, predicate: ["querySelector"], descendants: true }], ngImport: i0, template: `
14055
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: PieChartConfigDialogComponent, isStandalone: true, selector: "mm-pie-chart-config-dialog", inputs: { initialDataSourceType: "initialDataSourceType", initialQueryRtId: "initialQueryRtId", initialQueryName: "initialQueryName", initialQueryFamily: "initialQueryFamily", initialIgnoreTimeFilter: "initialIgnoreTimeFilter", initialEntitySelectorId: "initialEntitySelectorId", initialChartType: "initialChartType", initialCategoryField: "initialCategoryField", initialValueField: "initialValueField", initialShowLabels: "initialShowLabels", initialShowLegend: "initialShowLegend", initialLegendPosition: "initialLegendPosition", initialCkQueryTarget: "initialCkQueryTarget", initialCkGroupBy: "initialCkGroupBy", initialFilters: "initialFilters", initialCategoryColors: "initialCategoryColors", initialLabelPosition: "initialLabelPosition", initialHideLabelsBelowPercent: "initialHideLabelsBelowPercent" }, viewQueries: [{ propertyName: "querySelector", first: true, predicate: ["querySelector"], descendants: true }], ngImport: i0, template: `
13764
14056
  <div class="config-container">
13765
14057
 
13766
14058
  <div class="config-form" [class.loading]="isLoadingInitial">
@@ -13956,6 +14248,48 @@ class PieChartConfigDialogComponent {
13956
14248
  </kendo-dropdownlist>
13957
14249
  </div>
13958
14250
  }
14251
+
14252
+ @if (form.displayMode === 'labels') {
14253
+ <div class="form-field">
14254
+ <label for="pie-label-position">Label Position</label>
14255
+ <kendo-dropdownlist
14256
+ id="pie-label-position"
14257
+ data-pie-label-position
14258
+ [data]="labelPositions"
14259
+ [textField]="'label'"
14260
+ [valueField]="'value'"
14261
+ [valuePrimitive]="true"
14262
+ [(ngModel)]="form.labelPosition">
14263
+ </kendo-dropdownlist>
14264
+ </div>
14265
+ <div class="form-field">
14266
+ <label for="pie-hide-labels-below">Hide labels of slices below (%)</label>
14267
+ <kendo-numerictextbox
14268
+ id="pie-hide-labels-below"
14269
+ data-pie-hide-labels-below
14270
+ [min]="0"
14271
+ [max]="50"
14272
+ [decimals]="1"
14273
+ [format]="'n1'"
14274
+ [(ngModel)]="form.hideLabelsBelowPercent">
14275
+ </kendo-numerictextbox>
14276
+ <span class="section-hint">Small neighbouring slices no longer overlap; 0 labels every slice.</span>
14277
+ </div>
14278
+ }
14279
+
14280
+ <div class="form-field" data-pie-category-colors>
14281
+ <label>Category Colours</label>
14282
+ <span class="section-hint">Raw category value or label → hex colour, CSS variable (--brand-paid) or status (success, warning, error, info). Others use the theme palette.</span>
14283
+ @for (entry of categoryColorRows; track entry.id) {
14284
+ <div class="category-color-row">
14285
+ <kendo-textbox [(ngModel)]="entry.category" placeholder="Category (e.g. PAID)" [attr.aria-label]="'Category'"></kendo-textbox>
14286
+ <kendo-textbox [(ngModel)]="entry.color" placeholder="#2fb37a or --token" [attr.aria-label]="'Colour'"></kendo-textbox>
14287
+ <input type="color" class="category-color-swatch" [value]="swatchValue(entry.color)" (input)="entry.color = $any($event.target).value" [attr.aria-label]="'Pick colour'" />
14288
+ <button kendoButton fillMode="flat" type="button" [svgIcon]="removeIcon" (click)="removeCategoryColor(entry.id)" [attr.aria-label]="'Remove category colour'" title="Remove"></button>
14289
+ </div>
14290
+ }
14291
+ <button kendoButton fillMode="flat" type="button" [svgIcon]="addIcon" (click)="addCategoryColor()" data-pie-add-category-color>Add category colour</button>
14292
+ </div>
13959
14293
  </div>
13960
14294
  </div>
13961
14295
 
@@ -13970,7 +14304,7 @@ class PieChartConfigDialogComponent {
13970
14304
  </button>
13971
14305
  </div>
13972
14306
  </div>
13973
- `, isInline: true, styles: [":host{display:block;height:100%}.config-container{display:flex;flex-direction:column;height:100%}.action-bar{display:flex;justify-content:flex-end;gap:8px;padding:8px 16px;border-top:1px solid var(--kendo-color-border, #dee2e6)}.config-form{display:flex;flex-direction:column;flex:1;overflow-y:auto;gap:20px;padding:16px;position:relative}.config-form.loading{pointer-events:none}.config-section{padding:16px;background:var(--kendo-color-surface-alt, #f8f9fa);border:1px solid var(--kendo-color-border, #dee2e6);border-radius:4px}.section-title{margin:0 0 16px;font-size:1rem;font-weight:600;color:var(--kendo-color-primary, #0d6efd)}.section-hint{margin:0 0 12px;font-size:.85rem;color:var(--kendo-color-subtle, #6c757d)}.form-field{display:flex;flex-direction:column;gap:6px;margin-bottom:12px}.form-field:last-child{margin-bottom:0}.form-field label{font-weight:600;font-size:.9rem;color:var(--kendo-color-on-app-surface, #212529)}.required{color:var(--kendo-color-error, #dc3545)}.field-hint{margin:0;font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.radio-group{display:flex;gap:24px}.radio-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:400}.query-item{display:flex;flex-direction:column;gap:2px}.query-name{font-weight:500}.query-description{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.column-item{display:flex;justify-content:space-between;gap:16px}.column-path{font-weight:500}.column-type{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.RadioControlValueAccessor, selector: "input[type=radio]:not([ngNoCva])[formControlName],input[type=radio]:not([ngNoCva])[formControl],input[type=radio]:not([ngNoCva])[ngModel]", inputs: ["name", "formControlName", "value"] }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ButtonsModule }, { kind: "component", type: i2.ButtonComponent, selector: "button[kendoButton]", inputs: ["arrowIcon", "toggleable", "togglable", "selected", "tabIndex", "imageUrl", "iconPosition", "iconClass", "icon", "disabled", "size", "rounded", "fillMode", "themeColor", "svgIcon"], outputs: ["selectedChange", "click"], exportAs: ["kendoButton"] }, { kind: "ngmodule", type: InputsModule }, { kind: "directive", type: i3.RadioButtonDirective, selector: "input[kendoRadioButton]", inputs: ["size"] }, { kind: "ngmodule", type: DropDownsModule }, { kind: "directive", type: i4.ItemTemplateDirective, selector: "[kendoDropDownListItemTemplate],[kendoComboBoxItemTemplate],[kendoAutoCompleteItemTemplate],[kendoMultiSelectItemTemplate]" }, { kind: "component", type: i4.ComboBoxComponent, selector: "kendo-combobox", inputs: ["icon", "svgIcon", "inputAttributes", "showStickyHeader", "focusableId", "allowCustom", "data", "value", "textField", "valueField", "valuePrimitive", "valueNormalizer", "placeholder", "adaptiveMode", "adaptiveTitle", "adaptiveSubtitle", "popupSettings", "listHeight", "loading", "suggest", "clearButton", "disabled", "itemDisabled", "readonly", "tabindex", "tabIndex", "filterable", "virtual", "size", "rounded", "fillMode"], outputs: ["valueChange", "selectionChange", "filterChange", "open", "opened", "close", "closed", "focus", "blur", "inputFocus", "inputBlur", "escape"], exportAs: ["kendoComboBox"] }, { kind: "component", type: i4.DropDownListComponent, selector: "kendo-dropdownlist", inputs: ["customIconClass", "showStickyHeader", "icon", "svgIcon", "loading", "data", "value", "textField", "valueField", "adaptiveMode", "adaptiveTitle", "adaptiveSubtitle", "popupSettings", "listHeight", "defaultItem", "disabled", "itemDisabled", "readonly", "filterable", "virtual", "ignoreCase", "delay", "valuePrimitive", "tabindex", "tabIndex", "size", "rounded", "fillMode", "leftRightArrowsNavigation", "id"], outputs: ["valueChange", "filterChange", "selectionChange", "open", "opened", "close", "closed", "focus", "blur"], exportAs: ["kendoDropDownList"] }, { kind: "ngmodule", type: SVGIconModule }, { kind: "component", type: FieldFilterEditorComponent, selector: "mm-field-filter-editor", inputs: ["availableAttributes", "ckTypeId", "hideNavigationProperties", "attributePaths", "enableVariables", "availableVariables", "filters"], outputs: ["filtersChange"] }, { kind: "component", type: QuerySelectorComponent, selector: "mm-query-selector", inputs: ["placeholder", "hint", "disabled", "acceptFamilies"], outputs: ["querySelected", "queriesLoaded"] }, { kind: "component", type: SdTimeFilterToggleComponent, selector: "mm-sd-time-filter-toggle", inputs: ["family", "ignoreTimeFilter"], outputs: ["ignoreTimeFilterChange"] }, { kind: "component", type: EntitySelectorScopePickerComponent, selector: "mm-entity-selector-scope-picker", inputs: ["family", "entitySelectorId", "selectors"], outputs: ["entitySelectorIdChange"] }, { kind: "component", type: LoadingOverlayComponent, selector: "mm-loading-overlay", inputs: ["loading"] }], changeDetection: i0.ChangeDetectionStrategy.Eager });
14307
+ `, isInline: true, styles: [":host{display:block;height:100%}.config-container{display:flex;flex-direction:column;height:100%}.action-bar{display:flex;justify-content:flex-end;gap:8px;padding:8px 16px;border-top:1px solid var(--kendo-color-border, #dee2e6)}.config-form{display:flex;flex-direction:column;flex:1;overflow-y:auto;gap:20px;padding:16px;position:relative}.config-form.loading{pointer-events:none}.config-section{padding:16px;background:var(--kendo-color-surface-alt, #f8f9fa);border:1px solid var(--kendo-color-border, #dee2e6);border-radius:4px}.section-title{margin:0 0 16px;font-size:1rem;font-weight:600;color:var(--kendo-color-primary, #0d6efd)}.section-hint{margin:0 0 12px;font-size:.85rem;color:var(--kendo-color-subtle, #6c757d)}.form-field{display:flex;flex-direction:column;gap:6px;margin-bottom:12px}.form-field:last-child{margin-bottom:0}.form-field label{font-weight:600;font-size:.9rem;color:var(--kendo-color-on-app-surface, #212529)}.required{color:var(--kendo-color-error, #dc3545)}.field-hint{margin:0;font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.category-color-row{display:flex;gap:8px;align-items:center;margin-bottom:6px}.category-color-row kendo-textbox{flex:1;min-width:0}.category-color-swatch{width:32px;height:28px;padding:0;border:none;background:none;cursor:pointer}.radio-group{display:flex;gap:24px}.radio-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:400}.query-item{display:flex;flex-direction:column;gap:2px}.query-name{font-weight:500}.query-description{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.column-item{display:flex;justify-content:space-between;gap:16px}.column-path{font-weight:500}.column-type{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.RadioControlValueAccessor, selector: "input[type=radio]:not([ngNoCva])[formControlName],input[type=radio]:not([ngNoCva])[formControl],input[type=radio]:not([ngNoCva])[ngModel]", inputs: ["name", "formControlName", "value"] }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ButtonsModule }, { kind: "component", type: i2.ButtonComponent, selector: "button[kendoButton]", inputs: ["arrowIcon", "toggleable", "togglable", "selected", "tabIndex", "imageUrl", "iconPosition", "iconClass", "icon", "disabled", "size", "rounded", "fillMode", "themeColor", "svgIcon"], outputs: ["selectedChange", "click"], exportAs: ["kendoButton"] }, { kind: "ngmodule", type: InputsModule }, { kind: "component", type: i3.TextBoxComponent, selector: "kendo-textbox", inputs: ["focusableId", "title", "type", "disabled", "readonly", "tabindex", "value", "selectOnFocus", "showSuccessIcon", "showErrorIcon", "clearButton", "successIcon", "successSvgIcon", "errorIcon", "errorSvgIcon", "clearButtonIcon", "clearButtonSvgIcon", "size", "rounded", "fillMode", "tabIndex", "placeholder", "maxlength", "inputAttributes"], outputs: ["valueChange", "inputFocus", "inputBlur", "focus", "blur"], exportAs: ["kendoTextBox"] }, { kind: "component", type: i3.NumericTextBoxComponent, selector: "kendo-numerictextbox", inputs: ["focusableId", "disabled", "readonly", "title", "autoCorrect", "format", "max", "min", "decimals", "placeholder", "step", "spinners", "rangeValidation", "tabindex", "tabIndex", "changeValueOnScroll", "selectOnFocus", "value", "maxlength", "size", "rounded", "fillMode", "inputAttributes"], outputs: ["valueChange", "focus", "blur", "inputFocus", "inputBlur"], exportAs: ["kendoNumericTextBox"] }, { kind: "directive", type: i3.RadioButtonDirective, selector: "input[kendoRadioButton]", inputs: ["size"] }, { kind: "ngmodule", type: DropDownsModule }, { kind: "directive", type: i4.ItemTemplateDirective, selector: "[kendoDropDownListItemTemplate],[kendoComboBoxItemTemplate],[kendoAutoCompleteItemTemplate],[kendoMultiSelectItemTemplate]" }, { kind: "component", type: i4.ComboBoxComponent, selector: "kendo-combobox", inputs: ["icon", "svgIcon", "inputAttributes", "showStickyHeader", "focusableId", "allowCustom", "data", "value", "textField", "valueField", "valuePrimitive", "valueNormalizer", "placeholder", "adaptiveMode", "adaptiveTitle", "adaptiveSubtitle", "popupSettings", "listHeight", "loading", "suggest", "clearButton", "disabled", "itemDisabled", "readonly", "tabindex", "tabIndex", "filterable", "virtual", "size", "rounded", "fillMode"], outputs: ["valueChange", "selectionChange", "filterChange", "open", "opened", "close", "closed", "focus", "blur", "inputFocus", "inputBlur", "escape"], exportAs: ["kendoComboBox"] }, { kind: "component", type: i4.DropDownListComponent, selector: "kendo-dropdownlist", inputs: ["customIconClass", "showStickyHeader", "icon", "svgIcon", "loading", "data", "value", "textField", "valueField", "adaptiveMode", "adaptiveTitle", "adaptiveSubtitle", "popupSettings", "listHeight", "defaultItem", "disabled", "itemDisabled", "readonly", "filterable", "virtual", "ignoreCase", "delay", "valuePrimitive", "tabindex", "tabIndex", "size", "rounded", "fillMode", "leftRightArrowsNavigation", "id"], outputs: ["valueChange", "filterChange", "selectionChange", "open", "opened", "close", "closed", "focus", "blur"], exportAs: ["kendoDropDownList"] }, { kind: "ngmodule", type: SVGIconModule }, { kind: "component", type: FieldFilterEditorComponent, selector: "mm-field-filter-editor", inputs: ["availableAttributes", "ckTypeId", "hideNavigationProperties", "attributePaths", "enableVariables", "availableVariables", "filters"], outputs: ["filtersChange"] }, { kind: "component", type: QuerySelectorComponent, selector: "mm-query-selector", inputs: ["placeholder", "hint", "disabled", "acceptFamilies"], outputs: ["querySelected", "queriesLoaded"] }, { kind: "component", type: SdTimeFilterToggleComponent, selector: "mm-sd-time-filter-toggle", inputs: ["family", "ignoreTimeFilter"], outputs: ["ignoreTimeFilterChange"] }, { kind: "component", type: EntitySelectorScopePickerComponent, selector: "mm-entity-selector-scope-picker", inputs: ["family", "entitySelectorId", "selectors"], outputs: ["entitySelectorIdChange"] }, { kind: "component", type: LoadingOverlayComponent, selector: "mm-loading-overlay", inputs: ["loading"] }], changeDetection: i0.ChangeDetectionStrategy.Eager });
13974
14308
  }
13975
14309
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: PieChartConfigDialogComponent, decorators: [{
13976
14310
  type: Component,
@@ -14182,6 +14516,48 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
14182
14516
  </kendo-dropdownlist>
14183
14517
  </div>
14184
14518
  }
14519
+
14520
+ @if (form.displayMode === 'labels') {
14521
+ <div class="form-field">
14522
+ <label for="pie-label-position">Label Position</label>
14523
+ <kendo-dropdownlist
14524
+ id="pie-label-position"
14525
+ data-pie-label-position
14526
+ [data]="labelPositions"
14527
+ [textField]="'label'"
14528
+ [valueField]="'value'"
14529
+ [valuePrimitive]="true"
14530
+ [(ngModel)]="form.labelPosition">
14531
+ </kendo-dropdownlist>
14532
+ </div>
14533
+ <div class="form-field">
14534
+ <label for="pie-hide-labels-below">Hide labels of slices below (%)</label>
14535
+ <kendo-numerictextbox
14536
+ id="pie-hide-labels-below"
14537
+ data-pie-hide-labels-below
14538
+ [min]="0"
14539
+ [max]="50"
14540
+ [decimals]="1"
14541
+ [format]="'n1'"
14542
+ [(ngModel)]="form.hideLabelsBelowPercent">
14543
+ </kendo-numerictextbox>
14544
+ <span class="section-hint">Small neighbouring slices no longer overlap; 0 labels every slice.</span>
14545
+ </div>
14546
+ }
14547
+
14548
+ <div class="form-field" data-pie-category-colors>
14549
+ <label>Category Colours</label>
14550
+ <span class="section-hint">Raw category value or label → hex colour, CSS variable (--brand-paid) or status (success, warning, error, info). Others use the theme palette.</span>
14551
+ @for (entry of categoryColorRows; track entry.id) {
14552
+ <div class="category-color-row">
14553
+ <kendo-textbox [(ngModel)]="entry.category" placeholder="Category (e.g. PAID)" [attr.aria-label]="'Category'"></kendo-textbox>
14554
+ <kendo-textbox [(ngModel)]="entry.color" placeholder="#2fb37a or --token" [attr.aria-label]="'Colour'"></kendo-textbox>
14555
+ <input type="color" class="category-color-swatch" [value]="swatchValue(entry.color)" (input)="entry.color = $any($event.target).value" [attr.aria-label]="'Pick colour'" />
14556
+ <button kendoButton fillMode="flat" type="button" [svgIcon]="removeIcon" (click)="removeCategoryColor(entry.id)" [attr.aria-label]="'Remove category colour'" title="Remove"></button>
14557
+ </div>
14558
+ }
14559
+ <button kendoButton fillMode="flat" type="button" [svgIcon]="addIcon" (click)="addCategoryColor()" data-pie-add-category-color>Add category colour</button>
14560
+ </div>
14185
14561
  </div>
14186
14562
  </div>
14187
14563
 
@@ -14196,7 +14572,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
14196
14572
  </button>
14197
14573
  </div>
14198
14574
  </div>
14199
- `, changeDetection: ChangeDetectionStrategy.Eager, styles: [":host{display:block;height:100%}.config-container{display:flex;flex-direction:column;height:100%}.action-bar{display:flex;justify-content:flex-end;gap:8px;padding:8px 16px;border-top:1px solid var(--kendo-color-border, #dee2e6)}.config-form{display:flex;flex-direction:column;flex:1;overflow-y:auto;gap:20px;padding:16px;position:relative}.config-form.loading{pointer-events:none}.config-section{padding:16px;background:var(--kendo-color-surface-alt, #f8f9fa);border:1px solid var(--kendo-color-border, #dee2e6);border-radius:4px}.section-title{margin:0 0 16px;font-size:1rem;font-weight:600;color:var(--kendo-color-primary, #0d6efd)}.section-hint{margin:0 0 12px;font-size:.85rem;color:var(--kendo-color-subtle, #6c757d)}.form-field{display:flex;flex-direction:column;gap:6px;margin-bottom:12px}.form-field:last-child{margin-bottom:0}.form-field label{font-weight:600;font-size:.9rem;color:var(--kendo-color-on-app-surface, #212529)}.required{color:var(--kendo-color-error, #dc3545)}.field-hint{margin:0;font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.radio-group{display:flex;gap:24px}.radio-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:400}.query-item{display:flex;flex-direction:column;gap:2px}.query-name{font-weight:500}.query-description{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.column-item{display:flex;justify-content:space-between;gap:16px}.column-path{font-weight:500}.column-type{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}\n"] }]
14575
+ `, changeDetection: ChangeDetectionStrategy.Eager, styles: [":host{display:block;height:100%}.config-container{display:flex;flex-direction:column;height:100%}.action-bar{display:flex;justify-content:flex-end;gap:8px;padding:8px 16px;border-top:1px solid var(--kendo-color-border, #dee2e6)}.config-form{display:flex;flex-direction:column;flex:1;overflow-y:auto;gap:20px;padding:16px;position:relative}.config-form.loading{pointer-events:none}.config-section{padding:16px;background:var(--kendo-color-surface-alt, #f8f9fa);border:1px solid var(--kendo-color-border, #dee2e6);border-radius:4px}.section-title{margin:0 0 16px;font-size:1rem;font-weight:600;color:var(--kendo-color-primary, #0d6efd)}.section-hint{margin:0 0 12px;font-size:.85rem;color:var(--kendo-color-subtle, #6c757d)}.form-field{display:flex;flex-direction:column;gap:6px;margin-bottom:12px}.form-field:last-child{margin-bottom:0}.form-field label{font-weight:600;font-size:.9rem;color:var(--kendo-color-on-app-surface, #212529)}.required{color:var(--kendo-color-error, #dc3545)}.field-hint{margin:0;font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.category-color-row{display:flex;gap:8px;align-items:center;margin-bottom:6px}.category-color-row kendo-textbox{flex:1;min-width:0}.category-color-swatch{width:32px;height:28px;padding:0;border:none;background:none;cursor:pointer}.radio-group{display:flex;gap:24px}.radio-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:400}.query-item{display:flex;flex-direction:column;gap:2px}.query-name{font-weight:500}.query-description{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.column-item{display:flex;justify-content:space-between;gap:16px}.column-path{font-weight:500}.column-type{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}\n"] }]
14200
14576
  }], propDecorators: { querySelector: [{
14201
14577
  type: ViewChild,
14202
14578
  args: ['querySelector']
@@ -14230,6 +14606,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
14230
14606
  type: Input
14231
14607
  }], initialFilters: [{
14232
14608
  type: Input
14609
+ }], initialCategoryColors: [{
14610
+ type: Input
14611
+ }], initialLabelPosition: [{
14612
+ type: Input
14613
+ }], initialHideLabelsBelowPercent: [{
14614
+ type: Input
14233
14615
  }] } });
14234
14616
 
14235
14617
  const CHART_TYPE_MAPPING = {
@@ -30194,7 +30576,10 @@ function registerDefaultWidgets(registry) {
30194
30576
  initialShowLabels: pieWidget.showLabels,
30195
30577
  initialShowLegend: pieWidget.showLegend,
30196
30578
  initialLegendPosition: pieWidget.legendPosition,
30197
- initialFilters: pieWidget.filters
30579
+ initialFilters: pieWidget.filters,
30580
+ initialCategoryColors: pieWidget.categoryColors,
30581
+ initialLabelPosition: pieWidget.labelPosition,
30582
+ initialHideLabelsBelowPercent: pieWidget.hideLabelsBelowPercent
30198
30583
  };
30199
30584
  },
30200
30585
  applyConfigResult: (widget, result) => {
@@ -30233,7 +30618,10 @@ function registerDefaultWidgets(registry) {
30233
30618
  showLabels: result.showLabels,
30234
30619
  showLegend: result.showLegend,
30235
30620
  legendPosition: result.legendPosition,
30236
- filters
30621
+ filters,
30622
+ categoryColors: result.categoryColors,
30623
+ labelPosition: result.labelPosition,
30624
+ hideLabelsBelowPercent: result.hideLabelsBelowPercent
30237
30625
  };
30238
30626
  },
30239
30627
  // SOLID: Factory function
@@ -30266,7 +30654,8 @@ function registerDefaultWidgets(registry) {
30266
30654
  legendPosition: widget.legendPosition,
30267
30655
  ckQueryTarget: ckDataSource.queryTarget,
30268
30656
  ckGroupBy: ckDataSource.groupBy,
30269
- filters: widget.filters
30657
+ filters: widget.filters,
30658
+ ...pieChartPresentationConfig(widget)
30270
30659
  }
30271
30660
  };
30272
30661
  }
@@ -30292,7 +30681,8 @@ function registerDefaultWidgets(registry) {
30292
30681
  ...(dataSource.entitySelectorId && {
30293
30682
  entitySelectorId: dataSource.entitySelectorId
30294
30683
  }),
30295
- filters: widget.filters
30684
+ filters: widget.filters,
30685
+ ...pieChartPresentationConfig(widget)
30296
30686
  }
30297
30687
  };
30298
30688
  },
@@ -30316,7 +30706,8 @@ function registerDefaultWidgets(registry) {
30316
30706
  showLabels: config['showLabels'] ?? false,
30317
30707
  showLegend: config['showLegend'] ?? true,
30318
30708
  legendPosition: config['legendPosition'] ?? 'right',
30319
- filters: config['filters']
30709
+ filters: config['filters'],
30710
+ ...readPieChartPresentationConfig(config)
30320
30711
  };
30321
30712
  }
30322
30713
  // Default: persistentQuery
@@ -30332,7 +30723,8 @@ function registerDefaultWidgets(registry) {
30332
30723
  showLabels: config['showLabels'] ?? false,
30333
30724
  showLegend: config['showLegend'] ?? true,
30334
30725
  legendPosition: config['legendPosition'] ?? 'right',
30335
- filters: config['filters']
30726
+ filters: config['filters'],
30727
+ ...readPieChartPresentationConfig(config)
30336
30728
  };
30337
30729
  }
30338
30730
  });
@@ -31499,6 +31891,32 @@ function provideDefaultWidgets() {
31499
31891
  })
31500
31892
  ]);
31501
31893
  }
31894
+ /**
31895
+ * Optional presentation settings of a pie chart for the board JSON (AB#5622): written only when
31896
+ * set, so boards without them persist exactly as before.
31897
+ */
31898
+ function pieChartPresentationConfig(widget) {
31899
+ return {
31900
+ ...(widget.categoryColors && Object.keys(widget.categoryColors).length ? { categoryColors: widget.categoryColors } : {}),
31901
+ ...(widget.labelPosition ? { labelPosition: widget.labelPosition } : {}),
31902
+ ...(widget.hideLabelsBelowPercent ? { hideLabelsBelowPercent: widget.hideLabelsBelowPercent } : {})
31903
+ };
31904
+ }
31905
+ /** The string-valued entries of a JSON object (non-string colour values are dropped). */
31906
+ function stringEntries(record) {
31907
+ return Object.fromEntries(Object.entries(record).filter((entry) => typeof entry[1] === 'string'));
31908
+ }
31909
+ /** Reads {@link pieChartPresentationConfig} back; unknown or missing values are left out. */
31910
+ function readPieChartPresentationConfig(config) {
31911
+ const colors = config['categoryColors'];
31912
+ const position = config['labelPosition'];
31913
+ const minPercent = config['hideLabelsBelowPercent'];
31914
+ return {
31915
+ ...(colors && typeof colors === 'object' && !Array.isArray(colors) ? { categoryColors: stringEntries(colors) } : {}),
31916
+ ...(position === 'inside' || position === 'outside' || position === 'none' ? { labelPosition: position } : {}),
31917
+ ...(typeof minPercent === 'number' && minPercent > 0 ? { hideLabelsBelowPercent: minPercent } : {})
31918
+ };
31919
+ }
31502
31920
  /**
31503
31921
  * Injection token for MeshBoard options
31504
31922
  */
@@ -31530,6 +31948,9 @@ function provideMeshBoard(options) {
31530
31948
  const providers = [
31531
31949
  { provide: MESHBOARD_OPTIONS, useValue: opts }
31532
31950
  ];
31951
+ if (opts.messages) {
31952
+ providers.push({ provide: MESHBOARD_MESSAGES, useValue: opts.messages });
31953
+ }
31533
31954
  // Include default widgets unless explicitly disabled
31534
31955
  if (opts.includeDefaultWidgets !== false) {
31535
31956
  providers.push(provideAppInitializer(() => {
@@ -31539,6 +31960,13 @@ function provideMeshBoard(options) {
31539
31960
  }
31540
31961
  return makeEnvironmentProviders(providers);
31541
31962
  }
31963
+ /**
31964
+ * Provides the host translations of the MeshBoard widget texts (`MESHBOARD_MESSAGES`, AB#5622)
31965
+ * without `provideMeshBoard()` — for apps that register the widgets lazily.
31966
+ */
31967
+ function provideMeshBoardMessages(messages) {
31968
+ return makeEnvironmentProviders([{ provide: MESHBOARD_MESSAGES, useValue: messages }]);
31969
+ }
31542
31970
  /**
31543
31971
  * Provider function for registering custom widgets.
31544
31972
  * Use this to add your own widget types to MeshBoard.
@@ -32040,15 +32468,21 @@ function rowSpanForContent(contentHeight, rowHeight, gap, minRows) {
32040
32468
  const rows = Math.ceil((contentHeight + TILE_CHROME_HEIGHT + Math.max(0, gap)) / (rowHeight + Math.max(0, gap)));
32041
32469
  return Math.max(minRows, Math.min(MAX_CONTENT_ROWS, rows));
32042
32470
  }
32471
+ /**
32472
+ * Widget types whose tile shrinks to its content on the native (desktop) tier (AB#5622): the
32473
+ * cockpit "Needs attention" list, which otherwise leaves a large empty tile with few findings.
32474
+ */
32475
+ const DESKTOP_CONTENT_FIT_TYPES = new Set(['attentionList']);
32043
32476
  function placeWidgetsForTier(widgets, tier, configuredColumns, contentSizing) {
32044
32477
  if (tier === 'none') {
32045
- return widgets.map(widget => ({
32478
+ const placements = widgets.map(widget => ({
32046
32479
  widget,
32047
32480
  col: widget.col,
32048
32481
  row: widget.row,
32049
32482
  colSpan: widget.colSpan,
32050
32483
  rowSpan: widget.rowSpan
32051
32484
  }));
32485
+ return contentSizing ? fitDesktopTilesToContent(placements, contentSizing) : placements;
32052
32486
  }
32053
32487
  const columns = columnsForTier(tier, configuredColumns);
32054
32488
  const placements = [...widgets]
@@ -32071,6 +32505,53 @@ function placeWidgetsForTier(widgets, tier, configuredColumns, contentSizing) {
32071
32505
  }
32072
32506
  return placements;
32073
32507
  }
32508
+ /**
32509
+ * Native tier (AB#5622): {@link DESKTOP_CONTENT_FIT_TYPES} tiles take only the rows their content
32510
+ * needs — between 1 and the configured `rowSpan`, never more, so neighbours never overlap. Rows
32511
+ * that only the shrunk tiles occupied are then removed and the widgets below move up; rows that
32512
+ * were already empty in the stored layout stay as designed. Presentation only: the configs (and
32513
+ * thus the persisted row spans) are untouched. Mutates and returns `placements`.
32514
+ */
32515
+ function fitDesktopTilesToContent(placements, sizing) {
32516
+ const occupiedBefore = occupiedRows(placements);
32517
+ let changed = false;
32518
+ for (const placement of placements) {
32519
+ const height = sizing.heights.get(placement.widget.id);
32520
+ if (height === undefined || !DESKTOP_CONTENT_FIT_TYPES.has(placement.widget.type)) {
32521
+ continue;
32522
+ }
32523
+ const rows = Math.min(placement.widget.rowSpan, rowSpanForContent(height, sizing.rowHeight, sizing.gap, 1));
32524
+ if (rows < placement.rowSpan) {
32525
+ placement.rowSpan = rows;
32526
+ changed = true;
32527
+ }
32528
+ }
32529
+ if (!changed) {
32530
+ return placements;
32531
+ }
32532
+ const occupiedAfter = occupiedRows(placements);
32533
+ const freed = [...occupiedBefore].filter(row => !occupiedAfter.has(row));
32534
+ if (freed.length > 0) {
32535
+ for (const placement of placements) {
32536
+ const row = placement.row ?? 1;
32537
+ const shift = freed.filter(freedRow => freedRow < row).length;
32538
+ if (shift > 0) {
32539
+ placement.row = row - shift;
32540
+ }
32541
+ }
32542
+ }
32543
+ return placements;
32544
+ }
32545
+ function occupiedRows(placements) {
32546
+ const rows = new Set();
32547
+ for (const placement of placements) {
32548
+ const start = placement.row ?? 1;
32549
+ for (let row = start; row < start + Math.max(1, placement.rowSpan); row++) {
32550
+ rows.add(row);
32551
+ }
32552
+ }
32553
+ return rows;
32554
+ }
32074
32555
  /**
32075
32556
  * Keeps widgets that share a row side by side on the reduced grid (AB#5558): two span-3 widgets
32076
32557
  * of a 6-column board both round to 2 of 3 columns and would wrap onto two half-empty rows. When
@@ -32710,6 +33191,14 @@ class AddWidgetDialogComponent {
32710
33191
  return dataIcon;
32711
33192
  case 'pipelineExecutions':
32712
33193
  return chartAreaRangeIcon;
33194
+ case 'tenantCount':
33195
+ return dataIcon;
33196
+ case 'blueprintUpdates':
33197
+ return dataIcon;
33198
+ case 'servicesHealth':
33199
+ return connectorIcon;
33200
+ case 'versionInfo':
33201
+ return dataIcon;
32713
33202
  case 'recentItems':
32714
33203
  return clockArrowRotateIcon;
32715
33204
  default:
@@ -32759,6 +33248,14 @@ class AddWidgetDialogComponent {
32759
33248
  return 'Construction Kit models available, ResolveFailed as error';
32760
33249
  case 'pipelineExecutions':
32761
33250
  return 'Pipeline executions in the last 24 hours with failures and an hourly sparkline';
33251
+ case 'tenantCount':
33252
+ return 'Child tenants of this tenant (system cockpit)';
33253
+ case 'blueprintUpdates':
33254
+ return 'Installed blueprints with a newer catalog version';
33255
+ case 'servicesHealth':
33256
+ return 'Platform services whose health check reports Healthy';
33257
+ case 'versionInfo':
33258
+ return 'Versions the host application knows';
32762
33259
  case 'recentItems':
32763
33260
  return 'Pages, entities and boards the viewer opened recently — personal, from the host application';
32764
33261
  default:
@@ -33730,8 +34227,10 @@ class MeshBoardViewComponent {
33730
34227
  displayGridWidgets = computed(() => {
33731
34228
  const tier = this.compactTier();
33732
34229
  const config = this.config();
33733
- // Phone tier only (AB#5558): content-sized widgets grow their tile to fit.
33734
- const contentSizing = tier === 'phone'
34230
+ // Phone tier (AB#5558): content-sized widgets grow their tile to fit. Native tier outside edit
34231
+ // mode (AB#5622): the attention list shrinks to its content, at most its stored rows — edit
34232
+ // mode keeps the stored spans so drag/resize never persists a content-fitted size.
34233
+ const contentSizing = tier === 'phone' || (tier === 'none' && !this.isEditMode())
33735
34234
  ? { heights: this.stateService.widgetContentHeights(), rowHeight: config.rowHeight, gap: config.gap }
33736
34235
  : undefined;
33737
34236
  return placeWidgetsForTier(this.gridWidgets(), tier, config.columns, contentSizing);
@@ -35010,6 +35509,36 @@ const DEFAULT_COCKPIT_WIDGET_MESSAGES = {
35010
35509
  kpiCkModelsResolveFailed: '{count} ResolveFailed',
35011
35510
  kpiCkModelsImportingStatus: 'Importing',
35012
35511
  kpiCkModelsAllAvailable: 'All available',
35512
+ kpiNeedsTenantManagement: 'Needs the TenantManagement role.',
35513
+ kpiTenantsLabel: 'Tenants',
35514
+ kpiTenantsRegistered: 'Registered',
35515
+ kpiTenantsNone: 'No child tenants',
35516
+ kpiBlueprintsLabel: 'Blueprint updates',
35517
+ kpiBlueprintsUpToDate: 'Up to date',
35518
+ kpiBlueprintsAvailable: '{count} available',
35519
+ kpiBlueprintsNone: 'No blueprints installed',
35520
+ kpiBlueprintsInstalled: '{count} installed',
35521
+ kpiBlueprintsServiceManaged: '{count} service-managed pending',
35522
+ kpiBlueprintsTruncated: 'based on the first {read} of {total} catalog entries',
35523
+ kpiBlueprintsScanning: 'Scanning catalogs…',
35524
+ kpiServicesLabel: 'Services healthy',
35525
+ kpiServicesAllHealthy: 'All healthy',
35526
+ kpiServicesUnhealthy: '{count} unhealthy',
35527
+ kpiServicesDegraded: '{count} degraded',
35528
+ serviceStateDegraded: 'degraded',
35529
+ serviceStateUnhealthy: 'unhealthy',
35530
+ serviceStateUnknown: 'not reachable',
35531
+ kpiVersionLabel: 'Version',
35532
+ kpiVersionNone: 'No version information',
35533
+ attentionServiceTitle: '{name} service is {state}',
35534
+ attentionServiceText: 'Its health check reports a problem. The health details list the failing checks.',
35535
+ attentionServiceUnreachableText: 'Its health endpoint does not answer. Features that use the service may fail.',
35536
+ attentionServiceLink: 'Open health details',
35537
+ attentionServiceExplain: 'Why is the {name} service {state}?',
35538
+ attentionBlueprintUpdatesTitle: 'Blueprint updates available',
35539
+ attentionBlueprintUpdatesText: '{list}. Updates are applied on the Blueprints page.',
35540
+ attentionBlueprintUpdatesServiceManaged: 'Service-managed: {list} — applied by the owning service when it starts.',
35541
+ attentionBlueprintUpdatesLink: 'Open blueprints',
35013
35542
  recentLoadFailed: 'Recently opened items could not be read.',
35014
35543
  recentListLabel: 'Recently opened',
35015
35544
  recentEmpty: 'Nothing opened yet. Pages, entities and boards you open appear here.',
@@ -35081,12 +35610,29 @@ const COCKPIT_ROLES = {
35081
35610
  * never run, so a board embedded in an app without this token shows no builder findings.
35082
35611
  */
35083
35612
  const COCKPIT_VIEWER_ACCESS = new InjectionToken('COCKPIT_VIEWER_ACCESS');
35613
+ /**
35614
+ * The deprecated pre-4.x alias of a deployment-site target (`deployment-site` → `pool`,
35615
+ * `deployment-sites` → `pools`), or `null` for every other target. Used as the fallback when a
35616
+ * host's resolver has no page for the new kind yet (AB#5842).
35617
+ */
35618
+ function legacyCockpitLinkTarget(target) {
35619
+ switch (target.kind) {
35620
+ case 'deployment-site':
35621
+ return { kind: 'pool', rtId: target.rtId };
35622
+ case 'deployment-sites':
35623
+ return { kind: 'pools' };
35624
+ default:
35625
+ return null;
35626
+ }
35627
+ }
35084
35628
  /** The host's link resolver; without it findings and KPI tiles carry no links. */
35085
35629
  const COCKPIT_LINK_RESOLVER = new InjectionToken('COCKPIT_LINK_RESOLVER');
35086
35630
  /** Optional: without it the attention list shows no "✦ Explain" buttons. */
35087
35631
  const COCKPIT_EXPLAIN_HANDLER = new InjectionToken('COCKPIT_EXPLAIN_HANDLER');
35088
35632
  /** The host's recent items; without it the "Recent items" widget says "Not available" and collapses. */
35089
35633
  const COCKPIT_RECENT_ITEMS = new InjectionToken('COCKPIT_RECENT_ITEMS');
35634
+ /** The host's versions; without it the "Version" tile says "Not available" and collapses. */
35635
+ const COCKPIT_VERSION_SOURCE = new InjectionToken('COCKPIT_VERSION_SOURCE');
35090
35636
  /**
35091
35637
  * Provides the host services of the cockpit widgets. The factories run in an injection context,
35092
35638
  * so they may `inject()` the application's services:
@@ -35112,6 +35658,9 @@ function provideCockpitWidgetHost(host) {
35112
35658
  if (host.recents) {
35113
35659
  providers.push({ provide: COCKPIT_RECENT_ITEMS, useFactory: host.recents });
35114
35660
  }
35661
+ if (host.versions) {
35662
+ providers.push({ provide: COCKPIT_VERSION_SOURCE, useFactory: host.versions });
35663
+ }
35115
35664
  if (host.messages) {
35116
35665
  providers.push({ provide: COCKPIT_WIDGET_MESSAGES, useFactory: host.messages });
35117
35666
  }
@@ -35129,6 +35678,7 @@ class CockpitContextService {
35129
35678
  links = inject(COCKPIT_LINK_RESOLVER, { optional: true });
35130
35679
  explainHandler = inject(COCKPIT_EXPLAIN_HANDLER, { optional: true });
35131
35680
  ckModelService = inject(CkModelService);
35681
+ configuration = inject(CONFIGURATION_SERVICE, { optional: true });
35132
35682
  /** The current tenant, or `null` when the host provides none. */
35133
35683
  async tenantId() {
35134
35684
  try {
@@ -35157,6 +35707,21 @@ class CockpitContextService {
35157
35707
  return false;
35158
35708
  }
35159
35709
  }
35710
+ /**
35711
+ * Whether `tenantId` is the installation's system tenant (`systemTenantId` of the platform
35712
+ * configuration, `octosystem` when the host has none). Checks about the whole installation
35713
+ * (service health, AB#5558) run only there.
35714
+ */
35715
+ isSystemTenant(tenantId) {
35716
+ let systemTenantId;
35717
+ try {
35718
+ systemTenantId = this.configuration?.config?.systemTenantId;
35719
+ }
35720
+ catch {
35721
+ systemTenantId = undefined;
35722
+ }
35723
+ return tenantId.toLowerCase() === (systemTenantId || 'octosystem').toLowerCase();
35724
+ }
35160
35725
  /** Whether the viewer is a builder (`CockpitViewerAccess.isBuilder`); fails closed. */
35161
35726
  async isBuilder() {
35162
35727
  try {
@@ -35187,12 +35752,11 @@ class CockpitContextService {
35187
35752
  * keep their path unless the resolver rewrites it, and always keep their query parameters.
35188
35753
  */
35189
35754
  resolveLinkTarget(target, tenantId) {
35190
- let url;
35191
- try {
35192
- url = this.links?.resolve(target, tenantId) ?? null;
35193
- }
35194
- catch {
35195
- url = null;
35755
+ let url = this.resolveWithHost(target, tenantId);
35756
+ if (url === null) {
35757
+ // Hosts that predate System.Communication 4.x only map `pool` / `pools` (AB#5842).
35758
+ const legacy = legacyCockpitLinkTarget(target);
35759
+ url = legacy ? this.resolveWithHost(legacy, tenantId) : null;
35196
35760
  }
35197
35761
  if (target.kind !== 'route') {
35198
35762
  return url ? { path: url } : null;
@@ -35207,6 +35771,14 @@ class CockpitContextService {
35207
35771
  }
35208
35772
  return target.queryParams && Object.keys(target.queryParams).length > 0 ? { path, queryParams: { ...target.queryParams } } : { path };
35209
35773
  }
35774
+ resolveWithHost(target, tenantId) {
35775
+ try {
35776
+ return this.links?.resolve(target, tenantId) ?? null;
35777
+ }
35778
+ catch {
35779
+ return null;
35780
+ }
35781
+ }
35210
35782
  /** Whether "✦ Explain" buttons are shown. */
35211
35783
  get explainEnabled() {
35212
35784
  return !!this.explainHandler?.enabled;
@@ -35402,7 +35974,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
35402
35974
 
35403
35975
  /** Names of models in ResolveFailed fetched with the counts. */
35404
35976
  const RESOLVE_FAILED_NAMES_FETCHED = 10;
35405
- const SHARE_MS$2 = 10_000;
35977
+ const SHARE_MS$4 = 10_000;
35406
35978
  /**
35407
35979
  * CK model counts per state (`cockpitCkModelStates`, one request with aliased counts), shared for
35408
35980
  * a few seconds per tenant between the "CK model state" KPI and the ResolveFailed provider of the
@@ -35413,7 +35985,7 @@ class CockpitCkModelStatesService {
35413
35985
  shared = new Map();
35414
35986
  counts(tenantId, now = Date.now()) {
35415
35987
  const cached = this.shared.get(tenantId);
35416
- if (cached && now - cached.at <= SHARE_MS$2) {
35988
+ if (cached && now - cached.at <= SHARE_MS$4) {
35417
35989
  return cached.counts;
35418
35990
  }
35419
35991
  const counts = this.gql.fetch({ variables: { first: RESOLVE_FAILED_NAMES_FETCHED }, fetchPolicy: 'network-only' }).pipe(map$1(result => {
@@ -35550,7 +36122,7 @@ function summarizeAdapterOnline(adapters) {
35550
36122
  /** Upper bound of adapters read; beyond it the KPI and findings say "≥" / "first N". */
35551
36123
  const COCKPIT_ADAPTER_LIMIT = 500;
35552
36124
  /** Answers younger than this are shared between the "Adapter status" KPI and the attention list. */
35553
- const SHARE_MS$1 = 10_000;
36125
+ const SHARE_MS$3 = 10_000;
35554
36126
  /** A running adapter offline for longer than this is reported (ui-concept §5.5). */
35555
36127
  const ADAPTER_OFFLINE_GRACE_MS = 10 * 60 * 1000;
35556
36128
  /**
@@ -35564,7 +36136,7 @@ class CockpitAdapterStatesService {
35564
36136
  shared = new Map();
35565
36137
  states(tenantId, now = Date.now()) {
35566
36138
  const cached = this.shared.get(tenantId);
35567
- if (cached && now - cached.at <= SHARE_MS$1) {
36139
+ if (cached && now - cached.at <= SHARE_MS$3) {
35568
36140
  return cached.states;
35569
36141
  }
35570
36142
  const states = this.gql.fetch({ variables: { first: COCKPIT_ADAPTER_LIMIT }, fetchPolicy: 'network-only' }).pipe(map$1(result => {
@@ -35670,7 +36242,7 @@ function explainTarget(states, what) {
35670
36242
  const CockpitUnregisteredPoolsDocumentDto = gql `
35671
36243
  query cockpitUnregisteredPools($first: Int) {
35672
36244
  runtime {
35673
- systemCommunicationPool(
36245
+ systemCommunicationDeploymentSite(
35674
36246
  first: $first
35675
36247
  fieldFilter: [{attributePath: "communicationState", operator: EQUALS, comparisonValue: "UNREGISTERED"}]
35676
36248
  sortOrder: [{attributePath: "name", sortOrder: ASCENDING}]
@@ -35701,45 +36273,60 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
35701
36273
 
35702
36274
  const NAMES_FETCHED = 10;
35703
36275
  /**
35704
- * Pools no Communication Operator has registered for: their adapters and applications are not
35705
- * rolled out. Needs what the pools page needs (CommunicationManagement + System.Communication).
36276
+ * Deployment sites (`System.Communication/DeploymentSite`, called "pools" before
36277
+ * System.Communication 4.x) no Communication Operator has registered for: the adapters and
36278
+ * applications they host are not rolled out. Needs what the deployment sites page needs
36279
+ * (CommunicationManagement + System.Communication).
36280
+ *
36281
+ * The class name and the provider id `pools-unregistered` keep the pre-4.x wording on purpose:
36282
+ * the id is persisted in attention-list widget configs (`providerIds`) and must never change
36283
+ * (AB#5842).
35706
36284
  */
35707
36285
  class UnregisteredPoolsAttentionProvider {
35708
36286
  context = inject(CockpitContextService);
35709
- poolsGql = inject(CockpitUnregisteredPoolsDtoGQL);
36287
+ sitesGql = inject(CockpitUnregisteredPoolsDtoGQL);
36288
+ /** Persisted in widget configs — stable since AB#5558, deliberately not renamed in AB#5842. */
35710
36289
  id = 'pools-unregistered';
35711
- label = 'Pools not registered';
35712
- description = 'Pools without a connected Communication Operator. Viewers with CommunicationManagement.';
36290
+ label = 'Deployment sites not registered';
36291
+ description = 'Deployment sites without a connected Communication Operator. Viewers with CommunicationManagement.';
35713
36292
  isVisible() {
35714
36293
  return this.context.allows([COCKPIT_ROLES.CommunicationManagement], ['System.Communication']);
35715
36294
  }
35716
36295
  load() {
35717
- return this.poolsGql.fetch({ variables: { first: NAMES_FETCHED }, fetchPolicy: 'network-only' }).pipe(map$1(result => {
35718
- const connection = result.data?.runtime?.systemCommunicationPool;
35719
- const pools = (connection?.items ?? []).filter((pool) => !!pool);
35720
- const count = connection?.totalCount ?? pools.length;
36296
+ return this.sitesGql.fetch({ variables: { first: NAMES_FETCHED }, fetchPolicy: 'network-only' }).pipe(map$1(result => {
36297
+ const connection = result.data?.runtime?.systemCommunicationDeploymentSite;
36298
+ const sites = (connection?.items ?? []).filter((site) => !!site);
36299
+ const count = connection?.totalCount ?? sites.length;
35721
36300
  if (count === 0) {
35722
36301
  return [];
35723
36302
  }
35724
- const single = count === 1 && pools.length === 1 ? pools[0] : null;
36303
+ const single = count === 1 && sites.length === 1 ? sites[0] : null;
35725
36304
  if (single) {
35726
36305
  const name = single.name ?? String(single.rtId);
35727
36306
  return [{
35728
36307
  id: this.id,
35729
36308
  severity: 'warning',
35730
- title: `Pool "${name}" is not registered`,
35731
- text: 'No operator is connected. Adapters and applications in this pool are not rolled out.',
35732
- links: [{ label: 'Open pool', target: { kind: 'pool', rtId: String(single.rtId) } }, { label: 'All pools', target: { kind: 'pools' } }],
35733
- explain: { label: name, rtId: String(single.rtId), ckTypeId: 'System.Communication/Pool', prompt: `Why is the pool "${name}" not registered?` }
36309
+ title: `Deployment site "${name}" is not registered`,
36310
+ text: 'No operator is connected. Adapters and applications on this deployment site are not rolled out.',
36311
+ links: [
36312
+ { label: 'Open deployment site', target: { kind: 'deployment-site', rtId: String(single.rtId) } },
36313
+ { label: 'All deployment sites', target: { kind: 'deployment-sites' } }
36314
+ ],
36315
+ explain: {
36316
+ label: name,
36317
+ rtId: String(single.rtId),
36318
+ ckTypeId: 'System.Communication/DeploymentSite',
36319
+ prompt: `Why is the deployment site "${name}" not registered?`
36320
+ }
35734
36321
  }];
35735
36322
  }
35736
36323
  return [{
35737
36324
  id: this.id,
35738
36325
  severity: 'warning',
35739
- title: `${plural(count, 'pool')} not registered`,
35740
- text: `${nameList(pools.map(pool => pool.name ?? String(pool.rtId)), 3, count)}: no operator is connected, their adapters and applications are not rolled out.`,
35741
- links: [{ label: 'Open pools', target: { kind: 'pools' } }],
35742
- explain: { label: `${count} pools not registered`, prompt: `Why are ${count} pools not registered?` }
36326
+ title: `${plural(count, 'deployment site')} not registered`,
36327
+ text: `${nameList(sites.map(site => site.name ?? String(site.rtId)), 3, count)}: no operator is connected, their adapters and applications are not rolled out.`,
36328
+ links: [{ label: 'Open deployment sites', target: { kind: 'deployment-sites' } }],
36329
+ explain: { label: `${count} deployment sites not registered`, prompt: `Why are ${count} deployment sites not registered?` }
35743
36330
  }];
35744
36331
  }));
35745
36332
  }
@@ -35948,7 +36535,7 @@ function statisticsCoverage(stat) {
35948
36535
  /** Upper bound of data flows read for the executions KPI; beyond it the tile says "≥". */
35949
36536
  const COCKPIT_DATA_FLOW_LIMIT = 500;
35950
36537
  /** Answers younger than this are shared between the "Pipeline executions 24 h" KPI and the attention list. */
35951
- const SHARE_MS = 10_000;
36538
+ const SHARE_MS$2 = 10_000;
35952
36539
  /**
35953
36540
  * The data flows behind the "Pipeline executions 24 h" KPI and the failed-executions provider of
35954
36541
  * the attention list (AB#5622). Both ask within the same board load, so one request per tenant
@@ -35960,7 +36547,7 @@ class CockpitDataFlowExecutionsService {
35960
36547
  shared = new Map();
35961
36548
  executions(tenantId, now = Date.now()) {
35962
36549
  const cached = this.shared.get(tenantId);
35963
- if (cached && now - cached.at <= SHARE_MS) {
36550
+ if (cached && now - cached.at <= SHARE_MS$2) {
35964
36551
  return cached.flows;
35965
36552
  }
35966
36553
  const entry = { at: now, flows: EMPTY };
@@ -36185,6 +36772,145 @@ function ckModelKpi(counts, messages = DEFAULT_COCKPIT_WIDGET_MESSAGES) {
36185
36772
  link: { kind: 'ckModels' }
36186
36773
  };
36187
36774
  }
36775
+ /**
36776
+ * "Tenants" (system cockpit, AB#5558): child tenants of the current tenant — on the system tenant
36777
+ * every tenant of the installation. The tenant list has no state, so the tile counts only.
36778
+ */
36779
+ function tenantCountKpi(tenants, messages = DEFAULT_COCKPIT_WIDGET_MESSAGES) {
36780
+ const m = withDefaults(messages);
36781
+ const n = (value) => formatCount(value, m.numberLocale);
36782
+ let detail;
36783
+ if (tenants.firstIds.length > 0) {
36784
+ const rest = tenants.total - tenants.firstIds.length;
36785
+ const shown = tenants.firstIds.join(', ');
36786
+ detail = rest > 0 ? formatCockpitMessage(m.kpiCkModelsMoreNames, { names: shown, count: n(rest) }) : shown;
36787
+ }
36788
+ return {
36789
+ id: 'tenant-count',
36790
+ label: m.kpiTenantsLabel,
36791
+ value: n(tenants.total),
36792
+ detail,
36793
+ status: 'neutral',
36794
+ statusLabel: tenants.total === 0 ? m.kpiTenantsNone : m.kpiTenantsRegistered,
36795
+ link: { kind: 'tenants' }
36796
+ };
36797
+ }
36798
+ /**
36799
+ * "Blueprint updates" (system cockpit, AB#5558): installed blueprints with a newer catalog
36800
+ * version. Only blueprints the tenant installed count as updates to act on (warning);
36801
+ * service-managed ones (`System.*`) are applied by their service and only named in the detail.
36802
+ */
36803
+ function blueprintUpdatesKpi(status, messages = DEFAULT_COCKPIT_WIDGET_MESSAGES) {
36804
+ const m = withDefaults(messages);
36805
+ const n = (value) => formatCount(value, m.numberLocale);
36806
+ const own = status.updates.filter(update => !update.isServiceManaged);
36807
+ const managed = status.updates.length - own.length;
36808
+ const details = [];
36809
+ if (own.length > 0) {
36810
+ details.push(own.slice(0, 2).map(update => `${update.name} ${update.installedVersion} → ${update.availableVersion}`).join(', '));
36811
+ }
36812
+ else {
36813
+ details.push(formatCockpitMessage(m.kpiBlueprintsInstalled, { count: n(status.installed) }));
36814
+ }
36815
+ if (managed > 0) {
36816
+ details.push(formatCockpitMessage(m.kpiBlueprintsServiceManaged, { count: n(managed) }));
36817
+ }
36818
+ const note = truncationText(status.catalogRead, status.catalogTotal, m.kpiBlueprintsTruncated, m.numberLocale);
36819
+ if (note) {
36820
+ details.push(note);
36821
+ }
36822
+ let kpiStatus;
36823
+ let statusLabel;
36824
+ if (status.installed === 0 && status.updates.length === 0) {
36825
+ kpiStatus = 'neutral';
36826
+ statusLabel = m.kpiBlueprintsNone;
36827
+ }
36828
+ else if (own.length > 0) {
36829
+ kpiStatus = 'warning';
36830
+ statusLabel = formatCockpitMessage(m.kpiBlueprintsAvailable, { count: n(own.length) });
36831
+ }
36832
+ else {
36833
+ kpiStatus = 'success';
36834
+ statusLabel = m.kpiBlueprintsUpToDate;
36835
+ }
36836
+ return {
36837
+ id: 'blueprint-updates',
36838
+ label: m.kpiBlueprintsLabel,
36839
+ value: n(own.length),
36840
+ detail: details.join(' · ') || undefined,
36841
+ status: kpiStatus,
36842
+ statusLabel,
36843
+ link: { kind: 'blueprints' }
36844
+ };
36845
+ }
36846
+ /** The display word of a service health state (`degraded`, `unhealthy`, `not reachable`). */
36847
+ function serviceStateText(state, messages = DEFAULT_COCKPIT_WIDGET_MESSAGES) {
36848
+ const m = withDefaults(messages);
36849
+ switch (state) {
36850
+ case 'degraded':
36851
+ return m.serviceStateDegraded;
36852
+ case 'unknown':
36853
+ return m.serviceStateUnknown;
36854
+ default:
36855
+ return m.serviceStateUnhealthy;
36856
+ }
36857
+ }
36858
+ /**
36859
+ * "Services healthy" (system cockpit, AB#5558): platform services whose health check reports
36860
+ * Healthy, of all checked. Unhealthy and not answering are errors, degraded a warning; the tile
36861
+ * opens the health details of the first service with a problem (else Identity).
36862
+ */
36863
+ function servicesHealthKpi(services, messages = DEFAULT_COCKPIT_WIDGET_MESSAGES) {
36864
+ const m = withDefaults(messages);
36865
+ const n = (value) => formatCount(value, m.numberLocale);
36866
+ const healthy = services.filter(service => service.status === 'healthy');
36867
+ const failing = services.filter(service => service.status === 'unhealthy' || service.status === 'unknown');
36868
+ const degraded = services.filter(service => service.status === 'degraded');
36869
+ const problems = [...failing, ...degraded];
36870
+ let status;
36871
+ let statusLabel;
36872
+ if (failing.length > 0) {
36873
+ status = 'error';
36874
+ statusLabel = formatCockpitMessage(m.kpiServicesUnhealthy, { count: n(failing.length) });
36875
+ }
36876
+ else if (degraded.length > 0) {
36877
+ status = 'warning';
36878
+ statusLabel = formatCockpitMessage(m.kpiServicesDegraded, { count: n(degraded.length) });
36879
+ }
36880
+ else {
36881
+ status = 'success';
36882
+ statusLabel = m.kpiServicesAllHealthy;
36883
+ }
36884
+ return {
36885
+ id: 'services-health',
36886
+ label: m.kpiServicesLabel,
36887
+ value: `${n(healthy.length)} / ${n(services.length)}`,
36888
+ detail: problems.length > 0
36889
+ ? problems.map(service => `${service.name}: ${serviceStateText(service.status, m)}`).join(' · ')
36890
+ : services.map(service => service.name).join(', ') || undefined,
36891
+ status,
36892
+ statusLabel,
36893
+ link: { kind: 'serviceHealth', service: problems[0]?.service ?? services[0]?.service ?? 'identity' }
36894
+ };
36895
+ }
36896
+ /** "Version" (system cockpit, AB#5558): the host's versions; the first is the value. No page behind it. */
36897
+ function versionKpi(entries, messages = DEFAULT_COCKPIT_WIDGET_MESSAGES) {
36898
+ const m = withDefaults(messages);
36899
+ const valid = entries.filter(entry => !!entry?.label && !!entry.version);
36900
+ const [first, ...rest] = valid;
36901
+ return {
36902
+ id: 'version-info',
36903
+ label: m.kpiVersionLabel,
36904
+ value: first?.version ?? '—',
36905
+ detail: rest.map(entry => `${entry.label} ${entry.version}`).join(' · ') || undefined,
36906
+ status: 'neutral',
36907
+ statusLabel: first?.label ?? m.kpiVersionNone
36908
+ };
36909
+ }
36910
+ /** Messages with every optional member filled (members added later are optional). */
36911
+ function withDefaults(messages) {
36912
+ return resolveCockpitWidgetMessages(messages);
36913
+ }
36188
36914
  /** Scales the values to the box; the tallest value touches the top padding. */
36189
36915
  function sparklineGeometry(values, width = 120, height = 36, padding = 3) {
36190
36916
  if (values.length < 2) {
@@ -36349,6 +37075,325 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
36349
37075
  type: Injectable
36350
37076
  }] });
36351
37077
 
37078
+ /** Display names, in check order. */
37079
+ const COCKPIT_SERVICE_NAMES = {
37080
+ identity: 'Identity',
37081
+ 'asset-repository': 'Asset Repository',
37082
+ bot: 'Bot',
37083
+ 'communication-controller': 'Communication Controller'
37084
+ };
37085
+ const SHARE_MS$1 = 10_000;
37086
+ /** `HealthCheck` → status (`null` = no answer). */
37087
+ function toServiceHealthStatus(check) {
37088
+ switch (check?.status) {
37089
+ case HealthStatus.Healthy:
37090
+ return 'healthy';
37091
+ case HealthStatus.Degraded:
37092
+ return 'degraded';
37093
+ case HealthStatus.Unhealthy:
37094
+ return 'unhealthy';
37095
+ default:
37096
+ return 'unknown';
37097
+ }
37098
+ }
37099
+ /**
37100
+ * Health of the platform services (AB#5558, system cockpit): the same `/health` endpoints as the
37101
+ * MeshBoard's service health tiles, all at once, shared for a few seconds between the "Services"
37102
+ * KPI and the attention check. Optional services that are not part of the installation (empty
37103
+ * URL, or `/` from older platform-services) are left out.
37104
+ */
37105
+ class CockpitServiceHealthService {
37106
+ health = inject(HealthService);
37107
+ configuration = inject(CONFIGURATION_SERVICE, { optional: true });
37108
+ shared = null;
37109
+ services(now = Date.now()) {
37110
+ if (this.shared && now - this.shared.at <= SHARE_MS$1) {
37111
+ return this.shared.result;
37112
+ }
37113
+ const result = defer(() => from(this.check())).pipe(shareReplay({ bufferSize: 1, refCount: false }));
37114
+ this.shared = { at: now, result };
37115
+ return result;
37116
+ }
37117
+ async check() {
37118
+ const config = this.configuration?.config;
37119
+ const checks = [
37120
+ ['identity', () => this.health.getIdentityServiceAsync()],
37121
+ ['asset-repository', () => this.health.getAssetRepoServiceHealthAsync()],
37122
+ ['bot', () => this.health.getBotServiceAsync()]
37123
+ ];
37124
+ if (!config || isConfigured(config.communicationServices)) {
37125
+ checks.push(['communication-controller', () => this.health.getCommunicationControllerServiceAsync()]);
37126
+ }
37127
+ return Promise.all(checks.map(async ([service, read]) => {
37128
+ let check;
37129
+ try {
37130
+ check = await read();
37131
+ }
37132
+ catch {
37133
+ check = null;
37134
+ }
37135
+ return { service, name: COCKPIT_SERVICE_NAMES[service], status: toServiceHealthStatus(check) };
37136
+ }));
37137
+ }
37138
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CockpitServiceHealthService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
37139
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CockpitServiceHealthService, providedIn: 'root' });
37140
+ }
37141
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CockpitServiceHealthService, decorators: [{
37142
+ type: Injectable,
37143
+ args: [{ providedIn: 'root' }]
37144
+ }] });
37145
+ function isConfigured(url) {
37146
+ return !!url && url !== '/';
37147
+ }
37148
+
37149
+ /**
37150
+ * Platform services whose health check is not Healthy (system cockpit, AB#5558): one finding per
37151
+ * service — unhealthy or not answering is an error, degraded a warning — linking to the service's
37152
+ * health details. The services serve the whole installation, so the check runs on the system
37153
+ * tenant only (tenant cockpits are unchanged). Shares its requests with the "Services healthy" KPI.
37154
+ */
37155
+ class ServicesHealthAttentionProvider {
37156
+ context = inject(CockpitContextService);
37157
+ /** Resolved on first load: hosts and tests that never run the check need no data providers. */
37158
+ injector = inject(Injector);
37159
+ messagesSource = inject(COCKPIT_WIDGET_MESSAGES, { optional: true });
37160
+ id = 'services-unhealthy';
37161
+ label = 'Platform services with problems';
37162
+ description = 'Identity, Asset Repository, Bot or Communication Controller not healthy. System tenant only.';
37163
+ async isVisible(context) {
37164
+ return this.context.isSystemTenant(context.tenantId) && await this.context.allows([]);
37165
+ }
37166
+ load() {
37167
+ return this.injector.get(CockpitServiceHealthService).services().pipe(map$1(services => {
37168
+ const m = resolveCockpitWidgetMessages(readCockpitMessagesSource(this.messagesSource));
37169
+ return services
37170
+ .filter(service => service.status !== 'healthy')
37171
+ .map((service) => {
37172
+ const state = serviceStateText(service.status, m);
37173
+ const title = formatCockpitMessage(m.attentionServiceTitle, { name: service.name, state });
37174
+ return {
37175
+ id: `${this.id}:${service.service}`,
37176
+ severity: service.status === 'degraded' ? 'warning' : 'error',
37177
+ title,
37178
+ text: service.status === 'unknown' ? m.attentionServiceUnreachableText : m.attentionServiceText,
37179
+ links: [{ label: m.attentionServiceLink, target: { kind: 'serviceHealth', service: service.service } }],
37180
+ explain: { label: title, prompt: formatCockpitMessage(m.attentionServiceExplain, { name: service.name, state }) }
37181
+ };
37182
+ });
37183
+ }));
37184
+ }
37185
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: ServicesHealthAttentionProvider, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
37186
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: ServicesHealthAttentionProvider });
37187
+ }
37188
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: ServicesHealthAttentionProvider, decorators: [{
37189
+ type: Injectable
37190
+ }] });
37191
+
37192
+ const CockpitBlueprintStatusDocumentDto = gql `
37193
+ query cockpitBlueprintStatus($take: Int) {
37194
+ blueprints {
37195
+ installations {
37196
+ blueprintId
37197
+ isDependency
37198
+ }
37199
+ list(skip: 0, take: $take) {
37200
+ totalCount
37201
+ items {
37202
+ name
37203
+ version
37204
+ }
37205
+ }
37206
+ }
37207
+ }
37208
+ `;
37209
+ class CockpitBlueprintStatusDtoGQL extends i1.Query {
37210
+ document = CockpitBlueprintStatusDocumentDto;
37211
+ constructor(apollo) {
37212
+ super(apollo);
37213
+ }
37214
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CockpitBlueprintStatusDtoGQL, deps: [{ token: i1.Apollo }], target: i0.ɵɵFactoryTarget.Injectable });
37215
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CockpitBlueprintStatusDtoGQL, providedIn: 'root' });
37216
+ }
37217
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CockpitBlueprintStatusDtoGQL, decorators: [{
37218
+ type: Injectable,
37219
+ args: [{
37220
+ providedIn: 'root'
37221
+ }]
37222
+ }], ctorParameters: () => [{ type: i1.Apollo }] });
37223
+
37224
+ /** Catalog entries read with the installations (one page; the catalog lists one entry per name and version). */
37225
+ const COCKPIT_BLUEPRINT_CATALOG_LIMIT = 500;
37226
+ const SHARE_MS = 10_000;
37227
+ /**
37228
+ * Blueprints whose name starts with `System.` are service-managed: the owning service applies and
37229
+ * rolls them forward per tenant (mirrors `BlueprintIdExtensions.IsServiceManaged`).
37230
+ */
37231
+ const SERVICE_MANAGED_BLUEPRINT_PREFIX = 'System.';
37232
+ /**
37233
+ * `Name-Version` → parts. Splits at the last dash, like the Studio's blueprint pages (a `CkVersion`
37234
+ * never contains a dash).
37235
+ */
37236
+ function splitBlueprintId(blueprintId) {
37237
+ const index = blueprintId.lastIndexOf('-');
37238
+ return index < 0 ? { name: blueprintId, version: '' } : { name: blueprintId.slice(0, index), version: blueprintId.slice(index + 1) };
37239
+ }
37240
+ /**
37241
+ * Compares two blueprint versions like the backend's `CkVersion` (`Major.Minor.Revision`,
37242
+ * numeric, missing components 0): negative when `a` is older, positive when newer. A string
37243
+ * comparison would rate `2.10.0` below `2.9.0` (AB#4837).
37244
+ */
37245
+ function compareBlueprintVersions(a, b) {
37246
+ const left = versionComponents(a);
37247
+ const right = versionComponents(b);
37248
+ for (let i = 0; i < 3; i++) {
37249
+ if (left[i] !== right[i]) {
37250
+ return left[i] - right[i];
37251
+ }
37252
+ }
37253
+ return 0;
37254
+ }
37255
+ function versionComponents(version) {
37256
+ const parts = version.split('.');
37257
+ return [0, 1, 2].map(i => {
37258
+ const parsed = Number.parseInt(parts[i] ?? '', 10);
37259
+ return Number.isNaN(parsed) ? 0 : parsed;
37260
+ });
37261
+ }
37262
+ /** The status from one `cockpitBlueprintStatus` answer. */
37263
+ function toBlueprintStatus(data) {
37264
+ const installations = data?.blueprints?.installations ?? [];
37265
+ const catalog = data?.blueprints?.list;
37266
+ const highest = new Map();
37267
+ for (const item of catalog?.items ?? []) {
37268
+ if (!item?.name || !item.version) {
37269
+ continue;
37270
+ }
37271
+ const known = highest.get(item.name);
37272
+ if (!known || compareBlueprintVersions(item.version, known) > 0) {
37273
+ highest.set(item.name, item.version);
37274
+ }
37275
+ }
37276
+ const updates = [];
37277
+ let installed = 0;
37278
+ for (const installation of installations) {
37279
+ if (!installation?.blueprintId) {
37280
+ continue;
37281
+ }
37282
+ if (!installation.isDependency) {
37283
+ installed++;
37284
+ }
37285
+ const { name, version } = splitBlueprintId(installation.blueprintId);
37286
+ const available = highest.get(name);
37287
+ if (available && compareBlueprintVersions(available, version) > 0) {
37288
+ updates.push({ name, installedVersion: version, availableVersion: available, isServiceManaged: name.startsWith(SERVICE_MANAGED_BLUEPRINT_PREFIX) });
37289
+ }
37290
+ }
37291
+ updates.sort((a, b) => Number(a.isServiceManaged) - Number(b.isServiceManaged) || a.name.localeCompare(b.name));
37292
+ const read = catalog?.items?.length ?? 0;
37293
+ return { installed, updates, catalogRead: read, catalogTotal: Math.max(catalog?.totalCount ?? 0, read) };
37294
+ }
37295
+ /**
37296
+ * The tenant's blueprint status, shared for a few seconds per tenant between the "Blueprint
37297
+ * updates" KPI and the attention check.
37298
+ */
37299
+ class CockpitBlueprintStatusService {
37300
+ gql = inject(CockpitBlueprintStatusDtoGQL);
37301
+ shared = new Map();
37302
+ status(tenantId, now = Date.now()) {
37303
+ const cached = this.shared.get(tenantId);
37304
+ if (cached && now - cached.at <= SHARE_MS) {
37305
+ return cached.status;
37306
+ }
37307
+ const status = this.gql.fetch({ variables: { take: COCKPIT_BLUEPRINT_CATALOG_LIMIT }, fetchPolicy: 'network-only' }).pipe(map$1(result => toBlueprintStatus(result.data)), shareReplay({ bufferSize: 1, refCount: false }));
37308
+ this.shared.set(tenantId, { at: now, status });
37309
+ return status;
37310
+ }
37311
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CockpitBlueprintStatusService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
37312
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CockpitBlueprintStatusService, providedIn: 'root' });
37313
+ }
37314
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CockpitBlueprintStatusService, decorators: [{
37315
+ type: Injectable,
37316
+ args: [{ providedIn: 'root' }]
37317
+ }] });
37318
+
37319
+ /** "Name 1.0.0 → 1.1.0". */
37320
+ function describe(update) {
37321
+ return `${update.name} ${update.installedVersion} → ${update.availableVersion}`;
37322
+ }
37323
+ /**
37324
+ * Installed blueprints with a newer catalog version (system cockpit, AB#5558): one info finding
37325
+ * with the count as badge, linking to the installed blueprints. Service-managed blueprints
37326
+ * (`System.*`) are named separately — their service applies them. Runs on the system tenant only
37327
+ * for now (it reads the catalogs, so tenant cockpits do not pay for it); same role as the
37328
+ * Blueprints pages. Shares its request with the "Blueprint updates" KPI.
37329
+ */
37330
+ class BlueprintUpdatesAttentionProvider {
37331
+ context = inject(CockpitContextService);
37332
+ /** Resolved on first load: hosts and tests that never run the check need no data providers. */
37333
+ injector = inject(Injector);
37334
+ messagesSource = inject(COCKPIT_WIDGET_MESSAGES, { optional: true });
37335
+ id = 'blueprint-updates';
37336
+ label = 'Blueprint updates available';
37337
+ description = 'Installed blueprints with a newer version in the catalogs. System tenant, viewers with AdminPanelManagement.';
37338
+ async isVisible(context) {
37339
+ return this.context.isSystemTenant(context.tenantId) && await this.context.allows([COCKPIT_ROLES.AdminPanelManagement]);
37340
+ }
37341
+ load(context) {
37342
+ return this.injector.get(CockpitBlueprintStatusService).status(context.tenantId).pipe(map$1(status => {
37343
+ if (status.updates.length === 0) {
37344
+ return [];
37345
+ }
37346
+ const m = resolveCockpitWidgetMessages(readCockpitMessagesSource(this.messagesSource));
37347
+ const own = status.updates.filter(update => !update.isServiceManaged);
37348
+ const managed = status.updates.filter(update => update.isServiceManaged);
37349
+ const texts = [];
37350
+ if (own.length > 0) {
37351
+ texts.push(formatCockpitMessage(m.attentionBlueprintUpdatesText, { list: nameList(own.map(describe)) }));
37352
+ }
37353
+ if (managed.length > 0) {
37354
+ texts.push(formatCockpitMessage(m.attentionBlueprintUpdatesServiceManaged, { list: nameList(managed.map(describe)) }));
37355
+ }
37356
+ const title = m.attentionBlueprintUpdatesTitle;
37357
+ return [{
37358
+ id: this.id,
37359
+ severity: 'info',
37360
+ title,
37361
+ text: texts.join(' '),
37362
+ count: status.updates.length,
37363
+ links: [{ label: m.attentionBlueprintUpdatesLink, target: { kind: 'blueprints' } }]
37364
+ }];
37365
+ }));
37366
+ }
37367
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: BlueprintUpdatesAttentionProvider, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
37368
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: BlueprintUpdatesAttentionProvider });
37369
+ }
37370
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: BlueprintUpdatesAttentionProvider, decorators: [{
37371
+ type: Injectable
37372
+ }] });
37373
+
37374
+ /** Tenant ids read with the count. */
37375
+ const COCKPIT_CHILD_TENANT_IDS = 3;
37376
+ /**
37377
+ * Child tenant count (AB#5558, system cockpit) from the asset repository's tenant list
37378
+ * (`GET {tenantId}/v1/tenants`, the Child Tenants page). The list only knows id and database —
37379
+ * there is no per-tenant state (enabled, mode, health) to group by.
37380
+ */
37381
+ class CockpitChildTenantsService {
37382
+ assetRepo = inject(AssetRepoService);
37383
+ childTenants() {
37384
+ return defer(() => from(this.assetRepo.getTenants(0, COCKPIT_CHILD_TENANT_IDS))).pipe(map$1(result => {
37385
+ const ids = (result?.list ?? []).map(tenant => tenant?.tenantId).filter((id) => !!id);
37386
+ return { total: Math.max(result?.totalCount ?? 0, ids.length), firstIds: ids };
37387
+ }));
37388
+ }
37389
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CockpitChildTenantsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
37390
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CockpitChildTenantsService, providedIn: 'root' });
37391
+ }
37392
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CockpitChildTenantsService, decorators: [{
37393
+ type: Injectable,
37394
+ args: [{ providedIn: 'root' }]
37395
+ }] });
37396
+
36352
37397
  /** What a tile shows when its query failed (details are logged, never shown). */
36353
37398
  const KPI_ERROR_TEXT = 'The figure could not be loaded.';
36354
37399
  const COMMUNICATION_GATE = {
@@ -36361,7 +37406,14 @@ const COMMUNICATION_GATE = {
36361
37406
  const COCKPIT_KPI_GATES = {
36362
37407
  adapterStatus: COMMUNICATION_GATE,
36363
37408
  pipelineExecutions: COMMUNICATION_GATE,
36364
- ckModelState: { roles: [COCKPIT_ROLES.AdminPanelManagement], models: [], reason: 'Needs the AdminPanelManagement role.', reasonMessage: 'kpiNeedsAdminPanel' }
37409
+ ckModelState: { roles: [COCKPIT_ROLES.AdminPanelManagement], models: [], reason: 'Needs the AdminPanelManagement role.', reasonMessage: 'kpiNeedsAdminPanel' },
37410
+ // Same role as the Child Tenants page.
37411
+ tenantCount: { roles: [COCKPIT_ROLES.TenantManagement], models: [], reason: 'Needs the TenantManagement role.', reasonMessage: 'kpiNeedsTenantManagement' },
37412
+ // Same role as the Blueprints pages.
37413
+ blueprintUpdates: { roles: [COCKPIT_ROLES.AdminPanelManagement], models: [], reason: 'Needs the AdminPanelManagement role.', reasonMessage: 'kpiNeedsAdminPanel' },
37414
+ // The health endpoints and the health detail page are open to every signed-in user.
37415
+ servicesHealth: { roles: [], models: [], reason: '', reasonMessage: 'notAvailable' },
37416
+ versionInfo: { roles: [], models: [], reason: '', reasonMessage: 'notAvailable' }
36365
37417
  };
36366
37418
  /**
36367
37419
  * The cockpit KPIs (AB#5558, from the Home KPI strip of AB#5545): each KPI has its own gate and
@@ -36372,6 +37424,9 @@ class CockpitKpiService {
36372
37424
  adapterStates = inject(CockpitAdapterStatesService);
36373
37425
  ckModelStates = inject(CockpitCkModelStatesService);
36374
37426
  dataFlowExecutions = inject(CockpitDataFlowExecutionsService);
37427
+ /** The system cockpit's data services are resolved on first use, so hosts without them need no providers. */
37428
+ injector = inject(Injector);
37429
+ versions = inject(COCKPIT_VERSION_SOURCE, { optional: true });
36375
37430
  messagesSource = inject(COCKPIT_WIDGET_MESSAGES, { optional: true });
36376
37431
  /**
36377
37432
  * The KPI's state. Texts (label, status, detail, reasons) come from `messages`, else from the
@@ -36380,7 +37435,7 @@ class CockpitKpiService {
36380
37435
  kpi(kind, messages) {
36381
37436
  const gate = COCKPIT_KPI_GATES[kind];
36382
37437
  return defer(() => {
36383
- const texts = messages ?? resolveCockpitWidgetMessages(readCockpitMessagesSource(this.messagesSource));
37438
+ const texts = resolveCockpitWidgetMessages(messages ?? readCockpitMessagesSource(this.messagesSource));
36384
37439
  return from(this.resolve(gate)).pipe(switchMap(access => {
36385
37440
  if (access.kind === 'noTenant') {
36386
37441
  return of({ state: 'unavailable', reason: texts.noTenant, forBuilder: true });
@@ -36388,6 +37443,10 @@ class CockpitKpiService {
36388
37443
  if (access.kind === 'denied') {
36389
37444
  return of({ state: 'unavailable', reason: texts[gate.reasonMessage], forBuilder: access.builder });
36390
37445
  }
37446
+ if (kind === 'versionInfo' && !this.versions) {
37447
+ // Nothing to show without the host's versions: a quiet tile that collapses for non-builders.
37448
+ return of({ state: 'unavailable', reason: texts.notAvailable, forBuilder: false });
37449
+ }
36391
37450
  return this.load(kind, access.tenantId, texts).pipe(take(1), map$1((kpi) => ({ state: 'ready', kpi })));
36392
37451
  }), catchError$1(error => {
36393
37452
  // Details go to the console only; the tile shows a generic text.
@@ -36414,6 +37473,14 @@ class CockpitKpiService {
36414
37473
  return this.ckModelStates.counts(tenantId).pipe(map$1(counts => ckModelKpi(counts, texts)));
36415
37474
  case 'pipelineExecutions':
36416
37475
  return this.dataFlowExecutions.executions(tenantId).pipe(map$1(({ flows, totalCount }) => executionKpi(flows, totalCount, texts)));
37476
+ case 'tenantCount':
37477
+ return this.injector.get(CockpitChildTenantsService).childTenants().pipe(map$1(tenants => tenantCountKpi(tenants, texts)));
37478
+ case 'blueprintUpdates':
37479
+ return this.injector.get(CockpitBlueprintStatusService).status(tenantId).pipe(map$1(status => blueprintUpdatesKpi(status, texts)));
37480
+ case 'servicesHealth':
37481
+ return this.injector.get(CockpitServiceHealthService).services().pipe(map$1(services => servicesHealthKpi(services, texts)));
37482
+ case 'versionInfo':
37483
+ return defer(() => from(Promise.resolve(this.versions?.entries() ?? []))).pipe(map$1(entries => versionKpi(entries, texts)));
36417
37484
  }
36418
37485
  }
36419
37486
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CockpitKpiService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
@@ -36503,7 +37570,9 @@ const COCKPIT_WIDGET_STYLES = `
36503
37570
 
36504
37571
  /**
36505
37572
  * Reports the natural content height of a cockpit list widget to the board (AB#5558), so the
36506
- * phone tier grows the tile instead of clipping it (`MeshBoardStateService.setWidgetContentHeight`).
37573
+ * phone tier grows the tile instead of clipping it (`MeshBoardStateService.setWidgetContentHeight`)
37574
+ * and, on the desktop tier outside edit mode, the attention list shrinks to its content within its
37575
+ * configured rows (AB#5622, `DESKTOP_CONTENT_FIT_TYPES`).
36507
37576
  *
36508
37577
  * `content` must wrap the widget's whole content without any height constraint (no `height` /
36509
37578
  * `min-height` relative to the tile), otherwise a growing tile would report a growing height.
@@ -37007,6 +38076,12 @@ class CockpitKpiWidgetComponent {
37007
38076
  options = signal({ showDetail: true, showSparkline: true }, /* @ts-ignore */
37008
38077
  ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
37009
38078
  subscription = null;
38079
+ /** Set once a load has been running for {@link COCKPIT_KPI_SLOW_LOADING_MS}. */
38080
+ slowLoading = signal(false, /* @ts-ignore */
38081
+ ...(ngDevMode ? [{ debugName: "slowLoading" }] : /* istanbul ignore next */ []));
38082
+ slowTimer = null;
38083
+ kind = signal('adapterStatus', /* @ts-ignore */
38084
+ ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
37010
38085
  result = this._result.asReadonly();
37011
38086
  isLoading = computed(() => this._result().state === 'loading', /* @ts-ignore */
37012
38087
  ...(ngDevMode ? [{ debugName: "isLoading" }] : /* istanbul ignore next */ []));
@@ -37021,6 +38096,15 @@ class CockpitKpiWidgetComponent {
37021
38096
  }, /* @ts-ignore */
37022
38097
  ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
37023
38098
  kpi = this.data;
38099
+ /** "Loading…", or what a slow KPI is doing (blueprint catalog scan) after a second. */
38100
+ loadingText = computed(() => {
38101
+ const texts = this.texts();
38102
+ if (this.slowLoading() && this.kind() === 'blueprintUpdates') {
38103
+ return texts.kpiBlueprintsScanning ?? texts.loading;
38104
+ }
38105
+ return texts.loading;
38106
+ }, /* @ts-ignore */
38107
+ ...(ngDevMode ? [{ debugName: "loadingText" }] : /* istanbul ignore next */ []));
37024
38108
  showDetail = computed(() => this.options().showDetail, /* @ts-ignore */
37025
38109
  ...(ngDevMode ? [{ debugName: "showDetail" }] : /* istanbul ignore next */ []));
37026
38110
  /** Role requirements only for builders; other viewers get a neutral "Not available" (AB#5558 review). */
@@ -37063,6 +38147,7 @@ class CockpitKpiWidgetComponent {
37063
38147
  }
37064
38148
  ngOnDestroy() {
37065
38149
  this.subscription?.unsubscribe();
38150
+ this.clearSlowTimer();
37066
38151
  }
37067
38152
  refresh() {
37068
38153
  this.load();
@@ -37076,8 +38161,20 @@ class CockpitKpiWidgetComponent {
37076
38161
  });
37077
38162
  void this.context.tenantId().then(tenantId => this.tenantId.set(tenantId));
37078
38163
  const previous = this._result();
38164
+ const kind = kpiKindOf(config);
38165
+ this.kind.set(kind);
38166
+ this.clearSlowTimer();
38167
+ this.slowLoading.set(false);
38168
+ // Timer only — the subscription below stays untouched, so a slow scan never blocks the tile.
38169
+ this.slowTimer = setTimeout(() => {
38170
+ this.slowTimer = null;
38171
+ this.slowLoading.set(true);
38172
+ }, COCKPIT_KPI_SLOW_LOADING_MS);
37079
38173
  this.loadedTexts = untracked(() => this.texts());
37080
- this.subscription = this.kpiService.kpi(kpiKindOf(config), this.loadedTexts).subscribe(result => {
38174
+ this.subscription = this.kpiService.kpi(kind, this.loadedTexts).subscribe(result => {
38175
+ if (result.state !== 'loading') {
38176
+ this.clearSlowTimer();
38177
+ }
37081
38178
  // A refresh keeps the last figure until the new one arrives (no flicker).
37082
38179
  if (result.state === 'loading' && previous.state === 'ready') {
37083
38180
  return;
@@ -37089,6 +38186,12 @@ class CockpitKpiWidgetComponent {
37089
38186
  }
37090
38187
  });
37091
38188
  }
38189
+ clearSlowTimer() {
38190
+ if (this.slowTimer !== null) {
38191
+ clearTimeout(this.slowTimer);
38192
+ this.slowTimer = null;
38193
+ }
38194
+ }
37092
38195
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CockpitKpiWidgetComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
37093
38196
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: CockpitKpiWidgetComponent, isStandalone: true, selector: "mm-cockpit-kpi-widget", inputs: { config: { classPropertyName: "config", publicName: "config", isSignal: false, isRequired: false, transformFunction: null }, messages: { classPropertyName: "messages", publicName: "messages", isSignal: true, isRequired: false, transformFunction: null } }, usesOnChanges: true, ngImport: i0, template: `
37094
38197
  <ng-template #tileBody let-k>
@@ -37130,7 +38233,7 @@ class CockpitKpiWidgetComponent {
37130
38233
  <p class="cw-message cw-error-text" role="status" data-state="error">{{ reason() }}</p>
37131
38234
  }
37132
38235
  @default {
37133
- <p class="cw-message" role="status" data-state="loading">{{ texts().loading }}</p>
38236
+ <p class="cw-message" role="status" data-state="loading" aria-live="polite">{{ loadingText() }}</p>
37134
38237
  }
37135
38238
  }
37136
38239
  `, isInline: true, styles: [":host{display:block;width:100%;height:100%;--_cw-text: var(--mm-cockpit-text, var(--kendo-color-on-app-surface, #1f2937));--_cw-muted: var(--mm-cockpit-text-muted, var(--kendo-color-subtle, #6b7280));--_cw-surface: var(--mm-cockpit-surface, transparent);--_cw-border: var(--mm-cockpit-border, var(--kendo-color-border, rgba(0, 0, 0, .12)));--_cw-border-strong: var(--mm-cockpit-border-strong, var(--kendo-color-subtle, rgba(0, 0, 0, .3)));--_cw-success: var(--mm-cockpit-success, var(--kendo-color-success, #2e7d32));--_cw-warning: var(--mm-cockpit-warning, var(--kendo-color-warning, #b26a00));--_cw-error: var(--mm-cockpit-error, var(--kendo-color-error, #c62828));--_cw-info: var(--mm-cockpit-info, var(--kendo-color-info, #0277bd));--_cw-neutral: var(--mm-cockpit-neutral, var(--kendo-color-subtle, #6b7280));--_cw-accent: var(--mm-cockpit-accent, var(--kendo-color-primary, #1976d2));--_cw-ai: var(--mm-cockpit-ai, #7c3aed);color:var(--_cw-text)}.cw-status-chip{display:inline-flex;align-items:center;gap:6px;height:20px;padding:0 7px;border-radius:4px;font-size:.71875rem;font-weight:500;white-space:nowrap;color:var(--_cw-chip);background:color-mix(in srgb,var(--_cw-chip) 14%,transparent)}.cw-status-chip:before{content:\"\";width:6px;height:6px;border-radius:50%;background:currentColor}.cw-status-success{--_cw-chip: var(--_cw-success)}.cw-status-warning{--_cw-chip: var(--_cw-warning)}.cw-status-error{--_cw-chip: var(--_cw-error)}.cw-status-info{--_cw-chip: var(--_cw-info)}.cw-status-neutral{--_cw-chip: var(--_cw-neutral)}.cw-visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.cw-message{margin:0;padding:12px;color:var(--_cw-muted);font-size:.8125rem}a:focus-visible,button:focus-visible{outline:2px solid var(--_cw-accent);outline-offset:2px}\n", ".kpi-tile{display:flex;flex-direction:column;justify-content:center;gap:4px;height:100%;box-sizing:border-box;padding:8px 14px;color:inherit;text-decoration:none;min-width:0}a.kpi-tile:hover .kpi-value{text-decoration:underline;text-decoration-thickness:1px}.kpi-value{font-size:1.75rem;line-height:2.25rem;font-weight:600;font-variant-numeric:tabular-nums}.kpi-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;min-width:0}.kpi-detail{color:var(--_cw-muted);font-size:.75rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.kpi-spark{display:block;width:100%;height:36px;margin-top:6px}.spark-area{fill:color-mix(in srgb,var(--_cw-accent) 16%,transparent)}.spark-line{fill:none;stroke:var(--_cw-accent);stroke-width:1.5;vector-effect:non-scaling-stroke}.spark-dot{fill:var(--_cw-accent)}.cw-error-text{color:var(--_cw-error)}\n"], dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
@@ -37177,13 +38280,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
37177
38280
  <p class="cw-message cw-error-text" role="status" data-state="error">{{ reason() }}</p>
37178
38281
  }
37179
38282
  @default {
37180
- <p class="cw-message" role="status" data-state="loading">{{ texts().loading }}</p>
38283
+ <p class="cw-message" role="status" data-state="loading" aria-live="polite">{{ loadingText() }}</p>
37181
38284
  }
37182
38285
  }
37183
38286
  `, styles: [":host{display:block;width:100%;height:100%;--_cw-text: var(--mm-cockpit-text, var(--kendo-color-on-app-surface, #1f2937));--_cw-muted: var(--mm-cockpit-text-muted, var(--kendo-color-subtle, #6b7280));--_cw-surface: var(--mm-cockpit-surface, transparent);--_cw-border: var(--mm-cockpit-border, var(--kendo-color-border, rgba(0, 0, 0, .12)));--_cw-border-strong: var(--mm-cockpit-border-strong, var(--kendo-color-subtle, rgba(0, 0, 0, .3)));--_cw-success: var(--mm-cockpit-success, var(--kendo-color-success, #2e7d32));--_cw-warning: var(--mm-cockpit-warning, var(--kendo-color-warning, #b26a00));--_cw-error: var(--mm-cockpit-error, var(--kendo-color-error, #c62828));--_cw-info: var(--mm-cockpit-info, var(--kendo-color-info, #0277bd));--_cw-neutral: var(--mm-cockpit-neutral, var(--kendo-color-subtle, #6b7280));--_cw-accent: var(--mm-cockpit-accent, var(--kendo-color-primary, #1976d2));--_cw-ai: var(--mm-cockpit-ai, #7c3aed);color:var(--_cw-text)}.cw-status-chip{display:inline-flex;align-items:center;gap:6px;height:20px;padding:0 7px;border-radius:4px;font-size:.71875rem;font-weight:500;white-space:nowrap;color:var(--_cw-chip);background:color-mix(in srgb,var(--_cw-chip) 14%,transparent)}.cw-status-chip:before{content:\"\";width:6px;height:6px;border-radius:50%;background:currentColor}.cw-status-success{--_cw-chip: var(--_cw-success)}.cw-status-warning{--_cw-chip: var(--_cw-warning)}.cw-status-error{--_cw-chip: var(--_cw-error)}.cw-status-info{--_cw-chip: var(--_cw-info)}.cw-status-neutral{--_cw-chip: var(--_cw-neutral)}.cw-visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.cw-message{margin:0;padding:12px;color:var(--_cw-muted);font-size:.8125rem}a:focus-visible,button:focus-visible{outline:2px solid var(--_cw-accent);outline-offset:2px}\n", ".kpi-tile{display:flex;flex-direction:column;justify-content:center;gap:4px;height:100%;box-sizing:border-box;padding:8px 14px;color:inherit;text-decoration:none;min-width:0}a.kpi-tile:hover .kpi-value{text-decoration:underline;text-decoration-thickness:1px}.kpi-value{font-size:1.75rem;line-height:2.25rem;font-weight:600;font-variant-numeric:tabular-nums}.kpi-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;min-width:0}.kpi-detail{color:var(--_cw-muted);font-size:.75rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.kpi-spark{display:block;width:100%;height:36px;margin-top:6px}.spark-area{fill:color-mix(in srgb,var(--_cw-accent) 16%,transparent)}.spark-line{fill:none;stroke:var(--_cw-accent);stroke-width:1.5;vector-effect:non-scaling-stroke}.spark-dot{fill:var(--_cw-accent)}.cw-error-text{color:var(--_cw-error)}\n"] }]
37184
38287
  }], ctorParameters: () => [], propDecorators: { config: [{
37185
38288
  type: Input
37186
38289
  }], messages: [{ type: i0.Input, args: [{ isSignal: true, alias: "messages", required: false }] }] } });
38290
+ /**
38291
+ * After this long still loading, a slow KPI says what it is doing instead of "Loading…"
38292
+ * (AB#5622: the "Blueprint updates" tile scans every catalog, which can take several seconds).
38293
+ */
38294
+ const COCKPIT_KPI_SLOW_LOADING_MS = 1000;
37187
38295
  /** What viewers without builder roles see instead of role requirements. */
37188
38296
  const NOT_AVAILABLE_TEXT = 'Not available';
37189
38297
  /** The KPI a cockpit KPI widget shows. */
@@ -37193,6 +38301,11 @@ function kpiKindOf(config) {
37193
38301
  return 'ckModelState';
37194
38302
  case 'pipelineExecutions':
37195
38303
  return 'pipelineExecutions';
38304
+ case 'tenantCount':
38305
+ case 'blueprintUpdates':
38306
+ case 'servicesHealth':
38307
+ case 'versionInfo':
38308
+ return config.type;
37196
38309
  default:
37197
38310
  return 'adapterStatus';
37198
38311
  }
@@ -37571,11 +38684,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
37571
38684
 
37572
38685
  /** The library's attention providers, in display order (errors of the platform first). */
37573
38686
  const BUILT_IN_ATTENTION_PROVIDERS = [
38687
+ // System tenant only (AB#5558): hidden — and never loaded — on tenant cockpits.
38688
+ ServicesHealthAttentionProvider,
37574
38689
  CkModelsResolveFailedAttentionProvider,
37575
38690
  AdaptersAttentionProvider,
37576
38691
  FailedExecutionsAttentionProvider,
37577
38692
  UnregisteredPoolsAttentionProvider,
37578
- FeaturesNotInstalledAttentionProvider
38693
+ FeaturesNotInstalledAttentionProvider,
38694
+ BlueprintUpdatesAttentionProvider
37579
38695
  ];
37580
38696
  function parseConfig(data) {
37581
38697
  if (!data.config) {
@@ -37606,7 +38722,11 @@ function positiveInteger(value) {
37606
38722
  const KPI_DESCRIPTIONS = {
37607
38723
  adapterStatus: 'Adapters online of the adapters expected to run — the same rule as the Integration overview. Needs CommunicationManagement.',
37608
38724
  ckModelState: 'Construction Kit models available of all installed models; ResolveFailed is shown as an error. Needs AdminPanelManagement.',
37609
- pipelineExecutions: 'Pipeline executions of all data flows in the last 24 hours with failures and an hourly sparkline — the same counting as the Data Flows list. Needs CommunicationManagement.'
38725
+ pipelineExecutions: 'Pipeline executions of all data flows in the last 24 hours with failures and an hourly sparkline — the same counting as the Data Flows list. Needs CommunicationManagement.',
38726
+ tenantCount: 'Child tenants of this tenant — on the system tenant every tenant of the installation. Needs TenantManagement.',
38727
+ blueprintUpdates: 'Installed blueprints with a newer catalog version; service-managed ones are named separately. Needs AdminPanelManagement.',
38728
+ servicesHealth: 'Platform services (Identity, Asset Repository, Bot, Communication Controller) whose health check reports Healthy.',
38729
+ versionInfo: 'The versions the host application knows, e.g. its own build.'
37610
38730
  };
37611
38731
  /** Registration of one cockpit KPI widget type (one component and dialog for all three). */
37612
38732
  function registerKpiWidget(registry, type, label, defaultSize) {
@@ -37664,7 +38784,8 @@ function registerKpiWidget(registry, type, label, defaultSize) {
37664
38784
  }
37665
38785
  /**
37666
38786
  * Registers the cockpit widgets (AB#5558): "Attention list", "Adapter status", "CK model state",
37667
- * "Pipeline executions 24 h" and "Recent items". Persisted with `dataSourceType: 'static'` and a small JSON
38787
+ * "Pipeline executions 24 h", the system cockpit's "Tenants", "Blueprint updates", "Services
38788
+ * healthy" and "Version", and "Recent items". Persisted with `dataSourceType: 'static'` and a small JSON
37668
38789
  * config (see the README widget catalogue for the exact keys).
37669
38790
  */
37670
38791
  function registerCockpitWidgets(registry) {
@@ -37721,6 +38842,11 @@ function registerCockpitWidgets(registry) {
37721
38842
  registerKpiWidget(registry, 'adapterStatus', 'Adapter Status', { colSpan: 2, rowSpan: 1 });
37722
38843
  registerKpiWidget(registry, 'ckModelState', 'CK Model State', { colSpan: 2, rowSpan: 1 });
37723
38844
  registerKpiWidget(registry, 'pipelineExecutions', 'Pipeline Executions 24 h', { colSpan: 2, rowSpan: 1 });
38845
+ // System cockpit (AB#5558).
38846
+ registerKpiWidget(registry, 'tenantCount', 'Tenants', { colSpan: 2, rowSpan: 1 });
38847
+ registerKpiWidget(registry, 'blueprintUpdates', 'Blueprint Updates', { colSpan: 2, rowSpan: 1 });
38848
+ registerKpiWidget(registry, 'servicesHealth', 'Services Healthy', { colSpan: 2, rowSpan: 1 });
38849
+ registerKpiWidget(registry, 'versionInfo', 'Version', { colSpan: 2, rowSpan: 1 });
37724
38850
  registry.registerWidget({
37725
38851
  type: 'recentItems',
37726
38852
  label: 'Recent Items',
@@ -37777,5 +38903,5 @@ function provideCockpitWidgets() {
37777
38903
  * Generated bundle index. Do not edit.
37778
38904
  */
37779
38905
 
37780
- export { ADAPTER_OFFLINE_GRACE_MS, ATTENTION_SEVERITY_ORDER, AdaptersAttentionProvider, AddWidgetDialogComponent, AiInsightsConfigDialogComponent, AiInsightsService, AiInsightsWidgetComponent, AlertBannerConfigDialogComponent, AlertBannerWidgetComponent, AlertListConfigDialogComponent, AlertListWidgetComponent, AssociationsConfigDialogComponent, AttentionListConfigDialogComponent, AttentionListWidgetComponent, AutoRefreshTimerService, BUILT_IN_ATTENTION_PROVIDERS, BarChartConfigDialogComponent, BarChartWidgetComponent, COCKPIT_ADAPTER_LIMIT, COCKPIT_ATTENTION_PROVIDERS, COCKPIT_DATA_FLOW_LIMIT, COCKPIT_EXPLAIN_HANDLER, COCKPIT_FAILED_EXECUTIONS_OPTIONS, COCKPIT_KPI_GATES, COCKPIT_LINK_RESOLVER, COCKPIT_RECENT_ITEMS, COCKPIT_ROLES, COCKPIT_VIEWER_ACCESS, COCKPIT_WIDGET_MESSAGES, COCKPIT_WIDGET_TYPES, CkModelsResolveFailedAttentionProvider, CockpitAdapterStatesService, CockpitAttentionService, CockpitCkModelStatesService, CockpitContextService, CockpitDataFlowExecutionsService, CockpitKpiConfigDialogComponent, CockpitKpiService, CockpitKpiWidgetComponent, DAY_MS, DEFAULT_ATTENTION_MAX_ITEMS, DEFAULT_COCKPIT_WIDGET_MESSAGES, DEFAULT_FAILED_EXECUTIONS_OPTIONS, DEFAULT_RECENT_ITEMS_MAX, DEFAULT_TIME_ZONE_MODE, DashboardDataService, DashboardGridService, EditModeStateService, EditWidgetDialogComponent, EntityAssociationsWidgetComponent, EntityCardConfigDialogComponent, EntityCardWidgetComponent, EntityDetailDialogComponent, EntitySelectorEditorComponent, EntitySelectorToolbarComponent, FailedExecutionsAttentionProvider, FeaturesNotInstalledAttentionProvider, GaugeConfigDialogComponent, GaugeWidgetComponent, HISTOGRAM_HOURS, HOUR_MS, HeatmapConfigDialogComponent, HeatmapWidgetComponent, KPI_ERROR_TEXT, KpiConfigDialogComponent, KpiWidgetComponent, LazyMarkdownComponent, LineChartConfigDialogComponent, LineChartWidgetComponent, MAX_RECENT_ITEMS, MESHBOARD_CHROME_ROUTE_DATA, MESHBOARD_DESCRIPTION_MARKER, MESHBOARD_EDIT_QUERY_PARAM, MESHBOARD_HEADER_MODE_ROUTE_DATA, MESHBOARD_OPTIONS, MESHBOARD_TENANT_ID_PROVIDER, MarkdownConfigDialogComponent, MarkdownWidgetComponent, MeshBoardDataService, MeshBoardGridService, MeshBoardManagerDialogComponent, MeshBoardPersistenceService, MeshBoardSettingsDialogComponent, MeshBoardSettingsResult, MeshBoardStateService, MeshBoardViewComponent, NOT_AVAILABLE_TEXT, PieChartConfigDialogComponent, PieChartWidgetComponent, QueryExecutorService, QuerySelectorComponent, RecentItemsConfigDialogComponent, RecentItemsWidgetComponent, RuntimeEntitySelectorComponent, STATE_STATUS_BY_KEY, ServiceHealthConfigDialogComponent, ServiceHealthWidgetComponent, StatsGridConfigDialogComponent, StatsGridWidgetComponent, StatusIndicatorConfigDialogComponent, StatusIndicatorWidgetComponent, StatusListConfigDialogComponent, StatusListWidgetComponent, SummaryCardConfigDialogComponent, SummaryCardWidgetComponent, TableConfigDialogComponent, TableWidgetComponent, TableWidgetDataSourceDirective, UnregisteredPoolsAttentionProvider, WidgetFactoryService, WidgetGroupComponent, WidgetGroupConfigDialogComponent, WidgetNotConfiguredComponent, WidgetRegistryService, adapterKpi, adaptersInError, adaptersOffline, buildHourlyHistogram, categoryStatus, ckModelKpi, classifyQuery, compareMeshBoardNavigation, countFlowExecutions, countLabel, countPipelineExecutions, enabledButNotInstalled, executionKpi, failedExecutionsSeverity, formatCockpitMessage, formatCount, humanizeCategory, isAdapterAtRest, isAdapterExpectedToRun, isAdapterOnline, isCockpitWidgetType, isMeshBoardChrome, isMeshBoardHeaderMode, isServiceConfigured, joinEncodedDescription, kpiKindOf, latestStatisticsUpdate, loadNgxMarkdown, nameList, newMeshBoardConfig, normalizeNavigation, observeThemeChanges, pipelineExecutionInputs, plural, provideCockpitWidgetHost, provideCockpitWidgets, provideDefaultWidgets, provideMeshBoard, provideProcessWidget, provideWidgetRegistrations, queryFamily, readMeshBoardNavigation, recentRelativeTime, registerCockpitWidgets, registerDefaultWidgets, registerProcessWidget, resolveCockpitWidgetMessages, resolveMeshBoardChrome, resolveMeshBoardHeaderMode, selectProviders, sortAttentionFindings, sparklineGeometry, splitEncodedDescription, statusColor, summarizeAdapterOnline, truncationNote, withMeshBoardNavigation };
38906
+ export { ADAPTER_OFFLINE_GRACE_MS, ATTENTION_SEVERITY_ORDER, AdaptersAttentionProvider, AddWidgetDialogComponent, AiInsightsConfigDialogComponent, AiInsightsService, AiInsightsWidgetComponent, AlertBannerConfigDialogComponent, AlertBannerWidgetComponent, AlertListConfigDialogComponent, AlertListWidgetComponent, AssociationsConfigDialogComponent, AttentionListConfigDialogComponent, AttentionListWidgetComponent, AutoRefreshTimerService, BUILT_IN_ATTENTION_PROVIDERS, BarChartConfigDialogComponent, BarChartWidgetComponent, BlueprintUpdatesAttentionProvider, COCKPIT_ADAPTER_LIMIT, COCKPIT_ATTENTION_PROVIDERS, COCKPIT_BLUEPRINT_CATALOG_LIMIT, COCKPIT_CHILD_TENANT_IDS, COCKPIT_DATA_FLOW_LIMIT, COCKPIT_EXPLAIN_HANDLER, COCKPIT_FAILED_EXECUTIONS_OPTIONS, COCKPIT_KPI_GATES, COCKPIT_KPI_SLOW_LOADING_MS, COCKPIT_LINK_RESOLVER, COCKPIT_RECENT_ITEMS, COCKPIT_ROLES, COCKPIT_SERVICE_NAMES, COCKPIT_VERSION_SOURCE, COCKPIT_VIEWER_ACCESS, COCKPIT_WIDGET_MESSAGES, COCKPIT_WIDGET_TYPES, CkModelsResolveFailedAttentionProvider, CockpitAdapterStatesService, CockpitAttentionService, CockpitBlueprintStatusService, CockpitChildTenantsService, CockpitCkModelStatesService, CockpitContextService, CockpitDataFlowExecutionsService, CockpitKpiConfigDialogComponent, CockpitKpiService, CockpitKpiWidgetComponent, CockpitServiceHealthService, DAY_MS, DEFAULT_ATTENTION_MAX_ITEMS, DEFAULT_COCKPIT_WIDGET_MESSAGES, DEFAULT_FAILED_EXECUTIONS_OPTIONS, DEFAULT_MESHBOARD_MESSAGES, DEFAULT_RECENT_ITEMS_MAX, DEFAULT_TIME_ZONE_MODE, DashboardDataService, DashboardGridService, EditModeStateService, EditWidgetDialogComponent, EntityAssociationsWidgetComponent, EntityCardConfigDialogComponent, EntityCardWidgetComponent, EntityDetailDialogComponent, EntitySelectorEditorComponent, EntitySelectorToolbarComponent, FALLBACK_SERIES_COLORS, FailedExecutionsAttentionProvider, FeaturesNotInstalledAttentionProvider, GaugeConfigDialogComponent, GaugeWidgetComponent, HISTOGRAM_HOURS, HOUR_MS, HeatmapConfigDialogComponent, HeatmapWidgetComponent, KPI_ERROR_TEXT, KpiConfigDialogComponent, KpiWidgetComponent, LazyMarkdownComponent, LineChartConfigDialogComponent, LineChartWidgetComponent, MAX_RECENT_ITEMS, MESHBOARD_CHROME_ROUTE_DATA, MESHBOARD_DESCRIPTION_MARKER, MESHBOARD_EDIT_QUERY_PARAM, MESHBOARD_HEADER_MODE_ROUTE_DATA, MESHBOARD_LABEL_RESOLVER, MESHBOARD_MESSAGES, MESHBOARD_OPTIONS, MESHBOARD_TENANT_ID_PROVIDER, MarkdownConfigDialogComponent, MarkdownWidgetComponent, MeshBoardDataService, MeshBoardGridService, MeshBoardManagerDialogComponent, MeshBoardPersistenceService, MeshBoardSettingsDialogComponent, MeshBoardSettingsResult, MeshBoardStateService, MeshBoardViewComponent, NOT_AVAILABLE_TEXT, PieChartConfigDialogComponent, PieChartWidgetComponent, QueryExecutorService, QuerySelectorComponent, RecentItemsConfigDialogComponent, RecentItemsWidgetComponent, RuntimeEntitySelectorComponent, SERVICE_MANAGED_BLUEPRINT_PREFIX, STATE_STATUS_BY_KEY, ServiceHealthConfigDialogComponent, ServiceHealthWidgetComponent, ServicesHealthAttentionProvider, StatsGridConfigDialogComponent, StatsGridWidgetComponent, StatusIndicatorConfigDialogComponent, StatusIndicatorWidgetComponent, StatusListConfigDialogComponent, StatusListWidgetComponent, SummaryCardConfigDialogComponent, SummaryCardWidgetComponent, TableConfigDialogComponent, TableWidgetComponent, TableWidgetDataSourceDirective, UnregisteredPoolsAttentionProvider, WidgetFactoryService, WidgetGroupComponent, WidgetGroupConfigDialogComponent, WidgetNotConfiguredComponent, WidgetRegistryService, adapterKpi, adaptersInError, adaptersOffline, blueprintUpdatesKpi, buildHourlyHistogram, categoryStatus, chartSeriesColors, ckModelKpi, classifyQuery, compareBlueprintVersions, compareMeshBoardNavigation, countFlowExecutions, countLabel, countPipelineExecutions, enabledButNotInstalled, executionKpi, failedExecutionsSeverity, formatCockpitMessage, formatCount, humanizeCategory, isAdapterAtRest, isAdapterExpectedToRun, isAdapterOnline, isCockpitWidgetType, isMeshBoardChrome, isMeshBoardHeaderMode, isServiceConfigured, joinEncodedDescription, kpiKindOf, latestStatisticsUpdate, legacyCockpitLinkTarget, loadNgxMarkdown, nameList, newMeshBoardConfig, normalizeNavigation, observeThemeChanges, pipelineExecutionInputs, plural, provideCockpitWidgetHost, provideCockpitWidgets, provideDefaultWidgets, provideMeshBoard, provideMeshBoardMessages, provideProcessWidget, provideWidgetRegistrations, queryFamily, readMeshBoardNavigation, recentRelativeTime, registerCockpitWidgets, registerDefaultWidgets, registerProcessWidget, resolveChartColor, resolveCockpitWidgetMessages, resolveMeshBoardChrome, resolveMeshBoardHeaderMode, resolveMeshBoardLabel, resolveMeshBoardMessages, selectProviders, serviceStateText, servicesHealthKpi, sortAttentionFindings, sparklineGeometry, splitBlueprintId, splitEncodedDescription, statusColor, summarizeAdapterOnline, tenantCountKpi, toBlueprintStatus, toServiceHealthStatus, truncationNote, versionKpi, withMeshBoardNavigation };
37781
38907
  //# sourceMappingURL=meshmakers-octo-meshboard.mjs.map