feat: 🎸 Add section for client agent in settings (#2442)

* feat: 🎸 Add section for client agent in settings

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-14780
This commit is contained in:
Zhihe Li committed 2024-09-24 11:58:25 -04:00
1 parent de5defeab2
commit edaf19f59b
20 files changed
+403 -80

No files matched your search

+7 -7
View File
@@ -199,18 +199,18 @@ export default function initializeMockIPC(server, config) {
getDesktopVersion() {
return { desktopVersion: '1.0.0' };
}
getLogLevel() {
return 'debug';
return 'info';
}
setLogLevel() {
return 'warn';
}
getLogPath() {
return '~/.config/Boundary/logs/desktop-client.log';
}
setLogLevel() {}
clientAgentStatus() {
return { version: '0.0.1-dev', status: 'running' };
}
pauseClientAgent() {}
resumeClientAgent() {}
}
/**
@@ -22,6 +22,8 @@ back: Back
view: View
retry: Retry
play: Play
pause: Pause
resume: Resume
get-topic-help: Get help for this topic
copy-to-clipboard: Copy to Clipboard
copy-error-detail-to-clipboard: Copy error detail to clipboard
+7
View File
@@ -109,7 +109,11 @@ settings:
application: Application
cli: CLI
cache-daemon: Cache Daemon
client-agent:
title: Boundary Client Agent
description: Required to use Transparent Sessions
client: Client
version: Version
color-theme: Color Theme
provider: Provider
hcp: HashiCorp Cloud Platform
@@ -127,3 +131,6 @@ settings:
warn: Warn
info: Info
debug: Debug
alerts:
cache-daemon: There may be a problem with the cache daemon
client-agent: There may be a problem with the client agent
@@ -27,5 +27,3 @@ onboarding-failed:
required-field: This is a required field.
client-agent-failed:
sessions: Unable to retrieve sessions from the client agent.
settings-cache:
title: There may be a problem with the cache daemon
@@ -17,6 +17,7 @@ failed: Failed
installed: Installed
not-running: Not Running
running: Running
paused: Paused
authenticated: Authenticated
unauthenticated: Unauthenticated
loading:
@@ -10,7 +10,7 @@
{{t 'settings.client'}}
</Hds::Text::Display>
<Hds::Text::Body @tag='p' @weight='medium'>
{{@model.formattedDesktopVersion}}
{{@model.desktopVersion}}
</Hds::Text::Body>
</div>
<div>
@@ -18,7 +18,7 @@
{{t 'settings.cli'}}
</Hds::Text::Display>
<Hds::Text::Body @tag='p' @weight='medium'>
{{@model.formattedCliVersion}}
{{@model.cliVersion}}
</Hds::Text::Body>
</div>
<div>
@@ -26,8 +26,8 @@
{{t 'settings.cache-daemon'}}
</Hds::Text::Display>
<Hds::Text::Body @tag='p' @weight='medium' data-test-cache-version>
{{#if @model.formattedCacheVersion}}
{{@model.formattedCacheVersion}}
{{#if @model.cacheDaemonStatus.version}}
{{@model.cacheDaemonStatus.version}}
<Hds::Badge
@text={{t 'states.running'}}
@icon='running'
@@ -43,11 +43,11 @@
</Hds::Text::Body>
</div>
{{#if @model.cacheDaemonErrors}}
{{#if @model.cacheDaemonStatus.errors}}
<Hds::Alert @type='inline' @color='warning' as |A|>
<A.Title>{{t 'errors.settings-cache.title'}}</A.Title>
<A.Title>{{t 'settings.alerts.cache-daemon'}}</A.Title>
<A.Description>
{{#each @model.cacheDaemonErrors as |error|}}
{{#each @model.cacheDaemonStatus.errors as |error|}}
<Hds::Text::Body @tag='p'>
{{#if error.name}}
<strong>{{error.name}}: </strong>
@@ -0,0 +1,63 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{#if this.isClientAgentActive}}
<SettingsCard
@header={{t 'settings.client-agent.title'}}
@icon='network-alt'
@description={{t 'settings.client-agent.description'}}
>
<:state>
{{#if this.isClientAgentStatusRunning}}
<Hds::Badge
@text={{t 'states.running'}}
@icon='running'
@color='success'
/>
{{else}}
<Hds::Badge
@text={{t 'states.paused'}}
@icon='pause-circle'
@color='neutral'
/>
{{/if}}
</:state>
<:body>
<div>
<Hds::Text::Display>
{{t 'settings.version'}}
</Hds::Text::Display>
<Hds::Text::Body @tag='p' @weight='medium'>
{{@model.clientAgentStatus.version}}
</Hds::Text::Body>
</div>
{{#if @model.clientAgentStatus.errors}}
<Hds::Alert @type='inline' @color='warning' as |A|>
<A.Title>{{t 'settings.alerts.client-agent'}}</A.Title>
<A.Description>
{{#each @model.clientAgentStatus.errors as |error|}}
<Hds::Text::Body @tag='p'>
{{error.message}}
</Hds::Text::Body>
{{/each}}
</A.Description>
</Hds::Alert>
{{/if}}
</:body>
<:action>
<Hds::Button
@text={{t
(if this.isClientAgentStatusRunning 'actions.pause' 'actions.resume')
}}
@color='secondary'
@icon={{this.buttonIcon}}
@size='medium'
disabled={{(is-loading)}}
{{on 'click' this.changeClientAgentState}}
/>
</:action>
</SettingsCard>
{{/if}}
@@ -0,0 +1,68 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { loading } from 'ember-loading';
import { notifyError } from 'core/decorators/notify';
export default class SettingsCardClientAgentComponent extends Component {
// =services
@service ipc;
@service router;
@service loading;
// =attributes
/**
* Checks if the status returned by the client agent is "running"
* @return {boolean}
*/
get isClientAgentStatusRunning() {
return this.args.model.clientAgentStatus?.status === 'running';
}
/**
* Checks if the client agent is actively running
* @return {boolean}
*/
get isClientAgentActive() {
return (
this.args.model.clientAgentStatus?.status === 'running' ||
this.args.model.clientAgentStatus?.status === 'paused'
);
}
/**
* Return the button icon based on the current state of the client agent
* @return {string}
*/
get buttonIcon() {
if (this.loading.isLoading) {
return 'loading';
} else {
return this.isClientAgentStatusRunning ? 'pause' : 'play';
}
}
// =actions
/**
* Changes the state of the client agent based on the current state.
* @return {Promise<void>}
*/
@action
@loading
@notifyError(({ message }) => message)
async changeClientAgentState() {
if (this.isClientAgentStatusRunning) {
await this.ipc.invoke('pauseClientAgent');
} else {
await this.ipc.invoke('resumeClientAgent');
}
await this.router.refresh();
}
}
@@ -4,7 +4,6 @@
}}
<Hds::Card::Container @level='mid' @hasBorder={{true}}>
<div class='header-and-icon'>
<Hds::IconTile @icon={{@icon}} @size='small' />
<Hds::Text::Display @tag='h4'>
@@ -20,11 +19,10 @@
{{yield to='action'}}
</div>
{{/if}}
</div>
{{#if @description}}
<Hds::Text::Body @tag='p' @color='faint'>
<Hds::Text::Body @tag='p' @color='faint' @size='100'>
{{@description}}
</Hds::Text::Body>
{{/if}}
@@ -35,5 +33,4 @@
{{yield to='body'}}
</div>
{{/if}}
</Hds::Card::Container>
@@ -13,11 +13,21 @@ export default class SettingsCardLogsComponent extends Component {
// =attributes
/**
* Returns the list of available log levels
* @return {string[]}
*/
get logLevels() {
return ['error', 'warn', 'info', 'debug'];
}
// =methods
/**
* Sets the log level to the selected value
* @param value
* @return {Promise<void>}
*/
@action
async selectLogLevel({ target: { value } }) {
await this.ipc.invoke('setLogLevel', value);
@@ -13,6 +13,7 @@ export default class SettingsUserInfoComponent extends Component {
@service session;
@service intl;
// =attributes
/**
* Returns the username of the current user
* @type {string}
@@ -64,6 +64,8 @@ export default class ScopesScopeProjectsRoute extends Route {
);
if (isClientAgentRunning) {
this.job.start();
} else {
this.job.stop();
}
}
@@ -14,26 +14,38 @@ export default class ScopesScopeProjectsSettingsIndexRoute extends Route {
// =methods
async model() {
let cacheDaemonStatus,
formattedCacheVersion,
formattedCliVersion,
formattedDesktopVersion,
cacheDaemonErrors = [];
const { versionNumber: cliVersion } =
await this.ipc.invoke('getCliVersion');
formattedCliVersion = `v${cliVersion}`;
const { desktopVersion } = await this.ipc.invoke('getDesktopVersion');
formattedDesktopVersion = `v${desktopVersion}`;
const cacheDaemonStatus = await this.getCacheStatus();
const clientAgentStatus = await this.getClientAgentStatus();
const logLevel = await this.ipc.invoke('getLogLevel');
const logPath = await this.ipc.invoke('getLogPath');
const serverInformation = this.clusterUrl.rendererClusterUrl;
return {
desktopVersion: `v${desktopVersion}`,
cliVersion: `v${cliVersion}`,
cacheDaemonStatus,
clientAgentStatus,
logLevel,
logPath,
serverInformation,
};
}
async getCacheStatus() {
let cacheDaemonStatus;
let cacheDaemonStatusError = [];
try {
cacheDaemonStatus = await this.ipc.invoke('cacheDaemonStatus');
} catch (e) {
cacheDaemonErrors.push(e);
cacheDaemonStatusError.push(e);
}
const { version, users } = cacheDaemonStatus ?? {};
formattedCacheVersion = version?.match(/v\d+\.\d+\.\d+/)?.[0];
const cacheVersion = version?.match(/v\d+\.\d+\.\d+/)?.[0];
// Grab the errors from each resource
const userId = this.session.data.authenticated.user_id;
@@ -45,20 +57,24 @@ export default class ScopesScopeProjectsSettingsIndexRoute extends Route {
message: resource.last_error.error,
name: resource.name,
})) ?? [];
cacheDaemonErrors = [...cacheDaemonErrors, ...resourceErrors];
const logLevel = await this.ipc.invoke('getLogLevel');
const logPath = await this.ipc.invoke('getLogPath');
const serverInformation = this.clusterUrl.rendererClusterUrl;
const errors = [...cacheDaemonStatusError, ...resourceErrors];
return { version: cacheVersion, errors };
}
return {
cacheDaemonErrors,
formattedCliVersion,
formattedCacheVersion,
formattedDesktopVersion,
logLevel,
logPath,
serverInformation,
};
async getClientAgentStatus() {
let clientAgentStatus;
let clientAgentStatusError = [];
try {
clientAgentStatus = await this.ipc.invoke('clientAgentStatus');
} catch (e) {
clientAgentStatusError.push(e);
}
const { version, status, errors: errorMessages } = clientAgentStatus ?? {};
const statusErrors = errorMessages?.map((message) => ({ message })) ?? [];
const errors = [...clientAgentStatusError, ...statusErrors];
return { version: `v${version}`, status, errors };
}
}
@@ -4,6 +4,7 @@
}}
<SettingsCard::Application @model={{@model}} @toggle={{this.toggleTheme}} />
<SettingsCard::ClientAgent @model={{@model}} />
<SettingsCard::User />
<SettingsCard::Server @model={{@model}} />
<SettingsCard::Logs @model={{@model}} />
@@ -99,10 +99,11 @@ module.exports = {
* Spawn a child process and return a promise.
* Resolves on any output from stdout or stderr.
* @param command
* @param options
*/
spawn(command) {
spawn(command, options) {
return new Promise((resolve, reject) => {
const childProcess = spawn(path(), command);
const childProcess = spawn(path(), command, options);
childProcess.stdout.on('data', (data) => {
resolve({ childProcess, stdout: data.toString() });
});
+16 -1
View File
@@ -197,6 +197,21 @@ handle('isClientAgentRunning', async () => {
}
});
/**
* Returns the client agent's status
*/
handle('clientAgentStatus', async () => clientAgentDaemonManager.status());
/**
* Pauses the client agent
*/
handle('pauseClientAgent', async () => clientAgentDaemonManager.pause());
/**
* Resumes the client agent
*/
handle('resumeClientAgent', async () => clientAgentDaemonManager.resume());
/**
* Gets boundary cli version
*/
@@ -210,7 +225,7 @@ handle('getDesktopVersion', async () => ({ desktopVersion: releaseVersion }));
/**
* Returns the Cache daemon status
*/
handle('cacheDaemonStatus', async () => await cacheDaemonManager.status());
handle('cacheDaemonStatus', async () => cacheDaemonManager.status());
/**
* Returns the current log level
@@ -15,7 +15,11 @@ class ClientAgentDaemonManager {
*/
async status() {
const clientAgentStatusCommand = ['client-agent', 'status', '-format=json'];
const { stdout, stderr } = spawnSync(clientAgentStatusCommand);
// The status command can take a while to execute if it's not running as it
// retries multiple times so we should set a timeout to not wait too long
const { stderr, stdout } = await spawn(clientAgentStatusCommand, {
timeout: 500,
});
let parsedResponse = jsonify(stdout);
if (parsedResponse?.status_code === 200) {
@@ -47,6 +51,46 @@ class ClientAgentDaemonManager {
log.warn('Client Agent Sessions:', stderr);
return generateErrorPromise(stderr);
}
/**
* Pauses the client agent daemon.
* @return {Promise<*>}
*/
async pause() {
const clientAgentPauseCommand = ['client-agent', 'pause', '-format=json'];
// We use spawn here as pausing can take a while to execute and using spawnSync
// can cause the app to completely freeze and be unresponsive during that time
const { stderr, stdout } = await spawn(clientAgentPauseCommand, {
timeout: 10000,
});
let parsedResponse = jsonify(stdout);
if (parsedResponse?.status_code === 200) {
return parsedResponse.item;
}
log.warn('Client Agent Pause:', stderr);
return generateErrorPromise(stderr);
}
/**
* Resumes the client agent daemon.
* @return {Promise<*>}
*/
async resume() {
const clientAgentResumeCommand = ['client-agent', 'resume', '-format=json'];
const { stderr, stdout } = await spawn(clientAgentResumeCommand, {
timeout: 10000,
});
let parsedResponse = jsonify(stdout);
if (parsedResponse?.status_code === 200) {
return parsedResponse.item;
}
log.warn('Client Agent Resume:', stderr);
return generateErrorPromise(stderr);
}
}
// Export an instance so we get a singleton
@@ -34,6 +34,27 @@ class MockIPC {
isClientAgentRunning() {
return false;
}
cacheDaemonStatus() {
return { version: 'Boundary v1.0.0' };
}
getCliVersion() {
return { versionNumber: '1.0.0' };
}
getDesktopVersion() {
return { desktopVersion: '1.0.0' };
}
getLogLevel() {
return 'info';
}
getLogPath() {
return '~/.config/Boundary/logs/desktop-client.log';
}
setLogLevel() {}
clientAgentStatus() {
return { version: '0.0.1-dev', status: 'running' };
}
pauseClientAgent() {}
resumeClientAgent() {}
}
/**
@@ -18,13 +18,18 @@ module('Integration | Component | settings-card/application', function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks);
let model;
hooks.beforeEach(() => {
model = {
cacheDaemonStatus: { version: 'v4.0.0', errors: [] },
desktopVersion: 'v2.0.0',
cliVersion: 'v3.0.0',
};
});
test('it renders', async function (assert) {
this.set('model', {
cacheDaemonErrors: [],
formattedDesktopVersion: 'v2.0.0',
formattedCliVersion: 'v3.0.0',
formattedCacheVersion: 'v4.0.0',
});
this.set('model', model);
this.set('toggleTheme', () => {});
await render(
@@ -37,21 +42,17 @@ module('Integration | Component | settings-card/application', function (hooks) {
});
test('it renders errors', async function (assert) {
this.set('model', {
cacheDaemonErrors: [
{
message: `this is a resolvable alias error`,
name: 'resolvable-alias',
},
{
message: `this is a target error`,
name: 'target',
},
],
formattedDesktopVersion: 'v2.0.0',
formattedCliVersion: 'v3.0.0',
formattedCacheVersion: 'v4.0.0',
});
model.cacheDaemonStatus.errors = [
{
message: `this is a resolvable alias error`,
name: 'resolvable-alias',
},
{
message: `this is a target error`,
name: 'target',
},
];
this.set('model', model);
this.set('toggleTheme', () => {});
await render(
@@ -66,12 +67,7 @@ module('Integration | Component | settings-card/application', function (hooks) {
});
test('it indicates when cache daemon is running', async function (assert) {
this.set('model', {
cacheDaemonErrors: [],
formattedDesktopVersion: 'v2.0.0',
formattedCliVersion: 'v3.0.0',
formattedCacheVersion: 'v4.0.0',
});
this.set('model', model);
this.set('toggleTheme', () => {});
await render(
@@ -80,12 +76,8 @@ module('Integration | Component | settings-card/application', function (hooks) {
assert.dom(CACHE_DAEMON_VERSION).hasText('v4.0.0 Running');
this.set('model', {
cacheDaemonErrors: [],
formattedDesktopVersion: 'v2.0.0',
formattedCliVersion: 'v3.0.0',
formattedCacheVersion: '',
});
model.cacheDaemonStatus.version = '';
this.set('model', structuredClone(model));
assert.dom(CACHE_DAEMON_VERSION).hasText('Not Running');
});
});
@@ -0,0 +1,84 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupIntl } from 'ember-intl/test-support';
const STATUS_BADGE = '.hds-badge';
const ACTION_BUTTON = 'button';
const CARD_CONTAINER = 'hds-card__container';
const ERROR_MESSAGES = '.hds-alert__description > p';
const LAST_ERROR_MESSAGE = '.hds-alert__description > p:last-child';
module(
'Integration | Component | settings-card/client-agent',
function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks);
test('it renders running badge and pause button', async function (assert) {
this.set('model', {
clientAgentStatus: {
status: 'running',
errors: [],
},
});
await render(hbs`<SettingsCard::ClientAgent @model={{this.model}}/>`);
assert.dom(STATUS_BADGE).hasText('Running');
assert.dom(ACTION_BUTTON).hasText('Pause');
});
test('it renders paused badge and resume button', async function (assert) {
this.set('model', {
clientAgentStatus: {
status: 'paused',
errors: [],
},
});
await render(hbs`<SettingsCard::ClientAgent @model={{this.model}}/>`);
assert.dom(STATUS_BADGE).hasText('Paused');
assert.dom(ACTION_BUTTON).hasText('Resume');
});
test('it does not render component if there is no status', async function (assert) {
this.set('model', {
clientAgentStatus: {
errors: [{ message: 'agent is offline' }],
},
});
await render(hbs`<SettingsCard::ClientAgent @model={{this.model}}/>`);
assert.dom(STATUS_BADGE).doesNotExist();
assert.dom(ACTION_BUTTON).doesNotExist();
assert.dom(CARD_CONTAINER).doesNotExist();
});
test('it renders errors', async function (assert) {
this.set('model', {
clientAgentStatus: {
status: 'running',
errors: [
{ message: 'agent is wonky' },
{ message: 'another error' },
{ message: 'big oof' },
],
},
});
await render(hbs`<SettingsCard::ClientAgent @model={{this.model}}/>`);
assert.dom(ERROR_MESSAGES).isVisible({ count: 3 });
assert.dom(LAST_ERROR_MESSAGE).hasText('big oof');
});
},
);