Share canonical resource change labels

This commit is contained in:
rcourtman 2026-03-19 00:33:06 +00:00
parent af05d195b9
commit d00bb48bb2
6 changed files with 199 additions and 139 deletions

View file

@ -88,13 +88,16 @@ the unified table as well, so PBS and PMG entries must keep the same bounded
presentation and verification surface as the primary fleet rows. The shared
`ResourceFacetSummary` component now owns that chip rendering path, so any
future summary changes must preserve the same bounded row budget instead of
forking separate table-only presentation logic. Row summaries now also prefer
canonical `facetCounts` on each resource when they are available, so the hot
path can stay within the same budget while still reading totals from the
shared resource contract. The drawer history surface reuses the same governed
resource route helpers for relationship and related-resource links, so cross-
resource navigation stays within the existing infrastructure surface rather
than branching into custom detail-only routing.
forking separate table-only presentation logic. That component now also
consumes the shared `frontend-modern/src/utils/resourceChangePresentation.ts`
label helper for canonical change kinds, source types, and adapter provenance
so the chip wording stays consistent without adding extra hot-path branching.
Row summaries now also prefer canonical `facetCounts` on each resource when
they are available, so the hot path can stay within the same budget while
still reading totals from the shared resource contract. The drawer history
surface reuses the same governed resource route helpers for relationship and
related-resource links, so cross-resource navigation stays within the existing
infrastructure surface rather than branching into custom detail-only routing.
Governance metadata such as sensitivity and routing scope may be visible in
the table, but it must remain on the same bounded row-windowing and mounted-row
budget proved by `UnifiedResourceTable.performance.contract.test.tsx` rather

View file

@ -229,6 +229,10 @@ timeline endpoint is paginated. Relationship and timeline references in that
drawer now route through the canonical infrastructure resource filter, so the
resource graph remains navigable from the history surface instead of being
purely descriptive text.
`ResourceFacetSummary` now consumes the shared
`frontend-modern/src/utils/resourceChangePresentation.ts` label helper for
canonical change kinds, source types, and adapter provenance, so the chip
wording stays aligned across table, drawer, and intelligence surfaces.
Relationship cards in that drawer also surface `lastSeenAt` freshness and
optional metadata blocks, and timeline cards surface change metadata when it
is present, so the graph history view preserves the richer provenance already

View file

@ -2,11 +2,17 @@ import { For, Show, createMemo, type Component } from 'solid-js';
import type {
ResourceCapability,
ResourceChange,
ResourceChangeKind,
ResourceFacetCounts,
ResourceFacetSourceAdapter,
ResourceRelationship,
} from '@/types/resource';
import {
RESOURCE_CHANGE_KIND_ORDER,
RESOURCE_CHANGE_SOURCE_ADAPTER_ORDER,
RESOURCE_CHANGE_SOURCE_TYPE_ORDER,
getResourceChangeKindPresentation,
getResourceChangeSourceAdapterPresentation,
getResourceChangeSourceTypePresentation,
} from '@/utils/resourceChangePresentation';
type FacetBadge = {
label: string;
@ -29,130 +35,6 @@ const badgeBase =
const countLabel = (count: number, singular: string, plural = `${singular}s`) =>
`${count} ${count === 1 ? singular : plural}`;
const recentChangeKindOrder: ResourceChangeKind[] = [
'state_transition',
'restart',
'config_update',
'metric_anomaly',
'relationship_change',
'capability_change',
];
const recentChangeKindLabels: Record<
ResourceChangeKind,
{ label: string; plural: string; className: string }
> = {
state_transition: {
label: 'State transition',
plural: 'State transitions',
className: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
},
restart: {
label: 'Restart',
plural: 'Restarts',
className: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
},
config_update: {
label: 'Config update',
plural: 'Config updates',
className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
},
metric_anomaly: {
label: 'Anomaly',
plural: 'Anomalies',
className: 'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300',
},
relationship_change: {
label: 'Relationship change',
plural: 'Relationship changes',
className: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300',
},
capability_change: {
label: 'Capability change',
plural: 'Capability changes',
className: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300',
},
};
type RecentChangeSourceType =
| 'platform_event'
| 'pulse_diff'
| 'heuristic'
| 'user_action'
| 'agent_action';
const recentChangeSourceTypeOrder: RecentChangeSourceType[] = [
'platform_event',
'pulse_diff',
'heuristic',
'user_action',
'agent_action',
];
const recentChangeSourceTypeLabels: Record<
RecentChangeSourceType,
{ label: string; plural: string; className: string }
> = {
platform_event: {
label: 'Platform event',
plural: 'Platform events',
className: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
},
pulse_diff: {
label: 'Pulse diff',
plural: 'Pulse diffs',
className: 'bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300',
},
heuristic: {
label: 'Heuristic',
plural: 'Heuristics',
className: 'bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-900 dark:text-fuchsia-300',
},
user_action: {
label: 'User action',
plural: 'User actions',
className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
},
agent_action: {
label: 'Agent action',
plural: 'Agent actions',
className: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
},
};
const recentChangeSourceAdapterOrder: ResourceFacetSourceAdapter[] = [
'docker_adapter',
'proxmox_adapter',
'truenas_adapter',
'agent:ops-helper',
];
const recentChangeSourceAdapterLabels: Record<
ResourceFacetSourceAdapter,
{ label: string; plural: string; className: string }
> = {
docker_adapter: {
label: 'Docker adapter',
plural: 'Docker adapters',
className: 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300',
},
proxmox_adapter: {
label: 'Proxmox adapter',
plural: 'Proxmox adapters',
className: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
},
truenas_adapter: {
label: 'TrueNAS adapter',
plural: 'TrueNAS adapters',
className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
},
'agent:ops-helper': {
label: 'Ops helper',
plural: 'Ops helpers',
className: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
},
};
const buildFacetBadges = (
capabilities?: readonly ResourceCapability[] | null,
relationships?: readonly ResourceRelationship[] | null,
@ -190,10 +72,10 @@ const buildFacetBadges = (
const kindCounts = counts?.recentChangeKinds;
if (kindCounts) {
for (const kind of recentChangeKindOrder) {
for (const kind of RESOURCE_CHANGE_KIND_ORDER) {
const count = kindCounts[kind];
if (!count || count <= 0) continue;
const kindLabel = recentChangeKindLabels[kind];
const kindLabel = getResourceChangeKindPresentation(kind);
badges.push({
label: `${kindLabel.label} ${count}`,
title: countLabel(count, kindLabel.label.toLowerCase(), kindLabel.plural.toLowerCase()),
@ -204,10 +86,10 @@ const buildFacetBadges = (
const sourceTypeCounts = counts?.recentChangeSourceTypes;
if (sourceTypeCounts) {
for (const sourceType of recentChangeSourceTypeOrder) {
for (const sourceType of RESOURCE_CHANGE_SOURCE_TYPE_ORDER) {
const count = sourceTypeCounts[sourceType];
if (!count || count <= 0) continue;
const sourceTypeLabel = recentChangeSourceTypeLabels[sourceType];
const sourceTypeLabel = getResourceChangeSourceTypePresentation(sourceType);
badges.push({
label: `${sourceTypeLabel.label} ${count}`,
title: countLabel(
@ -222,10 +104,10 @@ const buildFacetBadges = (
const sourceAdapterCounts = counts?.recentChangeSourceAdapters;
if (sourceAdapterCounts) {
for (const sourceAdapter of recentChangeSourceAdapterOrder) {
for (const sourceAdapter of RESOURCE_CHANGE_SOURCE_ADAPTER_ORDER) {
const count = sourceAdapterCounts[sourceAdapter];
if (!count || count <= 0) continue;
const sourceAdapterLabel = recentChangeSourceAdapterLabels[sourceAdapter];
const sourceAdapterLabel = getResourceChangeSourceAdapterPresentation(sourceAdapter);
badges.push({
label: `${sourceAdapterLabel.label} ${count}`,
title: countLabel(

View file

@ -154,6 +154,7 @@ describe('UnifiedResourceTable performance contract', () => {
recentChanges: 3,
recentChangeKinds: {
restart: 2,
config_update: 1,
metric_anomaly: 1,
},
recentChangeSourceTypes: {
@ -173,6 +174,7 @@ describe('UnifiedResourceTable performance contract', () => {
expect(getByText('Relationships 1')).toBeInTheDocument();
expect(getByText('Timeline 3')).toBeInTheDocument();
expect(getByText('Restart 2')).toBeInTheDocument();
expect(getByText('Config update 1')).toBeInTheDocument();
expect(getByText('Anomaly 1')).toBeInTheDocument();
expect(getByText('Platform event 1')).toBeInTheDocument();
expect(getByText('Pulse diff 2')).toBeInTheDocument();
@ -210,6 +212,7 @@ describe('UnifiedResourceTable performance contract', () => {
recentChanges: 3,
recentChangeKinds: {
restart: 2,
config_update: 1,
metric_anomaly: 1,
},
recentChangeSourceTypes: {
@ -241,6 +244,7 @@ describe('UnifiedResourceTable performance contract', () => {
expect(getByText('Capabilities 1')).toBeInTheDocument();
expect(getByText('Relationships 1')).toBeInTheDocument();
expect(getByText('Timeline 3')).toBeInTheDocument();
expect(getByText('Config update 1')).toBeInTheDocument();
});
await waitFor(() => {
expect(getBodyRowCount(container)).toBe(PROFILES.S);

View file

@ -3,6 +3,9 @@ import { describe, expect, it } from 'vitest';
import {
formatResourceChangeHeadline,
formatResourceChangeKind,
getResourceChangeKindPresentation,
getResourceChangeSourceAdapterPresentation,
getResourceChangeSourceTypePresentation,
} from '@/utils/resourceChangePresentation';
describe('resourceChangePresentation utils', () => {
@ -34,4 +37,19 @@ describe('resourceChangePresentation utils', () => {
} as never),
).toBe('Config update: Updated canonical config');
});
it('exposes canonical kind, source type, and adapter presentations', () => {
expect(getResourceChangeKindPresentation('restart')).toMatchObject({
label: 'Restart',
plural: 'Restarts',
});
expect(getResourceChangeSourceTypePresentation('platform_event')).toMatchObject({
label: 'Platform event',
plural: 'Platform events',
});
expect(getResourceChangeSourceAdapterPresentation('proxmox_adapter')).toMatchObject({
label: 'Proxmox adapter',
plural: 'Proxmox adapters',
});
});
});

View file

@ -1,4 +1,153 @@
import type { ResourceChange } from '@/types/resource';
import type { ResourceChangeKind, ResourceFacetSourceAdapter } from '@/types/resource';
export interface ResourceChangeLabelPresentation {
label: string;
plural: string;
className: string;
}
export const RESOURCE_CHANGE_KIND_ORDER: ResourceChangeKind[] = [
'state_transition',
'restart',
'config_update',
'metric_anomaly',
'relationship_change',
'capability_change',
];
const RESOURCE_CHANGE_KIND_PRESENTATIONS: Record<
ResourceChangeKind,
ResourceChangeLabelPresentation
> = {
state_transition: {
label: 'State transition',
plural: 'State transitions',
className: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
},
restart: {
label: 'Restart',
plural: 'Restarts',
className: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
},
config_update: {
label: 'Config update',
plural: 'Config updates',
className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
},
metric_anomaly: {
label: 'Anomaly',
plural: 'Anomalies',
className: 'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300',
},
relationship_change: {
label: 'Relationship change',
plural: 'Relationship changes',
className: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300',
},
capability_change: {
label: 'Capability change',
plural: 'Capability changes',
className: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300',
},
};
type ResourceChangeSourceType =
| 'platform_event'
| 'pulse_diff'
| 'heuristic'
| 'user_action'
| 'agent_action';
export const RESOURCE_CHANGE_SOURCE_TYPE_ORDER: ResourceChangeSourceType[] = [
'platform_event',
'pulse_diff',
'heuristic',
'user_action',
'agent_action',
];
const RESOURCE_CHANGE_SOURCE_TYPE_PRESENTATIONS: Record<
ResourceChangeSourceType,
ResourceChangeLabelPresentation
> = {
platform_event: {
label: 'Platform event',
plural: 'Platform events',
className: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
},
pulse_diff: {
label: 'Pulse diff',
plural: 'Pulse diffs',
className: 'bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300',
},
heuristic: {
label: 'Heuristic',
plural: 'Heuristics',
className: 'bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-900 dark:text-fuchsia-300',
},
user_action: {
label: 'User action',
plural: 'User actions',
className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
},
agent_action: {
label: 'Agent action',
plural: 'Agent actions',
className: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
},
};
export const RESOURCE_CHANGE_SOURCE_ADAPTER_ORDER: ResourceFacetSourceAdapter[] = [
'docker_adapter',
'proxmox_adapter',
'truenas_adapter',
'agent:ops-helper',
];
const RESOURCE_CHANGE_SOURCE_ADAPTER_PRESENTATIONS: Record<
ResourceFacetSourceAdapter,
ResourceChangeLabelPresentation
> = {
docker_adapter: {
label: 'Docker adapter',
plural: 'Docker adapters',
className: 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300',
},
proxmox_adapter: {
label: 'Proxmox adapter',
plural: 'Proxmox adapters',
className: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
},
truenas_adapter: {
label: 'TrueNAS adapter',
plural: 'TrueNAS adapters',
className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
},
'agent:ops-helper': {
label: 'Ops helper',
plural: 'Ops helpers',
className: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
},
};
export function getResourceChangeKindPresentation(
kind: ResourceChangeKind,
): ResourceChangeLabelPresentation {
return RESOURCE_CHANGE_KIND_PRESENTATIONS[kind];
}
export function getResourceChangeSourceTypePresentation(
sourceType: ResourceChangeSourceType,
): ResourceChangeLabelPresentation {
return RESOURCE_CHANGE_SOURCE_TYPE_PRESENTATIONS[sourceType];
}
export function getResourceChangeSourceAdapterPresentation(
sourceAdapter: ResourceFacetSourceAdapter,
): ResourceChangeLabelPresentation {
return RESOURCE_CHANGE_SOURCE_ADAPTER_PRESENTATIONS[sourceAdapter];
}
export function formatResourceChangeKind(kind: ResourceChange['kind']): string {
switch (kind) {