fix(core): fix secrets scan upserts, connection health checks, and frontend improvements
- Add upsert pattern to SecretsScanService to prevent duplicate key errors on repeated scans - Auto-test connection health on startup so status reflects reality - Fix Actions view to read identity from appstate instead of broken localStorage hack - Fetch both project and group secrets in parallel, add "All Scopes" filter to Secrets view - Enable noCache on UtilityWebsiteServer to prevent stale browser cache
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||||
|
"@api.global/typedserver": "8.4.0",
|
||||||
"@design.estate/dees-catalog": "^3.43.3",
|
"@design.estate/dees-catalog": "^3.43.3",
|
||||||
"@design.estate/dees-element": "^2.1.6"
|
"@design.estate/dees-element": "^2.1.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -136,4 +136,8 @@ Deno.test('ConnectionManager with StorageManager: create and load', async () =>
|
|||||||
const conns = cm2.getConnections();
|
const conns = cm2.getConnections();
|
||||||
assertEquals(conns.length, 1);
|
assertEquals(conns.length, 1);
|
||||||
assertEquals(conns[0].id, conn.id);
|
assertEquals(conns[0].id, conn.id);
|
||||||
|
|
||||||
|
// Wait for background health checks to avoid resource leaks
|
||||||
|
await cm.healthCheckDone;
|
||||||
|
await cm2.healthCheckDone;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export class ConnectionManager {
|
|||||||
private connections: interfaces.data.IProviderConnection[] = [];
|
private connections: interfaces.data.IProviderConnection[] = [];
|
||||||
private storageManager: StorageManager;
|
private storageManager: StorageManager;
|
||||||
private smartSecret: plugins.smartsecret.SmartSecret;
|
private smartSecret: plugins.smartsecret.SmartSecret;
|
||||||
|
/** Resolves when background connection health checks complete */
|
||||||
|
public healthCheckDone: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
constructor(storageManager: StorageManager, smartSecret: plugins.smartsecret.SmartSecret) {
|
constructor(storageManager: StorageManager, smartSecret: plugins.smartsecret.SmartSecret) {
|
||||||
this.storageManager = storageManager;
|
this.storageManager = storageManager;
|
||||||
@@ -26,6 +28,25 @@ export class ConnectionManager {
|
|||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
await this.migrateLegacyFile();
|
await this.migrateLegacyFile();
|
||||||
await this.loadConnections();
|
await this.loadConnections();
|
||||||
|
// Auto-test all connections in the background
|
||||||
|
this.healthCheckDone = this.testAllConnections();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests all loaded connections in the background and updates their status.
|
||||||
|
* Fire-and-forget — does not block startup.
|
||||||
|
*/
|
||||||
|
private async testAllConnections(): Promise<void> {
|
||||||
|
for (const conn of this.connections) {
|
||||||
|
try {
|
||||||
|
const provider = this.getProvider(conn.id);
|
||||||
|
const result = await provider.testConnection();
|
||||||
|
conn.status = result.ok ? 'connected' : 'error';
|
||||||
|
await this.persistConnection(conn);
|
||||||
|
} catch {
|
||||||
|
conn.status = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export class OpsServer {
|
|||||||
domain: 'localhost',
|
domain: 'localhost',
|
||||||
feedMetadata: undefined,
|
feedMetadata: undefined,
|
||||||
bundledContent: bundledFiles,
|
bundledContent: bundledFiles,
|
||||||
|
noCache: true,
|
||||||
addCustomRoutes: async (typedserver) => {
|
addCustomRoutes: async (typedserver) => {
|
||||||
this.webhookHandler.registerRoutes(typedserver);
|
this.webhookHandler.registerRoutes(typedserver);
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -306,19 +306,26 @@ export const fetchSecretsAction = dataStatePart.createAction<{
|
|||||||
|
|
||||||
export const fetchAllSecretsAction = dataStatePart.createAction<{
|
export const fetchAllSecretsAction = dataStatePart.createAction<{
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
scope: 'project' | 'group';
|
scope?: 'project' | 'group';
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
try {
|
try {
|
||||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
// When no scope specified, fetch both project and group secrets in parallel
|
||||||
interfaces.requests.IReq_GetAllSecrets
|
const scopes: Array<'project' | 'group'> = dataArg.scope ? [dataArg.scope] : ['project', 'group'];
|
||||||
>('/typedrequest', 'getAllSecrets');
|
const results = await Promise.all(
|
||||||
const response = await typedRequest.fire({
|
scopes.map(async (scope) => {
|
||||||
identity: context.identity!,
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
connectionId: dataArg.connectionId,
|
interfaces.requests.IReq_GetAllSecrets
|
||||||
scope: dataArg.scope,
|
>('/typedrequest', 'getAllSecrets');
|
||||||
});
|
const response = await typedRequest.fire({
|
||||||
return { ...statePartArg.getState(), secrets: response.secrets };
|
identity: context.identity!,
|
||||||
|
connectionId: dataArg.connectionId,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
return response.secrets;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { ...statePartArg.getState(), secrets: results.flat() };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch all secrets:', err);
|
console.error('Failed to fetch all secrets:', err);
|
||||||
return statePartArg.getState();
|
return statePartArg.getState();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as plugins from '../../../plugins.js';
|
import * as plugins from '../../../plugins.js';
|
||||||
import * as interfaces from '../../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../../ts_interfaces/index.js';
|
||||||
|
import * as appstate from '../../../appstate.js';
|
||||||
import { viewHostCss } from '../../shared/index.js';
|
import { viewHostCss } from '../../shared/index.js';
|
||||||
import {
|
import {
|
||||||
DeesElement,
|
DeesElement,
|
||||||
@@ -160,16 +161,7 @@ export class GitopsViewActions extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getIdentity(): interfaces.data.IIdentity | null {
|
private getIdentity(): interfaces.data.IIdentity | null {
|
||||||
try {
|
return appstate.loginStatePart.getState().identity;
|
||||||
const stored = localStorage.getItem('smartstate_loginStatePart');
|
|
||||||
if (stored) {
|
|
||||||
const parsed = JSON.parse(stored);
|
|
||||||
return parsed.identity || null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshStatus(): Promise<void> {
|
private async refreshStatus(): Promise<void> {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export class GitopsViewSecrets extends DeesElement {
|
|||||||
accessor selectedConnectionId: string = '';
|
accessor selectedConnectionId: string = '';
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor selectedScope: 'project' | 'group' = 'project';
|
accessor selectedScope: 'all' | 'project' | 'group' = 'all';
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor selectedScopeId: string = '__all__';
|
accessor selectedScopeId: string = '__all__';
|
||||||
@@ -71,10 +71,16 @@ export class GitopsViewSecrets extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
private get filteredSecrets() {
|
private get filteredSecrets() {
|
||||||
if (this.selectedScopeId === '__all__') {
|
let secrets = this.dataState.secrets;
|
||||||
return this.dataState.secrets;
|
// Filter by scope (unless "all")
|
||||||
|
if (this.selectedScope !== 'all') {
|
||||||
|
secrets = secrets.filter((s) => s.scope === this.selectedScope);
|
||||||
}
|
}
|
||||||
return this.dataState.secrets.filter((s) => s.scopeId === this.selectedScopeId);
|
// Filter by entity if specific one selected
|
||||||
|
if (this.selectedScopeId !== '__all__') {
|
||||||
|
secrets = secrets.filter((s) => s.scopeId === this.selectedScopeId);
|
||||||
|
}
|
||||||
|
return secrets;
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
@@ -84,20 +90,23 @@ export class GitopsViewSecrets extends DeesElement {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const scopeOptions = [
|
const scopeOptions = [
|
||||||
|
{ option: 'All Scopes', key: 'all' },
|
||||||
{ option: 'Project', key: 'project' },
|
{ option: 'Project', key: 'project' },
|
||||||
{ option: 'Group', key: 'group' },
|
{ option: 'Group', key: 'group' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const entities = this.selectedScope === 'project'
|
const entities = this.selectedScope === 'group'
|
||||||
? this.dataState.projects.map((p) => ({ option: p.fullPath || p.name, key: p.id }))
|
? this.dataState.groups.map((g) => ({ option: g.fullPath || g.name, key: g.id }))
|
||||||
: this.dataState.groups.map((g) => ({ option: g.fullPath || g.name, key: g.id }));
|
: this.selectedScope === 'project'
|
||||||
|
? this.dataState.projects.map((p) => ({ option: p.fullPath || p.name, key: p.id }))
|
||||||
|
: [];
|
||||||
|
|
||||||
const entityOptions = [
|
const entityOptions = [
|
||||||
{ option: 'All', key: '__all__' },
|
{ option: 'All', key: '__all__' },
|
||||||
...entities,
|
...entities,
|
||||||
];
|
];
|
||||||
|
|
||||||
const isAllSelected = this.selectedScopeId === '__all__';
|
const isAllSelected = this.selectedScope === 'all' || this.selectedScopeId === '__all__';
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="view-title">Secrets</div>
|
<div class="view-title">Secrets</div>
|
||||||
@@ -119,20 +128,22 @@ export class GitopsViewSecrets extends DeesElement {
|
|||||||
.options=${scopeOptions}
|
.options=${scopeOptions}
|
||||||
.selectedOption=${scopeOptions.find((o) => o.key === this.selectedScope)}
|
.selectedOption=${scopeOptions.find((o) => o.key === this.selectedScope)}
|
||||||
@selectedOption=${(e: CustomEvent) => {
|
@selectedOption=${(e: CustomEvent) => {
|
||||||
this.selectedScope = e.detail.key as 'project' | 'group';
|
this.selectedScope = e.detail.key as 'all' | 'project' | 'group';
|
||||||
this.selectedScopeId = '__all__';
|
this.selectedScopeId = '__all__';
|
||||||
this.loadEntities();
|
this.loadEntities();
|
||||||
this.loadSecrets();
|
this.loadSecrets();
|
||||||
}}
|
}}
|
||||||
></dees-input-dropdown>
|
></dees-input-dropdown>
|
||||||
<dees-input-dropdown
|
${this.selectedScope !== 'all' ? html`
|
||||||
.label=${this.selectedScope === 'project' ? 'Project' : 'Group'}
|
<dees-input-dropdown
|
||||||
.options=${entityOptions}
|
.label=${this.selectedScope === 'project' ? 'Project' : 'Group'}
|
||||||
.selectedOption=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
|
.options=${entityOptions}
|
||||||
@selectedOption=${(e: CustomEvent) => {
|
.selectedOption=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
|
||||||
this.selectedScopeId = e.detail.key;
|
@selectedOption=${(e: CustomEvent) => {
|
||||||
}}
|
this.selectedScopeId = e.detail.key;
|
||||||
></dees-input-dropdown>
|
}}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
` : ''}
|
||||||
<dees-button
|
<dees-button
|
||||||
.disabled=${isAllSelected}
|
.disabled=${isAllSelected}
|
||||||
@click=${() => this.addSecret()}
|
@click=${() => this.addSecret()}
|
||||||
@@ -185,6 +196,7 @@ export class GitopsViewSecrets extends DeesElement {
|
|||||||
|
|
||||||
private async loadEntities() {
|
private async loadEntities() {
|
||||||
if (!this.selectedConnectionId) return;
|
if (!this.selectedConnectionId) return;
|
||||||
|
if (this.selectedScope === 'all') return;
|
||||||
if (this.selectedScope === 'project') {
|
if (this.selectedScope === 'project') {
|
||||||
await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, {
|
await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, {
|
||||||
connectionId: this.selectedConnectionId,
|
connectionId: this.selectedConnectionId,
|
||||||
@@ -198,14 +210,14 @@ export class GitopsViewSecrets extends DeesElement {
|
|||||||
|
|
||||||
private async loadSecrets() {
|
private async loadSecrets() {
|
||||||
if (!this.selectedConnectionId) return;
|
if (!this.selectedConnectionId) return;
|
||||||
|
// Always fetch both scopes — client-side filtering handles the rest
|
||||||
await appstate.dataStatePart.dispatchAction(appstate.fetchAllSecretsAction, {
|
await appstate.dataStatePart.dispatchAction(appstate.fetchAllSecretsAction, {
|
||||||
connectionId: this.selectedConnectionId,
|
connectionId: this.selectedConnectionId,
|
||||||
scope: this.selectedScope,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async addSecret() {
|
private async addSecret() {
|
||||||
if (this.selectedScopeId === '__all__') return;
|
if (this.selectedScope === 'all' || this.selectedScopeId === '__all__') return;
|
||||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||||
heading: 'Add Secret',
|
heading: 'Add Secret',
|
||||||
content: html`
|
content: html`
|
||||||
|
|||||||
Reference in New Issue
Block a user