diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue
index 10fa5dc..8362bc9 100644
--- a/frontend/src/components/PluginAssetPanels.vue
+++ b/frontend/src/components/PluginAssetPanels.vue
@@ -34,6 +34,35 @@
+
+
@@ -71,7 +100,7 @@ import api from '../api'
import { colorStyle } from '@/utils/colorStyle'
import {
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
- keyvalueFields, tableColumns, cell, badges,
+ keyvalueFields, tableColumns, cell, badges, tabSections,
} from './pluginAssetPanels'
const props = defineProps({
@@ -80,6 +109,12 @@ const props = defineProps({
const panels = ref([])
+// Which tab each 'tabs' panel is showing, keyed by panel id. Kept here rather
+// than on the panel object because panels are replaced wholesale on refetch.
+const tabs = ref({})
+function activeTab(panel) { return tabs.value[panel.id] || 0 }
+function setTab(panel, index) { tabs.value = { ...tabs.value, [panel.id]: index } }
+
// Fetch the declared panels for this asset, then each panel's data endpoint.
async function load() {
panels.value = []
@@ -118,10 +153,44 @@ watch(() => props.assetid, load, { immediate: true })
.pap-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
.pap-manage { font-size: 0.82rem; }
-.pap-kv { display: flex; flex-direction: column; gap: 0.4rem; }
-.pap-kv-row { display: flex; justify-content: space-between; gap: 1rem; }
+/* Two aligned columns rather than space-between: a ragged right edge is hard
+ to scan once a panel carries more than a handful of rows, and a long value
+ (a UNC path) otherwise pushes its label away from the others. */
+.pap-kv { display: grid; grid-template-columns: minmax(7rem, auto) 1fr; gap: 0.35rem 1rem; align-items: baseline; }
+.pap-kv-row { display: contents; }
.pap-kv-label { color: var(--text-light); }
-.pap-kv-value { color: var(--text); font-weight: 500; }
+.pap-kv-value { color: var(--text); font-weight: 500; overflow-wrap: anywhere; }
+/* Tab strip: a quiet segmented control, not browser-default buttons. The
+ active tab is marked by an underline AND weight, so it does not rely on
+ colour alone. */
+.pap-tabstrip {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.15rem;
+ margin-bottom: 0.85rem;
+ border-bottom: 1px solid var(--border);
+}
+.pap-tab {
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ padding: 0.35rem 0.7rem;
+ color: var(--text-light);
+ font-size: 0.78rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.pap-tab:hover { color: var(--text); }
+.pap-tab.on {
+ color: var(--primary);
+ border-bottom-color: var(--primary);
+ font-weight: 700;
+}
+
.pap-table-wrap { overflow-x: auto; }
.pap-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.pap-table th, .pap-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
diff --git a/frontend/src/components/pluginAssetPanels.js b/frontend/src/components/pluginAssetPanels.js
index 3872e41..7aebfa4 100644
--- a/frontend/src/components/pluginAssetPanels.js
+++ b/frontend/src/components/pluginAssetPanels.js
@@ -98,7 +98,24 @@ export function badges(panel) {
return list.map((b) => ({ label: b.label, color: b.color }))
}
+// tabs: [{ label, fields: [{label, value, mono}] }]. One section is shown at a
+// time, so a panel with 30 values stays the height of its largest section
+// instead of dragging one multicol column far past the other.
+export function tabSections(panel) {
+ const data = panel._data
+ const sections = (data && Array.isArray(data.sections)) ? data.sections : []
+ return sections
+ .map((s) => ({
+ label: s.label,
+ fields: (s.fields || []).map((f) => ({
+ label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
+ })),
+ }))
+ .filter((s) => s.fields.length > 0)
+}
+
export function panelVisible(panel) {
+ if (panel.render === 'tabs') return tabSections(panel).length > 0
if (panel.render === 'list') return rows(panel).length > 0 || !!panel.empty
if (panel.render === 'keyvalue') return keyvalueFields(panel).length > 0
if (panel.render === 'table') return rows(panel).length > 0
diff --git a/frontend/src/components/pluginAssetPanels.spec.js b/frontend/src/components/pluginAssetPanels.spec.js
index 7c65035..c48b00b 100644
--- a/frontend/src/components/pluginAssetPanels.spec.js
+++ b/frontend/src/components/pluginAssetPanels.spec.js
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'
import {
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
- keyvalueFields, tableColumns, badges, panelVisible,
+ keyvalueFields, tableColumns, badges, panelVisible, tabSections,
} from './pluginAssetPanels'
const warrantyPanel = {
@@ -98,3 +98,35 @@ describe('keyvalue + table + badge', () => {
expect(badges({ render: 'badge', _data: { badges: [{ label: 'B', color: '#222' }] } })).toEqual([{ label: 'B', color: '#222' }])
})
})
+
+describe('tabSections', () => {
+ const panel = {
+ render: 'tabs',
+ _data: {
+ sections: [
+ { label: 'General', fields: [{ label: 'Cnc', value: 'OKUMA' }] },
+ { label: 'eFocas', fields: [{ label: 'IpAddr', value: '192.168.1.1', mono: true }] },
+ // A section whose fields all dropped out must not leave an empty tab.
+ { label: 'NTSHR', fields: [] },
+ ],
+ },
+ }
+
+ it('returns one entry per non-empty section', () => {
+ expect(tabSections(panel).map((s) => s.label)).toEqual(['General', 'eFocas'])
+ })
+
+ it('formats field values and carries mono through', () => {
+ const efocas = tabSections(panel)[1]
+ expect(efocas.fields[0]).toEqual({ label: 'IpAddr', value: '192.168.1.1', mono: true })
+ })
+
+ it('is empty for a payload with no sections', () => {
+ expect(tabSections({ render: 'tabs', _data: null })).toEqual([])
+ })
+
+ it('drives panel visibility', () => {
+ expect(panelVisible(panel)).toBe(true)
+ expect(panelVisible({ render: 'tabs', _data: { sections: [] } })).toBe(false)
+ })
+})