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. */}
+
+
+ }
>