From 26871ef1ee031fb5a124abd1985f0c195b5dc682 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:49:55 +0100 Subject: [PATCH] Open a guest's saved web interface from its drawer header Phone and narrow workload rows hide the adjacent web link so guest names stay distinguishable. On a phone that left a saved service reachable only from the URL field on the drawer's Manage tab, three taps deep and behind a settings form. The guest drawer header now carries an Open action whenever a URL is saved, built from the shared WebInterfaceLink and drawer-header action class, so the service is one tap from the expanded row with a 44px phone target. Rows are unchanged: showing the row link at 393px truncated names until checkout-web-01 and checkout-web-02 read the same. Refs #1556. --- .../subsystems/performance-and-scalability.md | 7 ++++ .../758aa0a8df357dc4.json | 40 +++++++++++++++++++ .../components/Workloads/GuestDrawer.test.tsx | 40 +++++++++++++++++++ .../src/components/Workloads/GuestDrawer.tsx | 17 ++++++++ 4 files changed, 104 insertions(+) create mode 100644 frontend-modern/browser-verification/758aa0a8df357dc4.json diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 5315612235..520e96d735 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1111,6 +1111,13 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. rows without platform metadata retain the Proxmox-compatible behavior at the migration boundary. 11. Extend workload drawer derivations and runtime wiring through `frontend-modern/src/components/Workloads/guestDrawerModel.ts` and `frontend-modern/src/components/Workloads/useGuestDrawerState.ts`, and extend drawer overview rendering through `frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx`, rather than rebuilding canonical guest identity, discovery routing, or drawer-local normalization inside `frontend-modern/src/components/Workloads/GuestDrawer.tsx` + The guest drawer header owns the saved web-interface launch action. It + reuses the shared `WebInterfaceLink` and drawer-header action class, so + phone and narrow rows keep hiding the adjacent row link for name legibility + while the saved service stays one tap from the expanded row. The action + renders only for a saved URL and adds no fetch, polling or per-row work. + `GuestDrawer.test.tsx` verifies its target, the unsafe-URL warning, and + that opening it does not collapse the drawer. 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 diff --git a/frontend-modern/browser-verification/758aa0a8df357dc4.json b/frontend-modern/browser-verification/758aa0a8df357dc4.json new file mode 100644 index 0000000000..21acefbec2 --- /dev/null +++ b/frontend-modern/browser-verification/758aa0a8df357dc4.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "base_sha": "f5157115b7b224000ef7035285d9ce64bbe60bb7", + "verified_at": "2026-10-04T22:49:37Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawer.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawer.tsx": "7e2b570d0af3b0cdf15ddd0c8944520094434937a71774d5f6eba5d7578b69c9" + }, + "routes": [ + "/proxmox" + ], + "viewports": [ + { + "width": 1280, + "height": 800 + }, + { + "width": 393, + "height": 852 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Private mock stack built from this tree (backend :7701, Vite :5221, 3 nodes, 31 guests); web-interface URLs saved on five pve1 guests, one through the real Manage-tab field and four through PUT /api/guests/metadata.", + "Before: at 375 and 393 widths the saved row links were display:none in phone/narrow rows and the drawer header held only the name and collapse chevron, so the URL was reachable only from the Manage tab field.", + "Rejected alternative measured live: un-hiding the row link at 393 truncated names to 'checkout-w...' and 'postgres-p...', indistinguishable from checkout-web-02 and postgres-replica-01, so phone rows stay unchanged.", + "After: auth-service-01 and postgres-primary-01 drawers show an 'Open' header action at 69x44 on 375/393 phones and 69x32 at 1280, href to the saved URL, target _blank, rel noopener noreferrer; drawer heading not truncated; no document horizontal overflow at any width.", + "edge-proxy-01 (no saved URL) shows no header action. Desktop rows keep the adjacent row link. This is not physical-device, real-service reachability or release acceptance." + ], + "interactions": [ + "Tapped guest rows to expand drawers at 375, 393 and 1280 widths; switched to the Manage tab and saved a URL with Enter; the row link and header action appeared without reload.", + "Activated the header Open link with navigation prevented in the capture phase: the drawer stayed expanded at 393 and 1280 (the link stops propagation and does not hit the collapse overlay)." + ] +} diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index d7d9b65dfe..30c7f0e0db 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -1530,6 +1530,46 @@ describe('GuestDrawer', () => { }); }); + describe('header web interface action', () => { + it('opens the saved URL from the header without collapsing the drawer', () => { + const onClose = vi.fn(); + render(() => ( + + )); + + const open = screen.getByRole('link', { name: 'Open web interface for test-vm' }); + expect(open).toHaveAttribute('href', 'https://auth.home.example'); + expect(open).toHaveAttribute('target', '_blank'); + expect(open).toHaveAttribute('rel', 'noopener noreferrer'); + expect(open).toHaveTextContent('Open'); + expect(open.className).toContain('min-h-11'); + + fireEvent.click(open); + expect(onClose).not.toHaveBeenCalled(); + }); + + it.each([undefined, ' '])( + 'renders no header action without a saved URL (%j)', + (customUrl) => { + render(() => ); + + expect( + screen.queryByRole('link', { name: 'Open web interface for test-vm' }), + ).not.toBeInTheDocument(); + }, + ); + + it('never turns an unsafe saved URL into a header link', () => { + const unsafeUrl = 'data:text/html,'; + render(() => ); + + expect( + screen.queryByRole('link', { name: 'Open web interface for test-vm' }), + ).not.toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Invalid web interface URL' })).toBeInTheDocument(); + }); + }); + // ── Discovery tab integration ── describe('DiscoveryTab integration', () => { diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.tsx index 3d8a975510..ee4146f3e4 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.tsx @@ -1,10 +1,13 @@ import { Component, Show, Suspense, createMemo } from 'solid-js'; +import ExternalLinkIcon from 'lucide-solid/icons/external-link'; import { DiscoveryTab } from '../Discovery/DiscoveryTab'; +import { getDrawerHeaderActionButtonClass } from '@/components/shared/buttonModel'; import { DiscoveryLoadingFallback } from '@/components/shared/DiscoveryLoadingFallback'; import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading'; import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge'; import { ObjectDrawerHeader } from '@/components/shared/ObjectDrawerHeader'; import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs'; +import { WebInterfaceLink } from '@/components/shared/WebInterfaceLink'; import { getSimpleStatusIndicator } from '@/utils/status'; import { GUEST_DRAWER_BACKUP_PRECAUTION, @@ -70,9 +73,23 @@ const GuestDrawerContent: Component = (props) => { return (
+ {/* Phone rows hide the adjacent web link so names stay distinguishable; + the header keeps the saved service one tap from the expanded row. */} + + + + } >