feat: add status dots for pods (#4646)

* feat: add status dots for pods

### What does this PR do?

* Adds dots for the containers when listing a pod (podman or kubernetes)
* Hover to see the tooltip of the container name as well as the running
  status
* Dots will change when the container status has changed.
* Fixes a small redirection error bug when clicking a kubernetes pod

### Screenshot/screencast of this PR

<!-- Please include a screenshot or a screencast
explaining what is doing this PR -->

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

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

Closes https://github.com/containers/podman-desktop/issues/3925
Closes https://github.com/containers/podman-desktop/issues/4612

### How to test this PR?

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

Signed-off-by: Charlie Drage <charlie@charliedrage.com>

* update based on review

Signed-off-by: Charlie Drage <charlie@charliedrage.com>

* add outline dots

Signed-off-by: Charlie Drage <charlie@charliedrage.com>

---------

Signed-off-by: Charlie Drage <charlie@charliedrage.com>
This commit is contained in:
Charlie Drage 2023-11-09 16:32:20 -05:00 committed by GitHub
parent 39d36d7465
commit 2da3f543dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 648 additions and 34 deletions

View file

@ -59,14 +59,14 @@ import type { Telemetry } from '/@/plugin/telemetry/telemetry.js';
function toContainerStatus(state: V1ContainerState | undefined): string {
if (state) {
if (state.running) {
return 'Running';
return 'running';
} else if (state.terminated) {
return 'Terminated';
return 'terminated';
} else if (state.waiting) {
return 'Waiting';
return 'waiting';
}
}
return 'Unknown';
return 'unknown';
}
function toPodInfo(pod: V1Pod, contextName?: string): PodInfo {

View file

@ -23,6 +23,14 @@ export enum PodGroupInfoTypeUI {
export interface PodInfoContainerUI {
Id: string;
Names: string;
// This is a bit odd at the moment as we use the same PodInfoContainerUI for both Kubernetes and Podman Pods.
// For PODS:
// Status will either be: stopped, running, paused, exited, dead, created, degraded
// https://docs.podman.io/en/latest/_static/api.html#tag/pods/operation/PodListLibpod
// For Kubernetes:
// Status will either be: running, waiting, terminated
// see the toContainerStatus function in kubernetes-client.ts
Status: string;
}

View file

@ -62,7 +62,24 @@ const provider: ProviderInfo = {
const pod1: PodInfo = {
Cgroup: '',
Containers: [],
// Three containers within the pod, one running, one terminated, one exited
Containers: [
{
Names: 'container1',
Id: 'container1',
Status: 'running',
},
{
Names: 'container2',
Id: 'container2',
Status: 'terminated',
},
{
Names: 'container3',
Id: 'container3',
Status: 'exited',
},
],
Created: '',
Id: 'beab25123a40',
InfraId: 'pod1',
@ -78,7 +95,13 @@ const pod1: PodInfo = {
const pod2: PodInfo = {
Cgroup: '',
Containers: [],
Containers: [
{
Names: 'container4',
Id: 'container4',
Status: 'running',
},
],
Created: '',
Id: 'e8129c5720b3',
InfraId: 'pod2',
@ -92,9 +115,90 @@ const pod2: PodInfo = {
kind: 'podman',
};
// Pod with 11 containers that shows all the different statuses
// running, terminated, waiting, stopped, paused, exited, dead, created, degraded
// this makes it so that we "group" them as more than 10 containers equals grouping
const manyPod: PodInfo = {
Cgroup: '',
Containers: [
{
Names: 'container1',
Id: 'container1',
Status: 'running',
},
{
Names: 'container2',
Id: 'container2',
Status: 'terminated',
},
{
Names: 'container3',
Id: 'container3',
Status: 'waiting',
},
{
Names: 'container4',
Id: 'container4',
Status: 'stopped',
},
{
Names: 'container5',
Id: 'container5',
Status: 'paused',
},
{
Names: 'container6',
Id: 'container6',
Status: 'exited',
},
{
Names: 'container7',
Id: 'container7',
Status: 'dead',
},
{
Names: 'container8',
Id: 'container8',
Status: 'created',
},
{
Names: 'container9',
Id: 'container9',
Status: 'degraded',
},
{
Names: 'container10',
Id: 'container10',
Status: 'running',
},
{
Names: 'container11',
Id: 'container11',
Status: 'running',
},
],
Created: '',
Id: 'beab25123a40',
InfraId: 'manyPod',
Labels: {},
Name: 'manyPod',
Namespace: '',
Networks: [],
Status: 'running',
engineId: 'podman',
engineName: 'podman',
kind: 'podman',
};
const kubepod1: PodInfo = {
Cgroup: '',
Containers: [],
Containers: [
{
Names: 'container1',
Id: 'container1',
Status: 'running',
},
],
Created: '',
Id: 'beab25123a40',
InfraId: 'kubepod1',
@ -110,7 +214,13 @@ const kubepod1: PodInfo = {
const kubepod2: PodInfo = {
Cgroup: '',
Containers: [],
Containers: [
{
Names: 'container1',
Id: 'container1',
Status: 'running',
},
],
Created: '',
Id: 'e8129c5720b3',
InfraId: 'kubepod2',
@ -126,7 +236,13 @@ const kubepod2: PodInfo = {
const ocppod: PodInfo = {
Cgroup: '',
Containers: [],
Containers: [
{
Names: 'container1',
Id: 'container1',
Status: 'running',
},
],
Created: '',
Id: 'e8129c5720b3',
InfraId: 'ocppod',
@ -156,6 +272,14 @@ beforeAll(() => {
getContributedMenusMock.mockImplementation(() => Promise.resolve([]));
});
async function waitRender(customProperties: object): Promise<void> {
const result = render(PodsList, { ...customProperties });
// wait that result.component.$$.ctx[2] is set
while (result.component.$$.ctx[2] === undefined) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
test('Expect no pods being displayed', async () => {
getProvidersInfoMock.mockResolvedValue([provider]);
window.dispatchEvent(new CustomEvent('provider-lifecycle-change'));
@ -185,10 +309,12 @@ test('Expect single podman pod being displayed', async () => {
}
render(PodsList);
const pod1Details = screen.getByRole('cell', { name: 'pod1 beab2512 0 container' });
const pod1Details = screen.getByRole('cell', { name: 'pod1 beab2512' });
expect(pod1Details).toBeInTheDocument();
// Expect to have three "tooltips" which are the "dots".
const pod1Row = screen.getByRole('row', {
name: 'Toggle pod pod1 beab2512 0 container podman 0 seconds spinner spinner spinner',
name: 'Toggle pod pod1 beab2512 podman tooltip tooltip tooltip 0 seconds spinner spinner spinner',
});
expect(pod1Row).toBeInTheDocument();
});
@ -209,17 +335,14 @@ test('Expect 2 podman pods being displayed', async () => {
}
render(PodsList);
const pod1Details = screen.getByRole('cell', { name: 'pod1 beab2512 0 container' });
const pod1Details = screen.getByRole('cell', { name: 'pod1 beab2512' });
expect(pod1Details).toBeInTheDocument();
const pod1Row = screen.getByRole('row', {
name: 'Toggle pod pod1 beab2512 0 container podman 0 seconds spinner spinner spinner',
name: 'Toggle pod pod1 beab2512 podman tooltip tooltip tooltip 0 seconds spinner spinner spinner',
});
expect(pod1Row).toBeInTheDocument();
const pod2Details = screen.getByRole('cell', { name: 'pod2 e8129c57 0 container' });
expect(pod2Details).toBeInTheDocument();
const pod2Row = screen.getByRole('row', {
name: 'Toggle pod pod2 e8129c57 0 container podman 0 seconds spinner spinner spinner',
name: 'Toggle pod pod2 e8129c57 podman tooltip 0 seconds spinner spinner spinner',
});
expect(pod2Row).toBeInTheDocument();
});
@ -241,7 +364,7 @@ test('Expect single kubernetes pod being displayed', async () => {
render(PodsList);
const pod1Details = screen.getByRole('row', {
name: 'Toggle pod kubepod1 beab2512 0 container kubernetes 0 seconds spinner',
name: 'Toggle pod kubepod1 beab2512 kubernetes tooltip 0 seconds spinner',
});
expect(pod1Details).toBeInTheDocument();
});
@ -263,11 +386,11 @@ test('Expect 2 kubernetes pods being displayed', async () => {
render(PodsList);
const pod1Details = screen.getByRole('row', {
name: 'Toggle pod kubepod1 beab2512 0 container kubernetes 0 seconds spinner',
name: 'Toggle pod kubepod1 beab2512 kubernetes tooltip 0 seconds spinner',
});
expect(pod1Details).toBeInTheDocument();
const pod2Details = screen.getByRole('row', {
name: 'Toggle pod kubepod2 e8129c57 0 container kubernetes 0 seconds spinner',
name: 'Toggle pod kubepod2 e8129c57 kubernetes tooltip 0 seconds spinner',
});
expect(pod2Details).toBeInTheDocument();
});
@ -311,11 +434,11 @@ test('Expect the route to a pod details page is correctly encoded with an engine
await new Promise(resolve => setTimeout(resolve, 500));
}
render(PodsList);
const podDetails = screen.getByRole('cell', { name: 'ocppod e8129c57 0 container' });
const podDetails = screen.getByRole('cell', { name: 'ocppod e8129c57' });
expect(podDetails).toBeInTheDocument();
const podRow = screen.getByRole('row', {
name: 'Toggle pod ocppod e8129c57 0 container kubernetes 0 seconds spinner',
name: 'Toggle pod ocppod e8129c57 kubernetes tooltip 0 seconds spinner',
});
expect(podRow).toBeInTheDocument();
@ -326,3 +449,92 @@ test('Expect the route to a pod details page is correctly encoded with an engine
'/pods/kubernetes/ocppod/userid-dev%2Fapi-sandbox-123-openshiftapps-com%3A6443%2FuserId/logs',
);
});
test('Expect the pod1 row to have 3 status dots with the correct colors and the pod2 row to have 1 status dot', async () => {
getProvidersInfoMock.mockResolvedValue([provider]);
listPodsMock.mockResolvedValue([pod1, pod2]);
kubernetesListPodsMock.mockResolvedValue([]);
window.dispatchEvent(new CustomEvent('provider-lifecycle-change'));
window.dispatchEvent(new CustomEvent('extensions-already-started'));
while (get(providerInfos).length !== 1) {
await new Promise(resolve => setTimeout(resolve, 500));
}
while (get(podsInfos).length !== 2) {
await new Promise(resolve => setTimeout(resolve, 500));
}
waitRender(PodsList);
// Should render 4 status dots.
// 3 for the first pod, 1 for the second pod
// this should also appear REORGANIZED and in a different order.
const statusDots = screen.getAllByTestId('status-dot');
expect(statusDots.length).toBe(4);
expect(statusDots[0].title).toBe('container1: Running');
expect(statusDots[0]).toHaveClass('bg-status-running');
expect(statusDots[1].title).toBe('container3: Exited');
expect(statusDots[1]).toHaveClass('outline-status-exited');
expect(statusDots[2].title).toBe('container2: Terminated');
expect(statusDots[2]).toHaveClass('bg-status-terminated');
// 2nd row / 2nd pod
expect(statusDots[3].title).toBe('container4: Running');
expect(statusDots[3]).toHaveClass('bg-status-running');
});
test('Expect the manyPod row to show 9 dots representing every status', async () => {
getProvidersInfoMock.mockResolvedValue([provider]);
listPodsMock.mockResolvedValue([manyPod]);
kubernetesListPodsMock.mockResolvedValue([]);
window.dispatchEvent(new CustomEvent('provider-lifecycle-change'));
window.dispatchEvent(new CustomEvent('extensions-already-started'));
while (get(providerInfos).length !== 1) {
await new Promise(resolve => setTimeout(resolve, 500));
}
while (get(podsInfos).length !== 1) {
await new Promise(resolve => setTimeout(resolve, 500));
}
waitRender(PodsList);
// Should render 9 status dots representing all statuses from the 11 containers provided
// due to the functoin organizeContainers it will be reorganized and the order will be different
// it should be organized as follows:
// running, created, paused, waiting, degraded, exited, stopped, terminated, dead
const statusDots = screen.getAllByTestId('status-dot');
expect(statusDots.length).toBe(9);
expect(statusDots[0].title).toBe('Running: 3');
expect(statusDots[0]).toHaveClass('bg-status-running');
expect(statusDots[1].title).toBe('Created: 1');
expect(statusDots[1]).toHaveClass('outline-status-created');
expect(statusDots[2].title).toBe('Paused: 1');
expect(statusDots[2]).toHaveClass('bg-status-paused');
expect(statusDots[3].title).toBe('Waiting: 1');
expect(statusDots[3]).toHaveClass('bg-status-waiting');
expect(statusDots[4].title).toBe('Degraded: 1');
expect(statusDots[4]).toHaveClass('bg-status-degraded');
expect(statusDots[5].title).toBe('Exited: 1');
expect(statusDots[5]).toHaveClass('outline-status-exited');
expect(statusDots[6].title).toBe('Stopped: 1');
expect(statusDots[6]).toHaveClass('outline-status-stopped');
expect(statusDots[7].title).toBe('Terminated: 1');
expect(statusDots[7]).toHaveClass('bg-status-terminated');
expect(statusDots[8].title).toBe('Dead: 1');
expect(statusDots[8]).toHaveClass('bg-status-dead');
});

View file

@ -25,6 +25,7 @@ import Button from '../ui/Button.svelte';
import { faTrash } from '@fortawesome/free-solid-svg-icons';
import StateChange from '../ui/StateChange.svelte';
import ProviderInfo from '../ui/ProviderInfo.svelte';
import Dots from '../ui/Dots.svelte';
export let searchTerm = '';
$: searchPattern.set(searchTerm);
@ -235,6 +236,7 @@ function errorCallback(pod: PodInfoUI, errorMessage: string): void {
<th class="text-center font-extrabold w-10 px-2">Status</th>
<th>Name</th>
<th class="pl-3">Environment</th>
<th class="pl-3">Containers</th>
<th class="whitespace-nowrap px-6">Age</th>
<th class="text-right pr-2">Actions</th>
</tr>
@ -259,12 +261,6 @@ function errorCallback(pod: PodInfoUI, errorMessage: string): void {
</div>
<div class="flex flex-row items-center">
<div class="text-xs text-violet-400">{pod.shortId}</div>
<button
class="ml-1 text-xs font-extra-light text-gray-900"
class:cursor-pointer="{pod.containers.length > 0}"
on:click="{() => openContainersFromPod(pod)}">
{pod.containers.length} container{pod.containers.length > 1 ? 's' : ''}
</button>
</div>
</div>
</div>
@ -275,6 +271,21 @@ function errorCallback(pod: PodInfoUI, errorMessage: string): void {
</div>
</td>
<td class="pl-3 whitespace-nowrap">
<!-- If this is podman, make the dots clickable as it'll take us to the container menu
this does not work if you click on a kubernetes type pod -->
{#if pod.kind === 'podman'}
<button
class:cursor-pointer="{pod.containers.length > 0}"
on:click="{() => openContainersFromPod(pod)}">
<Dots containers="{pod.containers}" />
</button>
{:else}
<div class="flex items-center">
<Dots containers="{pod.containers}" />
</div>
{/if}
</td>
<td class="px-6 py-2 whitespace-nowrap w-10">
<div class="flex items-center">
<div class="text-sm text-gray-700">

View file

@ -0,0 +1,118 @@
/**********************************************************************
* Copyright (C) 2022 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 { test, expect } from 'vitest';
import { getStatusColor, organizeContainers } from './Dots';
import type { PodInfoContainerUI } from '../pod/PodInfoUI';
// Mock a PodInfoContainerUI object that contains containers of all the different statuses
// running, terminated, waiting, stopped, paused, exited, dead, created, degraded
const mockContainers: PodInfoContainerUI[] = [
{
Id: '1',
Names: 'container1',
Status: 'running',
},
{
Id: '2',
Names: 'container2',
Status: 'terminated',
},
{
Id: '3',
Names: 'container3',
Status: 'waiting',
},
{
Id: '4',
Names: 'container4',
Status: 'stopped',
},
{
Id: '5',
Names: 'container5',
Status: 'paused',
},
{
Id: '6',
Names: 'container6',
Status: 'exited',
},
{
Id: '7',
Names: 'container7',
Status: 'dead',
},
{
Id: '8',
Names: 'container8',
Status: 'created',
},
{
Id: '9',
Names: 'container9',
Status: 'degraded',
},
];
test('test getStatusColor returns the correct colors', () => {
expect(getStatusColor('running')).toBe('bg-status-running');
expect(getStatusColor('terminated')).toBe('bg-status-terminated');
expect(getStatusColor('waiting')).toBe('bg-status-waiting');
expect(getStatusColor('stopped')).toBe('outline-status-stopped');
expect(getStatusColor('paused')).toBe('bg-status-paused');
expect(getStatusColor('exited')).toBe('outline-status-exited');
expect(getStatusColor('dead')).toBe('bg-status-dead');
expect(getStatusColor('created')).toBe('outline-status-created');
expect(getStatusColor('degraded')).toBe('bg-status-degraded');
expect(getStatusColor('unknown')).toBe('bg-status-unknown');
});
test('test organizeContainers returns a record of containers organized by status', () => {
const organizedContainers = organizeContainers(mockContainers);
expect(organizedContainers.running.length).toBe(1);
expect(organizedContainers.terminated.length).toBe(1);
expect(organizedContainers.waiting.length).toBe(1);
expect(organizedContainers.stopped.length).toBe(1);
expect(organizedContainers.paused.length).toBe(1);
expect(organizedContainers.exited.length).toBe(1);
expect(organizedContainers.dead.length).toBe(1);
expect(organizedContainers.created.length).toBe(1);
expect(organizedContainers.degraded.length).toBe(1);
});
test('randomly re-order the containers and ensure they are still organized correctly after in the correct order', () => {
// Copy mockContainers array and shuffle it
const shuffledContainers = [...mockContainers].sort(() => Math.random() - 0.5);
// Organize the shuffled containers
const organizedContainers = organizeContainers(shuffledContainers);
// Make sure it's corrected ordered by:
// running, created, paused, waiting, degraded, exited, stopped, terminated, dead
expect(organizedContainers.running[0].Id).toBe('1');
expect(organizedContainers.created[0].Id).toBe('8');
expect(organizedContainers.paused[0].Id).toBe('5');
expect(organizedContainers.waiting[0].Id).toBe('3');
expect(organizedContainers.degraded[0].Id).toBe('9');
expect(organizedContainers.exited[0].Id).toBe('6');
expect(organizedContainers.stopped[0].Id).toBe('4');
expect(organizedContainers.terminated[0].Id).toBe('2');
expect(organizedContainers.dead[0].Id).toBe('7');
});

View file

@ -0,0 +1,26 @@
<script lang="ts">
import type { PodInfoContainerUI } from '../pod/PodInfoUI';
import StatusDot from './StatusDot.svelte';
import { organizeContainers } from './Dots';
import { capitalize } from './Util';
// All the possible statuses that will appear for both Pods and Kubernetes
export let containers: PodInfoContainerUI[];
$: organizedContainers = organizeContainers(containers);
</script>
<!-- If containers is more than 10, we will group them and show the number of containers -->
{#if containers.length > 10}
{#each Object.entries(organizedContainers) as [status, c]}
{#if c.length > 0}
<StatusDot status="{status}" tooltip="{capitalize(status)}: {c.length}" number="{c.length}" />
{/if}
{/each}
{:else}
{#each Object.entries(organizedContainers) as [status, c]}
{#each c as container}
<StatusDot status="{status}" name="{container.Names}" />
{/each}
{/each}
{/if}

View file

@ -0,0 +1,62 @@
import type { PodInfoContainerUI } from '../pod/PodInfoUI';
const allStatuses = ['running', 'created', 'paused', 'waiting', 'degraded', 'exited', 'stopped', 'terminated', 'dead'];
// All the possible statuses that will appear for both Pods and Kubernetes
// NOTE: See: https://tailwindcss.com/docs/content-configuration#dynamic-class-names
// we cannot do "partial" names like referencing 'bg-'+status because it will
// not be shown due to how svelte handles dynamic class names
export function getStatusColor(status: string): string {
// Define the mapping directly with Record
// must be either "bg-" or "outline-" for either solid / outline colors
const colors: Record<string, string> = {
// Podman & Kubernetes
running: 'bg-status-running',
// Kubernetes-only
terminated: 'bg-status-terminated',
waiting: 'bg-status-waiting',
// Podman-only
stopped: 'outline-status-stopped',
paused: 'bg-status-paused',
exited: 'outline-status-exited',
dead: 'bg-status-dead',
created: 'outline-status-created',
degraded: 'bg-status-degraded',
};
// Return the corresponding color class or a default if not found
return colors[status] || 'bg-status-unknown';
}
// Organize the containers by returning their status as the key + an array of containers by order of
// highest importance (running) to lowest (dead)
export function organizeContainers(containers: PodInfoContainerUI[]): Record<string, PodInfoContainerUI[]> {
const organizedContainers: Record<string, PodInfoContainerUI[]> = {
running: [],
created: [],
paused: [],
waiting: [],
degraded: [],
exited: [],
stopped: [],
terminated: [],
dead: [],
};
containers.forEach(container => {
const statusKey = container.Status.toLowerCase();
if (!organizedContainers[statusKey]) {
organizedContainers[statusKey] = [container];
} else {
organizedContainers[statusKey].push(container);
}
});
allStatuses.forEach(status => {
organizedContainers[status] = organizedContainers[status] || [];
});
return organizedContainers;
}

View file

@ -3,6 +3,7 @@ import type { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import type { IConnectionStatus } from '../preferences/Util';
import LoadingIcon from './LoadingIcon.svelte';
import Tooltip from './Tooltip.svelte';
import { capitalize } from './Util';
export let action: string;
export let icon: IconDefinition;
@ -19,10 +20,6 @@ $: disable =
$: loading = state?.inProgress && action === state?.action;
function capitalizeFirstLetter(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}
function getStyleByState(state: IConnectionStatus | undefined, action: string) {
if (
(action === 'start' && (state?.inProgress || state?.status !== 'stopped')) ||
@ -36,9 +33,9 @@ function getStyleByState(state: IConnectionStatus | undefined, action: string) {
}
</script>
<Tooltip tip="{capitalizeFirstLetter(action)}" bottom>
<Tooltip tip="{capitalize(action)}" bottom>
<button
aria-label="{capitalizeFirstLetter(action)}"
aria-label="{capitalize(action)}"
class="mx-2.5 my-2 {getStyleByState(state, action)}"
on:click="{clickAction}"
disabled="{disable}">

View file

@ -0,0 +1,68 @@
import '@testing-library/jest-dom/vitest';
import { test, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import StatusDot from './StatusDot.svelte';
const renderStatusDot = (containerStatus: string) => {
return render(StatusDot, { name: 'foobar', status: containerStatus });
};
test('Expect the dot to have the correct color for running status', () => {
renderStatusDot('running');
const dot = screen.getByTestId('status-dot');
expect(dot).toHaveClass('bg-status-running');
});
test('Expect the dot to have the correct color for terminated status', () => {
renderStatusDot('terminated');
const dot = screen.getByTestId('status-dot');
expect(dot).toHaveClass('bg-status-terminated');
});
test('Expect the dot to have the correct color for waiting status', () => {
renderStatusDot('waiting');
const dot = screen.getByTestId('status-dot');
expect(dot).toHaveClass('bg-status-waiting');
});
test('Expect the dot to have the correct color for stopped status', () => {
renderStatusDot('stopped');
const dot = screen.getByTestId('status-dot');
expect(dot).toHaveClass('outline-status-stopped');
});
test('Expect the dot to have the correct color for paused status', () => {
renderStatusDot('paused');
const dot = screen.getByTestId('status-dot');
expect(dot).toHaveClass('bg-status-paused');
});
test('Expect the dot to have the correct color for exited status', () => {
renderStatusDot('exited');
const dot = screen.getByTestId('status-dot');
expect(dot).toHaveClass('outline-status-exited');
});
test('Expect the dot to have the correct color for dead status', () => {
renderStatusDot('dead');
const dot = screen.getByTestId('status-dot');
expect(dot).toHaveClass('bg-status-dead');
});
test('Expect the dot to have the correct color for created status', () => {
renderStatusDot('created');
const dot = screen.getByTestId('status-dot');
expect(dot).toHaveClass('outline-status-created');
});
test('Expect the dot to have the correct color for degraded status', () => {
renderStatusDot('degraded');
const dot = screen.getByTestId('status-dot');
expect(dot).toHaveClass('bg-status-degraded');
});
test('Expect the dot to have the correct color for unknown status', () => {
renderStatusDot('unknown');
const dot = screen.getByTestId('status-dot');
expect(dot).toHaveClass('bg-status-unknown');
});

View file

@ -0,0 +1,37 @@
<!-- StatusDot.svelte -->
<script lang="ts">
import Tooltip from './Tooltip.svelte';
import { getStatusColor } from './Dots';
import { capitalize } from './Util';
export let status: string;
export let name: string = '';
export let tooltip: string = '';
export let number: number = 0;
// If the tooltip is blank, use the container name and status
// as the tooltip / title
if (tooltip === '' && name !== '' && status !== '') {
tooltip = `${name}: ${capitalize(status)}`;
}
// Get the color class for the status
// that could be either an outline or a fill
let dotClass = getStatusColor(status);
// If dotClass contains "outline", then we will use 'outline-1 outline-offset-[-2px]
</script>
<Tooltip tip="{tooltip}" top>
<div
class="w-2.5 h-2.5 mr-0.5 rounded-full text-center {dotClass.includes('outline')
? 'outline-2 outline-offset-[-2px] outline'
: ''} {getStatusColor(status)} {number ? 'mt-3' : ''}"
data-testid="status-dot"
title="{tooltip}">
</div>
<!-- If text -->
{#if number}
<div class="text-xs text-bold text-gray-600 mr-0.5">{number}</div>
{/if}
</Tooltip>

View file

@ -0,0 +1,27 @@
/**********************************************************************
* Copyright (C) 2023 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 { test, expect } from 'vitest';
import { capitalize } from './Util';
test('test capitalize function', () => {
expect(capitalize('test')).toBe('Test');
expect(capitalize('Test')).toBe('Test');
expect(capitalize('TEST')).toBe('TEST');
});

View file

@ -0,0 +1,21 @@
/**********************************************************************
* Copyright (C) 2023 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
***********************************************************************/
export function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}

View file

@ -43,6 +43,33 @@ module.exports = {
},
},
colors: {
// The "status" colours to be used for Podman and Kubernetes containers
// these can be referenced by in the form of "bg-status-running" or "text-status-running"
'status': {
// Podman & Kubernetes
'running': tailwindColors.green[400],
// Kubernetes only
'terminated': tailwindColors.red[500],
'waiting': tailwindColors.amber[600],
// Podman only
// Stopped & Exited are the same color / same thing in the eyes of statuses
'stopped': tailwindColors.gray[300],
'exited': tailwindColors.gray[300],
// "Warning"
'paused': tailwindColors.amber[600],
'degraded': tailwindColors.amber[700],
// Others
'created': tailwindColors.green[300],
'dead': tailwindColors.red[500],
// If we don't know the status, use gray
'unknown': tailwindColors.gray[100],
},
'charcoal': {
50: '#767676',
100: '#707073',