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:
2026-02-24 22:50:26 +00:00
parent 43131fa53c
commit e3f67d12a3
8 changed files with 78 additions and 40 deletions

View File

@@ -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"
}, },

View File

@@ -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;
}); });

View File

@@ -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';
}
}
} }
/** /**

View File

@@ -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

View File

@@ -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 {
// 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< const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetAllSecrets interfaces.requests.IReq_GetAllSecrets
>('/typedrequest', 'getAllSecrets'); >('/typedrequest', 'getAllSecrets');
const response = await typedRequest.fire({ const response = await typedRequest.fire({
identity: context.identity!, identity: context.identity!,
connectionId: dataArg.connectionId, connectionId: dataArg.connectionId,
scope: dataArg.scope, scope,
}); });
return { ...statePartArg.getState(), secrets: response.secrets }; 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();

View File

@@ -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> {

View File

@@ -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.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 })) ? 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 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,12 +128,13 @@ 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>
${this.selectedScope !== 'all' ? html`
<dees-input-dropdown <dees-input-dropdown
.label=${this.selectedScope === 'project' ? 'Project' : 'Group'} .label=${this.selectedScope === 'project' ? 'Project' : 'Group'}
.options=${entityOptions} .options=${entityOptions}
@@ -133,6 +143,7 @@ export class GitopsViewSecrets extends DeesElement {
this.selectedScopeId = e.detail.key; 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`