Tab the PC detail page, and let a panel say which tab it belongs to
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m24s

The page had grown to roughly ten cards in one flow, and three more surfaces
were wanted on it: installed software, live metrics, and the service controls.
Stacking those into the same column would have buried what the page is for.

Tabs were mocked and rejected on 2026-08-28, on the grounds that the strip
duplicated the sidebar and cost hero space. That objection is answered by
where the strip sits: the hero and the triage band stay above it and never
move, so "is this machine OK" is still answered without a click, and the strip
partitions the detail underneath rather than navigating anywhere.

WHY A PANEL DECLARES ITS OWN GROUP

Most cards on a detail page are not the page's. They arrive through the
ADR-010 get_asset_panels hook, so a page that sorted panels into tabs by
knowing their ids would defeat the hook it is built on. Panels now carry an
optional group instead, and the page only knows the five group names.

Resolution happens server-side, in the asset-panels route, so that an absent
or misspelled group becomes 'details' before the client ever sees it. The
client renders known groups only; an unrecognised value that survived to it
would be a panel that exists and is displayed nowhere. Contract 0.28.0 ->
0.29.0, additive - every existing plugin keeps working untouched, and the
endpoints status panel is deliberately left ungrouped so the default path is
exercised by a shipped plugin and not only by tests.

A tab is offered only when something fills it. An empty tab reads as broken
rather than as "this site does not collect that", so the strip is built from
the groups that actually returned data, and a tab that disappears on refetch
falls back to Details rather than leaving the page blank.

AssetDetailTabs is core, not a PCDetail component: machines, printers,
network devices and measuring tools all have the same shape, and the shared
asset-detail shell they need starts here.

Scope: product
This commit is contained in:
cproudlock
2026-08-31 14:24:16 -04:00
parent ae7b30a056
commit 2122c3b45c
10 changed files with 318 additions and 22 deletions

View File

@@ -84,8 +84,10 @@
Hardware and the rest follow. Panels self-order among themselves by
their declared position, and self-hide when a site has no plugin
answering for this asset type. -->
<div class="content-grid">
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
<AssetDetailTabs v-model="activegroup" :tabs="detailtabs" label="Computer detail sections" />
<div v-show="activegroup === 'details'" class="content-grid">
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" group="details" />
<!-- Left Column -->
<div class="content-column">
@@ -256,7 +258,25 @@
</div>
</div>
<!-- Installed Applications -->
<!-- Custom Fields -->
<CustomFieldsSection :assetid="computer.assetid" />
<!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ computer.notes }}</p>
</div>
</div>
</div>
<!-- Software. The core installed-applications list plus anything a plugin
declared for this group. -->
<div v-show="activegroup === 'software'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="software" />
<div class="content-column">
<div class="section-card" v-if="installedApps.length > 0">
<h3 class="section-title">Installed Applications</h3>
<div class="app-list">
@@ -276,21 +296,22 @@
</router-link>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="computer.assetid" />
<!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ computer.notes }}</p>
</div>
</div>
</div>
<!-- Metrics, Actions and Activity are entirely plugin-contributed: the
telemetry, the service controls and the event feed all belong to the
endpoints plugin, and core knows nothing about them. -->
<div v-show="activegroup === 'metrics'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="metrics" />
</div>
<div v-show="activegroup === 'actions'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="actions" />
</div>
<div v-show="activegroup === 'activity'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="activity" />
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
@@ -305,7 +326,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router'
import { computersApi, applicationsApi, endpointsApi } from '@/api'
@@ -315,6 +336,7 @@ import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import AssetTriageBand from '@/components/AssetTriageBand.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetDetailTabs from '@/components/AssetDetailTabs.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
@@ -344,6 +366,36 @@ const hasRecordCheckin = computed(() => {
})
const installedApps = ref([])
const assetPanels = ref(null)
// Which tab is showing. Always opens on Details: it holds what the asset IS,
// which is the answer somebody arriving from a search or a link came for.
const activegroup = ref('details')
// Only offer a tab that has something in it. An empty tab is worse than an
// absent one - it reads as broken rather than as "this site does not collect
// that". Details always shows because core fills it regardless of plugins.
//
// populatedGroups comes from PluginAssetPanels, which knows which panels
// actually returned data; Software is additionally true when core has an
// installed-applications list of its own.
const detailtabs = computed(() => {
const filled = assetPanels.value?.populatedGroups ?? new Set()
const has = (key) => filled.has ? filled.has(key) : false
const tabs = [{ key: 'details', label: 'Details' }]
if (has('software') || installedApps.value.length > 0) {
tabs.push({ key: 'software', label: 'Software', count: installedApps.value.length || null })
}
if (has('metrics')) tabs.push({ key: 'metrics', label: 'Metrics' })
if (has('actions')) tabs.push({ key: 'actions', label: 'Actions' })
if (has('activity')) tabs.push({ key: 'activity', label: 'Activity' })
return tabs
})
// A tab can disappear on refetch - a plugin gets disabled, telemetry stops
// answering. Fall back to Details rather than leaving the page showing nothing.
watch(detailtabs, (tabs) => {
if (!tabs.some((tab) => tab.key === activegroup.value)) activegroup.value = 'details'
})
const restarting = ref(false)
const refreshing = ref(false)

View File

@@ -351,6 +351,8 @@ class EndpointsPlugin(BasePlugin):
'permission': 'endpoints.command',
'empty': 'No monitored services reported.',
'position': 12,
# Start/Stop act on the machine, so this is a control surface
'group': 'actions',
},
{
'id': 'endpoints-tracked-processes',
@@ -389,6 +391,8 @@ class EndpointsPlugin(BasePlugin):
'permission': 'endpoints.command',
'empty': 'No monitored processes reported.',
'position': 13,
# observed process state, not something you act on here
'group': 'metrics',
},
{
# One feed instead of two cards. Events and commands answer the
@@ -432,6 +436,8 @@ class EndpointsPlugin(BasePlugin):
},
'empty': 'Nothing recorded for this endpoint yet.',
'position': 20,
# the event and command feed IS the activity tab
'group': 'activity',
},
{
'id': 'endpoints-interfaces',
@@ -451,6 +457,8 @@ class EndpointsPlugin(BasePlugin):
],
'empty': 'No network interfaces reported.',
'position': 16,
# adapter state is telemetry, not something you act on
'group': 'metrics',
},
{
# Installed-software (SBOM) inventory. Low-priority reference
@@ -471,6 +479,8 @@ class EndpointsPlugin(BasePlugin):
],
'empty': 'No installed software reported.',
'position': 30,
# the installed-software inventory
'group': 'software',
},
]