Skip to content

Commit

Permalink
feat: adds welcome page & pull / build buttons
Browse files Browse the repository at this point in the history
### What does this PR do?

* Updates the welcome / empty screen page so there is no more empty
  descriptions
* Adds a build / pull button for a test iamge
* Added API for opening an eternal link
* If pulling for more than 5 seconds, show a warning

### Screenshot / video of UI

<!-- If this PR is changing UI, please include
screenshots or screencasts showing the difference -->

### What issues does this PR fix or reference?

<!-- Include any related issues from Podman Desktop
repository (or from another issue tracker). -->

Closes #150

### How to test this PR?

<!-- Please explain steps to reproduce -->

Go to the welcome page with no builds, click on the pull / build
buttons.

Signed-off-by: Charlie Drage <[email protected]>
  • Loading branch information
cdrage authored and deboer-tim committed Mar 28, 2024
1 parent b0d4b6e commit 1f4508a
Show file tree
Hide file tree
Showing 7 changed files with 254 additions and 18 deletions.
4 changes: 4 additions & 0 deletions packages/backend/src/api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ export class BootcApiImpl implements BootcApi {
return await podmanDesktopApi.env.openExternal(podmanDesktopApi.Uri.file(folder));
}

async openLink(link: string): Promise<void> {
await podmanDesktopApi.env.openExternal(podmanDesktopApi.Uri.parse(link));
}

async generateUniqueBuildID(name: string): Promise<string> {
return this.history.getUnusedHistoryName(name);
}
Expand Down
12 changes: 7 additions & 5 deletions packages/frontend/src/Homepage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ vi.mock('./api/client', async () => {
return {
bootcClient: {
listHistoryInfo: vi.fn(),
listBootcImages: vi.fn(),
deleteBuilds: vi.fn(),
},
rpcBrowser: {
Expand Down Expand Up @@ -77,17 +78,18 @@ test('Homepage renders correctly with no past builds', async () => {
await waitRender(Homepage);

// No bootable container builds found should be present
expect(screen.queryByText('No bootable container builds found')).not.toBeNull();
// so expect the welcome page
expect(screen.queryByText('Welcome to Bootable Containers')).not.toBeNull();
});

test('Homepage renders correctly with multiple rows', async () => {
vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo);

await waitRender(Homepage);

// Wait until header 'No bootable container builds found' is removed
// Wait until header 'Welcome to Bootable Containers' is removed
// as that means it's fully loaded
while (screen.queryByText('No bootable container builds found')) {
while (screen.queryByText('Welcome to Bootable Containers')) {
await new Promise(resolve => setTimeout(resolve, 100));
}

Expand All @@ -104,9 +106,9 @@ test('Test clicking on delete button', async () => {

await waitRender(Homepage);

// Wait until header 'No bootable container builds found' is removed
// Wait until header 'Welcome to Bootable Containers' is removed
// as that means it's fully loaded
while (screen.queryByText('No bootable container builds found')) {
while (screen.queryByText('Welcome to Bootable Containers')) {
await new Promise(resolve => setTimeout(resolve, 100));
}

Expand Down
114 changes: 109 additions & 5 deletions packages/frontend/src/lib/BootcEmptyScreen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,116 @@
import '@testing-library/jest-dom/vitest';

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

import { expect, test, vi } from 'vitest';
import { bootcClient } from '../api/client';
import BootcEmptyScreen from './BootcEmptyScreen.svelte';
import type { ImageInfo } from '@podman-desktop/api';

const exampleTestImage = `quay.io/bootc-extension/httpd:latest`;

const mockBootcImages: ImageInfo[] = [
{
Id: 'quay.io/bootc-extension/httpd',
RepoTags: [exampleTestImage],
Labels: {
bootc: 'true',
},
engineId: 'engine1',
engineName: 'engine1',
ParentId: 'parent1',
Created: 0,
VirtualSize: 0,
Size: 0,
Containers: 0,
SharedSize: 0,
},
];

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

async function waitRender(customProperties: object): Promise<void> {
const result = render(BootcEmptyScreen, { ...customProperties });
// wait that result.component.$$.ctx[2] is set
while (result.component.$$.ctx[2] === undefined) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}

test('Expect empty screen', async () => {
render(BootcEmptyScreen);
const noDeployments = screen.getByRole('heading', { name: 'No bootable container builds found' });
test('Expect welcome screen header on empty build page', async () => {
vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue([]);
vi.mocked(bootcClient.listBootcImages).mockResolvedValue([]);
await waitRender(BootcEmptyScreen);
const noDeployments = screen.getByRole('heading', { name: 'Welcome to Bootable Containers' });
expect(noDeployments).toBeInTheDocument();
});

test('Expect build image button if example image does not exist', async () => {
vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue([]);
vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockBootcImages);
await waitRender(BootcEmptyScreen);

// Wait until the "Pull image" button DISSAPEARS
while (screen.queryAllByRole('button', { name: 'Pull image' }).length === 1) {
await new Promise(resolve => setTimeout(resolve, 100));
}

// Build image exists since there is the example image in our mocked mockBootcImages
const buildImage = screen.getByRole('button', { name: 'Build image' });
expect(buildImage).toBeInTheDocument();
});

test('Expect pull image button if example image does not exist', async () => {
vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue([]);
vi.mocked(bootcClient.listBootcImages).mockResolvedValue([]);
await waitRender(BootcEmptyScreen);

// Wait until the "Build image" button disappears
while (screen.queryAllByRole('button', { name: 'Build image' }).length === 1) {
await new Promise(resolve => setTimeout(resolve, 100));
}

// Pull image exists since there is no image in our mocked mockBootcImages
const pullImage = screen.getByRole('button', { name: 'Pull image' });
expect(pullImage).toBeInTheDocument();
});

test('Clicking on Pull image button should call bootcClient.pullImage', async () => {
vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue([]);
vi.mocked(bootcClient.listBootcImages).mockResolvedValue([]);
await waitRender(BootcEmptyScreen);

const pullImage = screen.getByRole('button', { name: 'Pull image' });
pullImage.click();
expect(bootcClient.pullImage).toHaveBeenCalled();
});

test('Clicking on Build image button should navigate to the build page', async () => {
vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue([]);
vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockBootcImages);
await waitRender(BootcEmptyScreen);

// Wait until the "Pull image" button disappears
while (screen.queryAllByRole('button', { name: 'Pull image' }).length === 1) {
await new Promise(resolve => setTimeout(resolve, 100));
}

const buildImage = screen.getByRole('button', { name: 'Build image' });
buildImage.click();
const [image, tag] = exampleTestImage.split(':');
expect(window.location.href).toContain(`/build/${encodeURIComponent(image)}/${encodeURIComponent(tag)}`);
});
117 changes: 111 additions & 6 deletions packages/frontend/src/lib/BootcEmptyScreen.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,114 @@
<script lang="ts">
import EmptyScreen from './upstream/EmptyScreen.svelte';
import BootcIcon from './BootcIcon.svelte';
import BootcSelkie from './BootcSelkie.svelte';
import Link from './upstream/Link.svelte';
import { faArrowCircleDown, faCube } from '@fortawesome/free-solid-svg-icons';
import Button from './upstream/Button.svelte';
import { onMount, tick } from 'svelte';
import { bootcClient, rpcBrowser } from '../api/client';
import { Messages } from '/@shared/src/messages/Messages';
import { router } from 'tinro';
let pullInProgress = false;
let imageExists = false;
let displayDisclaimer = false;
let bootcAvailableImages: any[] = [];
const exampleImage = 'quay.io/bootc-extension/httpd:latest';
const bootcImageBuilderSite = 'https://github.com/osbuild/bootc-image-builder';
const bootcSite = 'https://containers.github.io/bootc/';
const centosBootcSite = 'https://github.com/CentOS/centos-bootc';
const extensionSite = 'https://github.com/containers/podman-desktop-extension-bootc';
async function gotoBuild(): Promise<void> {
// Split the image name to get the image name and tag
// and go to /build/:image/:tag
// this will pre-select the image and tag in the build screen
const [image, tag] = exampleImage.split(':');
router.goto(`/build/${encodeURIComponent(image)}/${encodeURIComponent(tag)}`);
}
async function pullExampleImage() {
pullInProgress = true;
displayDisclaimer = false;
bootcClient.pullImage(exampleImage);
// After 5 seconds, check if pull is still in progress and display disclaimer if true
setTimeout(async () => {
if (pullInProgress) {
displayDisclaimer = true;
await tick(); // Ensure UI updates to reflect the new state
}
}, 5000);
}
onMount(async () => {
bootcAvailableImages = await bootcClient.listBootcImages();
return rpcBrowser.subscribe(Messages.MSG_IMAGE_PULL_UPDATE, async msg => {
if (msg.image === exampleImage) {
pullInProgress = !msg.success;
if (!pullInProgress) {
displayDisclaimer = false; // Ensure disclaimer is removed when not in progress
}
}
// Update the list of available images after a successful pull
if (msg.success) {
bootcAvailableImages = await bootcClient.listBootcImages();
}
});
});
// Each time bootcAvailableImages updates, check if 'quay.io/bootc-extension/httpd' is in RepoTags
$: {
if (bootcAvailableImages && bootcAvailableImages.some(image => image.RepoTags.includes(exampleImage))) {
imageExists = true;
}
}
</script>

<EmptyScreen
icon="{BootcIcon}"
title="No bootable container builds found"
message="Start your first build by clicking the 'Build' button" />
<div class="flex w-full h-full justify-center items-center">
<div class="flex flex-col h-full items-center text-center space-y-3">
<!-- Bootable Container Icon -->
<div class="text-gray-700 py-2">
<svelte:component this="{BootcSelkie}" size="120" />
</div>

<h1 class="text-xl pb-4">Welcome to Bootable Containers</h1>

<p class="text-gray-700 pb-4 max-w-xl">
Bootable Containers builds an entire bootable OS from your container image. Utilizing the technology of a
<Link externalRef="{centosBootcSite}">compatible image</Link>, <Link externalRef="{bootcImageBuilderSite}"
>bootc-image-builder</Link
>, and <Link externalRef="{bootcSite}">bootc</Link>, your container image is transformed into a bootable disk
image.
</p>

<p class="text-gray-700 pb-1 max-w-xl">
Create your first disk image by {imageExists ? 'building' : 'pulling'} the <Link
externalRef="{`https://${exampleImage}`}">example container image</Link
>:
</p>

<!-- Build / pull buttons -->
{#if imageExists}
<Button on:click="{() => gotoBuild()}" icon="{faCube}" aria-label="Build image" title="Build"
>Build {exampleImage}</Button>
{:else}
<Button
on:click="{() => pullExampleImage()}"
icon="{faArrowCircleDown}"
inProgress="{pullInProgress}"
aria-label="Pull image"
title="Pull image">Pull {exampleImage}</Button>
{/if}
{#if displayDisclaimer}
<p class="text-amber-500 text-xs">The file size of the image is over 1.5GB and may take a while to download.</p>
{/if}

<p class="text-gray-700 pt-8 max-w-xl">
Want to learn more including building your own Containerfile? Check out the <Link externalRef="{extensionSite}"
>extension documentation</Link
>.
</p>
</div>
</div>
20 changes: 20 additions & 0 deletions packages/frontend/src/lib/BootcSelkie.svelte

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/frontend/src/lib/upstream/Link.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { onMount, createEventDispatcher } from 'svelte';
import Fa from 'svelte-fa';
import { router } from 'tinro';
import { bootcClient } from '/@/api/client';
export let internalRef: string | undefined = undefined;
export let externalRef: string | undefined = undefined;
Expand All @@ -23,8 +24,7 @@ function click() {
if (internalRef) {
router.goto(internalRef);
} else if (externalRef) {
// TODO: Does not work at the moment
//window.openExternal(externalRef);
bootcClient.openLink(externalRef);
} else {
dispatch('click');
}
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/BootcAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ export abstract class BootcApi {
abstract listHistoryInfo(): Promise<BootcBuildInfo[]>;
abstract openFolder(folder: string): Promise<boolean>;
abstract generateUniqueBuildID(name: string): Promise<string>;
abstract openLink(link: string): Promise<void>;
}

0 comments on commit 1f4508a

Please sign in to comment.