1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Fix auto-update to use Lens notifications, and add update confirmation (#1831)

- Add auto-update and pre-release update user settings

- Add settings in user preferences for auto-updating (default false) and
  for allowing pre-release versions (default false)

- Use in-Lens notifications instead of OS notifications as those were
  found to be flaky

- Add rudimentary main->renderer notification system.

- Remove options, always confirm, never auto prelease

- Changed "yes later" to "yes on quit"

- move register IpcHandlers

- use moment instead of dateformat

- moved formatting notification buttons to renderer

- move to RenderButtons as function component

- explicitly only send notifications to main view

- move delay to utils, always retry even if check failed

- fix notification rendering and disabled the auto-updater for integration tests

- update integration runner to output logs on failure

- pin minikube version

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-01-11 09:08:47 -05:00 committed by GitHub
parent 1a4b6cddb5
commit df47d1713c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 295 additions and 61 deletions

View File

@ -37,7 +37,7 @@ jobs:
displayName: Cache Yarn packages
- script: make install-deps
displayName: Install dependencies
- script: make integration-win
- script: yarn integration:win
displayName: Run integration tests
- script: make build
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
@ -76,7 +76,7 @@ jobs:
displayName: Install dependencies
- script: make test
displayName: Run tests
- script: make integration-mac
- script: yarn integration:mac
displayName: Run integration tests
- script: make build
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
@ -126,13 +126,13 @@ jobs:
- bash: |
sudo apt-get update
sudo apt-get install libgconf-2-4 conntrack -y
curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
curl -LO https://storage.googleapis.com/minikube/releases/v1.15.1/minikube-linux-amd64
sudo install minikube-linux-amd64 /usr/local/bin/minikube
sudo minikube start --driver=none
# Although the kube and minikube config files are in placed $HOME they are owned by root
sudo chown -R $USER $HOME/.kube $HOME/.minikube
displayName: Install integration test dependencies
- script: xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration-linux
- script: xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' yarn integration:linux
displayName: Run integration tests
- bash: |
sudo chown root:root /

View File

@ -31,18 +31,6 @@ lint:
test: download-bins
yarn test
integration-linux:
yarn build:linux
yarn integration
integration-mac:
yarn build:mac
yarn integration
integration-win:
yarn build:win
yarn integration
test-app:
yarn test
@ -62,4 +50,4 @@ else
rm -rf binaries/client/*
rm -rf dist/*
rm -rf static/build/*
endif
endif

View File

@ -1,6 +1,6 @@
/*
Cluster tests are run if there is a pre-existing minikube cluster. Before running cluster tests the TEST_NAMESPACE
namespace is removed, if it exists, from the minikube cluster. Resources are created as part of the cluster tests in the
namespace is removed, if it exists, from the minikube cluster. Resources are created as part of the cluster tests in the
TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube
cluster and vice versa.
*/
@ -38,7 +38,7 @@ describe("Lens integration tests", () => {
beforeAll(appStart, 20000)
afterAll(async () => {
if (app && app.isRunning()) {
if (app?.isRunning()) {
return util.tearDown(app)
}
})
@ -140,7 +140,7 @@ describe("Lens integration tests", () => {
await addCluster()
}
}
describe("cluster pages", () => {
beforeAll(appStartAddCluster, 40000)
@ -150,7 +150,7 @@ describe("Lens integration tests", () => {
return util.tearDown(app)
}
})
const tests : {
drawer?: string
drawerId?: string
@ -393,7 +393,7 @@ describe("Lens integration tests", () => {
await app.client.click(`.sidebar-nav #${drawerId} span.link-text`)
await app.client.waitUntilTextExists(`a[href="/${pages[0].href}"]`, pages[0].name)
})
}
}
pages.forEach(({name, href, expectedSelector, expectedText}) => {
it(`shows ${drawer}->${name} page`, async () => {
expect(clusterAdded).toBe(true)
@ -409,7 +409,7 @@ describe("Lens integration tests", () => {
await expect(app.client.waitUntilTextExists(`a[href="/${pages[0].href}"]`, pages[0].name, 100)).rejects.toThrow()
})
}
})
})
})
describe("cluster operations", () => {
@ -420,7 +420,7 @@ describe("Lens integration tests", () => {
return util.tearDown(app)
}
})
it('shows default namespace', async () => {
expect(clusterAdded).toBe(true)
await app.client.click('a[href="/namespaces"]')
@ -468,6 +468,6 @@ describe("Lens integration tests", () => {
await app.client.click(".name=nginx-create-pod-test")
await app.client.waitUntilTextExists("div.drawer-title-text", "Pod: nginx-create-pod-test")
})
})
})
})
})
})

View File

@ -23,7 +23,10 @@
"build:mac": "yarn compile && electron-builder --mac --dir -c.productName=Lens",
"build:win": "yarn compile && electron-builder --win --dir -c.productName=Lens",
"test": "jest --env=jsdom src $@",
"integration": "jest --coverage integration $@",
"integration-runner": "jest --coverage integration $@",
"integration:linux": "scripts/integration.sh linux",
"integration:mac": "scripts/integration.sh mac",
"integration:win": "scripts/integration.sh win",
"dist": "yarn compile && electron-builder --publish onTag",
"dist:win": "yarn compile && electron-builder --publish onTag --x64 --ia32",
"dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null",
@ -195,6 +198,7 @@
"mobx": "^5.15.5",
"mobx-observable-history": "^1.0.3",
"mock-fs": "^4.12.0",
"moment": "^2.26.0",
"node-machine-id": "^1.1.12",
"node-pty": "^0.9.0",
"openid-client": "^3.15.2",
@ -294,7 +298,6 @@
"material-design-icons": "^3.0.1",
"mini-css-extract-plugin": "^0.9.0",
"mobx-react": "^6.2.2",
"moment": "^2.26.0",
"node-loader": "^0.6.0",
"node-sass": "^4.14.1",
"nodemon": "^2.0.4",

30
scripts/integration.sh Executable file
View File

@ -0,0 +1,30 @@
case $1 in
mac)
find ~/Library/Logs/Lens -type f -name *.log -delete
;;
linux)
find ~/.config/Logs/Lens -type f -name *.log -delete
;;
win)
find %APPDATA%/Logs/Lens -type f -name *.log -delete
;;
esac
yarn build:$1
DEBUG=true yarn integration-runner
if [ $? -ne 0 ]; then
case $1 in
mac)
find ~/Library/Logs/Lens -type f -name *.log -exec cat >&2 {} \;
;;
linux)
find ~/.config/Logs/Lens -type f -name *.log -exec cat >&2 {} \;
;;
win)
find %APPDATA%/Logs/Lens -type f -name *.log -exec cat >&2 {} \;
;;
esac
exit 1
fi

View File

@ -5,6 +5,9 @@
import { ipcMain, ipcRenderer, WebContents, webContents } from "electron"
import logger from "../main/logger";
export const NotificationChannelPrefix: IpcChannel = "notications:";
export const NotificationChannelAdd: IpcChannel = `${NotificationChannelPrefix}add`;
export type IpcChannel = string;
export interface IpcChannelOptions {
@ -50,7 +53,7 @@ export function createIpcChannel({ autoBind = true, once, timeout = 0, handle, c
return ipcChannel;
}
export interface IpcBroadcastParams<A extends any[] = any> {
export interface IpcBroadcastParams<A extends any[] = any[]> {
channel: IpcChannel
webContentId?: number; // send to single webContents view
frameId?: number; // send to inner frame of webContents

View File

@ -0,0 +1,3 @@
export function delay(duration: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, duration));
}

View File

@ -5,3 +5,4 @@ export * from "./camelCase"
export * from "./splitArray"
export * from "./getRandId"
export * from "./cloneJson"
export * from "./delay"

View File

@ -1,19 +1,140 @@
import { autoUpdater } from "electron-updater"
import logger from "./logger"
import { autoUpdater, UpdateInfo } from "electron-updater";
import logger from "./logger";
import { IpcChannel, NotificationChannelAdd, NotificationChannelPrefix } from "../common/ipc";
import { ipcMain } from "electron";
import { isDevelopment, isTestEnv } from "../common/vars";
import { SemVer } from "semver";
import moment from "moment";
import { WindowManager } from "./window-manager"
import { delay } from "../common/utils";
export default class AppUpdater {
class NotificationBackchannel {
private static _id = 0;
protected updateInterval: number = (1000 * 60 * 60 * 24) // once a day
constructor() {
autoUpdater.logger = logger
}
public start() {
setInterval(() => {
autoUpdater.checkForUpdatesAndNotify()
}, this.updateInterval)
return autoUpdater.checkForUpdatesAndNotify()
static nextId(): IpcChannel {
return `${NotificationChannelPrefix}${NotificationBackchannel._id++}`
}
}
const title = "Lens Updater";
async function autoUpdateCheck(windowManager: WindowManager): Promise<void> {
return new Promise(async resolve => {
const body = "Install and restart Lens?";
const yesNowChannel = NotificationBackchannel.nextId();
const yesLaterChannel = NotificationBackchannel.nextId();
const noChannel = NotificationBackchannel.nextId();
function cleanupChannels() {
ipcMain.removeAllListeners(yesNowChannel);
ipcMain.removeAllListeners(yesLaterChannel);
ipcMain.removeAllListeners(noChannel);
}
ipcMain
.on(yesNowChannel, async () => {
logger.info("[UPDATE CHECKER]: User chose to update immediately");
cleanupChannels();
await autoUpdater.downloadUpdate();
autoUpdater.quitAndInstall();
resolve();
})
.on(yesLaterChannel, async () => {
logger.info("[UPDATE CHECKER]: User chose to update on quit");
cleanupChannels();
await autoUpdater.downloadUpdate();
autoUpdater.autoInstallOnAppQuit = true;
resolve();
})
.on(noChannel, () => {
logger.info("[UPDATE CHECKER]: User chose not to update");
cleanupChannels();
resolve();
});
windowManager.mainView.webContents.send(NotificationChannelAdd, {
title,
body,
status: "info",
buttons: [
{
label: "Yes, now",
backchannel: yesNowChannel,
action: true,
},
{
label: "Yes, on quit",
backchannel: yesLaterChannel,
action: true,
},
{
label: "No",
backchannel: noChannel,
secondary: true
}
],
closeChannel: noChannel,
});
});
}
/**
* starts the automatic update checking
* @param interval milliseconds between interval to check on, defaults to 24h
*/
export function startUpdateChecking(windowManager: WindowManager, interval = 1000 * 60 * 60 * 24): void {
if (isDevelopment || isTestEnv) {
return;
}
autoUpdater.logger = logger;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater
.on("update-available", async (args: UpdateInfo) => {
try {
const releaseDate = moment(args.releaseDate);
const body = `Version ${args.version} was released on ${releaseDate.format("dddd, MMMM Do, yyyy")}.`;
windowManager.mainView.webContents.send(NotificationChannelAdd, {
title,
body,
status: "info",
timeout: 5000,
});
await autoUpdateCheck(windowManager);
} catch (error) {
logger.error("[UPDATE CHECKER]: notification failed", { error: String(error) })
}
})
.on("update-not-available", (args: UpdateInfo) => {
try {
const version = new SemVer(args.version);
const stream = version.prerelease === null ? "stable" : "prerelease";
const body = `Lens is running the latest ${stream} version.`;
windowManager.mainView.webContents.send(NotificationChannelAdd, {
title,
body,
status: "info",
timeout: 5000,
})
} catch (error) {
logger.error("[UPDATE CHECKER]: notification failed", { error: String(error) })
}
})
async function helper() {
while (true) {
await autoUpdater.checkForUpdates()
.catch(error => logger.error("[UPDATE CHECKER]: failed with an error", { error: String(error) }));
await delay(interval);
}
}
helper();
}

View File

@ -8,7 +8,7 @@ import path from "path"
import { LensProxy } from "./lens-proxy"
import { WindowManager } from "./window-manager";
import { ClusterManager } from "./cluster-manager";
import AppUpdater from "./app-updater"
import { startUpdateChecking } from "./app-updater"
import { shellSync } from "./shell-sync"
import { getFreePort } from "./port"
import { mangleProxyEnv } from "./proxy-env"
@ -39,8 +39,6 @@ async function main() {
logger.info(`🚀 Starting Lens from "${workingDir}"`)
tracker.event("app", "start");
const updater = new AppUpdater()
updater.start();
registerFileProtocol("static", __static);
@ -75,6 +73,8 @@ async function main() {
// create window manager and open app
windowManager = new WindowManager(proxyPort);
startUpdateChecking(windowManager);
}
app.on("ready", main);

View File

@ -94,7 +94,7 @@ export class KubeAuthProxy {
protected async sendIpcLogMessage(res: KubeAuthProxyLog) {
const channel = `kube-auth:${this.cluster.id}`
logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() });
broadcastIpc({ channel: channel, args: [res] });
broadcastIpc({ channel, args: [res] });
}
public exit() {

View File

@ -7,7 +7,7 @@ import { initMenu } from "./menu";
import { tracker } from "../common/tracker";
export class WindowManager {
protected mainView: BrowserWindow;
public readonly mainView: BrowserWindow;
protected splashWindow: BrowserWindow;
protected windowState: windowStateKeeper.State;

View File

@ -9,6 +9,7 @@ import { i18nStore } from "./i18n";
import { themeStore } from "./theme.store";
import { App } from "./components/app";
import { LensApp } from "./lens-app";
import { notificationsStore } from "./components/notifications/notifications.store";
type AppComponent = React.ComponentType & {
init?(): Promise<void>;

View File

@ -20,10 +20,19 @@
&.primary {
background: $buttonPrimaryBackground;
}
&.accent {
background: $buttonAccentBackground;
}
&.action {
background: $buttonActionBackground;
}
&.secondary {
background: $buttonSecondaryBackground;
}
&.plain {
color: inherit;
background: transparent;

View File

@ -8,6 +8,8 @@ export interface ButtonProps extends ButtonHTMLAttributes<any>, TooltipDecorator
waiting?: boolean;
primary?: boolean;
accent?: boolean;
secondary?: boolean;
action?: boolean;
plain?: boolean;
hidden?: boolean;
active?: boolean;
@ -23,12 +25,15 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
private button: HTMLButtonElement;
render() {
const { className, waiting, label, primary, accent, plain, hidden, active, big, round, tooltip, children, ...props } = this.props;
const {
className, waiting, label, primary, accent, plain, hidden, active, big,
round, tooltip, children, secondary, action, ...props
} = this.props;
const btnProps = props as Partial<ButtonProps>;
if (hidden) return null;
btnProps.className = cssNames('Button', className, {
waiting, primary, accent, plain, active, big, round,
waiting, primary, accent, plain, active, big, round, secondary, action,
});
const btnContent: ReactNode = (

View File

@ -25,6 +25,10 @@
margin-bottom: $margin * 2;
}
.ButtonPannel button:not(:last-of-type) {
margin-right: $margin;
}
> .message {
white-space: pre-line;
padding-left: $padding;

View File

@ -4,6 +4,10 @@ import { autobind } from "../../utils";
import isObject from "lodash/isObject"
import uniqueId from "lodash/uniqueId";
import { JsonApiErrorParsed } from "../../api/json-api";
import logger from "../../../main/logger";
import { ipcRenderer } from "electron";
import { IpcChannel, NotificationChannelAdd } from "../../../common/ipc";
import { Button, ButtonProps } from "../button";
export type IMessageId = string | number;
export type IMessage = React.ReactNode | React.ReactNode[] | JsonApiErrorParsed;
@ -19,6 +23,38 @@ export interface INotification {
message: IMessage;
status?: NotificationStatus;
timeout?: number; // auto-hiding timeout in milliseconds, 0 = no hide
onClose?(): void; // additonal logic on when the notification times out or is closed by the "x"
}
export interface MainNotification {
title: string;
body: string;
buttons?: ({
backchannel: IpcChannel;
} & ButtonProps)[];
status: NotificationStatus;
timeout?: number;
closeChannel?: IpcChannel;
}
function RenderButtons({ id, buttons }: { id: IpcChannel, buttons?: MainNotification["buttons"] }) {
if (!buttons) {
return null;
}
return (
<>
<br />
<div className="ButtonPannel flex row align-right box grow">
{buttons.map(({ backchannel, ...props}) => (
<Button {...props} onClick={() => {
ipcRenderer.send(backchannel);
notificationsStore.remove(id);
}} />
))}
</div>
</>
)
}
@autobind()
@ -27,6 +63,28 @@ export class NotificationsStore {
protected autoHideTimers = new Map<IMessageId, number>();
registerIpcListener(): void {
logger.info(`[NOTIFICATION-STORE] start to listen for notifications requests from main`);
ipcRenderer.on(NotificationChannelAdd, (event, model: MainNotification) => {
const id = uniqueId("notification_");
this.add({
message: (
<>
<b>{model.title}</b>
<p>{model.body}</p>
<RenderButtons id={id} buttons={model.buttons}/>
</>
),
id,
status: model.status,
timeout: model.timeout,
onClose: () => {
model.closeChannel && ipcRenderer.send(model.closeChannel);
}
});
})
}
addAutoHideTimer(notification: INotification) {
this.removeAutoHideTimer(notification);
const { id, timeout } = notification;

View File

@ -84,7 +84,10 @@ export class Notifications extends React.Component {
<div className="box center">
<Icon
material="close" className="close"
onClick={prevDefault(() => remove(notification))}
onClick={prevDefault(() => {
remove(notification);
notification.onClose?.();
})}
/>
</div>
</div>

View File

@ -12,6 +12,7 @@ import { ErrorBoundary } from "./components/error-boundary";
import { WhatsNew, whatsNewRoute } from "./components/+whats-new";
import { Notifications } from "./components/notifications";
import { ConfirmDialog } from "./components/confirm-dialog";
import { notificationsStore } from "./components/notifications/notifications.store";
@observer
export class LensApp extends React.Component {
@ -22,6 +23,8 @@ export class LensApp extends React.Component {
window.addEventListener("online", () => {
ipcRenderer.send("network:online")
})
notificationsStore.registerIpcListener();
}
render() {

View File

@ -29,12 +29,8 @@ export class ThemeStore {
{ id: "kontena-light", type: ThemeType.LIGHT },
];
@computed get activeThemeId() {
return userStore.preferences.colorTheme;
}
@computed get activeTheme(): Theme {
const activeTheme = this.themes.find(theme => theme.id === this.activeThemeId) || this.themes[0];
const activeTheme = this.themes.find(theme => theme.id === userStore.preferences.colorTheme) || this.themes[0];
return {
colors: {},
...activeTheme,
@ -43,9 +39,9 @@ export class ThemeStore {
constructor() {
// auto-apply active theme
reaction(() => this.activeThemeId, async themeId => {
reaction(() => this.activeTheme, async ({ id }) => {
try {
await this.loadTheme(themeId);
await this.loadTheme(id);
this.applyTheme();
} catch (err) {
userStore.resetTheme();

View File

@ -26,6 +26,8 @@
"buttonPrimaryBackground": "#3d90ce",
"buttonDefaultBackground": "#414448",
"buttonAccentBackground": "#e85555",
"buttonActionBackground": "#1dcc1f",
"buttonSecondaryBackground": "#edd70c",
"buttonDisabledBackground": "#808080",
"tableBgcStripe": "#2a2d33",
"tableBgcSelected": "#383c42",

View File

@ -27,6 +27,8 @@
"buttonPrimaryBackground": "#3d90ce",
"buttonDefaultBackground": "#414448",
"buttonAccentBackground": "#e85555",
"buttonActionBackground": "#1dcc1f",
"buttonSecondaryBackground": "#edd70c",
"buttonDisabledBackground": "#808080",
"tableBgcStripe": "#f8f8f8",
"tableBgcSelected": "#f4f5f5",

View File

@ -35,6 +35,8 @@ $sidebarBackground: var(--sidebarBackground);
$buttonPrimaryBackground: var(--buttonPrimaryBackground);
$buttonDefaultBackground: var(--buttonDefaultBackground);
$buttonAccentBackground: var(--buttonAccentBackground);
$buttonSecondaryBackground: var(--buttonSecondaryBackground);
$buttonActionBackground: var(--buttonActionBackground);
$buttonDisabledBackground: var(--buttonDisabledBackground);
// Tables
@ -125,4 +127,4 @@ $filterAreaBackground: var(--filterAreaBackground);
$selectOptionHoveredColor: var(--selectOptionHoveredColor);
$lineProgressBackground: var(--lineProgressBackground);
$radioActiveBackground: var(--radioActiveBackground);
$menuActiveBackground: var(--menuActiveBackground);
$menuActiveBackground: var(--menuActiveBackground);

View File

@ -159,4 +159,4 @@ export default function (): webpack.Configuration {
}),
],
}
}
}