feat: add task manager widget (#1724)

* feat: add task manager widget
fixes https://github.com/containers/podman-desktop/issues/1411

Change-Id: Ifa90b6c9ee40d3a239f509dd8b4148bf33f084aa
Signed-off-by: Florent Benoit <fbenoit@redhat.com>
This commit is contained in:
Florent BENOIT 2023-03-18 13:18:02 +01:00 committed by GitHub
parent d2628191b4
commit efb0d8496c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 605 additions and 0 deletions

View file

@ -295,6 +295,21 @@ export class PluginSystem {
statusBarRegistry.setEntry('help', false, 0, undefined, 'Help', 'fa fa-question-circle', true, 'help', undefined);
statusBarRegistry.setEntry(
'tasks',
false,
0,
undefined,
'tasks',
'fa fa-bell',
true,
'show-task-manager',
undefined,
);
commandRegistry.registerCommand('show-task-manager', () => {
apiSender.send('toggle-task-manager', '');
});
statusBarRegistry.setEntry(
'feedback',
false,

View file

@ -35,6 +35,7 @@ import RunImage from './lib/image/RunImage.svelte';
import SendFeedback from './lib/feedback/SendFeedback.svelte';
import ToastHandler from './lib/toast/ToastHandler.svelte';
import QuickPickInput from './lib/dialogs/QuickPickInput.svelte';
import TaskManager from './lib/task-manager/TaskManager.svelte';
router.mode.hash();
@ -87,6 +88,7 @@ window.events?.receive('display-help', () => {
{/if}
<div class="z-0 w-full h-full bg-zinc-800 flex flex-col overflow-y-scroll">
<TaskManager />
<SendFeedback />
<ToastHandler />
<QuickPickInput />

View file

@ -0,0 +1,26 @@
<script lang="ts">
export let size: string = '40';
</script>
<svg
width="{size}"
height="{size}"
class="{$$props.class}"
style="{$$props.style}"
xmlns="http://www.w3.org/2000/svg"
id="screenshot-03ba2b93-cedd-80a6-8002-080e6e29745f"
viewBox="-0 -0 20 16"
fill="none"
version="1.1"
><g id="shape-03ba2b93-cedd-80a6-8002-080e6e29745f" rx="0" ry="0" style="opacity: 0.75; fill: rgb(0, 0, 0);"
><g id="shape-03ba2b93-cedd-80a6-8002-080e6e29747f"
><g class="fills" id="fills-03ba2b93-cedd-80a6-8002-080e6e29747f"
><path
rx="0"
ry="0"
d="M5.737,3.706C6.453,2.581,7.628,1.776,9.000,1.559L9.000,1.000C9.000,0.448,9.447,0.000,10.000,0.000C10.553,0.000,11.000,0.448,11.000,1.000L11.000,1.559C13.266,1.918,15.000,3.881,15.000,6.250L15.000,7.294C15.000,8.712,15.484,10.091,16.369,11.200L16.834,11.781C16.972,11.984,17.044,12.256,16.956,12.500L19.712,14.659C20.037,14.915,20.097,15.387,19.840,15.712C19.584,16.037,19.112,16.097,18.787,15.840L0.287,1.340C-0.039,1.085,-0.096,0.613,0.160,0.287C0.415,-0.039,0.887,-0.096,1.213,0.160L5.737,3.706ZZM6.928,4.637L14.084,10.222C13.700,9.319,13.500,8.316,13.500,7.294L13.500,6.250C13.500,4.456,12.044,3.000,10.250,3.000L9.750,3.000C8.541,3.000,7.487,3.659,6.928,4.637ZL6.928,4.637ZZM5.000,7.294L5.000,6.941L6.459,8.091C6.334,9.303,5.922,10.475,5.259,11.500L10.787,11.500L12.694,13.000L3.750,13.000C3.462,13.000,3.200,12.834,3.074,12.575C2.949,12.315,2.984,12.006,3.166,11.781L3.631,11.200C4.516,10.091,5.000,8.712,5.000,7.294ZL5.000,7.294ZZM12.000,14.000C12.000,14.503,11.791,15.040,11.416,15.415C11.041,15.790,10.503,16.000,10.000,16.000C9.469,16.000,8.959,15.790,8.584,15.415C8.209,15.040,8.000,14.503,8.000,14.000L12.000,14.000ZZ"
style="fill: rgb(255, 255, 255); fill-opacity: 1;"></path
></g
></g
></g
></svg>

View file

@ -0,0 +1,33 @@
<script lang="ts">
export let size: string = '40';
</script>
<svg
width="{size}"
height="{size}"
class="{$$props.class}"
style="{$$props.style}"
viewBox="-0.5 -0.5 16 16"
fill="none"
version="1.1"
><g id="shape-03ba2b93-cedd-80a6-8002-080fc568c1f9" rx="0" ry="0" style="opacity: 0.75; fill: rgb(0, 0, 0);"
><g id="shape-03ba2b93-cedd-80a6-8002-080fc56918fe"
><g class="fills" id="fills-03ba2b93-cedd-80a6-8002-080fc56918fe"
><path
rx="0"
ry="0"
d="M13.125,4.688L9.375,4.688L9.375,3.750L13.125,3.750L13.125,4.688ZZM1.406,1.875C0.630,1.875,0.000,2.505,0.000,3.281L0.000,5.156C0.000,5.933,0.630,6.563,1.406,6.563L13.594,6.563C14.370,6.563,15.000,5.933,15.000,5.156L15.000,3.281C15.000,2.505,14.370,1.875,13.594,1.875L1.406,1.875ZZM13.125,10.313L13.125,11.250L5.625,11.250L5.625,10.313L13.125,10.313ZZM1.406,8.438C0.630,8.438,0.000,9.067,0.000,9.844L0.000,11.719C0.000,12.495,0.630,13.125,1.406,13.125L13.594,13.125C14.370,13.125,15.000,12.495,15.000,11.719L15.000,9.844C15.000,9.067,14.370,8.438,13.594,8.438L1.406,8.438ZZ"
style="fill: rgb(177, 178, 181); fill-opacity: 0;"></path
></g
><g id="strokes-03ba2b93-cedd-80a6-8002-080fc56918fe" class="strokes"
><g class="stroke-shape"
><path
rx="0"
ry="0"
d="M13.125,4.688L9.375,4.688L9.375,3.750L13.125,3.750L13.125,4.688ZZM1.406,1.875C0.630,1.875,0.000,2.505,0.000,3.281L0.000,5.156C0.000,5.933,0.630,6.563,1.406,6.563L13.594,6.563C14.370,6.563,15.000,5.933,15.000,5.156L15.000,3.281C15.000,2.505,14.370,1.875,13.594,1.875L1.406,1.875ZZM13.125,10.313L13.125,11.250L5.625,11.250L5.625,10.313L13.125,10.313ZZM1.406,8.438C0.630,8.438,0.000,9.067,0.000,9.844L0.000,11.719C0.000,12.495,0.630,13.125,1.406,13.125L13.594,13.125C14.370,13.125,15.000,12.495,15.000,11.719L15.000,9.844C15.000,9.067,14.370,8.438,13.594,8.438L1.406,8.438ZZ"
style="fill: none; stroke-width: 1; stroke: rgb(255, 255, 255); stroke-opacity: 1;"></path
></g
></g
></g
></g
></svg>

View file

@ -0,0 +1,145 @@
/**********************************************************************
* 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';
import { beforeAll, test, expect, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/svelte';
import TaskManager from './TaskManager.svelte';
import userEvent from '@testing-library/user-event';
import type { Task } from '/@/stores/tasks';
import { tasksInfo } from '/@/stores/tasks';
// fake the window.events object
beforeAll(() => {
(window.events as unknown) = {
receive: vi.fn(),
};
// reset store
tasksInfo.set([]);
});
const started = new Date().getTime();
const IN_PROGRESS_TASK: Task = { id: '1', name: 'Running Task 1', state: 'running', started, status: 'in-progress' };
const SUCCEED_TASK: Task = { id: '1', name: 'Running Task 1', state: 'completed', started, status: 'success' };
test('Expect that the tasks manager is hidden by default', async () => {
render(TaskManager, {});
// expect the tasks manager is not visible by default
const tasksManager = screen.queryByTitle('Tasks manager');
expect(tasksManager).not.toBeInTheDocument();
});
test('Expect that the tasks manager is visible by property', async () => {
render(TaskManager, { showTaskManager: true });
// expect the tasks manager is visible
const tasksManager = screen.queryByTitle('Tasks manager');
expect(tasksManager).toBeInTheDocument();
});
test('Expect that the tasks manager is hidden if user press the ESC key', async () => {
render(TaskManager, { showTaskManager: true });
// expect the tasks manager is visible
let tasksManager = screen.queryByTitle('Tasks manager');
expect(tasksManager).toBeInTheDocument();
// now, press the ESC key
await userEvent.keyboard('{Escape}');
// expect the tasks manager has been hidden
tasksManager = screen.queryByTitle('Tasks manager');
expect(tasksManager).not.toBeInTheDocument();
});
test('Expect that the tasks manager is hidden if user click on the hide button', async () => {
render(TaskManager, { showTaskManager: true });
// expect the tasks manager is visible
let tasksManager = screen.queryByTitle('Tasks manager');
expect(tasksManager).toBeInTheDocument();
// now, click on the Hide (Escape) button
const hideButton = screen.getByRole('button', { name: 'Hide (Escape)' });
await fireEvent.click(hideButton);
// expect the tasks manager has been hidden
tasksManager = screen.queryByTitle('Tasks manager');
expect(tasksManager).not.toBeInTheDocument();
});
test('Expect no tasks', async () => {
render(TaskManager, { showTaskManager: true });
// expect the "You have no tasks" is visible
const noTaskField = screen.queryByText('You have no tasks.');
expect(noTaskField).toBeInTheDocument();
});
test('Expect tasks', async () => {
tasksInfo.set([IN_PROGRESS_TASK]);
render(TaskManager, { showTaskManager: true });
// expect the "You Have no Tasks" is not visible
const noTaskField = screen.queryByText('You have no tasks.');
expect(noTaskField).not.toBeInTheDocument();
// expect the task is visible
const task = screen.queryByText('Running Task 1');
expect(task).toBeInTheDocument();
});
test('Expect delete completed tasks remove tasks', async () => {
tasksInfo.set([SUCCEED_TASK]);
render(TaskManager, { showTaskManager: true });
// expect the task name is visible
const task = screen.queryByText(SUCCEED_TASK.name);
expect(task).toBeInTheDocument();
// click on the button "Clear completed"
const clearCompletedButton = screen.getByRole('button', { name: 'Clear completed' });
expect(clearCompletedButton).toBeInTheDocument();
await fireEvent.click(clearCompletedButton);
// expect the task name is not visible
const afterTask = screen.queryByText(SUCCEED_TASK.name);
expect(afterTask).not.toBeInTheDocument();
// button is also gone
const afterClearCompletedButton = screen.queryByRole('button', { name: 'Clear completed' });
expect(afterClearCompletedButton).not.toBeInTheDocument();
});
test('Expect click on faClose icon remove the task', async () => {
tasksInfo.set([SUCCEED_TASK]);
render(TaskManager, { showTaskManager: true });
// expect the task name is visible
const task = screen.queryByText(SUCCEED_TASK.name);
expect(task).toBeInTheDocument();
// click on the button with title "Clear notification"
const clearCompletedButton = screen.getByRole('button', { name: 'Clear notification' });
expect(clearCompletedButton).toBeInTheDocument();
await fireEvent.click(clearCompletedButton);
// expect the task name is not visible
const afterTask = screen.queryByText(SUCCEED_TASK.name);
expect(afterTask).not.toBeInTheDocument();
});

View file

@ -0,0 +1,126 @@
<script lang="ts">
import {
faCheck,
faCheckCircle,
faCheckDouble,
faCheckSquare,
faChevronDown,
faCircle,
faInfoCircle,
} from '@fortawesome/free-solid-svg-icons';
import { clearCompletedTasks, tasksInfo } from '/@/stores/tasks';
import Fa from 'svelte-fa/src/fa.svelte';
import BellSlashIcon from '../images/BellSlashIcon.svelte';
import TaskIcon from '../images/TaskIcon.svelte';
import TaskManagerEmptyScreen from './TaskManagerEmptyScreen.svelte';
import TaskManagerGroup from './TaskManagerGroup.svelte';
// display or not the tasks manager (defaut is false)
export let showTaskManager = false;
$: runningTasks = $tasksInfo.filter(task => task.state === 'running');
$: completedTasks = $tasksInfo.filter(task => task.state === 'completed');
// hide the task manager
function hide() {
showTaskManager = false;
}
function clearCompleted() {
// needs to delete the task from the svelte store
clearCompletedTasks();
}
// If we hit ESC while the menu is open, close it
function handleEscape({ key }) {
// if the task manager is not open, do not check any keys
if (!showTaskManager) {
return;
}
if (key === 'Escape') {
showTaskManager = false;
}
}
// listen to the event "toggle-task-manager" to toggle the task manager
window.events?.receive('toggle-task-manager', () => {
showTaskManager = !showTaskManager;
});
</script>
<!-- track keys like "ESC" -->
<svelte:window on:keyup="{handleEscape}" />
{#if showTaskManager}
<div title="Tasks manager" class="fixed bottom-9 right-4 bg-zinc-900 h-96 w-80 z-40 ">
<!-- Draw the arrow below the box-->
<div
class="absolute bottom-0 z-50 right-[17px] transform -translate-x-1/2 translate-y-1/2 rotate-45 w-4 h-4 {$tasksInfo.length >
0
? 'bg-zinc-900'
: 'bg-zinc-800'} border-r border-b border-zinc-600 ">
</div>
<div title="" class="flex flex-col h-full w-full border border-zinc-600">
<!-- header of the task manager -->
<div class="flex flex-row w-full">
<!-- title of bars-->
<div class="p-2 flex flex-row items-center w-full text-gray-300">
<TaskIcon size="15" />
<div class="text-xs uppercase ml-2">tasks</div>
<div class="flex-1"></div>
<!--
<div title="Toggle Do Not Disturb Mode" class="cursor-pointer hover:bg-zinc-800 p-1">
<BellSlashIcon size="15" />
</div>
-->
<button on:click="{() => hide()}" title="Hide (Escape)" class="cursor-pointer hover:bg-zinc-800 p-1 ml-1">
<Fa icon="{faChevronDown}" size="15" />
</button>
</div>
</div>
{#if $tasksInfo.length > 0}
<div class="flex flex-col grow h-[100px] overflow-y-auto">
<!-- running tasks-->
{#if runningTasks.length > 0}
<div class="flex bg-zinc-700 px-4">
<TaskManagerGroup
lineColor="bg-zinc-800"
icon="{faCircle}"
tasks="{runningTasks}"
title="running tasks" />
</div>
{/if}
<!-- completed tasks-->
{#if completedTasks.length > 0}
<div class="flex bg-zinc-800 pt-1 px-4">
<TaskManagerGroup
lineColor="bg-zinc-400"
icon="{faCheck}"
tasks="{completedTasks}"
title="completed tasks" />
</div>
{/if}
</div>
{/if}
<!-- footer of the task manager -->
<!-- only if there are tasks-->
{#if completedTasks.length > 0}
<div class="flex flex-row w-full">
<div class="p-2 flex flex-row space-x-2 w-full text-gray-300">
<button on:click="{() => clearCompleted()}" class="pf-c-button pf-m-secondary">Clear completed</button>
<!--<button class="pf-c-button pf-m-secondary">View task history</button>-->
</div>
</div>
{/if}
<!-- display the empty screen -->
{#if $tasksInfo.length === 0}
<TaskManagerEmptyScreen />
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,11 @@
<script>
import TaskIcon from '../images/TaskIcon.svelte';
</script>
<div class="flex flex-row grow bg-zinc-800 items-center justify-center">
<div class="flex flex-col items-center">
<div class=""><TaskIcon /></div>
<div class="mt-4">You have no tasks.</div>
</div>
</div>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import Fa from 'svelte-fa/src/fa.svelte';
import TaskManagerItem from './TaskManagerItem.svelte';
import type { Task } from '/@/stores/tasks';
export let icon;
export let tasks: Task[];
export let title;
export let lineColor;
// check if the item is the last one
let lastItem = (a: unknown[], i: number) => i == a.length - 1;
</script>
<!-- Display a title and then the list of the tasks -->
<div class="flex flex-col w-full">
<div class="flex flex-row items-center w-full flex-nowrap">
<hr class="w-3 h-[2px] my-3 {lineColor} border-0" />
<div class="flex mx-2 flex-row items-center">
<Fa class="mr-1 text-purple-300" size="7" icon="{icon}" />
<div class="flex-nowrap uppercase font-bold text-xs">{title} ({tasks.length})</div>
</div>
<hr class="flex-grow flex w-max h-[2px] {lineColor} border-0" />
</div>
<div class="w-full">
{#each tasks as task, index}
<TaskManagerItem task="{task}" />
<!-- only if there are more items-->
{#if !lastItem(tasks, index)}
<hr class="w-full h-[1px] border-0 {lineColor}" />
{/if}
{/each}
</div>
</div>

View file

@ -0,0 +1,94 @@
<script lang="ts">
import { faClose, faInfoCircle, faSquareCheck, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import { createEventDispatcher, onMount } from 'svelte';
import Fa from 'svelte-fa/src/fa.svelte';
import { TaskManager, TaskUI } from './task-manager';
import { removeTask, Task } from '/@/stores/tasks';
export let task: Task;
const taskManager = new TaskManager();
const dispatch = createEventDispatcher();
let taskUI: TaskUI;
$: taskUI = taskManager.toTaskUi(task);
let icon;
let iconColor;
onMount(() => {
if (task.status === 'success') {
icon = faSquareCheck;
iconColor = 'text-green-600';
} else if (task.status === 'failure') {
icon = faTriangleExclamation;
iconColor = 'text-red-500';
} else {
icon = faInfoCircle;
iconColor = 'text-purple-500';
}
});
function closeCompleted(taskUI: TaskUI) {
// needs to delete the task from the svelte store
removeTask(taskUI.id);
}
function gotoTask(taskUI: TaskUI) {
// hide the task manager
window.events?.send('toggle-task-manager', '');
// and open the task
taskUI?.gotoTask();
}
</script>
<!-- Display a task item-->
<div class="flex flew-row w-full py-2">
<!-- first column is the icon-->
<div class="flex w-3 {iconColor} justify-center">
<Fa size="14" icon="{icon}" />
</div>
<!-- second column is about the task-->
<div class="flex flex-col w-full pl-2">
<div class="flex flex-row w-full">
<div title="{taskUI.name}" class="w-60 pb-1 cursor-default truncate">{taskUI.name}</div>
<div class="flex flex-col flex-grow items-end">
<!-- if completed task, display a close icon-->
{#if taskUI.state === 'completed'}
<button
title="Clear notification"
class="hover:bg-zinc-900 hover:text-purple-500"
on:click="{() => closeCompleted(taskUI)}"><Fa size="12" icon="{faClose}" /></button>
{/if}
</div>
</div>
<!-- age -->
<div class="text-gray-400 text-xs">{taskUI.age}</div>
<!-- if in-progress task, display a link to resume-->
{#if taskUI.status === 'in-progress'}
<div class="flex flex-row w-full">
{#if taskUI.progress >= 0}
<div class="w-32">
<div class="w-full h-4 mb-4 rounded-full bg-gray-600">
<div class="h-4 bg-purple-500 rounded-full " style="width: {taskUI.progress}%"></div>
</div>
</div>
<div class="ml-2 text-xs">{taskUI.progress}%</div>
{/if}
<div class="flex flex-1 flex-col w-full items-end text-purple-500 text-xs">
{#if taskUI.hasGotoTask}
<button class="text-purple-500 cursor-pointer" on:click="{() => gotoTask(taskUI)}">Go to task ></button>
{/if}
</div>
</div>
{/if}
<!-- if failed task, display the error-->
{#if taskUI.status === 'failure'}
<div class="flex flex-col w-full items-end text-purple-300 text-xs">
<div>View Error ></div>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,53 @@
/**********************************************************************
* 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 type { Task } from '/@/stores/tasks';
import humanizeDuration from 'humanize-duration';
export interface TaskUI extends Task {
age: string;
progress?: number;
hasGotoTask: boolean;
gotoTask?: () => void;
}
export class TaskManager {
toTaskUi(task: Task): TaskUI {
const taskUI: TaskUI = {
id: task.id,
name: task.name,
started: task.started,
state: task.state,
status: task.status,
hasGotoTask: false,
age: `${humanizeDuration(new Date().getTime() - task.started, { round: true, largest: 1 })} ago`,
};
if (task.status === 'in-progress') {
taskUI.progress = task.progress;
if (task.gotoTask) {
taskUI.hasGotoTask = true;
taskUI.gotoTask = task.gotoTask;
} else {
taskUI.hasGotoTask = false;
}
}
return taskUI;
}
}

View file

@ -0,0 +1,66 @@
/**********************************************************************
* 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 type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
export interface Task {
id: string;
name: string;
started: number;
state: 'running' | 'completed';
status: 'in-progress' | 'success' | 'failure';
progress?: number;
gotoTask?: () => void;
}
/**
* Defines the store used to define the tasks.
*/
export const tasksInfo: Writable<Task[]> = writable([]);
// refresh the array every second
setInterval(() => {
tasksInfo.update(tasks => [...tasks]);
}, 1000);
// remove element from the store
export function removeTask(id: string) {
tasksInfo.update(tasks => tasks.filter(task => task.id !== id));
}
// remove element from the store that are completed
export function clearCompletedTasks() {
tasksInfo.update(tasks => tasks.filter(task => task.state !== 'completed'));
}
let taskId = 0;
// create a new task
export function createTask(name: string): Task {
taskId++;
const task: Task = {
id: `${taskId}`,
name,
started: new Date().getTime(),
state: 'running',
status: 'in-progress',
};
tasksInfo.update(tasks => [...tasks, task]);
return task;
}