mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-25 04:33:03 +00:00
Introduce drawer access surface
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+23
-3
@@ -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(
|
||||
|
||||
+7
-2
@@ -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' }));
|
||||
|
||||
+20
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+18
-2
@@ -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;
|
||||
|
||||
+37
-78
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user