Skip to content

Commit

Permalink
feat: display notification on task manager (podman-desktop#4406) (pod…
Browse files Browse the repository at this point in the history
…man-desktop#4811)

* feat: display notification on task manager (podman-desktop#4406)

Signed-off-by: lstocchi <[email protected]>

* fix: fix tests

Signed-off-by: lstocchi <[email protected]>

* fix: add tests

Signed-off-by: lstocchi <[email protected]>

* fix: fix based on review

Signed-off-by: lstocchi <[email protected]>

* fix: fix tests

Signed-off-by: lstocchi <[email protected]>

---------

Signed-off-by: lstocchi <[email protected]>
  • Loading branch information
lstocchi authored Nov 24, 2023
1 parent a64d852 commit 2fa8bbf
Show file tree
Hide file tree
Showing 16 changed files with 530 additions and 88 deletions.
9 changes: 6 additions & 3 deletions packages/main/src/plugin/api/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,18 @@

type NotificationType = 'info' | 'warn' | 'error';

export interface NotificationCardOptions {
extensionId: string;
export interface NotificationInfo {
// title displayed on the top of the notification card
title: string;
// description displayed just below the title, it should explain what the notification is about
body?: string;
type: NotificationType;
// displayed below the description, centered in the notification card. It may contains actions (like commands/buttons and links)
markdownActions?: string;
}

export interface NotificationCardOptions extends NotificationInfo {
extensionId: string;
type: NotificationType;
// the notification will be added to the dashboard queue
highlight?: boolean;
// whether or not to emit an OS notification noise when showing the notification.
Expand Down
8 changes: 8 additions & 0 deletions packages/main/src/plugin/api/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,17 @@ export interface Task {
id: string;
name: string;
started: number;
}

export interface StatefulTask extends Task {
state: TaskState;
status: TaskStatus;
progress?: number;
gotoTask?: () => void;
error?: string;
}

export interface NotificationTask extends Task {
description: string;
markdownActions?: string;
}
6 changes: 3 additions & 3 deletions packages/main/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,8 @@ export class PluginSystem {

const exec = new Exec(proxy);

const taskManager = new TaskManager(apiSender);

const commandRegistry = new CommandRegistry(apiSender, telemetry);
const menuRegistry = new MenuRegistry(commandRegistry);
const kubeGeneratorRegistry = new KubeGeneratorRegistry();
Expand All @@ -414,7 +416,7 @@ export class PluginSystem {
const fileSystemMonitoring = new FilesystemMonitoring();
const customPickRegistry = new CustomPickRegistry(apiSender);
const onboardingRegistry = new OnboardingRegistry(configurationRegistry, context);
const notificationRegistry = new NotificationRegistry(apiSender);
const notificationRegistry = new NotificationRegistry(apiSender, taskManager);
const kubernetesClient = new KubernetesClient(apiSender, configurationRegistry, fileSystemMonitoring, telemetry);
await kubernetesClient.init();
const closeBehaviorConfiguration = new CloseBehavior(configurationRegistry);
Expand Down Expand Up @@ -712,8 +714,6 @@ export class PluginSystem {

const authentication = new AuthenticationImpl(apiSender);

const taskManager = new TaskManager(apiSender);

const cliToolRegistry = new CliToolRegistry(apiSender, exec, telemetry);

const imageChecker = new ImageCheckerImpl(apiSender);
Expand Down
9 changes: 8 additions & 1 deletion packages/main/src/plugin/notification-registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,19 @@ import { beforeEach, expect, test, vi } from 'vitest';
import type { ApiSenderType } from './api.js';
import type { Disposable } from './types/disposable.js';
import { NotificationRegistry } from './notification-registry.js';
import type { TaskManager } from './task-manager.js';

let notificationRegistry: NotificationRegistry;
const extensionId = 'myextension.id';
const apiSender: ApiSenderType = { send: vi.fn() } as unknown as ApiSenderType;
const createNotificationtaskMock = vi.fn();
const taskManager: TaskManager = { createNotificationTask: createNotificationtaskMock } as unknown as TaskManager;

let registerNotificationDisposable: Disposable;

/* eslint-disable @typescript-eslint/no-empty-function */
beforeEach(() => {
notificationRegistry = new NotificationRegistry(apiSender);
notificationRegistry = new NotificationRegistry(apiSender, taskManager);
registerNotificationDisposable = notificationRegistry.registerExtension(extensionId);
});

Expand Down Expand Up @@ -61,6 +64,10 @@ test('expect notification added to the queue', async () => {
expect(queue[0].extensionId).toEqual(extensionId);
expect(queue[0].title).toEqual('title');
expect(queue[0].type).toEqual('info');
expect(createNotificationtaskMock).toBeCalledWith({
title: 'title',
body: 'description',
});
});

test('expect latest added notification is in top of the queue', async () => {
Expand Down
12 changes: 11 additions & 1 deletion packages/main/src/plugin/notification-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ import { Notification } from 'electron';
import type { ApiSenderType } from './api.js';
import { Disposable } from './types/disposable.js';
import type * as containerDesktopAPI from '@podman-desktop/api';
import type { TaskManager } from './task-manager.js';

export class NotificationRegistry {
private notificationId = 0;
private notificationQueue: NotificationCard[] = [];

constructor(private apiSender: ApiSenderType) {}
constructor(
private apiSender: ApiSenderType,
private taskManager: TaskManager,
) {}

registerExtension(extensionId: string): Disposable {
return Disposable.create(() => {
Expand All @@ -45,6 +49,12 @@ export class NotificationRegistry {
this.notificationQueue.unshift(notification);
// send event
this.apiSender.send('notifications-updated');
// create task
this.taskManager.createNotificationTask({
title: notification.title,
body: notification.body,
markdownActions: notification.markdownActions,
});
// we show the notification
const disposeShowNotification = this.showNotification({
title: notification.title,
Expand Down
171 changes: 171 additions & 0 deletions packages/main/src/plugin/task-manager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**********************************************************************
* 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 { expect, test, vi } from 'vitest';
import { TaskManager } from './task-manager.js';
import type { ApiSenderType } from './api.js';

const apiSenderSendMock = vi.fn();

const apiSender = {
send: apiSenderSendMock,
} as unknown as ApiSenderType;

test('create stateful task with title', async () => {
const taskManager = new TaskManager(apiSender);
const task = taskManager.createTask('title');
expect(task.id).equal('main-1');
expect(task.name).equal('title');
expect(task.state).equal('running');
expect(task.status).equal('in-progress');
expect(apiSenderSendMock).toBeCalledWith('task-created', task);
});

test('create stateful task without title', async () => {
const taskManager = new TaskManager(apiSender);
const task = taskManager.createTask(undefined);
expect(task.id).equal('main-1');
expect(task.name).equal('Task 1');
expect(task.state).equal('running');
expect(task.status).equal('in-progress');
expect(apiSenderSendMock).toBeCalledWith('task-created', task);
});

test('create multiple stateful tasks with title', async () => {
const taskManager = new TaskManager(apiSender);
const task = taskManager.createTask('title');
expect(task.id).equal('main-1');
expect(task.name).equal('title');
expect(task.state).equal('running');
expect(task.status).equal('in-progress');
expect(apiSenderSendMock).toBeCalledWith('task-created', task);

const task2 = taskManager.createTask('another title');
expect(task2.id).equal('main-2');
expect(task2.name).equal('another title');
expect(task2.state).equal('running');
expect(task2.status).equal('in-progress');
expect(apiSenderSendMock).toBeCalledWith('task-created', task2);

const task3 = taskManager.createTask('third title');
expect(task3.id).equal('main-3');
expect(task3.name).equal('third title');
expect(task3.state).equal('running');
expect(task3.status).equal('in-progress');
expect(apiSenderSendMock).toBeCalledWith('task-created', task3);
});

test('create notification task with body', async () => {
const taskManager = new TaskManager(apiSender);
const task = taskManager.createNotificationTask({
title: 'title',
body: 'body',
});
expect(task.id).equal('main-1');
expect(task.name).equal('title');
expect(task.description).equal('body');
expect(task.markdownActions).toBeUndefined();
expect(apiSenderSendMock).toBeCalledWith('task-created', task);
});

test('create stateful task without body', async () => {
const taskManager = new TaskManager(apiSender);
const task = taskManager.createNotificationTask({
title: 'title',
});
expect(task.id).equal('main-1');
expect(task.name).equal('title');
expect(task.description).equal('');
expect(task.markdownActions).toBeUndefined();
expect(apiSenderSendMock).toBeCalledWith('task-created', task);
});

test('create stateful task with markdown actions', async () => {
const taskManager = new TaskManager(apiSender);
const task = taskManager.createNotificationTask({
title: 'title',
markdownActions: 'action',
});
expect(task.id).equal('main-1');
expect(task.name).equal('title');
expect(task.description).equal('');
expect(task.markdownActions).equal('action');
expect(apiSenderSendMock).toBeCalledWith('task-created', task);
});

test('create multiple stateful tasks with title', async () => {
const taskManager = new TaskManager(apiSender);
const task = taskManager.createNotificationTask({
title: 'title',
});
expect(task.id).equal('main-1');
expect(task.name).equal('title');
expect(task.description).equal('');
expect(task.markdownActions).toBeUndefined();
expect(apiSenderSendMock).toBeCalledWith('task-created', task);

const task2 = taskManager.createNotificationTask({
title: 'second title',
});
expect(task2.id).equal('main-2');
expect(task2.name).equal('second title');
expect(task2.description).equal('');
expect(task2.markdownActions).toBeUndefined();
expect(apiSenderSendMock).toBeCalledWith('task-created', task2);

const task3 = taskManager.createNotificationTask({
title: 'third title',
});
expect(task3.id).equal('main-3');
expect(task3.name).equal('third title');
expect(task3.description).equal('');
expect(task3.markdownActions).toBeUndefined();
expect(apiSenderSendMock).toBeCalledWith('task-created', task3);
});

test('return true if statefulTask', async () => {
const taskManager = new TaskManager(apiSender);
const task = taskManager.createTask('title');
const result = taskManager.isStatefulTask(task);
expect(result).toBeTruthy();
});

test('return false if it is not a statefulTask', async () => {
const taskManager = new TaskManager(apiSender);
const task = taskManager.createNotificationTask({
title: 'title',
});
const result = taskManager.isStatefulTask(task);
expect(result).toBeFalsy();
});

test('return true if notificationTask', async () => {
const taskManager = new TaskManager(apiSender);
const task = taskManager.createNotificationTask({
title: 'title',
});
const result = taskManager.isNotificationTask(task);
expect(result).toBeTruthy();
});

test('return false if it is not a notificationTask', async () => {
const taskManager = new TaskManager(apiSender);
const task = taskManager.createTask('title');
const result = taskManager.isNotificationTask(task);
expect(result).toBeFalsy();
});
31 changes: 27 additions & 4 deletions packages/main/src/plugin/task-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
***********************************************************************/

import type { ApiSenderType } from './api.js';
import type { Task } from '/@/plugin/api/task.js';
import type { NotificationInfo } from './api/notification.js';
import type { NotificationTask, StatefulTask, Task } from '/@/plugin/api/task.js';

/**
* Contribution manager to provide the list of external OCI contributions
Expand All @@ -29,9 +30,9 @@ export class TaskManager {

constructor(private apiSender: ApiSenderType) {}

public createTask(title: string | undefined): Task {
public createTask(title: string | undefined): StatefulTask {
this.taskId++;
const task: Task = {
const task: StatefulTask = {
id: `main-${this.taskId}`,
name: title ? title : `Task ${this.taskId}`,
started: new Date().getTime(),
Expand All @@ -43,10 +44,32 @@ export class TaskManager {
return task;
}

public createNotificationTask(notificationInfo: NotificationInfo): NotificationTask {
this.taskId++;
const task: NotificationTask = {
id: `main-${this.taskId}`,
name: notificationInfo.title,
started: new Date().getTime(),
description: notificationInfo.body || '',
markdownActions: notificationInfo.markdownActions,
};
this.tasks.set(task.id, task);
this.apiSender.send('task-created', task);
return task;
}

public updateTask(task: Task) {
this.apiSender.send('task-updated', task);
if (task.state === 'completed') {
if (this.isStatefulTask(task) && task.state === 'completed') {
this.tasks.delete(task.id);
}
}

isStatefulTask(task: Task): task is StatefulTask {
return 'state' in task;
}

isNotificationTask(task: Task): task is NotificationTask {
return 'description' in task;
}
}
4 changes: 2 additions & 2 deletions packages/renderer/src/lib/image/build-image-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import { router } from 'tinro';
import { type BuildImageInfo, buildImagesInfo } from '/@/stores/build-images';
import { createTask, removeTask } from '/@/stores/tasks';
import { createTask, isStatefulTask, removeTask } from '/@/stores/tasks';
import type { Task } from '../../../../main/src/plugin/api/task';

export interface BuildImageCallback {
Expand Down Expand Up @@ -156,7 +156,7 @@ function getKey(): symbol {
// anonymous function to collect events
export function eventCollect(key: symbol, eventName: 'finish' | 'stream' | 'error', data: string): void {
const task = allTasks.get(key);
if (task) {
if (task && isStatefulTask(task)) {
if (eventName === 'error') {
// If we errored out, we should store the error message in the task so it is correctly displayed
task.error = data;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { createConnectionsInfo } from '/@/stores/create-connections';
import { router } from 'tinro';

import type { Logger as LoggerType } from '@podman-desktop/api';
import { createTask, removeTask } from '/@/stores/tasks';
import { createTask, isStatefulTask, removeTask } from '/@/stores/tasks';
import type { Task } from '../../../../main/src/plugin/api/task';

export interface ConnectionCallback extends LoggerType {
Expand Down Expand Up @@ -163,7 +163,7 @@ function getKey(): symbol {

export function eventCollect(key: symbol, eventName: 'log' | 'warn' | 'error' | 'finish', args: string[]): void {
const task = allTasks.get(key);
if (task) {
if (task && isStatefulTask(task)) {
if (eventName === 'error') {
task.status = 'failure';
task.error = args.join('\n');
Expand Down
Loading

0 comments on commit 2fa8bbf

Please sign in to comment.