Introduce drawer access surface

This commit is contained in:
rcourtman
2026-03-23 14:07:02 +00:00
parent a200ee8be8
commit 3c51288aa0
12 changed files with 254 additions and 174 deletions
@@ -324,8 +324,11 @@ render shell, `frontend-modern/src/components/shared/useWebInterfaceUrlFieldStat
owns metadata fetch/save/remove lifecycle, success/error state, and suggested
URL runtime, and `frontend-modern/src/components/shared/webInterfaceUrlFieldModel.ts`
owns URL validation, target-label normalization, and suggested-URL presentation
rules. Future web-interface URL work should extend those owners instead of
pushing metadata transport or validation back into the shared shell.
rules. The shared primitive now also supports an embedded mode with a caller-
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.
The shared help icon now follows that same owner split.
`frontend-modern/src/components/shared/HelpIcon.tsx` stays the render shell,
`frontend-modern/src/components/shared/useHelpIconState.ts` owns open state,
@@ -424,10 +424,10 @@ with the resource name, status, or primary identity line.
That header badge row now also deduplicates identical visible labels, so
agent-backed nodes do not repeat `Agent` when both the canonical resource type
and a merged source resolve to the same badge text.
The current-state card's `Quick links` row now only promotes runtime-scoped
workloads drill-down routes, so ordinary host drawers do not surface a generic
host-wide `Workloads` jump that reads like default navigation chrome instead of
resource-specific operational context.
Runtime-scoped workloads drill-down routes now live in a dedicated `Access`
disclosure instead of a `Current state` row, so ordinary host drawers do not
surface a generic host-wide `Workloads` jump as default runtime chrome and
first-read status stays separate from the next place a user can go or inspect.
That same `Current state` card now only shows `Mode` when the resource carries
an actual canonical source mode, so ordinary hosts do not surface an empty or
meaningless mode row when no source-type contract is present.
@@ -435,26 +435,27 @@ Inside that top card pair, the operational and supporting context rows stay inli
instead of sitting in a collapsed `Details` disclosure or nested bordered
cards, so the first read stays like one linear sheet rather than a stack of
cards inside the overview shell.
Discovery support now also lives inside an overview-only `Analysis`
instead of a peer drawer tab, so supplemental discovery detail stays available
without claiming the same navigation weight as runtime, identity, or
service-specific operational views.
That discovery surface is now a compact support row with a one-line summary and
an on-demand metadata panel, so the actionable web-interface path stays primary
while deeper discovery inspection remains available without reading like a
second peer overview surface.
For ordinary host discovery, that support row now stays even quieter: the
surface is titled `Analysis`, and the collapsed state does not repeat a
baseline `Host analysis via <hostname>` summary when the discovery target is
just the same host identity already shown elsewhere in the drawer.
The discovery metadata panel now expands directly under the outer support
disclosure instead of nesting a second bordered card, so the support surface
reads as one flattened reveal instead of a card inside a card.
That discovery support surface now also follows the same shell/runtime split
as the rest of the drawer: `DiscoveryTab.tsx` owns presentation and
disclosures, while `useDiscoveryTabState.ts` owns API fetches, websocket
progress, and note/discovery mutations.
The overview keeps host, service, investigation, and discovery detail as
Discovery support now lives as an `Analysis` reveal inside that same
overview-only `Access` surface instead of a peer drawer tab, so supplemental
inspection stays available without claiming the same navigation weight as
runtime, identity, or service-specific operational views.
That access surface is now a compact support row with a one-line summary,
embedded web-interface controls, scoped runtime links, and an on-demand
analysis panel, so the actionable access path stays primary while deeper
discovery inspection remains available without reading like a second peer
overview surface.
For ordinary host discovery, that analysis entry stays even quieter: the
collapsed `Access` state does not repeat a baseline `Host analysis via
<hostname>` summary when the discovery target is just the same host identity
already shown elsewhere in the drawer.
The analysis panel now expands directly inside the outer `Access` disclosure
instead of as a second peer support block, so the access surface reads as one
flattened reveal instead of another card group under the overview.
That access-side analysis surface still follows the same shell/runtime split as
the rest of the drawer: `DiscoveryTab.tsx` owns presentation and disclosures,
while `useDiscoveryTabState.ts` owns API fetches, websocket progress, and
note/discovery mutations.
The overview keeps access, host, service, and investigation detail as
collapsed sibling disclosures under the primary card pair, so the drawer keeps
the top-level shape to current-state/identity plus `Change history` before any
secondary operational context appears.
@@ -480,8 +481,8 @@ That host-details section now reads as a simple vertical stack of detail cards
instead of a wrapped card grid, so the opened state stays linear instead of
feeling like a second dashboard.
Within that summary shell, current-state facts now stay operational: only
distinct platform IDs, quick links, and platform-signal badges remain with
runtime status, while aliases, IPs, and tags live only under the dedicated
distinct platform IDs and platform-signal badges remain with runtime status,
while scoped links move to `Access` and aliases, IPs, and tags live only under the dedicated
`Identity` card.
That keeps first read status-first while still preserving canonical identity
metadata on the same top-level summary surface instead of mixing identity
@@ -498,7 +499,7 @@ breakdowns stay available without displacing the common runtime and identity
hierarchy on first read.
The drawer’s secondary support sections now share the same responsive
flex-wrap card-group pattern used by the workloads drawer, so change history,
service details, host details, investigation context, and discovery context
access, service, host, and investigation context
read side by side on wider screens instead of as a single full-width stack.
Host uses that same flex-wrap pattern inside the disclosure for the
system, hardware, storage, and network cards, so the drawer matches the
@@ -167,24 +167,6 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
</div>
</div>
</Show>
<Show when={drawer.relatedLinks().length > 0}>
<div class="flex flex-col gap-1">
<span class="text-muted">Quick links</span>
<div class="flex flex-wrap gap-2">
<For each={drawer.relatedLinks()}>
{(link) => (
<a
href={link.href}
aria-label={link.ariaLabel}
class="inline-flex items-center rounded border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-200 dark:hover:bg-blue-900"
>
{link.compactLabel}
</a>
)}
</For>
</div>
</div>
</Show>
</div>
</Show>
</div>
@@ -914,6 +896,95 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
</SupportDisclosure>
</Show>
<Show when={drawer.hasAccessContext()}>
<SupportDisclosure
title="Access"
summary={drawer.accessSummary()}
expanded={drawer.showAccessContext()}
onToggle={() => drawer.setShowAccessContext((value) => !value)}
showLabel="Show access"
hideLabel="Hide access"
class="h-full"
contentClass="mt-3 space-y-3"
dataTestId="resource-access-section"
>
<Show when={drawer.relatedLinks().length > 0}>
<div class="space-y-1">
<div class="text-[10px] font-medium uppercase tracking-wide text-base-content">
Links
</div>
<div class="flex flex-wrap gap-2">
<For each={drawer.relatedLinks()}>
{(link) => (
<a
href={link.href}
aria-label={link.ariaLabel}
class="inline-flex items-center rounded border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-200 dark:hover:bg-blue-900"
>
{link.compactLabel}
</a>
)}
</For>
</div>
</div>
</Show>
<Show when={drawer.discoveryConfig()}>
{(config) => (
<div class="space-y-3">
<WebInterfaceUrlField
metadataKind={config().metadataKind}
metadataId={config().metadataId}
targetLabel={config().targetLabel}
title="Web interface"
embedded
/>
<div class="space-y-2 border-t border-border pt-3" data-testid="resource-access-analysis">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-[10px] font-medium uppercase tracking-wide text-base-content">
Analysis
</div>
<Show when={drawer.discoveryContextSummary()}>
<div class="mt-1 text-[10px] text-base-content">
{drawer.discoveryContextSummary()}
</div>
</Show>
</div>
<button
type="button"
onClick={() => drawer.setShowDiscoveryContext((value) => !value)}
class="inline-flex items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[10px] font-medium text-base-content transition-colors hover:bg-base"
>
{drawer.showDiscoveryContext() ? 'Hide analysis' : 'Open analysis'}
</button>
</div>
<Show when={drawer.showDiscoveryContext()}>
<Suspense
fallback={
<div class="flex items-center justify-center py-8">
<div class="animate-spin h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full" />
<span class="ml-2 text-sm text-muted">{getDiscoveryLoadingState().text}</span>
</div>
}
>
<DiscoveryTab
resourceType={config().resourceType}
agentId={config().agentId}
resourceId={config().resourceId}
hostname={config().hostname}
/>
</Suspense>
</Show>
</div>
</div>
)}
</Show>
</SupportDisclosure>
</Show>
<Show when={drawer.hasInvestigationContext()}>
<SupportDisclosure
title="Context"
@@ -1043,45 +1114,6 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
</SupportDisclosure>
</Show>
</div>
<Show when={drawer.discoveryConfig()}>
{(config) => (
<div class="space-y-2">
<WebInterfaceUrlField
metadataKind={config().metadataKind}
metadataId={config().metadataId}
targetLabel={config().targetLabel}
/>
<SupportDisclosure
title="Analysis"
summary={drawer.discoveryContextSummary()}
expanded={drawer.showDiscoveryContext()}
onToggle={() => drawer.setShowDiscoveryContext((value) => !value)}
showLabel="Open analysis"
hideLabel="Hide analysis"
class="h-full"
dataTestId="resource-discovery-context"
>
<Suspense
fallback={
<div class="flex items-center justify-center py-8">
<div class="animate-spin h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full" />
<span class="ml-2 text-sm text-muted">{getDiscoveryLoadingState().text}</span>
</div>
}
>
<DiscoveryTab
resourceType={config().resourceType}
agentId={config().agentId}
resourceId={config().resourceId}
hostname={config().hostname}
/>
</Suspense>
</SupportDisclosure>
</div>
)}
</Show>
</div>
);
};
@@ -8,6 +8,7 @@ import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/Reso
import resourceDetailDrawerHistoryStateSource from '@/components/Infrastructure/useResourceDetailDrawerHistoryState.ts?raw';
import resourceDetailDrawerDerivedStateSource from '@/components/Infrastructure/useResourceDetailDrawerDerivedState.ts?raw';
import resourceDetailDrawerDiscoveryModelSource from '@/components/Infrastructure/resourceDetailDiscoveryModel.ts?raw';
import resourceDetailDrawerIdentityModelSource from '@/components/Infrastructure/resourceDetailDrawerIdentityModel.ts?raw';
import resourceDetailDrawerOperationalModelSource from '@/components/Infrastructure/resourceDetailDrawerOperationalModel.ts?raw';
import resourceDetailDrawerServiceModelSource from '@/components/Infrastructure/resourceDetailDrawerServiceModel.ts?raw';
import resourceDetailDrawerDockerActionsStateSource from '@/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts?raw';
@@ -150,13 +151,27 @@ describe('ResourceDetailDrawer change history section', () => {
expect(resourceDetailDrawerDerivedStateSource).toContain(
"from './resourceDetailDrawerServiceModel'",
);
expect(resourceDetailDrawerDerivedStateSource).toContain(
"from './resourceDetailDrawerIdentityModel'",
);
expect(resourceDetailDrawerDiscoveryModelSource).toContain('export const toDiscoveryConfig');
expect(resourceDetailDrawerIdentityModelSource).toContain(
'export const buildResourceIdentityView',
);
expect(resourceDetailDrawerIdentityModelSource).toContain(
'export const buildDiscoveryContextSummary',
);
expect(resourceDetailDrawerIdentityModelSource).toContain(
'export const buildResourceDebugBundle',
);
expect(resourceDetailDrawerDerivedStateSource).not.toContain('buildWorkloadsHref');
expect(resourceDetailDrawerDerivedStateSource).not.toContain('buildServiceDetailLinks');
expect(resourceDetailDrawerDerivedStateSource).not.toContain('const supportedBadge =');
expect(resourceDetailDrawerDerivedStateSource).not.toContain(
'const links: Array<{ href: string;',
);
expect(resourceDetailDrawerDerivedStateSource).not.toContain('ALIAS_COLLAPSE_THRESHOLD');
expect(resourceDetailDrawerDerivedStateSource).not.toContain('formatIdentifierLabel');
expect(resourceDetailDrawerOperationalModelSource).toContain(
'export const buildKubernetesCapabilityBadges',
);
@@ -338,14 +353,15 @@ describe('ResourceDetailDrawer change history section', () => {
expect(screen.queryByText('Container Updates')).toBeNull();
expect(screen.queryByText('Check Updates')).toBeNull();
expect(screen.queryByText('Show update controls')).toBeNull();
expect(screen.getByText('Analysis')).toBeInTheDocument();
expect(screen.getByText('Access')).toBeInTheDocument();
expect(screen.queryByText('Analysis')).toBeNull();
expect(
screen.queryByText('Supporting metadata only. The web interface path above stays primary.'),
).toBeNull();
expect(screen.getByRole('button', { name: 'Open analysis' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Show access' })).toBeInTheDocument();
expect(
screen
.getByTestId('resource-discovery-context')
.getByTestId('resource-access-section')
.querySelector('.mt-3.rounded.border.border-border.bg-surface.p-2\\.5'),
).toBeNull();
expect(screen.queryByText('Details')).toBeNull();
@@ -376,6 +392,10 @@ describe('ResourceDetailDrawer change history section', () => {
expect(screen.queryByText('Relationships 1')).toBeNull();
expect(screen.queryByText('AI')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Show access' }));
expect(screen.getByText('Analysis')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Open analysis' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Show context' }));
await screen.findByText('AI');
expect(
@@ -143,18 +143,23 @@ describe('ResourceDetailDrawer runtime and identity cards', () => {
);
expect(queryByRole('button', { name: 'Analysis' })).toBeNull();
expect(getByText('Analysis')).toBeInTheDocument();
expect(getByText('Access')).toBeInTheDocument();
expect(queryByText('Analysis')).toBeNull();
expect(queryByText('Host analysis via host-1')).toBeNull();
expect(
queryByText('Supporting metadata only. The web interface path above stays primary.'),
).toBeNull();
expect(queryByTestId('discovery-tab')).toBeNull();
expect(
getByTestId('resource-discovery-context').querySelector(
getByTestId('resource-access-section').querySelector(
'.mt-3.rounded.border.border-border.bg-surface.p-2\\.5',
),
).toBeNull();
expect(getByRole('button', { name: 'Show access' })).toBeInTheDocument();
fireEvent.click(getByRole('button', { name: 'Show access' }));
expect(getByText('Analysis')).toBeInTheDocument();
expect(getByRole('button', { name: 'Open analysis' })).toBeInTheDocument();
fireEvent.click(getByRole('button', { name: 'Open analysis' }));
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
buildAccessSummary,
buildHostDetailCards,
buildHostDetailSummary,
buildKubernetesCapabilityBadges,
@@ -118,7 +119,7 @@ describe('resourceDetailDrawerOperationalModel', () => {
expect(buildHostDetailSummary(cards)).toBe(
'System, Hardware, Storage, Network, Disks, and Temperatures',
);
expect(hasRuntimeOperationalContext([], [])).toBe(false);
expect(hasRuntimeOperationalContext([])).toBe(false);
});
it('builds canonical related links from workloads and service detail surfaces', () => {
@@ -153,4 +154,22 @@ describe('resourceDetailDrawerOperationalModel', () => {
),
).toEqual([]);
});
it('builds access summaries from web access and scoped links', () => {
expect(buildAccessSummary({ hasWebInterface: true, links: [] })).toBe('Web interface');
expect(
buildAccessSummary({
hasWebInterface: true,
links: [
{
href: '/workloads?type=app-container&agent=agent-1',
label: 'Open in Workloads',
compactLabel: 'Workloads',
ariaLabel: 'Open related workloads for Host 1',
},
],
}),
).toBe('Web interface · 1 link');
expect(buildAccessSummary({ hasWebInterface: false, links: [] })).toBeNull();
});
});
@@ -176,6 +176,23 @@ export const buildHostDetailSummary = (hostDetailCards: string[]): string | null
return categories;
};
export const buildAccessSummary = (options: {
hasWebInterface: boolean;
links: ResourceDetailDrawerOperationalLink[];
}): string | null => {
const parts: string[] = [];
if (options.hasWebInterface) {
parts.push('Web interface');
}
if (options.links.length > 0) {
parts.push(`${options.links.length} link${options.links.length === 1 ? '' : 's'}`);
}
return parts.join(' · ') || null;
};
export const buildRelatedLinks = (
resource: Resource,
displayName: string,
@@ -204,5 +221,4 @@ export const buildRelatedLinks = (
export const hasRuntimeOperationalContext = (
badges: ResourceDetailDrawerOperationalBadge[],
links: ResourceDetailDrawerOperationalLink[],
): boolean => badges.length > 0 || links.length > 0;
): boolean => badges.length > 0;
@@ -11,8 +11,6 @@ import {
getUnifiedSourceBadges,
} from '@/utils/resourceBadgePresentation';
import {
getPrimaryResourceIdentityRows,
getResourceIdentityAliases,
getPreferredResourceClusterName,
getPreferredResourceDisplayName,
} from '@/utils/resourceIdentity';
@@ -26,7 +24,6 @@ import {
} from '@/utils/resourcePolicyPresentation';
import type { ResourceIntelligence } from '@/types/aiIntelligence';
import {
ALIAS_COLLAPSE_THRESHOLD,
buildTemperatureRows,
toAgentFromResource,
toNodeFromProxmox,
@@ -36,7 +33,6 @@ import {
type PlatformData,
} from '@/components/Infrastructure/resourceDetailMappers';
import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel';
import { formatIdentifierLabel } from '@/utils/textPresentation';
import {
buildPbsVisibleJobBreakdown,
buildPmgVisibleMailBreakdown,
@@ -46,6 +42,7 @@ import {
getServiceDetailsSummary,
} from './resourceDetailDrawerServiceModel';
import {
buildAccessSummary,
buildHostDetailCards,
buildHostDetailSummary,
buildKubernetesCapabilityBadges,
@@ -53,6 +50,13 @@ import {
buildSourceSummary,
hasRuntimeOperationalContext as buildHasRuntimeOperationalContext,
} from './resourceDetailDrawerOperationalModel';
import {
buildDiscoveryContextSummary,
buildIdentityMatchInfo,
buildResourceDebugBundle,
buildResourceIdentityView,
buildSourceSections,
} from './resourceDetailDrawerIdentityModel';
type DrawerTab = 'overview' | 'mail' | 'namespaces' | 'deployments' | 'swarm' | 'debug';
@@ -212,39 +216,16 @@ export const useResourceDetailDrawerDerivedState = (
);
const sourceSummary = createMemo(() => buildSourceSummary(mergedSources(), sourceStatus()));
const identityAliasValues = createMemo(() => getResourceIdentityAliases(resource));
const identityIpValues = createMemo(() => resource.identity?.ips ?? []);
const primaryIdentityRows = createMemo(() => getPrimaryResourceIdentityRows(resource));
const identityCardHasRichData = createMemo(
() =>
primaryIdentityRows().length > 0 ||
identityIpValues().length > 0 ||
(resource.tags?.length || 0) > 0 ||
identityAliasValues().length > 0,
);
const aliasPreviewValues = createMemo(() =>
identityAliasValues().slice(0, ALIAS_COLLAPSE_THRESHOLD),
);
const hasAliasOverflow = createMemo(
() => identityAliasValues().length > ALIAS_COLLAPSE_THRESHOLD,
);
const identityView = createMemo(() => buildResourceIdentityView(resource));
const identityAliasValues = createMemo(() => identityView().identityAliasValues);
const identityIpValues = createMemo(() => identityView().identityIpValues);
const primaryIdentityRows = createMemo(() => identityView().primaryIdentityRows);
const identityCardHasRichData = createMemo(() => identityView().identityCardHasRichData);
const aliasPreviewValues = createMemo(() => identityView().aliasPreviewValues);
const hasAliasOverflow = createMemo(() => identityView().hasAliasOverflow);
const hasMergedSources = createMemo(() => mergedSources().length > 1);
const discoveryConfig = createMemo(() => toDiscoveryConfig(resource));
const discoveryContextSummary = createMemo(() => {
const config = discoveryConfig();
if (!config) return null;
if (config.resourceType === 'agent') {
return null;
}
const discoveryMode =
config.resourceType === 'agent'
? 'Host analysis'
: `${formatIdentifierLabel(config.resourceType)} analysis`;
return config.hostname ? `${discoveryMode} via ${config.hostname}` : discoveryMode;
});
const discoveryContextSummary = createMemo(() => buildDiscoveryContextSummary(discoveryConfig()));
const hostDetailCards = createMemo(() =>
buildHostDetailCards({
@@ -271,53 +252,29 @@ export const useResourceDetailDrawerDerivedState = (
});
const relatedLinks = createMemo(() => buildRelatedLinks(resource, displayName()));
const accessSummary = createMemo(() =>
buildAccessSummary({
hasWebInterface: Boolean(discoveryConfig()),
links: relatedLinks(),
}),
);
const hasAccessContext = createMemo(
() => Boolean(discoveryConfig()) || relatedLinks().length > 0,
);
const hasRuntimeOperationalContext = createMemo(
() => buildHasRuntimeOperationalContext(kubernetesCapabilityBadges(), relatedLinks()),
() => buildHasRuntimeOperationalContext(kubernetesCapabilityBadges()),
);
const sourceSections = createMemo(() => {
const data = platformData();
if (!data) {
return [] as Array<{ id: string; label: string; payload: unknown }>;
}
const sections = [
{ id: 'proxmox', label: 'Proxmox', payload: data.proxmox },
{ id: 'agent', label: 'Agent', payload: data.agent },
{ id: 'docker', label: 'Containers', payload: data.docker },
{ id: 'pbs', label: 'PBS', payload: data.pbs },
{ id: 'pmg', label: 'PMG', payload: data.pmg },
{ id: 'kubernetes', label: 'Kubernetes', payload: data.kubernetes },
{ id: 'metrics', label: 'Metrics', payload: data.metrics },
];
return sections.filter((section) => section.payload !== undefined);
});
const identityMatchInfo = createMemo(() => {
const data = platformData();
return (
data?.identityMatch ??
data?.matchResults ??
data?.matchCandidates ??
data?.matches ??
undefined
);
});
const debugBundle = createMemo(() => ({
resource,
identity: {
resourceIdentity: resource.identity,
matchInfo: identityMatchInfo(),
},
sources: {
const sourceSections = createMemo(() => buildSourceSections(platformData()));
const identityMatchInfo = createMemo(() => buildIdentityMatchInfo(platformData()));
const debugBundle = createMemo(() =>
buildResourceDebugBundle({
resource,
platformData: platformData(),
sourceStatus: sourceStatus(),
proxmox: platformData()?.proxmox,
agent: platformData()?.agent,
docker: platformData()?.docker,
pbs: platformData()?.pbs,
pmg: platformData()?.pmg,
kubernetes: platformData()?.kubernetes,
metrics: platformData()?.metrics,
},
}));
identityMatchInfo: identityMatchInfo(),
}),
);
const debugJson = createMemo(() => JSON.stringify(debugBundle(), null, 2));
const tabs = createMemo(() => {
@@ -402,6 +359,8 @@ export const useResourceDetailDrawerDerivedState = (
hasMergedSources,
discoveryConfig,
discoveryContextSummary,
accessSummary,
hasAccessContext,
hasHostDetails,
hostDetailSummary,
hasServiceDetails,
@@ -21,6 +21,7 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
const [debugEnabled] = createLocalStorageBooleanSignal(STORAGE_KEYS.DEBUG_MODE, false);
const [copied, setCopied] = createSignal(false);
const [showReportModal, setShowReportModal] = createSignal(false);
const [showAccessContext, setShowAccessContext] = createSignal(false);
const [showInvestigationContext, setShowInvestigationContext] = createSignal(false);
const [showCorrelationContext, setShowCorrelationContext] = createSignal(false);
const [showDiscoveryContext, setShowDiscoveryContext] = createSignal(false);
@@ -86,6 +87,8 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
copied,
showReportModal,
setShowReportModal,
showAccessContext,
setShowAccessContext,
showInvestigationContext,
setShowInvestigationContext,
showCorrelationContext,
@@ -6,12 +6,17 @@ export type { WebInterfaceUrlFieldProps } from './webInterfaceUrlFieldModel';
export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props) => {
const state = useWebInterfaceUrlFieldState(props);
const title = () => props.title?.trim() || 'Web Interface URL';
const rootClass = () =>
props.embedded
? props.class ?? ''
: `rounded border border-border bg-surface p-3 shadow-sm ${props.class ?? ''}`.trim();
return (
<Show when={state.metadataId()}>
<div class={`rounded border border-border bg-surface p-3 shadow-sm ${props.class ?? ''}`}>
<div class={rootClass()}>
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
Web Interface URL
{title()}
</div>
<div class="flex items-center gap-2">
<input
@@ -53,6 +53,21 @@ describe('WebInterfaceUrlField', () => {
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
it('supports embedded rendering with a custom title', async () => {
const { container } = render(() => (
<WebInterfaceUrlField
metadataKind="agent"
metadataId="host-1"
targetLabel="agent"
title="Web interface"
embedded
/>
));
expect(await screen.findByText('Web interface')).toBeInTheDocument();
expect(container.querySelector('.shadow-sm')).toBeNull();
});
it('saves a host URL through metadata API', async () => {
render(() => (
<WebInterfaceUrlField
@@ -4,6 +4,7 @@ export interface WebInterfaceUrlFieldProps {
metadataKind: 'guest' | 'agent';
metadataId?: string;
targetLabel?: string;
title?: string;
customUrl?: string;
onCustomUrlChange?: (url: string) => void;
suggestedUrl?: string;
@@ -11,6 +12,7 @@ export interface WebInterfaceUrlFieldProps {
suggestedUrlReasonTitle?: string;
suggestedUrlDiagnostic?: string;
discoveryLoading?: boolean;
embedded?: boolean;
class?: string;
}