Promote Machines web interface editor

This commit is contained in:
rcourtman
2026-05-27 09:29:51 +01:00
parent c386054172
commit 5692325031
8 changed files with 174 additions and 115 deletions
@@ -26,6 +26,7 @@ interface ResourceDetailDrawerProps {
onClose?: () => void;
presentation?: ResourceDetailDrawerPresentation;
resolveResourceLabel?: (resourceId: string) => string | null | undefined;
initialShowAccessContext?: boolean;
initialShowTrueNASDetails?: boolean;
}
@@ -55,6 +56,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
resource: props.resource,
presentation: presentation(),
resolveResourceLabel: props.resolveResourceLabel,
initialShowAccessContext: props.initialShowAccessContext,
initialShowTrueNASDetails: props.initialShowTrueNASDetails,
});
const headingId = () => `resource-detail-drawer-heading-${props.resource.id}`;
@@ -337,6 +339,7 @@ export const ResourceDetailDrawer: Component<ResourceDetailDrawerProps> = (props
onClose={props.onClose}
presentation={props.presentation}
resolveResourceLabel={props.resolveResourceLabel}
initialShowAccessContext={props.initialShowAccessContext}
initialShowTrueNASDetails={props.initialShowTrueNASDetails}
/>
);
@@ -181,6 +181,113 @@ const timelineSourceAdapterOptions: Array<{
})),
];
const AccessDisclosure: Component<{
drawer: UseResourceDetailDrawerStateResult;
class?: string;
}> = (props) => (
<SupportDisclosure
title="Access"
summary={props.drawer.accessSummary()}
expanded={props.drawer.showAccessContext()}
onToggle={() => props.drawer.setShowAccessContext((value) => !value)}
showLabel="Show access"
hideLabel="Hide access"
class={props.class}
contentClass="mt-3 space-y-3"
dataTestId="resource-access-section"
>
<Show when={props.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={props.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={props.drawer.discoveryConfig()}>
{(config) => (
<div class="space-y-3">
<WebInterfaceUrlField
metadataKind={config().metadataKind}
metadataId={config().metadataId}
targetLabel={config().targetLabel}
title="Web interface"
discoveryLoading={props.drawer.discoveryLoading()}
suggestedUrl={props.drawer.discoveryIdentifiedSummary()?.suggestedUrl}
suggestedUrlReasonText={
props.drawer.discoveryIdentifiedSummary()?.suggestedUrlReasonText
}
suggestedUrlReasonTitle={
props.drawer.discoveryIdentifiedSummary()?.suggestedUrlReasonTitle
}
suggestedUrlDiagnostic={
props.drawer.discoveryIdentifiedSummary()?.suggestedUrlDiagnostic
}
embedded
/>
<Show when={!props.drawer.hasDiscoveryTab()}>
<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={props.drawer.discoveryContextSummary()}>
<div class="mt-1 text-[10px] text-base-content">
{props.drawer.discoveryContextSummary()}
</div>
</Show>
</div>
<button
type="button"
onClick={() => props.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"
>
{props.drawer.showDiscoveryContext() ? 'Hide analysis' : 'Open analysis'}
</button>
</div>
<Show when={props.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}
commandsEnabled={props.drawer.agentMeta()?.commandsEnabled}
/>
</Suspense>
</Show>
</div>
</Show>
</div>
)}
</Show>
</SupportDisclosure>
);
export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOverviewTabProps> = (
props,
) => {
@@ -204,9 +311,15 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
const shouldPromoteTrueNASDetails = () => compactTableRow() && drawer.hasTrueNASDetails();
const shouldPromoteHostDetails = () =>
compactTableRow() && isPulseAgentPlatformResource(resource) && drawer.hasHostDetails();
const shouldPromoteAccessContext = () =>
compactTableRow() && drawer.showAccessContext() && drawer.hasAccessContext();
return (
<div class="space-y-3">
<Show when={shouldPromoteAccessContext()}>
<AccessDisclosure drawer={drawer} />
</Show>
<Show when={shouldPromoteTrueNASDetails()}>
<TrueNASDetailsDisclosure drawer={drawer} class="space-y-2" contentClass="space-y-2" />
</Show>
@@ -518,7 +631,7 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
drawer.hasVMwareDetails() ||
drawer.hasTrueNASDetails() ||
(drawer.hasHostDetails() && !shouldPromoteHostDetails()) ||
drawer.hasAccessContext() ||
(drawer.hasAccessContext() && !shouldPromoteAccessContext()) ||
drawer.hasInvestigationContext()
}
>
@@ -530,112 +643,8 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
<TrueNASDetailsDisclosure drawer={drawer} class="h-full" />
</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"
discoveryLoading={drawer.discoveryLoading()}
suggestedUrl={drawer.discoveryIdentifiedSummary()?.suggestedUrl}
suggestedUrlReasonText={
drawer.discoveryIdentifiedSummary()?.suggestedUrlReasonText
}
suggestedUrlReasonTitle={
drawer.discoveryIdentifiedSummary()?.suggestedUrlReasonTitle
}
suggestedUrlDiagnostic={
drawer.discoveryIdentifiedSummary()?.suggestedUrlDiagnostic
}
embedded
/>
<Show when={!drawer.hasDiscoveryTab()}>
<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}
commandsEnabled={drawer.agentMeta()?.commandsEnabled}
/>
</Suspense>
</Show>
</div>
</Show>
</div>
)}
</Show>
</SupportDisclosure>
<Show when={drawer.hasAccessContext() && !shouldPromoteAccessContext()}>
<AccessDisclosure drawer={drawer} class="h-full" />
</Show>
<Show when={drawer.hasInvestigationContext()}>
@@ -23,6 +23,7 @@ export interface UseResourceDetailDrawerStateOptions {
resource: Resource;
resolveResourceLabel?: (resourceId: string) => string | null | undefined;
presentation?: ResourceDetailDrawerPresentation;
initialShowAccessContext?: boolean;
initialShowTrueNASDetails?: boolean;
}
@@ -36,7 +37,9 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
const [copied, setCopied] = createSignal(false);
const [showReportModal, setShowReportModal] = createSignal(false);
const [showHistoryFilters, setShowHistoryFilters] = createSignal(false);
const [showAccessContext, setShowAccessContext] = createSignal(false);
const [showAccessContext, setShowAccessContext] = createSignal(
options.initialShowAccessContext === true,
);
const [showInvestigationContext, setShowInvestigationContext] = createSignal(false);
const [showDiscoveryContext, setShowDiscoveryContext] = createSignal(false);
const [showHostDetails, setShowHostDetails] = createSignal(
@@ -73,6 +76,12 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
}
});
createEffect(() => {
if (options.initialShowAccessContext === true) {
setShowAccessContext(true);
}
});
createEffect(() => {
const current = activeTab();
const available = new Set(derived.tabs().map((tab) => tab.id));
@@ -172,7 +172,7 @@ export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props
</Show>
<p class="mt-1.5 text-[10px] text-muted">
Add a URL to quickly access this {state.targetLabel()}'s web interface from Workloads.
Add a URL to quickly access this {state.targetLabel()}'s web interface from Pulse.
</p>
</div>
</Show>
@@ -136,7 +136,7 @@ describe('WebInterfaceUrlField', () => {
expect(
await screen.findByText(
"Add a URL to quickly access this workload's web interface from Workloads.",
"Add a URL to quickly access this workload's web interface from Pulse.",
),
).toBeInTheDocument();
});
@@ -11,6 +11,7 @@ export type PlatformResourceDetailState = {
expandedResourceId: () => string | null;
isExpanded: (resource: ResourceLike) => boolean;
detailRowId: (resource: ResourceLike) => string;
open: (resource: ResourceLike) => void;
toggle: (resource: ResourceLike) => void;
close: (resource?: ResourceLike) => void;
handleActivationKey: (
@@ -31,6 +32,9 @@ export function createPlatformResourceDetailState(options: {
const isExpanded = (resource: ResourceLike): boolean => expandedResourceId() === resource.id;
const detailRowId = (resource: ResourceLike): string => `${options.idPrefix}-${resource.id}`;
const open = (resource: ResourceLike) => {
setExpandedResourceId(resource.id);
};
const toggle = (resource: ResourceLike) => {
setExpandedResourceId((current) => (current === resource.id ? null : resource.id));
};
@@ -51,6 +55,7 @@ export function createPlatformResourceDetailState(options: {
expandedResourceId,
isExpanded,
detailRowId,
open,
toggle,
close,
handleActivationKey,
@@ -70,6 +75,7 @@ export const PlatformResourceDetailTableRow: Component<{
detailRowId: string;
colSpan: number;
resolveResourceLabel?: (resourceId: string) => string | null | undefined;
initialShowAccessContext?: boolean;
initialShowTrueNASDetails?: boolean;
onClose?: () => void;
}> = (props) => {
@@ -92,6 +98,7 @@ export const PlatformResourceDetailTableRow: Component<{
resource={props.resource}
presentation="table-row"
resolveResourceLabel={props.resolveResourceLabel}
initialShowAccessContext={props.initialShowAccessContext}
initialShowTrueNASDetails={initialShowTrueNASDetails()}
onClose={props.onClose}
/>
@@ -1008,6 +1008,7 @@ export const AgentsMachinesTable: Component<{
);
const drawer = createPlatformResourceDetailState({ idPrefix: 'agents-machine-drawer' });
const [agentMetadataById, setAgentMetadataById] = createSignal<Record<string, AgentMetadata>>({});
const [accessTargetResourceId, setAccessTargetResourceId] = createSignal<string | null>(null);
const visibleColumns = createMemo(
() => columnVisibility.visibleColumns() as AgentMachineColumn[],
);
@@ -1209,6 +1210,22 @@ export const AgentsMachinesTable: Component<{
const detailRowId = () => drawer.detailRowId(machine);
const agentMetadataId = () => agentMetadataIdFor(machine);
const savedWebInterfaceUrl = () => savedAgentCustomUrlFor(agentMetadataId());
const clearAccessTargetIfCurrent = () => {
if (accessTargetResourceId() === machine.id) setAccessTargetResourceId(null);
};
const toggleDetails = () => {
const wasExpanded = isExpanded();
drawer.toggle(machine);
if (wasExpanded) clearAccessTargetIfCurrent();
};
const handleDetailsActivationKey: JSX.EventHandler<
HTMLTableRowElement,
KeyboardEvent
> = (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleDetails();
};
return (
<>
@@ -1217,8 +1234,8 @@ export const AgentsMachinesTable: Component<{
aria-controls={isExpanded() ? detailRowId() : undefined}
aria-expanded={isExpanded() ? 'true' : 'false'}
data-agents-machine-row={machine.id}
onClick={() => drawer.toggle(machine)}
onKeyDown={drawer.handleActivationKey(machine)}
onClick={toggleDetails}
onKeyDown={handleDetailsActivationKey}
tabIndex={0}
>
<TableCell
@@ -1393,7 +1410,8 @@ export const AgentsMachinesTable: Component<{
name={name()}
canConfigure={Boolean(agentMetadataId())}
onConfigure={() => {
if (!isExpanded()) drawer.toggle(machine);
setAccessTargetResourceId(machine.id);
drawer.open(machine);
}}
/>
</TableCell>
@@ -1440,7 +1458,11 @@ export const AgentsMachinesTable: Component<{
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={detailColspan()}
onClose={() => drawer.close(machine)}
initialShowAccessContext={accessTargetResourceId() === machine.id}
onClose={() => {
drawer.close(machine);
clearAccessTargetIfCurrent();
}}
/>
</>
);
@@ -6,7 +6,12 @@ import { RESOURCE_METADATA_CHANGED_EVENT } from '@/utils/resourceMetadataEvents'
import { AgentsMachinesTable } from '../AgentsMachinesTable';
vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({
ResourceDetailDrawer: () => <div data-testid="resource-detail-drawer" />,
ResourceDetailDrawer: (props: { initialShowAccessContext?: boolean }) => (
<div
data-testid="resource-detail-drawer"
data-initial-show-access-context={String(props.initialShowAccessContext ?? false)}
/>
),
}));
vi.mock('@/api/agentMetadata', () => ({
@@ -277,6 +282,10 @@ describe('AgentsMachinesTable', () => {
screen.getByRole('button', { name: 'Add web interface URL for Event Host' }),
);
expect(screen.getByTestId('resource-detail-drawer')).toBeInTheDocument();
expect(screen.getByTestId('resource-detail-drawer')).toHaveAttribute(
'data-initial-show-access-context',
'true',
);
window.dispatchEvent(
new CustomEvent(RESOURCE_METADATA_CHANGED_EVENT, {