mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Incorporate current upstream layout and Docker editing repairs
Preserve the complete reviewed publication batch and upstream commit identities before final-source validation. Change-source: pulse-maintainer
This commit is contained in:
@@ -2340,6 +2340,13 @@ not a replacement status card, CTA band, or page-local nested card.
|
||||
disclosure; a non-expandable identity cell must otherwise expose its
|
||||
complete value without requiring hover. Default data rows stay single-line
|
||||
so scan density and row rhythm are not traded for automatic text wrapping.
|
||||
A summary value that must truncate sits in a `block truncate` span as the
|
||||
cell's only child, so it fills the fixed-layout column and ends in an
|
||||
ellipsis inside it on the row's text line. A capped `inline-block max-w-*
|
||||
truncate` span ignores the column width: it spills past a narrower cell
|
||||
with no ellipsis, wastes room in a wider one, and sits about 2 pixels above
|
||||
the row baseline. `platformOverviewLayout.guardrails.test.ts` holds the
|
||||
Docker, Kubernetes, Proxmox and vSphere table sources to that rule.
|
||||
When a platform table row itself owns that keyboard- and touch-operable
|
||||
disclosure, `SummaryRowActionButton` removes the redundant visible chevron
|
||||
below the shared mobile breakpoint while preserving the button for screen
|
||||
@@ -5184,6 +5191,13 @@ owned title so feature drawers can place web-interface controls inside a larger
|
||||
access surface without forking the save/remove/runtime behavior. Future
|
||||
web-interface URL work should extend those owners instead of pushing metadata
|
||||
transport or validation back into the shared shell.
|
||||
`WebInterfaceUrlField.tsx` renders read-only when
|
||||
`sessionCanWriteMonitoringMetadata()` is false. That mirrors the backend
|
||||
metadata write gate: browser sessions always pass, while API-token sessions
|
||||
such as kiosk links need `monitoring:write` or the wildcard scope. Read-only
|
||||
sessions keep the saved URL with its open and copy controls and a one-line
|
||||
explanation, but get no input, Save, Remove, or suggested-URL adoption that the
|
||||
server would refuse.
|
||||
Missing-suggested-URL diagnostics remain useful only when the operator has no
|
||||
saved or entered URL; once a custom web-interface URL is present, the shared
|
||||
field must suppress "no suggested URL" warnings so Discovery does not make a
|
||||
|
||||
@@ -1175,6 +1175,13 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
|
||||
keep unchanged row objects across inventory ticks so an open editor never
|
||||
remounts the input being typed in.
|
||||
`WorkloadsSurface.performance.contract.test.tsx` pins that wiring.
|
||||
The editor takes a source rather than guests: workload tables pass live
|
||||
guests and metadata so the row model loads only with the panel, while the
|
||||
Docker containers table passes ready rows and lazy-loads the trigger so
|
||||
it does not merge into the chunk the Docker and workloads pages share.
|
||||
Saves run one metadata write per changed link, then dispatch the
|
||||
metadata-changed events together so whole-snapshot listeners coalesce
|
||||
onto one in-flight refetch instead of refetching per link.
|
||||
Drawer history charts belong to `frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx`.
|
||||
A current metric may remain visible in a chart legend only when labelled
|
||||
`current`; it must never be expanded into synthetic timestamps or a flat
|
||||
|
||||
@@ -3094,6 +3094,17 @@ presentation and sort change only; it adds no backup, restore, or retention
|
||||
authority. Pinned by `proxmoxBackupRecoveryModel.test.ts` and
|
||||
`ProxmoxCoverageTable.test.tsx`.
|
||||
|
||||
### Backup table values truncate inside their own column
|
||||
|
||||
The coverage Node cell, the restore-evidence Location and Details cells, and
|
||||
the recoverable table's Location and Details cells render their value in a
|
||||
block span that fills the fixed-layout cell, so a long node name, datastore path
|
||||
or detail ends in an ellipsis inside its column on the row's text line, with the
|
||||
full value on the title. A capped inline-block span let a long detail run past
|
||||
its cell with no ellipsis and sat above the row. This is presentation only; it
|
||||
adds no backup, restore, or retention authority. Pinned by
|
||||
`ProxmoxCoverageTable.test.tsx` and `platformOverviewLayout.guardrails.test.ts`.
|
||||
|
||||
### Retained Patrol objectives do not create recovery authority
|
||||
|
||||
The shared `internal/api` retained-objective endpoints may scope an outcome to
|
||||
|
||||
@@ -1212,7 +1212,12 @@ RBAC names first, then the namespace scope, then the short Lifecycle and Data
|
||||
shape values, then Kind. Kubernetes summary cells truncate through a block span
|
||||
that fills the cell, so a long value ends in an ellipsis inside its own column
|
||||
on the row's text line; a fixed-width inline-block cap spilled past narrow
|
||||
cells without an ellipsis and sat above the row baseline.
|
||||
cells without an ellipsis and sat above the row baseline. Docker image, network,
|
||||
volume, service, task, secret and config cells, Proxmox backup coverage,
|
||||
restore-evidence and recoverable cells, the Mail Gateway drawer's top domains,
|
||||
and vSphere host vCenter cells follow the same rule. The Docker service Update
|
||||
cell still wraps on phones and from md truncates inside its column instead of
|
||||
at a 10rem cap.
|
||||
Duration and interval cells keep the same split: unified-resource or
|
||||
source-specific consumers own which elapsed duration, human fallback, or poll
|
||||
interval field is meaningful, while dense platform table rendering must use
|
||||
@@ -1453,6 +1458,12 @@ pod UIDs, and generated registry IDs remain fallback migration, action,
|
||||
discovery, or metrics coordinates and must not be the primary metadata write
|
||||
key. Missing cluster/host, namespace, kind, or name scope must fail closed to
|
||||
the legacy resource key rather than constructing a broadened stable key.
|
||||
The Docker containers table's bulk web-link editor is a third writer of these
|
||||
records. It keys every row through `toDiscoveryConfig`, the container drawer's
|
||||
own metadata target, and lists only rows whose target is guest metadata, so
|
||||
bulk and drawer saves for a container read and write the same
|
||||
`app-container:<host>:name:<normalized-name>` record and the editor never
|
||||
falls back to a runtime-ID key.
|
||||
Product-originated resource references may arrive as registered unified
|
||||
resource IDs, source-specific IDs, or canonical identity aliases. The
|
||||
unified-resource registry owns resolving those references through
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "914fabfc1fef6d728bf145cf3097557ef82eafe7",
|
||||
"verified_at": "2026-10-05T13:10:50Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/docker/DockerConfigsTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerNetworksTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerSecretsTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerServicesTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerTasksTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerVolumesTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx",
|
||||
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/docker/DockerConfigsTable.tsx": "5e5aa4ec47bff613fa1e61fffc6e5f78fbb52a11e74fd692f632f7d9f39c68ed",
|
||||
"frontend-modern/src/features/docker/DockerImagesTable.tsx": "dad1a0a668160d0590d376d35da94f0e2e500965852631fff40e85a09ece62c1",
|
||||
"frontend-modern/src/features/docker/DockerNetworksTable.tsx": "a4fbc6cfe58dabb7e453e0e65b6bc6aeb72bcc05945fb68a6571e503f44722c9",
|
||||
"frontend-modern/src/features/docker/DockerSecretsTable.tsx": "3a68466cc647a84f4bc7cf9d9b6184ebf0476b9f21d05b9329ae400cfd9f7460",
|
||||
"frontend-modern/src/features/docker/DockerServicesTable.tsx": "aa3cf92e9aeb611027cec10dc3596e81907732e20930c06ba5d9275451f42c1e",
|
||||
"frontend-modern/src/features/docker/DockerTasksTable.tsx": "02435077ae71767e6f6db6bc0f3441a585661013dd62c35892e48229cc53e7b6",
|
||||
"frontend-modern/src/features/docker/DockerVolumesTable.tsx": "020345e4bc6c9a4389410cbfbe566aa34dae8ea7c702f65246bdfb75ce9e3e96",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "b16eaab7cc00f13a858b290f453e46ee1886c1e50a9a542999f4568a0d42aead",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx": "18c6dbea9038cc6561f5f914f61e67954abc4a3a93f83560b76398ae2915b7ba",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx": "f8f3695bbd200fa8a8f7091ec6431faa1dada5fbdbb98b80b9fc8c5f7ede304b",
|
||||
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx": "e6caa80836b45e9509d450e6816645062adc1cf6ce29041810e5daf70491ac6f"
|
||||
},
|
||||
"routes": [
|
||||
"/docker/images",
|
||||
"/docker/networks",
|
||||
"/docker/storage",
|
||||
"/docker/swarm",
|
||||
"/proxmox/backups/date",
|
||||
"/proxmox/backups/coverage",
|
||||
"/proxmox/mail",
|
||||
"/vmware/overview"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 390,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 600,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 700,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 900,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 1100,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Live DOM probe on main at 700 and 1440 over every changed span (Docker images, networks, volumes, services, tasks, secrets, configs; Proxmox recoverable, coverage Node and restore-evidence Location/Details, Mail Gateway drawer Top domains; vSphere hosts vCenter): all 1000 measured spans are the only child of their cell and every table, including the drawer's Top domains table, computes table-layout fixed",
|
||||
"Main: 66 spans ran past their cell with no ellipsis (Service Image 225px and Secret Labels 184px at 700, Mountpoint 51px and restore-evidence Details 22px at 1440, PMG domains 71px at 700), and every inline-block span sat 2 to 2.5px above the row's text line",
|
||||
"Patched: no span runs past its cell, all 134 truncated values end in an ellipsis inside their own column, and each value sits on the row's text line (0px; the 11px restore-evidence rows read 0.5px)",
|
||||
"Docker service Update cell still wraps Rollback started onto two lines below md (700) and truncates inside its column from md (1440) instead of at a 10rem cap",
|
||||
"Clip sweep (scrollWidth over clientWidth on every cell, screen-reader text excluded) across all eight routes at 390 to 1440: clipped cells 931 on main and 928 patched over 19209 measured cells, no column worse at any width (Secret Labels, Mountpoint and Used by each one better)",
|
||||
"Before and after element screenshots of all eleven affected tables at 700 and 1440 show hard cuts such as registry.exa and mail-relay.customer-opera on main becoming registry... and mail-relay.customer-op... inside the cell, with Created and Mountpoint back on the row line",
|
||||
"Mock PMG reports no domain stats, so the drawer's Top domains table was mounted by adding eight domains (4 to 54 characters) to the real /api/pmg/instances response through a Playwright route intercept"
|
||||
],
|
||||
"interactions": [
|
||||
"Expanded the first three Proxmox coverage rows to mount the restore-evidence table",
|
||||
"Opened the first Mail Gateway row's drawer to mount the Top domains table",
|
||||
"Loaded each Docker, Proxmox backups (by date and coverage) and vSphere overview tab fresh at every viewport and waited for the row count to settle before measuring"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "914fabfc1fef6d728bf145cf3097557ef82eafe7",
|
||||
"verified_at": "2026-10-05T13:09:31Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/WorkloadWebLinksAction.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadWebLinksState.ts",
|
||||
"frontend-modern/src/components/Workloads/workloadWebLinksModel.ts",
|
||||
"frontend-modern/src/components/shared/WebInterfaceUrlField.tsx",
|
||||
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
|
||||
"frontend-modern/src/stores/sessionCapabilities.ts",
|
||||
"frontend-modern/src/stores/sessionPresentationPolicy.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/WorkloadWebLinksAction.tsx": "319ff6e1265c7383699a0d916e0256a3d55d3844c06b33577197aa13f975e2e8",
|
||||
"frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx": "198f5688928616f5ef6f3b2fbfdfd95ab9ae54bc62a46faaa0067cabac46c2c6",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "e8bdb9ba10380b569366231a4e1a8324078c1ac5140ca3a0597d09e6f75689a1",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadWebLinksState.ts": "5c84b588fdbe003d1afb3d328aec05635c3e2f4c42a5bb543cb836f121f38389",
|
||||
"frontend-modern/src/components/Workloads/workloadWebLinksModel.ts": "5aa7607047001ad01a5090a06d714c7a30a1e77f446660fb25d9954346166342",
|
||||
"frontend-modern/src/components/shared/WebInterfaceUrlField.tsx": "bf904f63e28ee27e571b55d48595b33058231686e4634c66fee248dd15dee784",
|
||||
"frontend-modern/src/features/docker/DockerContainersTable.tsx": "4bcbc9060d78a78b4c2371cc2d5822bbf0ac01bd69f5ae476c7f050a6cbe5d1a",
|
||||
"frontend-modern/src/stores/sessionCapabilities.ts": "77da62c75db732e85c78c5a7f60d53ac6bff5058e34ea14783bcafc27ac0636a",
|
||||
"frontend-modern/src/stores/sessionPresentationPolicy.ts": "ed40857a67062002721738ad51e3a7a6c909c384f28a74569ca21f3aa695f648"
|
||||
},
|
||||
"routes": [
|
||||
"/docker",
|
||||
"/proxmox"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
},
|
||||
{
|
||||
"width": 375,
|
||||
"height": 812
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack from this tree (backend :7701, Vite :5221): Docker page with 7 containers across a Docker host and Docker-in-LXC guests; Proxmox page with 31 guests.",
|
||||
"Docker containers header shows Edit links; the panel says 'for each container', lists all 7 containers with host and image detail, keyed app-container:<host>:name:<name> exactly as toDiscoveryConfig resolves for the container drawer.",
|
||||
"Saving two container links, then a mix of two additions and two removals, wrote each through PUT /api/guests/metadata, closed the panel and updated the table links; the network log showed exactly two /api/resources refetches per save regardless of how many links changed.",
|
||||
"Token gate with the admin bypass off: a monitoring:read token saw the Proxmox guests (31 rows) and Docker containers without any Edit links action, and the guest drawer's Web Interface URL showed the saved URL, open and copy, and 'This session can open saved web interface URLs but not change them.' with no input, Save or Remove; the drawer header Open action stayed. A monitoring:read+write token saw Edit links and saved a container link successfully.",
|
||||
"Phone 375x812: Docker editor full-width with 44px inputs and no horizontal overflow. Production build: WorkloadsSurface 69.08 kB, Docker 28.56 kB, DrawerDiskListCard unchanged at 6.08 kB, trigger 1.6 kB and panel 4.2 kB as separate lazy chunks; no bundle violations. Not physical-device, vSphere-estate or release acceptance.",
|
||||
"Re-checked after the commit hook's Prettier pass reformatted two staged files (whitespace only): the Docker editor opened, saved inventory-api through PUT /api/guests/metadata, closed and the table gained the link."
|
||||
],
|
||||
"interactions": [
|
||||
"Opened Edit links on the Docker page at 1280 and 375 widths; typed links, cleared links, submitted the form; reopened to confirm saved values.",
|
||||
"Loaded the app as a read-only API token and as a write-scoped token (local test tokens set in session storage) and opened the Proxmox guest drawer Manage tab and the Docker containers header in each."
|
||||
]
|
||||
}
|
||||
@@ -1,21 +1,23 @@
|
||||
import { Show, Suspense, createSignal, lazy, type Component } from 'solid-js';
|
||||
import Link2Icon from 'lucide-solid/icons/link-2';
|
||||
import { TABLE_CARD_HEADER_CLEAR_BUTTON_CLASS } from '@/components/shared/TableCardHeader';
|
||||
import { sessionCanWriteMonitoringMetadata } from '@/stores/sessionCapabilities';
|
||||
import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import type { WorkloadGuestMetadataMap } from './workloadGuestMetadataRecord';
|
||||
import type { WorkloadWebLinkDrafts } from './workloadWebLinksModel';
|
||||
import type { WorkloadWebLinkDrafts, WorkloadWebLinksSource } from './workloadWebLinksModel';
|
||||
|
||||
// The panel and its row model load on first open, so they stay out of the
|
||||
// WorkloadsSurface chunk and do no work while the editor is closed.
|
||||
const WorkloadWebLinksDialog = lazy(() => import('./WorkloadWebLinksDialog'));
|
||||
|
||||
export interface WorkloadWebLinksActionProps {
|
||||
guests: () => WorkloadGuest[];
|
||||
guestMetadata: () => WorkloadGuestMetadataMap;
|
||||
source: WorkloadWebLinksSource;
|
||||
}
|
||||
|
||||
/** Table-header entry point for setting many guest web links at once. */
|
||||
/**
|
||||
* Table-header entry point for setting many web links at once. Hidden where a
|
||||
* save would be refused: read-only presentations and API-token sessions
|
||||
* without `monitoring:write`.
|
||||
*/
|
||||
export const WorkloadWebLinksAction: Component<WorkloadWebLinksActionProps> = (props) => {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
// Drafts outlive the panel so closing it by accident keeps typed links.
|
||||
@@ -24,7 +26,7 @@ export const WorkloadWebLinksAction: Component<WorkloadWebLinksActionProps> = (p
|
||||
const preload = () => void WorkloadWebLinksDialog.preload();
|
||||
|
||||
return (
|
||||
<Show when={!presentationPolicyIsReadOnly()}>
|
||||
<Show when={!presentationPolicyIsReadOnly() && sessionCanWriteMonitoringMetadata()}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
@@ -43,8 +45,7 @@ export const WorkloadWebLinksAction: Component<WorkloadWebLinksActionProps> = (p
|
||||
<Show when={open()}>
|
||||
<Suspense fallback={null}>
|
||||
<WorkloadWebLinksDialog
|
||||
guests={props.guests}
|
||||
guestMetadata={props.guestMetadata}
|
||||
source={props.source}
|
||||
drafts={drafts}
|
||||
setDrafts={setDrafts}
|
||||
onClose={() => setOpen(false)}
|
||||
|
||||
@@ -40,8 +40,8 @@ export const WorkloadWebLinksDialog: Component<WorkloadWebLinksDialogProps> = (p
|
||||
Web links
|
||||
</h2>
|
||||
<p class="text-xs text-muted">
|
||||
Add the address you open in your browser for each guest. Pulse shows it as a link
|
||||
beside the guest.
|
||||
Add the address you open in your browser for each {state.noun.one}. Pulse shows it as
|
||||
a link beside the {state.noun.one}.
|
||||
</p>
|
||||
</div>
|
||||
<ActionIconButton
|
||||
@@ -61,7 +61,7 @@ export const WorkloadWebLinksDialog: Component<WorkloadWebLinksDialogProps> = (p
|
||||
{state.linkedCount()} of {state.rows().length} in this view have a link
|
||||
</p>
|
||||
<FilterSegmentedControl
|
||||
aria-label="Show guests"
|
||||
aria-label={`Show ${state.noun.many}`}
|
||||
value={state.filter()}
|
||||
onChange={(value) => state.setFilter(value as WorkloadWebLinkFilter)}
|
||||
options={[
|
||||
@@ -85,8 +85,8 @@ export const WorkloadWebLinksDialog: Component<WorkloadWebLinksDialogProps> = (p
|
||||
fallback={
|
||||
<p class="py-6 text-center text-sm text-muted">
|
||||
{state.rows().length === 0
|
||||
? 'No guests in this view.'
|
||||
: 'Every guest in this view has a link.'}
|
||||
? `No ${state.noun.many} in this view.`
|
||||
: `Every ${state.noun.one} in this view has a link.`}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -249,8 +249,11 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
|
||||
title={props.tableTitle}
|
||||
actions={
|
||||
<WorkloadWebLinksAction
|
||||
guests={state.filteredGuests}
|
||||
guestMetadata={state.guestMetadata}
|
||||
source={{
|
||||
kind: 'workloads',
|
||||
guests: state.filteredGuests,
|
||||
guestMetadata: state.guestMetadata,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -10,10 +10,11 @@ import {
|
||||
import { WorkloadWebLinksAction } from '../WorkloadWebLinksAction';
|
||||
import type { WorkloadGuestMetadataMap } from '../workloadGuestMetadataRecord';
|
||||
|
||||
const { updateMetadataMock, successMock, readOnlyMock } = vi.hoisted(() => ({
|
||||
const { updateMetadataMock, successMock, readOnlyMock, canWriteMock } = vi.hoisted(() => ({
|
||||
updateMetadataMock: vi.fn(),
|
||||
successMock: vi.fn(),
|
||||
readOnlyMock: vi.fn(() => false),
|
||||
canWriteMock: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/guestMetadata', () => ({
|
||||
@@ -28,6 +29,10 @@ vi.mock('@/stores/sessionPresentationPolicy', () => ({
|
||||
presentationPolicyIsReadOnly: readOnlyMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/sessionCapabilities', () => ({
|
||||
sessionCanWriteMonitoringMetadata: canWriteMock,
|
||||
}));
|
||||
|
||||
const makeGuest = (overrides: Partial<WorkloadGuest>): WorkloadGuest =>
|
||||
({
|
||||
node: 'pve1',
|
||||
@@ -57,7 +62,7 @@ const renderAction = (metadata: WorkloadGuestMetadataMap = {}) => {
|
||||
}));
|
||||
};
|
||||
window.addEventListener(RESOURCE_METADATA_CHANGED_EVENT, onChanged);
|
||||
render(() => <WorkloadWebLinksAction guests={guests} guestMetadata={guestMetadata} />);
|
||||
render(() => <WorkloadWebLinksAction source={{ kind: 'workloads', guests, guestMetadata }} />);
|
||||
return {
|
||||
events,
|
||||
dispose: () => window.removeEventListener(RESOURCE_METADATA_CHANGED_EVENT, onChanged),
|
||||
@@ -82,6 +87,7 @@ describe('WorkloadWebLinksAction', () => {
|
||||
updateMetadataMock.mockResolvedValue({});
|
||||
successMock.mockReset();
|
||||
readOnlyMock.mockReturnValue(false);
|
||||
canWriteMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -97,6 +103,85 @@ describe('WorkloadWebLinksAction', () => {
|
||||
expect(screen.queryByRole('button', { name: /Edit links/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is absent for API-token sessions that cannot write monitoring metadata', () => {
|
||||
canWriteMock.mockReturnValue(false);
|
||||
harness = renderAction();
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Edit links/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edits ready-made rows, such as Docker containers, in their own words', async () => {
|
||||
const [containerUrl, setContainerUrl] = createSignal('');
|
||||
const rows = () => [
|
||||
{
|
||||
metadataId: 'app-container:docker-main:name:grafana',
|
||||
name: 'grafana',
|
||||
detail: 'docker-main · grafana/grafana:11',
|
||||
status: 'running',
|
||||
addressHint: '',
|
||||
savedUrl: containerUrl(),
|
||||
},
|
||||
];
|
||||
render(() => (
|
||||
<WorkloadWebLinksAction
|
||||
source={{ kind: 'rows', noun: { one: 'container', many: 'containers' }, rows }}
|
||||
/>
|
||||
));
|
||||
const dialog = await openEditor();
|
||||
|
||||
expect(dialog.getByText(/for each container/)).toBeInTheDocument();
|
||||
fireEvent.input(dialog.getByLabelText(/grafana/), {
|
||||
target: { value: 'https://grafana.example' },
|
||||
});
|
||||
fireEvent.click(dialog.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(successMock).toHaveBeenCalledWith('Saved 1 link.'));
|
||||
expect(updateMetadataMock).toHaveBeenCalledWith('app-container:docker-main:name:grafana', {
|
||||
customUrl: 'https://grafana.example',
|
||||
});
|
||||
// The resource-backed table catches up on its next refetch.
|
||||
setContainerUrl('https://grafana.example');
|
||||
});
|
||||
|
||||
it('holds just-saved links while a resource-backed table has not refetched yet', async () => {
|
||||
updateMetadataMock.mockImplementation(async (id: string) => {
|
||||
if (id === 'b') throw new Error('Permission denied');
|
||||
return {};
|
||||
});
|
||||
const rows = () =>
|
||||
['a', 'b'].map((id) => ({
|
||||
metadataId: id,
|
||||
name: `container-${id}`,
|
||||
detail: '',
|
||||
status: 'running',
|
||||
addressHint: '',
|
||||
savedUrl: '',
|
||||
}));
|
||||
render(() => (
|
||||
<WorkloadWebLinksAction
|
||||
source={{ kind: 'rows', noun: { one: 'container', many: 'containers' }, rows }}
|
||||
/>
|
||||
));
|
||||
const dialog = await openEditor();
|
||||
|
||||
fireEvent.input(dialog.getByLabelText(/container-a/), {
|
||||
target: { value: 'https://a.example' },
|
||||
});
|
||||
fireEvent.input(dialog.getByLabelText(/container-b/), {
|
||||
target: { value: 'https://b.example' },
|
||||
});
|
||||
fireEvent.click(dialog.getByRole('button', { name: 'Save 2 links' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dialog.getByLabelText(/container-b/)).toHaveAccessibleDescription('Permission denied'),
|
||||
);
|
||||
expect(dialog.getByLabelText(/container-a/)).toHaveValue('https://a.example');
|
||||
expect(dialog.getByTestId('workload-web-links-summary')).toHaveTextContent(
|
||||
'1 of 2 in this view have a link',
|
||||
);
|
||||
expect(dialog.getByRole('status')).toHaveTextContent('1 unsaved change');
|
||||
});
|
||||
|
||||
it('lists every guest in the view with its saved link and a labelled input', async () => {
|
||||
harness = renderAction({ [authId]: { id: authId, customUrl: 'https://auth.example' } });
|
||||
const dialog = await openEditor();
|
||||
|
||||
+2
-1
@@ -1399,7 +1399,8 @@ describe('Workloads performance contract', () => {
|
||||
'<TableCardHeader title={props.title} actions={props.actions} />',
|
||||
);
|
||||
expect(workloadsSource).toContain('<WorkloadWebLinksAction');
|
||||
expect(workloadsSource).toContain('guests={state.filteredGuests}');
|
||||
expect(workloadsSource).toContain('guests: state.filteredGuests,');
|
||||
expect(workloadsSource).toContain("kind: 'workloads',");
|
||||
expect(workloadPanelSource).toContain(
|
||||
'getWorkloadGuestMetadataRecord(guest(), props.guestMetadata())',
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { WorkloadGuest } from '@/types/workloads';
|
||||
import { getWorkloadMetadataId } from '@/utils/workloads';
|
||||
import {
|
||||
buildWorkloadWebLinkRows,
|
||||
buildWorkloadWebLinkSourceRows,
|
||||
getWorkloadWebLinksNoun,
|
||||
filterWorkloadWebLinkRows,
|
||||
getWorkloadWebLinkChanges,
|
||||
getWorkloadWebLinkPlaceholder,
|
||||
@@ -143,4 +145,30 @@ describe('workloadWebLinksModel', () => {
|
||||
'http://[fd00::5]',
|
||||
);
|
||||
});
|
||||
it('normalises, de-duplicates and sorts ready-made rows from other tables', () => {
|
||||
const row = {
|
||||
detail: '',
|
||||
status: 'running',
|
||||
addressHint: '',
|
||||
};
|
||||
const source = {
|
||||
kind: 'rows' as const,
|
||||
noun: { one: 'container', many: 'containers' },
|
||||
rows: () => [
|
||||
{ ...row, metadataId: 'b', name: 'web-10', savedUrl: ' https://web.example ' },
|
||||
{ ...row, metadataId: 'a', name: 'web-9', savedUrl: '' },
|
||||
{ ...row, metadataId: 'b', name: 'duplicate', savedUrl: '' },
|
||||
{ ...row, metadataId: '', name: 'no-identity', savedUrl: '' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(buildWorkloadWebLinkSourceRows(source)).toEqual([
|
||||
{ ...row, metadataId: 'a', name: 'web-9', savedUrl: '' },
|
||||
{ ...row, metadataId: 'b', name: 'web-10', savedUrl: 'https://web.example' },
|
||||
]);
|
||||
expect(getWorkloadWebLinksNoun(source)).toEqual({ one: 'container', many: 'containers' });
|
||||
expect(
|
||||
getWorkloadWebLinksNoun({ kind: 'workloads', guests: () => [], guestMetadata: () => ({}) }),
|
||||
).toEqual({ one: 'guest', many: 'guests' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { createMemo, createSignal, createUniqueId, type Setter } from 'solid-js';
|
||||
import { GuestMetadataAPI } from '@/api/guestMetadata';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import { dispatchResourceMetadataChanged } from '@/utils/resourceMetadataEvents';
|
||||
import type { WorkloadGuestMetadataMap } from './workloadGuestMetadataRecord';
|
||||
import {
|
||||
buildWorkloadWebLinkRows,
|
||||
buildWorkloadWebLinkSourceRows,
|
||||
filterWorkloadWebLinkRows,
|
||||
formatWorkloadWebLinkCount,
|
||||
getWorkloadWebLinkChanges,
|
||||
getWorkloadWebLinksNoun,
|
||||
keepStableWorkloadWebLinkRows,
|
||||
validateWorkloadWebLinkChanges,
|
||||
type WorkloadWebLinkChange,
|
||||
type WorkloadWebLinkDrafts,
|
||||
type WorkloadWebLinkFilter,
|
||||
type WorkloadWebLinkRow,
|
||||
type WorkloadWebLinksSource,
|
||||
} from './workloadWebLinksModel';
|
||||
|
||||
export interface WorkloadWebLinksStateProps {
|
||||
guests: () => WorkloadGuest[];
|
||||
guestMetadata: () => WorkloadGuestMetadataMap;
|
||||
source: WorkloadWebLinksSource;
|
||||
/** Owned by the table-header trigger so closing the panel keeps typed links. */
|
||||
drafts: () => WorkloadWebLinkDrafts;
|
||||
setDrafts: Setter<WorkloadWebLinkDrafts>;
|
||||
@@ -31,18 +31,21 @@ export interface WorkloadWebLinksStateProps {
|
||||
*/
|
||||
export function useWorkloadWebLinksState(props: WorkloadWebLinksStateProps) {
|
||||
const formId = `workload-web-links-${createUniqueId()}`;
|
||||
const noun = getWorkloadWebLinksNoun(props.source);
|
||||
const [filter, setFilter] = createSignal<WorkloadWebLinkFilter>('all');
|
||||
const [errors, setErrors] = createSignal<Record<string, string>>({});
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
// Resource-backed tables only learn a saved link on their next refetch, so
|
||||
// hold what this panel just saved rather than flash the stale value back.
|
||||
const [justSaved, setJustSaved] = createSignal<Record<string, string>>({});
|
||||
|
||||
const rows = createMemo<WorkloadWebLinkRow[]>(
|
||||
(previous) =>
|
||||
keepStableWorkloadWebLinkRows(
|
||||
previous,
|
||||
buildWorkloadWebLinkRows(props.guests(), props.guestMetadata()),
|
||||
),
|
||||
[],
|
||||
);
|
||||
const rows = createMemo<WorkloadWebLinkRow[]>((previous) => {
|
||||
const saved = justSaved();
|
||||
const next = buildWorkloadWebLinkSourceRows(props.source).map((row) =>
|
||||
row.metadataId in saved ? { ...row, savedUrl: saved[row.metadataId] } : row,
|
||||
);
|
||||
return keepStableWorkloadWebLinkRows(previous, next);
|
||||
}, []);
|
||||
const visibleRows = createMemo(() => filterWorkloadWebLinkRows(rows(), filter()));
|
||||
const linkedCount = createMemo(() => rows().filter((row) => row.savedUrl).length);
|
||||
const changes = createMemo(() => getWorkloadWebLinkChanges(rows(), props.drafts()));
|
||||
@@ -87,27 +90,40 @@ export function useWorkloadWebLinksState(props: WorkloadWebLinksStateProps) {
|
||||
|
||||
setSaving(true);
|
||||
const failed: Record<string, string> = {};
|
||||
let savedCount = 0;
|
||||
const saved: WorkloadWebLinkChange[] = [];
|
||||
for (const change of pending) {
|
||||
try {
|
||||
await GuestMetadataAPI.updateMetadata(change.metadataId, { customUrl: change.url });
|
||||
dispatchResourceMetadataChanged({
|
||||
metadataKind: 'guest',
|
||||
metadataId: change.metadataId,
|
||||
customUrl: change.url,
|
||||
});
|
||||
props.setDrafts(({ [change.metadataId]: _saved, ...rest }) => rest);
|
||||
savedCount += 1;
|
||||
saved.push(change);
|
||||
} catch (error) {
|
||||
failed[change.metadataId] =
|
||||
error instanceof Error && error.message ? error.message : 'Could not save this link.';
|
||||
}
|
||||
}
|
||||
|
||||
setJustSaved((current) => ({
|
||||
...current,
|
||||
...Object.fromEntries(saved.map((change) => [change.metadataId, change.url])),
|
||||
}));
|
||||
props.setDrafts((current) => {
|
||||
const next = { ...current };
|
||||
for (const change of saved) delete next[change.metadataId];
|
||||
return next;
|
||||
});
|
||||
// One burst after the writes: listeners that refetch whole snapshots
|
||||
// coalesce onto the same in-flight request instead of one per link.
|
||||
for (const change of saved) {
|
||||
dispatchResourceMetadataChanged({
|
||||
metadataKind: 'guest',
|
||||
metadataId: change.metadataId,
|
||||
customUrl: change.url,
|
||||
});
|
||||
}
|
||||
setSaving(false);
|
||||
setErrors(failed);
|
||||
|
||||
if (Object.keys(failed).length === 0) {
|
||||
notificationStore.success(`Saved ${formatWorkloadWebLinkCount(savedCount)}.`);
|
||||
notificationStore.success(`Saved ${formatWorkloadWebLinkCount(saved.length)}.`);
|
||||
props.onClose();
|
||||
return;
|
||||
}
|
||||
@@ -116,6 +132,7 @@ export function useWorkloadWebLinksState(props: WorkloadWebLinksStateProps) {
|
||||
|
||||
return {
|
||||
formId,
|
||||
noun,
|
||||
filter,
|
||||
setFilter,
|
||||
drafts: props.drafts,
|
||||
|
||||
@@ -37,27 +37,68 @@ const getWorkloadWebLinkDetail = (guest: WorkloadGuest): string =>
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
/** One row per saved-link identity, name-sorted the way people scan a list. */
|
||||
const finalizeWorkloadWebLinkRows = (rows: readonly WorkloadWebLinkRow[]): WorkloadWebLinkRow[] => {
|
||||
const seen = new Set<string>();
|
||||
return rows
|
||||
.filter((row) => {
|
||||
if (!row.metadataId || seen.has(row.metadataId)) return false;
|
||||
seen.add(row.metadataId);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||
};
|
||||
|
||||
export const buildWorkloadWebLinkRows = (
|
||||
guests: readonly WorkloadGuest[],
|
||||
byId: WorkloadGuestMetadataMap,
|
||||
): WorkloadWebLinkRow[] => {
|
||||
const rows: WorkloadWebLinkRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const guest of guests) {
|
||||
const metadataId = getWorkloadMetadataId(guest);
|
||||
if (!metadataId || seen.has(metadataId)) continue;
|
||||
seen.add(metadataId);
|
||||
rows.push({
|
||||
metadataId,
|
||||
): WorkloadWebLinkRow[] =>
|
||||
finalizeWorkloadWebLinkRows(
|
||||
guests.map((guest) => ({
|
||||
metadataId: getWorkloadMetadataId(guest),
|
||||
name: guest.name,
|
||||
detail: getWorkloadWebLinkDetail(guest),
|
||||
status: guest.status,
|
||||
addressHint: guest.ipAddresses?.find((address) => address.trim())?.trim() ?? '',
|
||||
savedUrl: normalizeWebInterfaceUrl(getWorkloadGuestMetadataRecord(guest, byId)?.customUrl),
|
||||
});
|
||||
}
|
||||
return rows.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||
};
|
||||
})),
|
||||
);
|
||||
|
||||
export interface WorkloadWebLinksNoun {
|
||||
one: string;
|
||||
many: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the editor lists. Workload tables hand over their live guests so the
|
||||
* row model only loads with the panel; other tables hand over ready rows keyed
|
||||
* by the same guest-metadata identity their drawers save under.
|
||||
*/
|
||||
export type WorkloadWebLinksSource =
|
||||
| {
|
||||
kind: 'workloads';
|
||||
guests: () => WorkloadGuest[];
|
||||
guestMetadata: () => WorkloadGuestMetadataMap;
|
||||
}
|
||||
| {
|
||||
kind: 'rows';
|
||||
noun: WorkloadWebLinksNoun;
|
||||
rows: () => WorkloadWebLinkRow[];
|
||||
};
|
||||
|
||||
const GUEST_NOUN: WorkloadWebLinksNoun = { one: 'guest', many: 'guests' };
|
||||
|
||||
export const getWorkloadWebLinksNoun = (source: WorkloadWebLinksSource): WorkloadWebLinksNoun =>
|
||||
source.kind === 'rows' ? source.noun : GUEST_NOUN;
|
||||
|
||||
export const buildWorkloadWebLinkSourceRows = (
|
||||
source: WorkloadWebLinksSource,
|
||||
): WorkloadWebLinkRow[] =>
|
||||
source.kind === 'workloads'
|
||||
? buildWorkloadWebLinkRows(source.guests(), source.guestMetadata())
|
||||
: finalizeWorkloadWebLinkRows(
|
||||
source.rows().map((row) => ({ ...row, savedUrl: normalizeWebInterfaceUrl(row.savedUrl) })),
|
||||
);
|
||||
|
||||
const sameWorkloadWebLinkRow = (a: WorkloadWebLinkRow, b: WorkloadWebLinkRow): boolean =>
|
||||
a.metadataId === b.metadataId &&
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, Show } from 'solid-js';
|
||||
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
|
||||
import { sessionCanWriteMonitoringMetadata } from '@/stores/sessionCapabilities';
|
||||
import { Button, CopyValueButton } from './Button';
|
||||
import { DiscoveryProvenanceMarker } from './DiscoveryProvenanceMarker';
|
||||
import { getInfoCardFrameClass } from './InfoCardFrame';
|
||||
@@ -11,6 +12,9 @@ export type { WebInterfaceUrlFieldProps } from './webInterfaceUrlFieldModel';
|
||||
|
||||
export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props) => {
|
||||
const state = useWebInterfaceUrlFieldState(props);
|
||||
// Metadata writes need monitoring:write; read-only API-token sessions such
|
||||
// as kiosk links see the saved URL without controls that would be refused.
|
||||
const canEdit = () => sessionCanWriteMonitoringMetadata();
|
||||
const title = () => props.title?.trim() || 'Web Interface URL';
|
||||
const rootClass = () =>
|
||||
props.embedded ? (props.class ?? '') : getInfoCardFrameClass({ class: props.class });
|
||||
@@ -22,27 +26,41 @@ export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props
|
||||
{title()}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
class="min-w-[180px] flex-1 text-xs px-2.5 py-1.5 border border-border rounded-md bg-surface text-base-content focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="https://198.51.100.100:8080"
|
||||
value={state.urlValue()}
|
||||
onInput={(e) => state.setUrlValue(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
void state.handleSaveUrl();
|
||||
}
|
||||
}}
|
||||
disabled={state.urlSaving()}
|
||||
/>
|
||||
<Button
|
||||
variant="primaryFlat"
|
||||
size="sm"
|
||||
disabled={state.urlSaving() || state.urlValue().trim() === state.normalizedCurrentUrl()}
|
||||
onClick={() => void state.handleSaveUrl()}
|
||||
<Show
|
||||
when={canEdit()}
|
||||
fallback={
|
||||
<span
|
||||
class="min-w-[180px] flex-1 truncate text-xs text-base-content"
|
||||
title={state.normalizedCurrentUrl() || undefined}
|
||||
>
|
||||
{state.normalizedCurrentUrl() || 'No URL saved'}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<input
|
||||
type="url"
|
||||
class="min-w-[180px] flex-1 text-xs px-2.5 py-1.5 border border-border rounded-md bg-surface text-base-content focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="https://198.51.100.100:8080"
|
||||
value={state.urlValue()}
|
||||
onInput={(e) => state.setUrlValue(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
void state.handleSaveUrl();
|
||||
}
|
||||
}}
|
||||
disabled={state.urlSaving()}
|
||||
/>
|
||||
<Button
|
||||
variant="primaryFlat"
|
||||
size="sm"
|
||||
disabled={
|
||||
state.urlSaving() || state.urlValue().trim() === state.normalizedCurrentUrl()
|
||||
}
|
||||
onClick={() => void state.handleSaveUrl()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={state.normalizedCurrentUrl()}>
|
||||
<WebInterfaceLink
|
||||
url={state.normalizedCurrentUrl()}
|
||||
@@ -64,7 +82,7 @@ export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props
|
||||
size="lg"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={state.normalizedCurrentUrl()}>
|
||||
<Show when={canEdit() && state.normalizedCurrentUrl()}>
|
||||
<Button
|
||||
variant="dangerOutline"
|
||||
size="sm"
|
||||
@@ -96,7 +114,7 @@ export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<Show when={state.invalidSuggestedUrlError()}>
|
||||
<Show when={canEdit() && state.invalidSuggestedUrlError()}>
|
||||
{(error) => (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -108,7 +126,7 @@ export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={state.showSuggestedDiagnostic()}>
|
||||
<Show when={canEdit() && state.showSuggestedDiagnostic()}>
|
||||
<div class="mt-2 rounded-sm border border-amber-200 bg-amber-50 p-2 text-[11px] text-amber-800 dark:border-amber-800 dark:bg-amber-900/25 dark:text-amber-200">
|
||||
<div class="flex items-center gap-1.5 font-medium">
|
||||
<span>{state.suggestedUrlFallback().title}</span>
|
||||
@@ -118,7 +136,7 @@ export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={state.showSuggestedUrl()}>
|
||||
<Show when={canEdit() && state.showSuggestedUrl()}>
|
||||
<div class="mt-2 p-2 rounded-sm bg-blue-50 border border-blue-200 dark:bg-blue-900/25 dark:border-blue-800">
|
||||
<div class="mb-1 flex items-center gap-1.5 text-[10px] font-medium text-blue-700 dark:text-blue-300">
|
||||
<span>{state.normalizedCurrentUrl() ? 'Discovered URL' : 'Suggested URL'}</span>
|
||||
@@ -169,7 +187,12 @@ export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props
|
||||
</Show>
|
||||
|
||||
<p class="mt-1.5 whitespace-normal text-[10px] text-muted">
|
||||
Add a URL to quickly access this {state.targetLabel()}'s web interface from Pulse.
|
||||
<Show
|
||||
when={canEdit()}
|
||||
fallback="This session can open saved web interface URLs but not change them."
|
||||
>
|
||||
Add a URL to quickly access this {state.targetLabel()}'s web interface from Pulse.
|
||||
</Show>
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -46,11 +46,13 @@ import { WebInterfaceUrlField } from '@/components/shared/WebInterfaceUrlField';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation';
|
||||
import { RESOURCE_METADATA_CHANGED_EVENT } from '@/utils/resourceMetadataEvents';
|
||||
import { syncSessionCapabilities } from '@/stores/sessionCapabilities';
|
||||
|
||||
describe('WebInterfaceUrlField', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
syncSessionCapabilities(null);
|
||||
});
|
||||
|
||||
it('keeps the URL field on shell, runtime, and model owners', () => {
|
||||
@@ -83,6 +85,35 @@ describe('WebInterfaceUrlField', () => {
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the saved URL without edit controls to sessions that cannot write metadata', async () => {
|
||||
syncSessionCapabilities({ tokenScopes: ['monitoring:read'] });
|
||||
render(() => (
|
||||
<WebInterfaceUrlField
|
||||
metadataKind="guest"
|
||||
metadataId="guest-1"
|
||||
targetLabel="workload"
|
||||
customUrl="https://grafana.example"
|
||||
suggestedUrl="http://10.0.0.5:3000"
|
||||
/>
|
||||
));
|
||||
|
||||
expect(await screen.findByText('https://grafana.example')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Open saved web interface URL' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://grafana.example',
|
||||
);
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Use (this|instead)/ })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('This session can open saved web interface URLs but not change them.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
syncSessionCapabilities({ tokenScopes: ['monitoring:read', 'monitoring:write'] });
|
||||
expect(await screen.findByRole('button', { name: 'Save' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports embedded rendering with a custom title', async () => {
|
||||
const { container } = render(() => (
|
||||
<WebInterfaceUrlField
|
||||
|
||||
@@ -194,7 +194,7 @@ export const DockerConfigsTable: Component<DockerNativeTableProps> = (props) =>
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-56 truncate"
|
||||
class="block truncate"
|
||||
title={dockerTextValue(resource.docker?.objectCreatedAt)}
|
||||
>
|
||||
<PlatformTableRelativeTimeValue
|
||||
@@ -206,7 +206,7 @@ export const DockerConfigsTable: Component<DockerNativeTableProps> = (props) =>
|
||||
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-88 truncate"
|
||||
class="block truncate"
|
||||
title={dockerLabelsSummary(resource.docker?.labels)}
|
||||
>
|
||||
{dockerLabelsSummary(resource.docker?.labels)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getResourceStaleness } from '@/features/platformPage/resourceStaleness';
|
||||
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
|
||||
import { For, Show, Suspense, createMemo, lazy, type Component, type JSX } from 'solid-js';
|
||||
import { useSearchParams } from '@solidjs/router';
|
||||
import { type FilterDef } from '@/components/shared/FilterBar';
|
||||
import { UpdateButton } from '@/components/shared/ContainerUpdateBadge';
|
||||
@@ -19,6 +19,11 @@ import { ResourceNameWithWebInterfaceLink } from '@/components/shared/WebInterfa
|
||||
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
|
||||
import type {
|
||||
WorkloadWebLinkRow,
|
||||
WorkloadWebLinksNoun,
|
||||
} from '@/components/Workloads/workloadWebLinksModel';
|
||||
import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel';
|
||||
import {
|
||||
getWorkloadTableLayoutMode,
|
||||
getWorkloadTableLayoutModeForContainer,
|
||||
@@ -137,6 +142,31 @@ const runtimeSummary = (resource: Resource): string => {
|
||||
return runtime || version || '—';
|
||||
};
|
||||
|
||||
// Lazy so the trigger keeps its own chunk instead of joining (and renaming)
|
||||
// the chunk the Docker and workloads pages already share.
|
||||
const WorkloadWebLinksAction = lazy(() => import('@/components/Workloads/WorkloadWebLinksAction'));
|
||||
|
||||
const CONTAINER_WEB_LINKS_NOUN: WorkloadWebLinksNoun = { one: 'container', many: 'containers' };
|
||||
|
||||
// Keyed by the identity the container drawer saves its web link under, so the
|
||||
// bulk editor and the drawer field read and write the same record.
|
||||
const toContainerWebLinkRows = (resource: Resource): WorkloadWebLinkRow[] => {
|
||||
const target = toDiscoveryConfig(resource);
|
||||
if (target?.metadataKind !== 'guest' || !target.metadataId) return [];
|
||||
return [
|
||||
{
|
||||
metadataId: target.metadataId,
|
||||
name: dockerResourceName(resource),
|
||||
detail: [dockerHostName(resource), dockerTextValue(resource.docker?.image)]
|
||||
.filter((part) => part && part !== '—')
|
||||
.join(' · '),
|
||||
status: asTrimmedString(resource.docker?.containerState || resource.status) ?? '',
|
||||
addressHint: '',
|
||||
savedUrl: resource.customUrl ?? '',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const containerState = (resource: Resource): string =>
|
||||
dockerTextValue(resource.docker?.containerState || resource.status);
|
||||
|
||||
@@ -821,6 +851,17 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
|
||||
>
|
||||
<PlatformTableShell
|
||||
title={props.title ?? 'Containers'}
|
||||
actions={
|
||||
<Suspense fallback={null}>
|
||||
<WorkloadWebLinksAction
|
||||
source={{
|
||||
kind: 'rows',
|
||||
noun: CONTAINER_WEB_LINKS_NOUN,
|
||||
rows: () => scopedRows().flatMap(toContainerWebLinkRows),
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
footer={
|
||||
<p
|
||||
class="px-3 py-2 text-xs leading-relaxed text-muted"
|
||||
|
||||
@@ -221,10 +221,7 @@ export const DockerImagesTable: Component<
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content platform-table-phone-hidden`}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-[20rem] truncate"
|
||||
title={operational().consumerSummary}
|
||||
>
|
||||
<span class="block truncate" title={operational().consumerSummary}>
|
||||
{operational().consumerSummary}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -569,7 +569,7 @@ export const DockerNetworksTable: Component<DockerNetworksTableProps> = (props)
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
|
||||
>
|
||||
<span class="inline-block max-w-[18rem] truncate" title={subnets()}>
|
||||
<span class="block truncate" title={subnets()}>
|
||||
{subnets()}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -209,7 +209,7 @@ export const DockerSecretsTable: Component<DockerNativeTableProps> = (props) =>
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-48 truncate"
|
||||
class="block truncate"
|
||||
title={dockerTextValue(resource.docker?.objectCreatedAt)}
|
||||
>
|
||||
<PlatformTableRelativeTimeValue
|
||||
@@ -221,7 +221,7 @@ export const DockerSecretsTable: Component<DockerNativeTableProps> = (props) =>
|
||||
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-[16rem] truncate"
|
||||
class="block truncate"
|
||||
title={dockerLabelsSummary(resource.docker?.labels)}
|
||||
>
|
||||
{dockerLabelsSummary(resource.docker?.labels)}
|
||||
|
||||
@@ -331,14 +331,14 @@ export const DockerServicesTable: Component<{
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} platform-table-narrow-hidden hidden text-base-content md:table-cell`}
|
||||
>
|
||||
<span class="truncate inline-block max-w-32" title={stack()}>
|
||||
<span class="block truncate" title={stack()}>
|
||||
{stack()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content md:table-cell`}
|
||||
>
|
||||
<span class="truncate inline-block max-w-[18rem]" title={image()}>
|
||||
<span class="block truncate" title={image()}>
|
||||
{image()}
|
||||
</span>
|
||||
</TableCell>
|
||||
@@ -361,7 +361,7 @@ export const DockerServicesTable: Component<{
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-full whitespace-normal wrap-break-word leading-3 md:max-w-40 md:truncate md:leading-normal"
|
||||
class="block whitespace-normal wrap-break-word leading-3 md:truncate md:leading-normal"
|
||||
title={update().title}
|
||||
>
|
||||
{update().label}
|
||||
|
||||
@@ -238,7 +238,7 @@ export const DockerTasksTable: Component<DockerNativeTableProps> = (props) => {
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-56 truncate"
|
||||
class="block truncate"
|
||||
title={dockerTextValue(
|
||||
resource.docker?.error ||
|
||||
resource.docker?.message ||
|
||||
|
||||
@@ -246,7 +246,7 @@ export const DockerVolumesTable: Component<DockerNativeTableProps> = (props) =>
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-48 truncate"
|
||||
class="block truncate"
|
||||
title={formatPlatformTableDateTimeValue(resource.docker?.createdAt)}
|
||||
>
|
||||
<PlatformTableRelativeTimeValue
|
||||
@@ -259,7 +259,7 @@ export const DockerVolumesTable: Component<DockerNativeTableProps> = (props) =>
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-88 truncate"
|
||||
class="block truncate"
|
||||
title={dockerTextValue(resource.docker?.mountpoint)}
|
||||
>
|
||||
{dockerTextValue(resource.docker?.mountpoint)}
|
||||
|
||||
@@ -182,6 +182,48 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('Docker native tables', () => {
|
||||
it('offers bulk web-link editing keyed by the container drawer identity', async () => {
|
||||
// The trigger and panel are lazy-loaded; transform them up front so they resolve promptly.
|
||||
await import('@/components/Workloads/WorkloadWebLinksAction');
|
||||
await import('@/components/Workloads/WorkloadWebLinksDialog');
|
||||
renderInRouter(() => (
|
||||
<DockerContainersTable
|
||||
resources={[
|
||||
makeResource({
|
||||
id: 'container-1',
|
||||
type: 'app-container',
|
||||
name: 'edge-web',
|
||||
status: 'running',
|
||||
customUrl: 'https://edge-web.example',
|
||||
docker: {
|
||||
hostSourceId: 'docker-host-edge',
|
||||
containerId: 'native-container-1',
|
||||
hostname: 'edge-01',
|
||||
image: 'nginx:latest',
|
||||
containerState: 'running',
|
||||
},
|
||||
}),
|
||||
]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No containers"
|
||||
emptyDescription="No containers"
|
||||
showToolbar={false}
|
||||
/>
|
||||
));
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit links' }));
|
||||
const dialog = within(await screen.findByRole('dialog', { name: 'Web links' }));
|
||||
const input = dialog.getByLabelText(/edge-web/);
|
||||
|
||||
expect(dialog.getByText(/for each container/)).toBeInTheDocument();
|
||||
expect(input).toHaveValue('https://edge-web.example');
|
||||
expect(input).toHaveAttribute(
|
||||
'data-metadata-id',
|
||||
'app-container:docker-host-edge:name:edge-web',
|
||||
);
|
||||
expect(dialog.getByText('edge-01 · nginx:latest')).toBeInTheDocument();
|
||||
}, 30_000);
|
||||
|
||||
it('renders Docker container API fields', () => {
|
||||
setViewportWidth(WORKLOAD_TABLE_WIDE_LAYOUT_WIDTH);
|
||||
|
||||
|
||||
+12
-1
@@ -395,11 +395,22 @@ describe('platform overview layout guardrails', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps Kubernetes summary cells truncating inside their own column', () => {
|
||||
it('keeps platform summary cells truncating inside their own column', () => {
|
||||
// A capped inline-block (max-w-52 truncate) ignored the column width, so a
|
||||
// long value spilled past a narrow cell without an ellipsis and sat above
|
||||
// the row's text line; a block span fills the cell and ellipsizes there.
|
||||
for (const source of [
|
||||
dockerConfigsTableSource,
|
||||
dockerImagesTableSource,
|
||||
dockerNetworksTableSource,
|
||||
dockerSecretsTableSource,
|
||||
dockerServicesTableSource,
|
||||
dockerTasksTableSource,
|
||||
dockerVolumesTableSource,
|
||||
proxmoxCoverageTableSource,
|
||||
proxmoxMailGatewayDrawerSource,
|
||||
proxmoxRecoverableTableSource,
|
||||
vsphereHostsTableSource,
|
||||
kubernetesAutoscalingTableSource,
|
||||
kubernetesConfigTableSource,
|
||||
kubernetesControllersTableSource,
|
||||
|
||||
@@ -350,7 +350,7 @@ export function ProxmoxCoverageTable(props: {
|
||||
fallback={<span class="text-muted">—</span>}
|
||||
>
|
||||
{(node) => (
|
||||
<span class="inline-block max-w-full truncate" title={node()}>
|
||||
<span class="block truncate" title={node()}>
|
||||
{node()}
|
||||
</span>
|
||||
)}
|
||||
@@ -586,10 +586,7 @@ export function ProxmoxCoverageTable(props: {
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-[18rem] truncate"
|
||||
title={artifact.location}
|
||||
>
|
||||
<span class="block truncate" title={artifact.location}>
|
||||
{artifact.location}
|
||||
</span>
|
||||
</TableCell>
|
||||
@@ -634,7 +631,7 @@ export function ProxmoxCoverageTable(props: {
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-[24rem] truncate"
|
||||
class="block truncate"
|
||||
title={artifact.detailTitle ?? artifact.detail}
|
||||
>
|
||||
{artifact.detail || '—'}
|
||||
|
||||
@@ -649,7 +649,7 @@ export const ProxmoxMailGatewayDrawer: Component<{
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('name')} font-mono text-[11px] text-base-content`}
|
||||
>
|
||||
<span class="inline-block max-w-56 truncate" title={domain.domain}>
|
||||
<span class="block truncate" title={domain.domain}>
|
||||
{domain.domain || '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -160,7 +160,7 @@ export function ProxmoxRecoverableTable(props: {
|
||||
</TableCell>
|
||||
<Show when={columnVisible('location')}>
|
||||
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
|
||||
<span class="inline-block max-w-[16rem] truncate" title={artifact.location}>
|
||||
<span class="block truncate" title={artifact.location}>
|
||||
{artifact.location}
|
||||
</span>
|
||||
</TableCell>
|
||||
@@ -192,10 +192,7 @@ export function ProxmoxRecoverableTable(props: {
|
||||
</TableCell>
|
||||
<Show when={columnVisible('details')}>
|
||||
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
|
||||
<span
|
||||
class="inline-block max-w-[20rem] truncate"
|
||||
title={artifact.detailTitle ?? artifact.detail}
|
||||
>
|
||||
<span class="block truncate" title={artifact.detailTitle ?? artifact.detail}>
|
||||
{artifact.detail || '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -258,4 +258,32 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
||||
);
|
||||
expect(titles.join(' ')).not.toMatch(/canonical|provider evidence|independent recovery/);
|
||||
});
|
||||
|
||||
it('keeps a long node name truncating inside its own column', () => {
|
||||
// A capped inline-block sat above the row's text line; a block span fills
|
||||
// the fixed-layout cell and ends in an ellipsis there.
|
||||
const node = 'pve-production-rack-04-node-17.example.internal';
|
||||
render(() => (
|
||||
<ProxmoxCoverageTable
|
||||
rows={[{ ...row, workload: { ...row.workload, node } }] as WorkloadCoverageRow[]}
|
||||
hasAnyRows
|
||||
emptyIcon={<span />}
|
||||
emptyTitle=""
|
||||
emptyDescription=""
|
||||
sortKey={(() => 'posture') as Accessor<CoverageSortKey>}
|
||||
sortDirection={() => 'asc'}
|
||||
onSort={() => {}}
|
||||
expandedKeys={new Set<string>()}
|
||||
onToggleExpand={() => {}}
|
||||
showTaskColumn={false}
|
||||
layoutWidth={() => 1_200}
|
||||
/>
|
||||
));
|
||||
|
||||
const span = document.querySelector(`td span[title="${node}"]`);
|
||||
expect(span).toHaveTextContent(node);
|
||||
expect(span?.parentElement?.children).toHaveLength(1);
|
||||
expect(span?.getAttribute('class')).toBe('block truncate');
|
||||
expect(span?.closest('table')).toHaveClass('table-fixed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -503,7 +503,7 @@ export const VsphereHostsTable: Component<{
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden font-mono text-[11px] text-base-content md:table-cell`}
|
||||
>
|
||||
<span class="inline-block max-w-48 truncate" title={vcenter()}>
|
||||
<span class="block truncate" title={vcenter()}>
|
||||
{vcenter()}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
sessionCanWriteMonitoringMetadata,
|
||||
sessionCapabilities,
|
||||
sessionCapabilitiesResolved,
|
||||
syncSessionCapabilities,
|
||||
@@ -132,4 +133,25 @@ describe('session capabilities store', () => {
|
||||
expect(next).not.toHaveProperty('assistantEnabled');
|
||||
expect(sessionCapabilities()).not.toHaveProperty('assistantEnabled');
|
||||
});
|
||||
it('lets browser sessions write monitoring metadata, since they carry no token scopes', () => {
|
||||
syncSessionCapabilities({ sessionCapabilities: { demoMode: false } });
|
||||
expect(sessionCanWriteMonitoringMetadata()).toBe(true);
|
||||
|
||||
syncSessionCapabilities({ sessionCapabilities: { demoMode: false }, tokenScopes: [] });
|
||||
expect(sessionCanWriteMonitoringMetadata()).toBe(true);
|
||||
});
|
||||
|
||||
it('mirrors the backend scope gate for API-token sessions', () => {
|
||||
syncSessionCapabilities({ tokenScopes: ['monitoring:read'] });
|
||||
expect(sessionCanWriteMonitoringMetadata()).toBe(false);
|
||||
|
||||
syncSessionCapabilities({ tokenScopes: ['monitoring:read', 'monitoring:write'] });
|
||||
expect(sessionCanWriteMonitoringMetadata()).toBe(true);
|
||||
|
||||
syncSessionCapabilities({ tokenScopes: ['*'] });
|
||||
expect(sessionCanWriteMonitoringMetadata()).toBe(true);
|
||||
|
||||
syncSessionCapabilities(null);
|
||||
expect(sessionCanWriteMonitoringMetadata()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { MONITORING_WRITE_SCOPE } from '@/constants/apiScopes';
|
||||
import type { SecurityStatus, SecurityStatusSessionCapabilities } from '@/types/config';
|
||||
|
||||
const DEFAULT_SESSION_CAPABILITIES: SecurityStatusSessionCapabilities = {
|
||||
@@ -8,6 +9,8 @@ const DEFAULT_SESSION_CAPABILITIES: SecurityStatusSessionCapabilities = {
|
||||
const [sessionCapabilities, setSessionCapabilities] =
|
||||
createSignal<SecurityStatusSessionCapabilities>({ ...DEFAULT_SESSION_CAPABILITIES });
|
||||
const [sessionCapabilitiesResolved, setSessionCapabilitiesResolved] = createSignal(false);
|
||||
// Present only for API-token sessions; browser sessions carry no token scopes.
|
||||
const [sessionTokenScopes, setSessionTokenScopes] = createSignal<readonly string[] | null>(null);
|
||||
|
||||
function normalizeSessionCapabilities(
|
||||
capabilities?: Partial<SecurityStatusSessionCapabilities> | null,
|
||||
@@ -19,12 +22,25 @@ function normalizeSessionCapabilities(
|
||||
}
|
||||
|
||||
export function syncSessionCapabilities(
|
||||
status?: Pick<SecurityStatus, 'sessionCapabilities'> | null,
|
||||
status?: Pick<SecurityStatus, 'sessionCapabilities' | 'tokenScopes'> | null,
|
||||
): SecurityStatusSessionCapabilities {
|
||||
const next = normalizeSessionCapabilities(status?.sessionCapabilities);
|
||||
setSessionCapabilities(next);
|
||||
const scopes = status?.tokenScopes?.filter((scope) => scope.trim());
|
||||
setSessionTokenScopes(scopes && scopes.length > 0 ? scopes : null);
|
||||
setSessionCapabilitiesResolved(true);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the backend metadata write gate (`EnsureScope(monitoring:write)`):
|
||||
* browser sessions always pass, while API-token sessions such as kiosk links
|
||||
* need `monitoring:write` or the wildcard scope.
|
||||
*/
|
||||
export function sessionCanWriteMonitoringMetadata(): boolean {
|
||||
const scopes = sessionTokenScopes();
|
||||
if (!scopes) return true;
|
||||
return scopes.includes('*') || scopes.includes(MONITORING_WRITE_SCOPE);
|
||||
}
|
||||
|
||||
export { sessionCapabilities, sessionCapabilitiesResolved };
|
||||
|
||||
@@ -43,7 +43,7 @@ function normalizeSessionPresentationPolicy(
|
||||
export function syncSessionPresentationPolicy(
|
||||
status?: Pick<
|
||||
SecurityStatus,
|
||||
'sessionCapabilities' | 'presentationPolicy' | 'settingsCapabilities'
|
||||
'sessionCapabilities' | 'presentationPolicy' | 'settingsCapabilities' | 'tokenScopes'
|
||||
> | null,
|
||||
): SecurityStatusPresentationPolicy {
|
||||
syncSessionCapabilities(status);
|
||||
|
||||
Reference in New Issue
Block a user