mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
parent
841100caa7
commit
93420753aa
11 changed files with 270 additions and 133 deletions
|
|
@ -3718,6 +3718,87 @@
|
|||
"evidence_tier": "test-proof"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "RA33",
|
||||
"summary": "Comparable Pulse pages must compose repeated user-facing affordances from shared templates once a pattern exists: resource actions, fields, state badges, table controls, row affordances, and launch links must be owned in one shared primitive/model boundary with automated usage guardrails instead of drifting into page-local variants.",
|
||||
"kind": "invariant",
|
||||
"blocking_level": "repo-ready",
|
||||
"proof_type": "automated",
|
||||
"lane_ids": [
|
||||
"L8",
|
||||
"L13"
|
||||
],
|
||||
"subsystem_ids": [
|
||||
"frontend-primitives",
|
||||
"monitoring"
|
||||
],
|
||||
"release_gate_ids": [],
|
||||
"proof_commands": [
|
||||
{
|
||||
"id": "shared-primitive-guardrails",
|
||||
"run": [
|
||||
"npm",
|
||||
"--prefix",
|
||||
"frontend-modern",
|
||||
"test",
|
||||
"--",
|
||||
"--run",
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "shared-template-drift-guardrails",
|
||||
"run": [
|
||||
"node",
|
||||
"frontend-modern/scripts/shared-template-audit.mjs"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidence": [
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/status.json",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/subsystems/frontend-primitives.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/scripts/shared-template-audit.mjs",
|
||||
"kind": "file",
|
||||
"evidence_tier": "test-proof"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"kind": "file",
|
||||
"evidence_tier": "test-proof"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/shared/WebInterfaceNameLink.tsx",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/Workloads/GuestRow.tsx",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/features/standalone/agentMachineTableModel.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx",
|
||||
"kind": "file"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidence_reference_policy": {
|
||||
|
|
|
|||
|
|
@ -2355,6 +2355,13 @@ Missing-suggested-URL diagnostics remain useful only when the operator has no
|
|||
saved or entered URL; once a custom web-interface URL is present, the shared
|
||||
field must suppress "no suggested URL" warnings so Discovery does not make a
|
||||
valid manual endpoint look broken.
|
||||
Saved web-interface launch affordances must also stay on a shared primitive
|
||||
instead of page-local table columns or one-off external-link anchors.
|
||||
`frontend-modern/src/components/shared/WebInterfaceNameLink.tsx` owns the
|
||||
resource-name link shell, new-tab safety attributes, row-click/key propagation
|
||||
containment, fallback text, and accessible launch labels. Workload guest rows
|
||||
and standalone machine rows compose that primitive so a saved URL is opened by
|
||||
clicking the resource name on every comparable runtime table.
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -161,6 +161,11 @@ regression protection.
|
|||
`docker.uptimeSeconds`, `kubernetes.uptimeSeconds`); platforms whose
|
||||
adapters only populate the canonical field — vSphere is the working
|
||||
example — would otherwise render blank uptime cells for every row.
|
||||
Workload guest rows may compose the shared saved web-interface name-link
|
||||
shell from `frontend-primitives`, but row rendering must receive any saved
|
||||
URL from already-loaded row metadata and must not add per-row metadata
|
||||
fetches, Discovery probes, or page-local external-link logic on the table
|
||||
hot path.
|
||||
4. Keep shared auth gating in `internal/api/router.go` cheap and local: pre-auth quick-setup and recovery routing may short-circuit on loopback/session/token checks, but they must not trigger chart, metrics, or broad persistence fan-out on the protected request hot path.
|
||||
The same rule applies to public setup-script lifecycle routes: `/api/auto-register`
|
||||
and `/api/auto-unregister` may bypass the global auth wall so their handlers can
|
||||
|
|
|
|||
82
frontend-modern/scripts/shared-template-audit.mjs
Normal file
82
frontend-modern/scripts/shared-template-audit.mjs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { relative, resolve } from 'node:path';
|
||||
|
||||
const root = resolve(new URL('..', import.meta.url).pathname);
|
||||
|
||||
const read = (path) => readFileSync(resolve(root, path), 'utf8');
|
||||
|
||||
const rules = [
|
||||
{
|
||||
id: 'runtime-web-interface-name-link',
|
||||
summary:
|
||||
'Runtime resource rows must launch saved web-interface URLs through the shared name-link template, not a page-local link or separate web column.',
|
||||
canonical: 'src/components/shared/WebInterfaceNameLink.tsx',
|
||||
requiredConsumers: [
|
||||
'src/components/Workloads/GuestRow.tsx',
|
||||
'src/features/standalone/AgentsMachinesTable.tsx',
|
||||
],
|
||||
forbiddenPatterns: [
|
||||
{
|
||||
path: 'src/components/Workloads/GuestRow.tsx',
|
||||
patterns: ['target="_blank"', 'rel="noopener noreferrer"'],
|
||||
},
|
||||
{
|
||||
path: 'src/features/standalone/AgentsMachinesTable.tsx',
|
||||
patterns: [
|
||||
'target="_blank"',
|
||||
'rel="noopener noreferrer"',
|
||||
'AgentMachineWebLinkCell',
|
||||
'data-agent-machine-web-link',
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'src/features/standalone/agentMachineTableModel.ts',
|
||||
patterns: ["id: 'web'", "label: 'Web'"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
|
||||
for (const rule of rules) {
|
||||
const canonicalSource = read(rule.canonical);
|
||||
const canonicalExport = rule.canonical
|
||||
.split('/')
|
||||
.at(-1)
|
||||
?.replace(/\.[^.]+$/, '');
|
||||
if (canonicalExport && !canonicalSource.includes(canonicalExport)) {
|
||||
failures.push(`${rule.id}: canonical template ${rule.canonical} does not export itself`);
|
||||
}
|
||||
|
||||
for (const consumer of rule.requiredConsumers) {
|
||||
const source = read(consumer);
|
||||
if (canonicalExport && !source.includes(canonicalExport)) {
|
||||
failures.push(`${rule.id}: ${consumer} must compose ${canonicalExport}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const check of rule.forbiddenPatterns) {
|
||||
const source = read(check.path);
|
||||
for (const pattern of check.patterns) {
|
||||
if (source.includes(pattern)) {
|
||||
failures.push(`${rule.id}: ${check.path} must not contain ${JSON.stringify(pattern)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('Shared template audit failed:');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Shared template audit passed (${rules.length} rule${rules.length === 1 ? '' : 's'} from ${relative(
|
||||
process.cwd(),
|
||||
import.meta.url.replace('file://', ''),
|
||||
)})`,
|
||||
);
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton';
|
||||
import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge';
|
||||
import { getWorkloadGuestDiskStatusMessage } from '@/utils/workloadGuestPresentation';
|
||||
import { WebInterfaceNameLink } from '@/components/shared/WebInterfaceNameLink';
|
||||
import type { GuestRowProps } from './guestRowModel';
|
||||
import { useGuestRowState } from './useGuestRowState';
|
||||
import type { WorkloadTableMetric } from './workloadMetricHistoryModel';
|
||||
|
|
@ -191,30 +192,12 @@ export function GuestRow(props: GuestRowProps) {
|
|||
size="xs"
|
||||
/>
|
||||
<div class="flex items-center gap-1.5 min-w-0 group/name">
|
||||
<Show
|
||||
when={customUrl()}
|
||||
fallback={
|
||||
<span
|
||||
class="text-[11px] font-medium text-base-content select-none truncate"
|
||||
title={props.guest.name}
|
||||
>
|
||||
{props.guest.name}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(href) => (
|
||||
<a
|
||||
href={href()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-[11px] font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline select-none truncate"
|
||||
title={`Open ${href()}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{props.guest.name}
|
||||
</a>
|
||||
)}
|
||||
</Show>
|
||||
<WebInterfaceNameLink
|
||||
name={props.guest.name}
|
||||
url={customUrl()}
|
||||
class="text-[11px] font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline select-none truncate"
|
||||
fallbackClass="text-[11px] font-medium text-base-content select-none truncate"
|
||||
/>
|
||||
{/* Show backup indicator in name cell only if backup column is hidden */}
|
||||
<Show when={!isColVisible('backup') && supportsBackup()}>
|
||||
<BackupIndicator
|
||||
|
|
|
|||
|
|
@ -753,6 +753,8 @@ describe('GuestRow', () => {
|
|||
const link = container.querySelector('a[href="https://example.com"]');
|
||||
expect(link).toBeTruthy();
|
||||
expect(link?.getAttribute('target')).toBe('_blank');
|
||||
expect(link?.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
expect(link?.getAttribute('aria-label')).toBe('Open web interface for test-vm');
|
||||
expect(link?.textContent).toBe('test-vm');
|
||||
expect(container.querySelector('td[data-workload-col="link"]')).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ import selectionCardGroupStateSource from '@/components/shared/useSelectionCardG
|
|||
import webInterfaceUrlFieldSource from '@/components/shared/WebInterfaceUrlField.tsx?raw';
|
||||
import webInterfaceUrlFieldModelSource from '@/components/shared/webInterfaceUrlFieldModel.ts?raw';
|
||||
import webInterfaceUrlFieldStateSource from '@/components/shared/useWebInterfaceUrlFieldState.ts?raw';
|
||||
import webInterfaceNameLinkSource from '@/components/shared/WebInterfaceNameLink.tsx?raw';
|
||||
import upgradeNavigationSource from '@/utils/upgradeNavigation.ts?raw';
|
||||
import guestRowSource from '@/components/Workloads/GuestRow.tsx?raw';
|
||||
import workloadsTableSource from '@/components/Workloads/WorkloadsTable.tsx?raw';
|
||||
|
|
@ -96,6 +97,8 @@ import workloadPanelSource from '@/components/Workloads/WorkloadPanel.tsx?raw';
|
|||
import guestRowStateSource from '@/components/Workloads/useGuestRowState.ts?raw';
|
||||
import workloadSelectionStateSource from '@/components/Workloads/useWorkloadSelectionState.ts?raw';
|
||||
import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?raw';
|
||||
import agentsMachinesTableSource from '@/features/standalone/AgentsMachinesTable.tsx?raw';
|
||||
import agentMachineTableModelSource from '@/features/standalone/agentMachineTableModel.ts?raw';
|
||||
import unifiedResourceHostTableCardSource from '@/components/Infrastructure/UnifiedResourceHostTableCard.tsx?raw';
|
||||
import unifiedResourceServiceInfrastructureCardSource from '@/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx?raw';
|
||||
import unifiedResourcePBSTableSectionSource from '@/components/Infrastructure/UnifiedResourcePBSTableSection.tsx?raw';
|
||||
|
|
@ -268,7 +271,9 @@ describe('shared primitive guardrails', () => {
|
|||
expect(aiModelPickerSource).toContain('const handleOptionKeyDown = (');
|
||||
expect(aiModelPickerSource).toContain('role="dialog"');
|
||||
expect(aiModelPickerSource).toContain('role="listbox"');
|
||||
expect(aiModelPickerSource).toContain('aria-controls={isOpen() ? `${pickerId}-listbox` : undefined}');
|
||||
expect(aiModelPickerSource).toContain(
|
||||
'aria-controls={isOpen() ? `${pickerId}-listbox` : undefined}',
|
||||
);
|
||||
expect(aiModelPickerSource).toContain('onKeyDown={(event) => handleOptionKeyDown');
|
||||
expect(aiModelPickerSource).toContain('aria-selected={isSelectedRoute(model.id)}');
|
||||
expect(aiModelPickerSource).toContain('aria-label={optionAriaLabel(');
|
||||
|
|
@ -907,6 +912,23 @@ describe('shared primitive guardrails', () => {
|
|||
expect(webInterfaceUrlFieldModelSource).toContain('shouldShowWebInterfaceSuggestedUrl');
|
||||
});
|
||||
|
||||
it('keeps runtime web-interface launch on the shared resource-name template', () => {
|
||||
expect(webInterfaceNameLinkSource).toContain('export const WebInterfaceNameLink');
|
||||
expect(webInterfaceNameLinkSource).toContain('target="_blank"');
|
||||
expect(webInterfaceNameLinkSource).toContain('rel="noopener noreferrer"');
|
||||
expect(webInterfaceNameLinkSource).toContain('event.stopPropagation()');
|
||||
expect(webInterfaceNameLinkSource).toContain('Open web interface for');
|
||||
|
||||
expect(guestRowSource).toContain('WebInterfaceNameLink');
|
||||
expect(agentsMachinesTableSource).toContain('WebInterfaceNameLink');
|
||||
expect(guestRowSource).not.toContain('target="_blank"');
|
||||
expect(agentsMachinesTableSource).not.toContain('target="_blank"');
|
||||
expect(agentsMachinesTableSource).not.toContain('AgentMachineWebLinkCell');
|
||||
expect(agentsMachinesTableSource).not.toContain('data-agent-machine-web-link');
|
||||
expect(agentMachineTableModelSource).not.toContain("id: 'web'");
|
||||
expect(agentMachineTableModelSource).not.toContain("label: 'Web'");
|
||||
});
|
||||
|
||||
it('keeps help icon on shell, runtime, and model owners', () => {
|
||||
expect(helpIconSource).toContain('useHelpIconState');
|
||||
expect(helpIconSource).not.toContain('getHelpContent(');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { Show, type Component, type JSX } from 'solid-js';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
|
||||
export interface WebInterfaceNameLinkProps {
|
||||
name: string;
|
||||
url?: string | null;
|
||||
class?: string;
|
||||
fallbackClass?: string;
|
||||
title?: string;
|
||||
ariaLabel?: string;
|
||||
children?: JSX.Element;
|
||||
}
|
||||
|
||||
export const WebInterfaceNameLink: Component<WebInterfaceNameLinkProps> = (props) => {
|
||||
const url = () => asTrimmedString(props.url) ?? '';
|
||||
const label = () => props.children ?? props.name;
|
||||
const fallbackClass = () => props.fallbackClass ?? props.class ?? '';
|
||||
const linkClass = () => props.class ?? '';
|
||||
const linkTitle = () => props.title ?? `Open ${url()}`;
|
||||
const ariaLabel = () => props.ariaLabel ?? `Open web interface for ${props.name}`;
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={url()}
|
||||
fallback={
|
||||
<span class={fallbackClass()} title={props.name}>
|
||||
{label()}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(href) => (
|
||||
<a
|
||||
href={href()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={linkClass()}
|
||||
title={linkTitle()}
|
||||
aria-label={ariaLabel()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{label()}
|
||||
</a>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebInterfaceNameLink;
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
|
||||
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
|
||||
import Loader2Icon from 'lucide-solid/icons/loader-2';
|
||||
import MoreHorizontalIcon from 'lucide-solid/icons/more-horizontal';
|
||||
import PlusIcon from 'lucide-solid/icons/plus';
|
||||
import Trash2Icon from 'lucide-solid/icons/trash-2';
|
||||
import {
|
||||
For,
|
||||
|
|
@ -23,6 +21,7 @@ import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
|
|||
import { ColumnPicker } from '@/components/shared/ColumnPicker';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
|
||||
import { WebInterfaceNameLink } from '@/components/shared/WebInterfaceNameLink';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -721,65 +720,6 @@ const AgentMachineRaidCell: Component<{
|
|||
);
|
||||
};
|
||||
|
||||
const AgentMachineWebLinkCell: Component<{
|
||||
url: string;
|
||||
name: string;
|
||||
canConfigure: boolean;
|
||||
onConfigure: () => void;
|
||||
}> = (props) => {
|
||||
const url = () => asTrimmedString(props.url) ?? '';
|
||||
const label = () => `Open web interface for ${props.name}`;
|
||||
const configureLabel = () => `Add web interface URL for ${props.name}`;
|
||||
|
||||
return (
|
||||
<div class="flex justify-center">
|
||||
<Show
|
||||
when={url()}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.canConfigure}
|
||||
fallback={
|
||||
<span class="inline-flex h-7 w-7 items-center justify-center text-xs text-muted">
|
||||
—
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60"
|
||||
title="Add web interface URL"
|
||||
aria-label={configureLabel()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
props.onConfigure();
|
||||
}}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<PlusIcon class="h-3.5 w-3.5 opacity-60" />
|
||||
</button>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(href) => (
|
||||
<a
|
||||
data-agent-machine-web-link
|
||||
href={href()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded-md text-blue-600 transition-colors hover:bg-blue-50 hover:text-blue-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 dark:text-blue-400 dark:hover:bg-blue-950 dark:hover:text-blue-300"
|
||||
title={href()}
|
||||
aria-label={label()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ExternalLinkIcon class="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AgentMachineActionsCell: Component<{
|
||||
name: string;
|
||||
agentId: string;
|
||||
|
|
@ -1129,8 +1069,6 @@ const machineColumnWidthClass = (columnId: AgentMachineColumnId): string => {
|
|||
return 'hidden md:table-cell md:w-[5%]';
|
||||
case 'lastSeen':
|
||||
return 'hidden lg:table-cell lg:w-[6%]';
|
||||
case 'web':
|
||||
return 'hidden xl:table-cell xl:w-[4%]';
|
||||
case 'ip':
|
||||
return 'hidden xl:table-cell xl:w-[8%]';
|
||||
case 'raid':
|
||||
|
|
@ -1246,7 +1184,6 @@ 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 [actionsMenuResourceId, setActionsMenuResourceId] = createSignal<string | null>(null);
|
||||
const [confirmingRemovalResourceId, setConfirmingRemovalResourceId] = createSignal<string | null>(
|
||||
null,
|
||||
|
|
@ -1335,7 +1272,6 @@ export const AgentsMachinesTable: Component<{
|
|||
await removeAgentMetadata(agentMetadataIdFor(machine));
|
||||
setLocallyRemovedResourceIds((current) => ({ ...current, [resourceId]: true }));
|
||||
drawer.close(machine);
|
||||
if (accessTargetResourceId() === resourceId) setAccessTargetResourceId(null);
|
||||
notificationStore.success(`${name} removed from Pulse`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to remove machine';
|
||||
|
|
@ -1561,13 +1497,8 @@ export const AgentsMachinesTable: Component<{
|
|||
const agentMetadataId = () => agentMetadataIdFor(machine);
|
||||
const agentRemovalId = () => agentRemovalIdFor(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,
|
||||
|
|
@ -1605,9 +1536,12 @@ export const AgentsMachinesTable: Component<{
|
|||
title={machine.status || 'unknown'}
|
||||
ariaHidden
|
||||
/>
|
||||
<span class="truncate font-semibold text-base-content" title={name()}>
|
||||
{name()}
|
||||
</span>
|
||||
<WebInterfaceNameLink
|
||||
name={name()}
|
||||
url={savedWebInterfaceUrl()}
|
||||
class="truncate font-semibold text-blue-600 hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
|
||||
fallbackClass="truncate font-semibold text-base-content"
|
||||
/>
|
||||
</div>
|
||||
<Show
|
||||
when={machineSubtitle()}
|
||||
|
|
@ -1756,21 +1690,6 @@ export const AgentsMachinesTable: Component<{
|
|||
{lastSeenLabel()}
|
||||
</TableCell>
|
||||
</Show>
|
||||
<Show when={columnVisibility.isColumnVisible('web')}>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('badge')} ${machineColumnWidthClass('web')} text-base-content`}
|
||||
>
|
||||
<AgentMachineWebLinkCell
|
||||
url={savedWebInterfaceUrl()}
|
||||
name={name()}
|
||||
canConfigure={Boolean(agentMetadataId())}
|
||||
onConfigure={() => {
|
||||
setAccessTargetResourceId(machine.id);
|
||||
drawer.open(machine);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</Show>
|
||||
<Show when={columnVisibility.isColumnVisible('ip')}>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} ${machineColumnWidthClass('ip')} text-base-content`}
|
||||
|
|
@ -1828,10 +1747,8 @@ export const AgentsMachinesTable: Component<{
|
|||
open={isExpanded()}
|
||||
detailRowId={detailRowId()}
|
||||
colSpan={detailColspan()}
|
||||
initialShowAccessContext={accessTargetResourceId() === machine.id}
|
||||
onClose={() => {
|
||||
drawer.close(machine);
|
||||
clearAccessTargetIfCurrent();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -500,7 +500,7 @@ describe('AgentsMachinesTable', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('opens saved agent web interface URLs from machine rows', async () => {
|
||||
it('opens saved agent web interface URLs from the machine name', async () => {
|
||||
getAllAgentMetadataMock.mockResolvedValueOnce({
|
||||
'web-host': { id: 'web-host', customUrl: 'https://web-host.local:9443' },
|
||||
});
|
||||
|
|
@ -522,9 +522,14 @@ describe('AgentsMachinesTable', () => {
|
|||
|
||||
const link = await screen.findByRole('link', { name: 'Open web interface for Web Host' });
|
||||
expect(link).toHaveAttribute('href', 'https://web-host.local:9443');
|
||||
expect(link).toHaveTextContent('Web Host');
|
||||
expect(screen.queryByRole('columnheader', { name: 'Web' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Add web interface URL for Web Host' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps machine web links in sync when detail metadata changes', async () => {
|
||||
it('keeps machine name web links in sync when detail metadata changes', async () => {
|
||||
render(() => (
|
||||
<AgentsMachinesTable
|
||||
resources={[
|
||||
|
|
@ -540,15 +545,6 @@ describe('AgentsMachinesTable', () => {
|
|||
/>
|
||||
));
|
||||
|
||||
await fireEvent.click(
|
||||
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, {
|
||||
detail: {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ export type AgentMachineColumnId =
|
|||
| 'uptime'
|
||||
| 'temp'
|
||||
| 'lastSeen'
|
||||
| 'web'
|
||||
| 'ip'
|
||||
| 'raid'
|
||||
| 'arch'
|
||||
|
|
@ -65,12 +64,6 @@ export const AGENT_MACHINE_COLUMNS: AgentMachineColumn[] = [
|
|||
sortKey: 'lastSeen',
|
||||
toggleable: true,
|
||||
},
|
||||
{
|
||||
id: 'web',
|
||||
label: 'Web',
|
||||
kind: 'badge',
|
||||
toggleable: true,
|
||||
},
|
||||
{
|
||||
id: 'ip',
|
||||
label: 'IP',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue