Skip to content

Commit

Permalink
feat: details page for disk images (#866)
Browse files Browse the repository at this point in the history
* feat: details page for disk images

Modifies the build logs to have a proper details page for disk images, similar
to how Podman Desktop handles Images or Containers.

The two tabs are Summary and Build Logs. The summary page is simple for now
but could be expanded; the build logs doesn't really match what we typically
have in Podman Desktop, but it fits in better here than a standalone page.

Fixes #856.

Signed-off-by: Tim deBoer <git@tdeboer.ca>

* chore: removing console.log, added util tests

Removed unnecessary console.log debugging and added tests for Util.

Signed-off-by: Tim deBoer <git@tdeboer.ca>

---------

Signed-off-by: Tim deBoer <git@tdeboer.ca>
  • Loading branch information
deboer-tim authored Sep 30, 2024
1 parent 40be954 commit 7b17a94
Show file tree
Hide file tree
Showing 16 changed files with 378 additions and 60 deletions.
8 changes: 3 additions & 5 deletions packages/frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getRouterState } from './api/client';
import Homepage from './Homepage.svelte';
import { rpcBrowser } from '/@/api/client';
import { Messages } from '/@shared/src/messages/Messages';
import Logs from './Logs.svelte';
import DiskImageDetails from './lib/disk-image/DiskImageDetails.svelte';
router.mode.hash();
Expand All @@ -36,10 +36,8 @@ onMount(() => {
<Route path="/build" breadcrumb="Build">
<Build />
</Route>
<Route path="/logs/:base64BuildImageName/:base64FolderLocation" breadcrumb="Logs" let:meta>
<Logs
base64BuildImageName={meta.params.base64BuildImageName}
base64FolderLocation={meta.params.base64FolderLocation} />
<Route path="/details/:id/*" breadcrumb="Disk Image Details" let:meta>
<DiskImageDetails id={meta.params.id} />
</Route>
<Route path="/build/:name/:tag" breadcrumb="Build" let:meta>
<Build imageName={decodeURIComponent(meta.params.name)} imageTag={decodeURIComponent(meta.params.tag)} />
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/lib/BootcActions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,5 @@ test('Test clicking on logs button', async () => {
const logsButton = screen.getAllByRole('button', { name: 'Build Logs' })[0];
logsButton.click();

expect(window.location.href).toContain('/logs');
expect(window.location.href).toContain('/build');
});
5 changes: 1 addition & 4 deletions packages/frontend/src/lib/BootcActions.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@ async function deleteBuild(): Promise<void> {
// Navigate to the build
async function gotoLogs(): Promise<void> {
// Convert object.folder to base64
const base64FolderLocation = btoa(object.folder);
const base64BuildImageName = btoa(object.image);
router.goto(`/logs/${base64BuildImageName}/${base64FolderLocation}`);
router.goto(`/details/${btoa(object.id)}/build`);
}
</script>

Expand Down
11 changes: 11 additions & 0 deletions packages/frontend/src/lib/BootcImageColumn.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,14 @@ test('Expect to render as name:tag', async () => {
const name = screen.getByText('image1:latest');
expect(name).not.toBeNull();
});

test('Expect click goes to details page', async () => {
render(BootcImageColumn, { object: mockHistoryInfo });

const name = screen.getByText('image1:latest');
expect(name).not.toBeNull();

name.click();

expect(window.location.href).toContain('/summary');
});
13 changes: 10 additions & 3 deletions packages/frontend/src/lib/BootcImageColumn.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
<script lang="ts">
import { router } from 'tinro';
import type { BootcBuildInfo } from '/@shared/src/models/bootc';
export let object: BootcBuildInfo;
function openDetails() {
router.goto(`/details/${btoa(object.id)}/summary`);
}
</script>

<div class="text-[var(--pd-table-body-text-highlight)] overflow-hidden text-ellipsis">
{object.image}:{object.tag}
</div>
<button class="hover:cursor-pointer flex flex-col max-w-full" on:click={() => openDetails()}>
<div class="text-[var(--pd-table-body-text-highlight)] max-w-full overflow-hidden text-ellipsis">
{object.image}:{object.tag}
</div>
</button>
66 changes: 66 additions & 0 deletions packages/frontend/src/lib/disk-image/DiskImageDetails.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import '@testing-library/jest-dom/vitest';

import { render, screen } from '@testing-library/svelte';
import { beforeEach, expect, test, vi } from 'vitest';
import { bootcClient } from '/@/api/client';

import DiskImageDetails from './DiskImageDetails.svelte';
import type { BootcBuildInfo } from '/@shared/src/models/bootc';
import { tick } from 'svelte';

const image: BootcBuildInfo = {
id: 'id1',
image: 'my-image',
imageId: 'image-id',
tag: 'latest',
engineId: 'podman',
type: ['ami'],
folder: '/bootc',
};

vi.mock('/@/api/client', async () => {
return {
bootcClient: {
listHistoryInfo: vi.fn(),
},
rpcBrowser: {
subscribe: () => {
return {
unsubscribe: () => {},
};
},
},
};
});

beforeEach(() => {
vi.clearAllMocks();
});

test('Confirm renders disk image details', async () => {
vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue([image]);

render(DiskImageDetails, { id: btoa(image.id) });

// allow UI time to update
await tick();

expect(screen.getByText(image.image + ':' + image.tag)).toBeInTheDocument();
});
62 changes: 62 additions & 0 deletions packages/frontend/src/lib/disk-image/DiskImageDetails.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script lang="ts">
import { DetailsPage, Tab } from '@podman-desktop/ui-svelte';
import { router } from 'tinro';
import DiskImageIcon from '/@/lib/DiskImageIcon.svelte';
import DiskImageDetailsBuild from './DiskImageDetailsBuild.svelte';
import Route from '../Route.svelte';
import DiskImageDetailsSummary from './DiskImageDetailsSummary.svelte';
import { onMount } from 'svelte';
import type { BootcBuildInfo } from '/@shared/src/models/bootc';
import { getTabUrl, isTabSelected } from '../upstream/Util';
import { historyInfo } from '/@/stores/historyInfo';
export let id: string;
let diskImage: BootcBuildInfo;
let detailsPage: DetailsPage;
onMount(() => {
const actualId = atob(id);
return historyInfo.subscribe(value => {
const matchingImage = value.find(image => image.id === actualId);
if (matchingImage) {
try {
diskImage = matchingImage;
} catch (err) {
console.error(err);
}
} else if (detailsPage) {
// the disk image has been deleted
goToHomePage();
}
});
});
export function goToHomePage(): void {
router.goto('/');
}
</script>

<DetailsPage
bind:this={detailsPage}
title="{diskImage?.image}:{diskImage?.tag}"
breadcrumbLeftPart="Bootable Containers"
breadcrumbRightPart="Disk Image Details"
breadcrumbTitle="Go back to homepage"
onclose={goToHomePage}
onbreadcrumbClick={goToHomePage}>
<DiskImageIcon slot="icon" size="30px" />
<svelte:fragment slot="tabs">
<Tab title="Summary" selected={isTabSelected($router.path, 'summary')} url={getTabUrl($router.path, 'summary')} />
<Tab title="Build Log" selected={isTabSelected($router.path, 'build')} url={getTabUrl($router.path, 'build')} />
</svelte:fragment>
<svelte:fragment slot="content">
<Route path="/summary" breadcrumb="Summary">
<DiskImageDetailsSummary image={diskImage} />
</Route>
<Route path="/build" breadcrumb="Build Log">
<DiskImageDetailsBuild folder={diskImage?.folder} />
</Route>
</svelte:fragment>
</DetailsPage>
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,21 @@

import { render, screen, waitFor } from '@testing-library/svelte';
import { vi, test, expect, beforeAll } from 'vitest';
import Logs from './Logs.svelte';
import { bootcClient } from './api/client';
import DiskImageDetailsBuild from './DiskImageDetailsBuild.svelte';
import { bootcClient } from '/@/api/client';

vi.mock('./api/client', async () => ({
vi.mock('/@/api/client', async () => ({
bootcClient: {
loadLogsFromFolder: vi.fn(),
getConfigurationValue: vi.fn(),
},
rpcBrowser: {
subscribe: () => {
return {
unsubscribe: () => {},
};
},
},
}));

beforeAll(() => {
Expand Down Expand Up @@ -59,10 +66,8 @@ test('Render logs and terminal setup', async () => {
vi.mocked(bootcClient.loadLogsFromFolder).mockResolvedValue(mockLogs);
vi.mocked(bootcClient.getConfigurationValue).mockResolvedValue(14);

const base64FolderLocation = btoa('/path/to/logs');
const base64BuildImageName = btoa('test-image');

render(Logs, { base64FolderLocation, base64BuildImageName });
const folderLocation = '/path/to/logs';
render(DiskImageDetailsBuild, { folder: folderLocation });

// Wait for the logs to be shown
await waitFor(() => {
Expand All @@ -77,12 +82,10 @@ test('Handles empty logs correctly', async () => {
vi.mocked(bootcClient.loadLogsFromFolder).mockResolvedValue('');
vi.mocked(bootcClient.getConfigurationValue).mockResolvedValue(14);

const base64FolderLocation = btoa('/empty/logs');
const base64BuildImageName = btoa('empty-image');

render(Logs, { base64FolderLocation, base64BuildImageName });
const folderLocation = '/empty/logs';
render(DiskImageDetailsBuild, { folder: folderLocation });

// Verify no logs message is displayed when logs are empty
const emptyMessage = await screen.findByText(/Unable to read image-build.log file from \/empty\/logs/);
const emptyMessage = await screen.findByText('Unable to read image-build.log file from /empty/logs');
expect(emptyMessage).toBeDefined();
});
Original file line number Diff line number Diff line change
@@ -1,21 +1,15 @@
<script lang="ts">
import '@xterm/xterm/css/xterm.css';
import { DetailsPage, EmptyScreen, FormPage } from '@podman-desktop/ui-svelte';
import { EmptyScreen } from '@podman-desktop/ui-svelte';
import { FitAddon } from '@xterm/addon-fit';
import { Terminal } from '@xterm/xterm';
import { onDestroy, onMount } from 'svelte';
import { router } from 'tinro';
import DiskImageIcon from './lib/DiskImageIcon.svelte';
import { bootcClient } from './api/client';
import { getTerminalTheme } from './lib/upstream/terminal-theme';
import { bootcClient } from '/@/api/client';
import { getTerminalTheme } from '/@/lib/upstream/terminal-theme';
export let base64FolderLocation: string;
export let base64BuildImageName: string;
// Decode the base64 folder location to a normal string path
const folderLocation = atob(base64FolderLocation);
const buildImageName = atob(base64BuildImageName);
export let folder: string | undefined;
// Log
let logsXtermDiv: HTMLDivElement;
Expand All @@ -31,7 +25,11 @@ let logsTerminal: Terminal;
let logInterval: NodeJS.Timeout;
async function fetchFolderLogs() {
const logs = await bootcClient.loadLogsFromFolder(folderLocation);
if (!folder) {
return;
}
const logs = await bootcClient.loadLogsFromFolder(folder);
// We will write only the new logs to the terminal,
// this is a simple way of updating the logs as we update it by calling the function
Expand Down Expand Up @@ -110,27 +108,16 @@ export function goToHomePage(): void {
}
</script>

<DetailsPage
title="{buildImageName} build logs"
breadcrumbLeftPart="Bootable Containers"
breadcrumbRightPart="{buildImageName} build logs"
breadcrumbTitle="Go back to homepage"
onclose={goToHomePage}
onbreadcrumbClick={goToHomePage}>
<DiskImageIcon slot="icon" size="30px" />
<svelte:fragment slot="content">
<EmptyScreen
icon={undefined}
title="No log file"
message="Unable to read image-build.log file from {folderLocation}"
hidden={noLogs === false} />

<div
class="min-w-full flex flex-col"
class:invisible={noLogs === true}
class:h-0={noLogs === true}
class:h-full={noLogs === false}
bind:this={logsXtermDiv}>
</div>
</svelte:fragment>
</DetailsPage>
<EmptyScreen
icon={undefined}
title="No log file"
message="Unable to read image-build.log file from {folder}"
hidden={noLogs === false} />

<div
class="min-w-full flex flex-col p-[5px] pr-0 bg-[var(--pd-terminal-background)]"
class:invisible={noLogs === true}
class:h-0={noLogs === true}
class:h-full={noLogs === false}
bind:this={logsXtermDiv}>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import '@testing-library/jest-dom/vitest';

import { render, screen } from '@testing-library/svelte';
import { beforeEach, expect, test, vi } from 'vitest';

import type { BootcBuildInfo } from '/@shared/src/models/bootc';
import DiskImageDetailsSummary from './DiskImageDetailsSummary.svelte';

const image: BootcBuildInfo = {
id: 'id1',
image: 'my-image',
imageId: 'image-id',
tag: 'latest',
engineId: 'podman',
type: ['ami'],
folder: '/bootc',
};

vi.mock('/@/api/client', async () => {
return {
rpcBrowser: {
subscribe: () => {
return {
unsubscribe: () => {},
};
},
},
};
});

beforeEach(() => {
vi.resetAllMocks();
vi.clearAllMocks();
});

test('Expect to show image summary', async () => {
render(DiskImageDetailsSummary, { image: image });

expect(screen.getByText(image.image + ':' + image.tag)).toBeInTheDocument();
expect(screen.getByText(image.type[0])).toBeInTheDocument();
expect(screen.getByText(image.folder)).toBeInTheDocument();
});
Loading

0 comments on commit 7b17a94

Please sign in to comment.