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",
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "8.4.0",
|
||||
"@design.estate/dees-catalog": "^3.43.3",
|
||||
"@design.estate/dees-element": "^2.1.6"
|
||||
},
|
||||
|
||||
@@ -136,4 +136,8 @@ Deno.test('ConnectionManager with StorageManager: create and load', async () =>
|
||||
const conns = cm2.getConnections();
|
||||
assertEquals(conns.length, 1);
|
||||
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 storageManager: StorageManager;
|
||||
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) {
|
||||
this.storageManager = storageManager;
|
||||
@@ -26,6 +28,25 @@ export class ConnectionManager {
|
||||
async init(): Promise<void> {
|
||||
await this.migrateLegacyFile();
|
||||
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',
|
||||
feedMetadata: undefined,
|
||||
bundledContent: bundledFiles,
|
||||
noCache: true,
|
||||
addCustomRoutes: async (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<{
|
||||
connectionId: string;
|
||||
scope: 'project' | 'group';
|
||||
scope?: 'project' | 'group';
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetAllSecrets
|
||||
>('/typedrequest', 'getAllSecrets');
|
||||
const response = await typedRequest.fire({
|
||||
identity: context.identity!,
|
||||
connectionId: dataArg.connectionId,
|
||||
scope: dataArg.scope,
|
||||
});
|
||||
return { ...statePartArg.getState(), secrets: response.secrets };
|
||||
// When no scope specified, fetch both project and group secrets in parallel
|
||||
const scopes: Array<'project' | 'group'> = dataArg.scope ? [dataArg.scope] : ['project', 'group'];
|
||||
const results = await Promise.all(
|
||||
scopes.map(async (scope) => {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetAllSecrets
|
||||
>('/typedrequest', 'getAllSecrets');
|
||||
const response = await typedRequest.fire({
|
||||
identity: context.identity!,
|
||||
connectionId: dataArg.connectionId,
|
||||
scope,
|
||||
});
|
||||
return response.secrets;
|
||||
}),
|
||||
);
|
||||
return { ...statePartArg.getState(), secrets: results.flat() };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch all secrets:', err);
|
||||
return statePartArg.getState();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import * as interfaces from '../../../../ts_interfaces/index.js';
|
||||
import * as appstate from '../../../appstate.js';
|
||||
import { viewHostCss } from '../../shared/index.js';
|
||||
import {
|
||||
DeesElement,
|
||||
@@ -160,16 +161,7 @@ export class GitopsViewActions extends DeesElement {
|
||||
}
|
||||
|
||||
private getIdentity(): interfaces.data.IIdentity | null {
|
||||
try {
|
||||
const stored = localStorage.getItem('smartstate_loginStatePart');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed.identity || null;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
return appstate.loginStatePart.getState().identity;
|
||||
}
|
||||
|
||||
private async refreshStatus(): Promise<void> {
|
||||
|
||||
@@ -33,7 +33,7 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
accessor selectedConnectionId: string = '';
|
||||
|
||||
@state()
|
||||
accessor selectedScope: 'project' | 'group' = 'project';
|
||||
accessor selectedScope: 'all' | 'project' | 'group' = 'all';
|
||||
|
||||
@state()
|
||||
accessor selectedScopeId: string = '__all__';
|
||||
@@ -71,10 +71,16 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
];
|
||||
|
||||
private get filteredSecrets() {
|
||||
if (this.selectedScopeId === '__all__') {
|
||||
return this.dataState.secrets;
|
||||
let secrets = 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 {
|
||||
@@ -84,20 +90,23 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
}));
|
||||
|
||||
const scopeOptions = [
|
||||
{ option: 'All Scopes', key: 'all' },
|
||||
{ option: 'Project', key: 'project' },
|
||||
{ option: 'Group', key: 'group' },
|
||||
];
|
||||
|
||||
const entities = this.selectedScope === 'project'
|
||||
? 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 }));
|
||||
const entities = this.selectedScope === 'group'
|
||||
? 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 = [
|
||||
{ option: 'All', key: '__all__' },
|
||||
...entities,
|
||||
];
|
||||
|
||||
const isAllSelected = this.selectedScopeId === '__all__';
|
||||
const isAllSelected = this.selectedScope === 'all' || this.selectedScopeId === '__all__';
|
||||
|
||||
return html`
|
||||
<div class="view-title">Secrets</div>
|
||||
@@ -119,20 +128,22 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
.options=${scopeOptions}
|
||||
.selectedOption=${scopeOptions.find((o) => o.key === this.selectedScope)}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedScope = e.detail.key as 'project' | 'group';
|
||||
this.selectedScope = e.detail.key as 'all' | 'project' | 'group';
|
||||
this.selectedScopeId = '__all__';
|
||||
this.loadEntities();
|
||||
this.loadSecrets();
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-dropdown
|
||||
.label=${this.selectedScope === 'project' ? 'Project' : 'Group'}
|
||||
.options=${entityOptions}
|
||||
.selectedOption=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedScopeId = e.detail.key;
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
${this.selectedScope !== 'all' ? html`
|
||||
<dees-input-dropdown
|
||||
.label=${this.selectedScope === 'project' ? 'Project' : 'Group'}
|
||||
.options=${entityOptions}
|
||||
.selectedOption=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedScopeId = e.detail.key;
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
` : ''}
|
||||
<dees-button
|
||||
.disabled=${isAllSelected}
|
||||
@click=${() => this.addSecret()}
|
||||
@@ -185,6 +196,7 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
|
||||
private async loadEntities() {
|
||||
if (!this.selectedConnectionId) return;
|
||||
if (this.selectedScope === 'all') return;
|
||||
if (this.selectedScope === 'project') {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
@@ -198,14 +210,14 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
|
||||
private async loadSecrets() {
|
||||
if (!this.selectedConnectionId) return;
|
||||
// Always fetch both scopes — client-side filtering handles the rest
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchAllSecretsAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
scope: this.selectedScope,
|
||||
});
|
||||
}
|
||||
|
||||
private async addSecret() {
|
||||
if (this.selectedScopeId === '__all__') return;
|
||||
if (this.selectedScope === 'all' || this.selectedScopeId === '__all__') return;
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Add Secret',
|
||||
content: html`
|
||||
|
||||
Reference in New Issue
Block a user