feat: 🎸 Add fallback if client daemon is down or errors (#2085)

* feat: 🎸 Add fallback if client daemon is down or errors
* test: 💍 Add tests for client daemon being down

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12026
This commit is contained in:
Zhihe Li committed 2024-01-16 10:17:46 -05:00
1 parent 9046c01238
commit 2e36d3c3e2
8 files changed
+195 -119

No files matched your search

@@ -39,6 +39,21 @@ const paginateResults = (array, page, pageSize) => {
return array.slice(start, end);
};
const fetchControllerData = async (context, next) => {
const { data } = context.request;
const { query } = data;
const { recursive, scope_id, page, pageSize } = query;
// If we get an error or client daemon is unavailable, fall back to calling the API
context.request.data.query = { recursive, scope_id };
const results = await next(context.request);
const models = results.content.toArray();
const paginatedResult = paginateResults(models, page, pageSize);
paginatedResult.meta = { totalItems: models.length };
return paginatedResult;
};
/**
* Handler to sit in front of the API request layer
* so we can request from the daemon first
@@ -60,18 +75,17 @@ export default class ClientDaemonHandler {
'isClientDaemonRunning',
);
// eslint-disable-next-line no-unused-vars
let { recursive, scope_id, page, pageSize, ...remainingQuery } = query;
let searchQuery = '';
if (
!Object.keys(supportedTypes).includes(type) ||
!isClientDaemonRunning
) {
return next(context.request);
return fetchControllerData(context, next);
}
// TODO: Remove usages of recursive from callers and scope_id since
// all calls to daemon are recursive, scope_id can be part of search query instead
// eslint-disable-next-line no-unused-vars
let { recursive, scope_id, page, pageSize, ...remainingQuery } = query;
let searchQuery = '';
if (remainingQuery.query) {
const { search, filters } = remainingQuery.query;
@@ -91,12 +105,20 @@ export default class ClientDaemonHandler {
resource: pluralize(type),
};
let clientDaemonResults = {};
try {
clientDaemonResults = await this.ipc.invoke(
'searchClientDaemon',
remainingQuery,
);
} catch (e) {
return fetchControllerData(context, next);
}
// Currently returns with a singular top level field with resource name
// e.g. { targets: [...] } or { sessions: [..] }
// So this just unwraps to the array, or undefined
const [results] = Object.values(
await this.ipc.invoke('searchClientDaemon', remainingQuery),
);
const [results] = Object.values(clientDaemonResults);
const payload = { items: paginateResults(results, page, pageSize) };
const schema = store.modelFor(type);
@@ -12,6 +12,7 @@ export default class ScopesScopeProjectsSessionsIndexRoute extends Route {
@service session;
@service store;
@service ipc;
// =attributes
@@ -64,6 +65,7 @@ export default class ScopesScopeProjectsSessionsIndexRoute extends Route {
const from = transition.from?.name;
const projects = this.modelFor('scopes.scope.projects');
const projectIds = projects.map((project) => project.id);
const { id: scope_id } = this.modelFor('scopes.scope');
const filters = {
user_id: [{ equals: this.session.data.authenticated.user_id }],
@@ -87,6 +89,8 @@ export default class ScopesScopeProjectsSessionsIndexRoute extends Route {
}
const queryOptions = {
scope_id,
recursive: true,
query: { filters },
page,
pageSize,
@@ -101,6 +105,8 @@ export default class ScopesScopeProjectsSessionsIndexRoute extends Route {
this.allSessions = await this.store.query(
'session',
{
scope_id,
recursive: true,
query: {
filters: {
user_id: [{ equals: this.session.data.authenticated.user_id }],
@@ -109,7 +115,11 @@ export default class ScopesScopeProjectsSessionsIndexRoute extends Route {
},
options,
);
this.allTargets = await this.store.query('target', {}, options);
this.allTargets = await this.store.query(
'target',
{ scope_id, recursive: true },
options,
);
}
return {
@@ -118,6 +128,7 @@ export default class ScopesScopeProjectsSessionsIndexRoute extends Route {
allSessions: this.allSessions,
allTargets: this.allTargets,
totalItems,
isClientDaemonRunning: await this.ipc.invoke('isClientDaemonRunning'),
};
}
@@ -20,6 +20,7 @@ export default class ScopesScopeProjectsTargetsIndexRoute extends Route {
@service router;
@service session;
@service store;
@service ipc;
// =attributes
@@ -83,7 +84,8 @@ export default class ScopesScopeProjectsTargetsIndexRoute extends Route {
{ search, scopes, availableSessions, types, page, pageSize },
transition,
) {
await this.getAllTargets(transition);
const { id: scope_id } = this.modelFor('scopes.scope');
await this.getAllTargets(transition, scope_id);
const projects = this.modelFor('scopes.scope.projects');
const projectIds = projects.map((project) => project.id);
@@ -103,6 +105,8 @@ export default class ScopesScopeProjectsTargetsIndexRoute extends Route {
// Retrieve all sessions so that the session and activeSessions getters
// in the target model always retrieve the most up-to-date sessions.
const sessions = await this.store.query('session', {
recursive: true,
scope_id,
query: {
filters: {
user_id: [{ equals: this.session.data.authenticated.user_id }],
@@ -118,6 +122,8 @@ export default class ScopesScopeProjectsTargetsIndexRoute extends Route {
this.addActiveSessionFilters(filters, availableSessions, sessions);
let targets = await this.store.query('target', {
recursive: true,
scope_id,
query: { search, filters },
page,
pageSize,
@@ -125,8 +131,6 @@ export default class ScopesScopeProjectsTargetsIndexRoute extends Route {
});
const totalItems = targets.meta?.totalItems;
// TODO: Filter targets by scope we're in manually
// const { id: scope_id } = this.modelFor('scopes.scope');
// Filter out targets to which users do not have the connect ability
targets = targets.filter((target) =>
this.can.can('connect target', target),
@@ -137,6 +141,7 @@ export default class ScopesScopeProjectsTargetsIndexRoute extends Route {
projects,
allTargets: this.allTargets,
totalItems,
isClientDaemonRunning: await this.ipc.invoke('isClientDaemonRunning'),
};
}
@@ -156,13 +161,17 @@ export default class ScopesScopeProjectsTargetsIndexRoute extends Route {
* @param transition
* @returns {Promise<void>}
*/
async getAllTargets(transition) {
async getAllTargets(transition, scope_id) {
const from = transition.from?.name;
// Query all targets for defining filtering values if entering route for first time
if (from !== 'scopes.scope.projects.targets.index') {
const options = { pushToStore: false };
const allTargets = await this.store.query('target', {}, options);
const allTargets = await this.store.query(
'target',
{ scope_id, recursive: true },
options,
);
// Filter out targets to which users do not have the connect ability
this.allTargets = allTargets.filter((target) =>
@@ -4,55 +4,57 @@
}}
{{#if (or @model.sessions @model.allSessions)}}
<Hds::SegmentedGroup as |S|>
<S.Generic>
<Dropdown
@name={{t 'resources.target.title'}}
@itemOptions={{this.availableTargets}}
@checkedItems={{this.targets}}
@applyFilter={{fn this.applyFilter 'targets'}}
@isSearchable={{true}}
@listPosition='bottom-left'
/>
</S.Generic>
<S.Generic>
<Dropdown
@name={{t 'form.status.label'}}
@itemOptions={{this.sessionStatusOptions}}
@checkedItems={{this.status}}
@applyFilter={{fn this.applyFilter 'status'}}
/>
</S.Generic>
<S.Generic>
<Dropdown
@name={{t 'resources.scope.title'}}
@itemOptions={{this.availableScopes}}
@checkedItems={{this.scopes}}
@applyFilter={{fn this.applyFilter 'scopes'}}
@isSearchable={{true}}
as |FD selectItem itemOptions|
>
{{#let (group-by itemOptions 'scopeModel') as |orgs|}}
{{#each orgs as |org|}}
<FD.Generic>
<FlightIcon @name='org' />
{{org.key.displayName}}
</FD.Generic>
{{#each org.items as |project|}}
<FD.Checkbox
@value={{project.id}}
checked={{includes project.id this.scopes}}
{{on 'change' selectItem}}
>
{{project.displayName}}
</FD.Checkbox>
{{#if @model.isClientDaemonRunning}}
<Hds::SegmentedGroup as |S|>
<S.Generic>
<Dropdown
@name={{t 'resources.target.title'}}
@itemOptions={{this.availableTargets}}
@checkedItems={{this.targets}}
@applyFilter={{fn this.applyFilter 'targets'}}
@isSearchable={{true}}
@listPosition='bottom-left'
/>
</S.Generic>
<S.Generic>
<Dropdown
@name={{t 'form.status.label'}}
@itemOptions={{this.sessionStatusOptions}}
@checkedItems={{this.status}}
@applyFilter={{fn this.applyFilter 'status'}}
/>
</S.Generic>
<S.Generic>
<Dropdown
@name={{t 'resources.scope.title'}}
@itemOptions={{this.availableScopes}}
@checkedItems={{this.scopes}}
@applyFilter={{fn this.applyFilter 'scopes'}}
@isSearchable={{true}}
as |FD selectItem itemOptions|
>
{{#let (group-by itemOptions 'scopeModel') as |orgs|}}
{{#each orgs as |org|}}
<FD.Generic>
<FlightIcon @name='org' />
{{org.key.displayName}}
</FD.Generic>
{{#each org.items as |project|}}
<FD.Checkbox
@value={{project.id}}
checked={{includes project.id this.scopes}}
{{on 'change' selectItem}}
>
{{project.displayName}}
</FD.Checkbox>
{{/each}}
{{/each}}
{{/each}}
{{/let}}
</Dropdown>
</S.Generic>
</Hds::SegmentedGroup>
<FilterTags @filters={{this.filters}} />
{{/let}}
</Dropdown>
</S.Generic>
</Hds::SegmentedGroup>
<FilterTags @filters={{this.filters}} />
{{/if}}
{{#if @model.sessions}}
<Hds::Table
@model={{this.sortedSessions}}
@@ -4,61 +4,64 @@
}}
{{#if this.showFilters}}
<Hds::SegmentedGroup as |S|>
<S.TextInput
@value={{this.search}}
@type='search'
placeholder={{t 'actions.search'}}
aria-label={{t 'actions.search'}}
{{on 'input' this.handleSearchInput}}
/>
<S.Generic>
<Dropdown
@name={{t 'resources.target.titles.active-sessions'}}
@itemOptions={{this.availableSessionOptions}}
@checkedItems={{this.availableSessions}}
@applyFilter={{fn this.applyFilter 'availableSessions'}}
{{#if @model.isClientDaemonRunning}}
<Hds::SegmentedGroup as |S|>
<S.TextInput
@value={{this.search}}
@type='search'
placeholder={{t 'actions.search'}}
aria-label={{t 'actions.search'}}
{{on 'input' this.handleSearchInput}}
/>
</S.Generic>
<S.Generic>
<Dropdown
@name={{t 'form.type.label'}}
@itemOptions={{this.targetTypeOptions}}
@checkedItems={{this.types}}
@applyFilter={{fn this.applyFilter 'types'}}
/>
</S.Generic>
<S.Generic>
<Dropdown
@name={{t 'resources.scope.title'}}
@itemOptions={{this.availableScopes}}
@checkedItems={{this.scopes}}
@applyFilter={{fn this.applyFilter 'scopes'}}
@isSearchable={{true}}
as |FD selectItem itemOptions|
>
{{#let (group-by itemOptions 'scopeModel') as |orgs|}}
{{#each orgs as |org|}}
<FD.Generic>
<FlightIcon @name='org' />
{{org.key.displayName}}
</FD.Generic>
{{#each org.items as |project|}}
<FD.Checkbox
@value={{project.id}}
data-test-checkbox={{project.name}}
checked={{includes project.id this.scopes}}
{{on 'change' selectItem}}
>
{{project.displayName}}
</FD.Checkbox>
<S.Generic>
<Dropdown
@name={{t 'resources.target.titles.active-sessions'}}
@itemOptions={{this.availableSessionOptions}}
@checkedItems={{this.availableSessions}}
@applyFilter={{fn this.applyFilter 'availableSessions'}}
/>
</S.Generic>
<S.Generic>
<Dropdown
@name={{t 'form.type.label'}}
@itemOptions={{this.targetTypeOptions}}
@checkedItems={{this.types}}
@applyFilter={{fn this.applyFilter 'types'}}
/>
</S.Generic>
<S.Generic>
<Dropdown
@name={{t 'resources.scope.title'}}
@itemOptions={{this.availableScopes}}
@checkedItems={{this.scopes}}
@applyFilter={{fn this.applyFilter 'scopes'}}
@isSearchable={{true}}
as |FD selectItem itemOptions|
>
{{#let (group-by itemOptions 'scopeModel') as |orgs|}}
{{#each orgs as |org|}}
<FD.Generic>
<FlightIcon @name='org' />
{{org.key.displayName}}
</FD.Generic>
{{#each org.items as |project|}}
<FD.Checkbox
@value={{project.id}}
data-test-checkbox={{project.name}}
checked={{includes project.id this.scopes}}
{{on 'change' selectItem}}
>
{{project.displayName}}
</FD.Checkbox>
{{/each}}
{{/each}}
{{/each}}
{{/let}}
</Dropdown>
</S.Generic>
</Hds::SegmentedGroup>
<FilterTags @filters={{this.filters}} />
{{/let}}
</Dropdown>
</S.Generic>
</Hds::SegmentedGroup>
<FilterTags @filters={{this.filters}} />
{{/if}}
{{#if @model.targets}}
<Hds::Table
@model={{@model.targets}}
+4 -3
View File
@@ -146,9 +146,10 @@ handle('searchClientDaemon', async (request) =>
* Check to see if the client daemon is running. We use the presence of a
* socket path as a proxy for whether the daemon is running.
*/
handle('isClientDaemonRunning', async () =>
Boolean(clientDaemonManager.socketPath),
);
handle('isClientDaemonRunning', async () => {
clientDaemonManager.status();
return Boolean(clientDaemonManager.socketPath);
});
/**
* Handler to help create terminal windows. We don't use the helper `handle` method
@@ -180,6 +180,7 @@ module('Acceptance | projects | sessions | index', function (hooks) {
await click(`[href="${urls.sessions}"]`);
assert.strictEqual(currentURL(), urls.sessions);
assert.dom('.hds-segmented-group').exists();
assert.dom('tbody tr').exists({ count: sessionsCount });
});
@@ -398,4 +399,16 @@ module('Acceptance | projects | sessions | index', function (hooks) {
.dom(`[data-test-session-detail-link="${instances.session2.id}"]`)
.isVisible();
});
test('sessions list view still loads with no client daemon', async function (assert) {
this.ipcStub.withArgs('isClientDaemonRunning').returns(false);
this.stubClientDaemonSearch();
const sessionsCount = this.server.schema.sessions.all().models.length;
await visit(urls.globalSessions);
assert.dom('.hds-segmented-group').doesNotExist();
assert.strictEqual(currentURL(), urls.globalSessions);
assert.dom('tbody tr').exists({ count: sessionsCount });
});
});
@@ -177,6 +177,7 @@ module('Acceptance | projects | targets | index', function (hooks) {
await click(`[href="${urls.targets}"]`);
await a11yAudit();
assert.dom('.hds-segmented-group').exists();
assert.strictEqual(currentURL(), urls.targets);
assert.strictEqual(getTargetCount(), targetsCount);
});
@@ -455,4 +456,18 @@ module('Acceptance | projects | targets | index', function (hooks) {
.dom(`[data-test-target-project-id="${instances.scopes.project2.id}"]`)
.doesNotExist();
});
test('targets list view still loads with no client daemon', async function (assert) {
this.ipcStub.withArgs('isClientDaemonRunning').returns(false);
this.stubClientDaemonSearch();
const targetsCount = getTargetCount();
await visit(urls.projects);
await click(`[href="${urls.targets}"]`);
assert.dom('.hds-segmented-group').doesNotExist();
assert.strictEqual(currentURL(), urls.targets);
assert.dom('tbody tr').exists({ count: targetsCount });
});
});