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.
This commit is contained in:
rcourtman
2026-10-04 23:49:55 +01:00
parent f5157115b7
commit 26871ef1ee
4 changed files with 104 additions and 0 deletions
@@ -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
@@ -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)."
]
}
@@ -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(() => (
<GuestDrawer guest={makeGuest()} customUrl="https://auth.home.example" onClose={onClose} />
));
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(() => <GuestDrawer guest={makeGuest()} customUrl={customUrl} onClose={vi.fn()} />);
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,<script>alert(1)</script>';
render(() => <GuestDrawer guest={makeGuest()} customUrl={unsafeUrl} onClose={vi.fn()} />);
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', () => {
@@ -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<GuestDrawerProps> = (props) => {
return (
<section class="space-y-3" aria-labelledby={headingId()}>
{/* Phone rows hide the adjacent web link so names stay distinguishable;
the header keeps the saved service one tap from the expanded row. */}
<ObjectDrawerHeader
collapseLabel={`Collapse ${props.guest.name} details`}
onCollapse={props.onClose}
actions={
<Show when={props.customUrl?.trim()}>
<WebInterfaceLink
url={props.customUrl}
ariaLabel={`Open web interface for ${props.guest.name}`}
class={getDrawerHeaderActionButtonClass()}
>
<ExternalLinkIcon class="h-3.5 w-3.5" aria-hidden="true" />
<span>Open</span>
</WebInterfaceLink>
</Show>
}
>
<DrawerSubjectHeading
headingId={headingId()}