mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Refresh Docker custom URLs after container updates (#1054)
This commit is contained in:
parent
fcd2384dd5
commit
71351de987
2 changed files with 228 additions and 11 deletions
|
|
@ -650,7 +650,7 @@ const DockerHostGroupHeader: Component<{
|
|||
);
|
||||
};
|
||||
|
||||
const DockerContainerRow: Component<{
|
||||
export const DockerContainerRow: Component<{
|
||||
row: Extract<DockerRow, { kind: 'container' }>;
|
||||
isMobile: Accessor<boolean>;
|
||||
showHostContext?: boolean;
|
||||
|
|
@ -1073,7 +1073,7 @@ const DockerContainerRow: Component<{
|
|||
</tr>
|
||||
|
||||
<UrlEditPopover
|
||||
isOpen={urlEdit.isEditing() && urlEdit.editingId() === container.id}
|
||||
isOpen={urlEdit.isEditing() && urlEdit.editingId() === metadataId()}
|
||||
value={urlEdit.editingValue()}
|
||||
position={urlEdit.position()}
|
||||
isSaving={urlEdit.isSaving()}
|
||||
|
|
@ -2046,15 +2046,32 @@ const areTasksEqual = (a: DockerTask[], b: DockerTask[]) => {
|
|||
return true;
|
||||
};
|
||||
|
||||
const buildDockerMetadataSignature = (hosts: DockerHost[]): string =>
|
||||
hosts
|
||||
.flatMap((host) => {
|
||||
const containerKeys = (host.containers || []).map(
|
||||
(container) => `${host.id}:container:${container.id}`,
|
||||
);
|
||||
const serviceKeys = (host.services || []).map(
|
||||
(service) => `${host.id}:service:${service.id || service.name || ''}`,
|
||||
);
|
||||
return [host.id, ...containerKeys, ...serviceKeys];
|
||||
})
|
||||
.sort()
|
||||
.join('|');
|
||||
|
||||
const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
||||
// Use the breakpoint hook for responsive behavior
|
||||
const { isMobile } = useBreakpoint();
|
||||
|
||||
// Docker resource metadata for tracking custom URLs
|
||||
const [guestMetadata, setGuestMetadata] = createSignal<DockerMetadataRecord>(getDockerGuestMetadataCache());
|
||||
const resourceIdentitySignature = createMemo(() =>
|
||||
buildDockerMetadataSignature(props.hosts || []),
|
||||
);
|
||||
let lastLoadedResourceSignature: string | null = null;
|
||||
|
||||
// Load guest metadata on mount
|
||||
onMount(async () => {
|
||||
const refreshGuestMetadata = async () => {
|
||||
try {
|
||||
const metadata = await DockerMetadataAPI.getAllMetadata();
|
||||
setGuestMetadata(metadata ?? {});
|
||||
|
|
@ -2062,16 +2079,22 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
|||
} catch (err) {
|
||||
logger.debug('Failed to load Docker metadata', err);
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const signature = resourceIdentitySignature();
|
||||
if (signature === lastLoadedResourceSignature) {
|
||||
return;
|
||||
}
|
||||
lastLoadedResourceSignature = signature;
|
||||
void refreshGuestMetadata();
|
||||
});
|
||||
|
||||
// Load guest metadata on mount
|
||||
onMount(() => {
|
||||
// Listen for metadata changes from other sources (e.g., AI, other tabs)
|
||||
const handleMetadataChanged = async () => {
|
||||
try {
|
||||
const metadata = await DockerMetadataAPI.getAllMetadata();
|
||||
setGuestMetadata(metadata ?? {});
|
||||
setDockerGuestMetadataCache(metadata ?? {});
|
||||
} catch (err) {
|
||||
logger.debug('Failed to refresh Docker metadata', err);
|
||||
}
|
||||
await refreshGuestMetadata();
|
||||
};
|
||||
|
||||
window.addEventListener('pulse:metadata-changed', handleMetadataChanged);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
|
||||
import { DockerContainerRow, DockerUnifiedTable } from '@/components/Docker/DockerUnifiedTable';
|
||||
|
||||
const getAllDockerMetadataMock = vi.fn();
|
||||
const updateDockerMetadataMock = vi.fn();
|
||||
|
||||
vi.mock('@/components/shared/StatusDot', () => ({
|
||||
StatusDot: () => <span data-testid="status-dot" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/Card', () => ({
|
||||
Card: (props: any) => <div>{props.children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/EmptyState', () => ({
|
||||
EmptyState: (props: any) => <div>{props.title}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/responsive', () => ({
|
||||
ResponsiveMetricCell: () => <div data-testid="responsive-metric-cell" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Dashboard/StackedMemoryBar', () => ({
|
||||
StackedMemoryBar: () => <div data-testid="stacked-memory-bar" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Docker/UpdateBadge', () => ({
|
||||
UpdateButton: () => <div data-testid="update-button" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Discovery/DiscoveryTab', () => ({
|
||||
DiscoveryTab: () => <div data-testid="discovery-tab" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/HistoryChart', () => ({
|
||||
HistoryChart: () => <div data-testid="history-chart" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/alertsActivation', () => ({
|
||||
useAlertsActivation: () => ({
|
||||
getMetricThresholds: () => ({ warning: 75, critical: 90 }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useBreakpoint', () => ({
|
||||
useBreakpoint: () => ({
|
||||
isMobile: () => false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/usePersistentSignal', async () => {
|
||||
const solid = await import('solid-js');
|
||||
return {
|
||||
usePersistentSignal: <T,>(_key: string, initialValue: T) => solid.createSignal(initialValue),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/api/dockerMetadata', () => ({
|
||||
DockerMetadataAPI: {
|
||||
updateMetadata: (...args: unknown[]) => updateDockerMetadataMock(...args),
|
||||
getAllMetadata: (...args: unknown[]) => getAllDockerMetadataMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/toast', () => ({
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('DockerContainerRow custom URL editor', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
getAllDockerMetadataMock.mockReset();
|
||||
updateDockerMetadataMock.mockReset();
|
||||
});
|
||||
|
||||
it('opens the URL editor using the stable docker metadata id', async () => {
|
||||
const host = {
|
||||
id: 'docker-host-1',
|
||||
hostname: 'docker-host-1.local',
|
||||
displayName: 'Docker Host One',
|
||||
status: 'online',
|
||||
totalMemoryBytes: 8 * 1024 * 1024 * 1024,
|
||||
} as any;
|
||||
|
||||
const container = {
|
||||
id: 'container-123',
|
||||
name: 'app',
|
||||
image: 'ghcr.io/example/app:latest',
|
||||
state: 'running',
|
||||
status: 'running',
|
||||
cpuPercent: 0,
|
||||
memoryPercent: 0,
|
||||
memoryUsageBytes: 0,
|
||||
memoryLimitBytes: 0,
|
||||
restartCount: 0,
|
||||
labels: {},
|
||||
ports: [],
|
||||
networks: [],
|
||||
mounts: [],
|
||||
} as any;
|
||||
|
||||
const metadataId = 'docker-host-1:container:container-123';
|
||||
|
||||
render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<DockerContainerRow
|
||||
row={{ kind: 'container', id: metadataId, host, container } as any}
|
||||
isMobile={() => false}
|
||||
guestMetadata={{
|
||||
[metadataId]: {
|
||||
id: metadataId,
|
||||
customUrl: 'https://app.internal',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
|
||||
fireEvent.click(screen.getByTitle('Edit URL'));
|
||||
|
||||
expect(await screen.findByDisplayValue('https://app.internal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refreshes migrated metadata when a container runtime id changes', async () => {
|
||||
const createHost = (containerId: string, customUrl?: string) => ({
|
||||
id: 'docker-host-1',
|
||||
hostname: 'docker-host-1.local',
|
||||
displayName: 'Docker Host One',
|
||||
status: 'online',
|
||||
totalMemoryBytes: 8 * 1024 * 1024 * 1024,
|
||||
containers: [
|
||||
{
|
||||
id: containerId,
|
||||
name: 'app',
|
||||
image: 'ghcr.io/example/app:latest',
|
||||
state: 'running',
|
||||
status: 'running',
|
||||
cpuPercent: 0,
|
||||
memoryPercent: 0,
|
||||
memoryUsageBytes: 0,
|
||||
memoryLimitBytes: 0,
|
||||
restartCount: 0,
|
||||
labels: {},
|
||||
ports: [],
|
||||
networks: [],
|
||||
mounts: [],
|
||||
},
|
||||
],
|
||||
services: [],
|
||||
...(customUrl
|
||||
? {
|
||||
customUrl,
|
||||
}
|
||||
: {}),
|
||||
}) as any;
|
||||
|
||||
getAllDockerMetadataMock
|
||||
.mockResolvedValueOnce({
|
||||
'docker-host-1:container:container-old': {
|
||||
id: 'docker-host-1:container:container-old',
|
||||
customUrl: 'https://old.internal',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
'docker-host-1:container:container-new': {
|
||||
id: 'docker-host-1:container:container-new',
|
||||
customUrl: 'https://new.internal',
|
||||
},
|
||||
});
|
||||
|
||||
let setHosts!: (hosts: any[]) => void;
|
||||
|
||||
render(() => {
|
||||
const [hosts, setHostsSignal] = createSignal([createHost('container-old')]);
|
||||
setHosts = setHostsSignal;
|
||||
return <DockerUnifiedTable hosts={hosts()} groupingMode="flat" />;
|
||||
});
|
||||
|
||||
expect(await screen.findByTitle('Open https://old.internal')).toBeInTheDocument();
|
||||
|
||||
setHosts([createHost('container-new')]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAllDockerMetadataMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(await screen.findByTitle('Open https://new.internal')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue