Files
gitops/ts_web/appstate.ts
Juergen Kunz e3f67d12a3 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
2026-02-24 22:50:26 +00:00

590 lines
19 KiB
TypeScript

import * as plugins from './plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
// ============================================================================
// Smartstate instance
// ============================================================================
export const appState = new plugins.domtools.plugins.smartstate.Smartstate();
// ============================================================================
// State Part Interfaces
// ============================================================================
export interface ILoginState {
identity: interfaces.data.IIdentity | null;
isLoggedIn: boolean;
}
export interface IConnectionsState {
connections: interfaces.data.IProviderConnection[];
activeConnectionId: string | null;
}
export interface IDataState {
projects: interfaces.data.IProject[];
groups: interfaces.data.IGroup[];
secrets: interfaces.data.ISecret[];
pipelines: interfaces.data.IPipeline[];
pipelineJobs: interfaces.data.IPipelineJob[];
currentJobLog: string;
}
export interface IUiState {
activeView: string;
autoRefresh: boolean;
refreshInterval: number;
}
// ============================================================================
// State Parts
// ============================================================================
export const loginStatePart = await appState.getStatePart<ILoginState>(
'login',
{
identity: null,
isLoggedIn: false,
},
'persistent',
);
export const connectionsStatePart = await appState.getStatePart<IConnectionsState>(
'connections',
{
connections: [],
activeConnectionId: null,
},
'soft',
);
export const dataStatePart = await appState.getStatePart<IDataState>(
'data',
{
projects: [],
groups: [],
secrets: [],
pipelines: [],
pipelineJobs: [],
currentJobLog: '',
},
'soft',
);
export const uiStatePart = await appState.getStatePart<IUiState>(
'ui',
{
activeView: 'overview',
autoRefresh: true,
refreshInterval: 30000,
},
);
// ============================================================================
// Helpers
// ============================================================================
interface IActionContext {
identity: interfaces.data.IIdentity | null;
}
const getActionContext = (): IActionContext => {
return { identity: loginStatePart.getState().identity };
};
// ============================================================================
// Login Actions
// ============================================================================
export const loginAction = loginStatePart.createAction<{
username: string;
password: string;
}>(async (statePartArg, dataArg) => {
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_AdminLogin
>('/typedrequest', 'adminLogin');
const response = await typedRequest.fire({
username: dataArg.username,
password: dataArg.password,
});
return {
identity: response.identity || null,
isLoggedIn: !!response.identity,
};
} catch (err) {
console.error('Login failed:', err);
return { identity: null, isLoggedIn: false };
}
});
export const logoutAction = loginStatePart.createAction(async (_statePartArg) => {
const context = getActionContext();
try {
if (context.identity) {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_AdminLogout
>('/typedrequest', 'adminLogout');
await typedRequest.fire({ identity: context.identity });
}
} catch (err) {
console.error('Logout error:', err);
}
return { identity: null, isLoggedIn: false };
});
// ============================================================================
// Connections Actions
// ============================================================================
export const fetchConnectionsAction = connectionsStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetConnections
>('/typedrequest', 'getConnections');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), connections: response.connections };
} catch (err) {
console.error('Failed to fetch connections:', err);
return statePartArg.getState();
}
});
export const createConnectionAction = connectionsStatePart.createAction<{
name: string;
providerType: interfaces.data.TProviderType;
baseUrl: string;
token: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateConnection
>('/typedrequest', 'createConnection');
await typedRequest.fire({
identity: context.identity!,
...dataArg,
});
// Re-fetch
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetConnections
>('/typedrequest', 'getConnections');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), connections: listResp.connections };
} catch (err) {
console.error('Failed to create connection:', err);
return statePartArg.getState();
}
});
export const testConnectionAction = connectionsStatePart.createAction<{
connectionId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_TestConnection
>('/typedrequest', 'testConnection');
const result = await typedRequest.fire({
identity: context.identity!,
connectionId: dataArg.connectionId,
});
// Re-fetch to get updated status
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetConnections
>('/typedrequest', 'getConnections');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), connections: listResp.connections };
} catch (err) {
console.error('Failed to test connection:', err);
return statePartArg.getState();
}
});
export const deleteConnectionAction = connectionsStatePart.createAction<{
connectionId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteConnection
>('/typedrequest', 'deleteConnection');
await typedRequest.fire({
identity: context.identity!,
connectionId: dataArg.connectionId,
});
const state = statePartArg.getState();
return {
...state,
connections: state.connections.filter((c) => c.id !== dataArg.connectionId),
activeConnectionId: state.activeConnectionId === dataArg.connectionId ? null : state.activeConnectionId,
};
} catch (err) {
console.error('Failed to delete connection:', err);
return statePartArg.getState();
}
});
// ============================================================================
// Projects Actions
// ============================================================================
export const fetchProjectsAction = dataStatePart.createAction<{
connectionId: string;
search?: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetProjects
>('/typedrequest', 'getProjects');
const response = await typedRequest.fire({
identity: context.identity!,
connectionId: dataArg.connectionId,
search: dataArg.search,
});
return { ...statePartArg.getState(), projects: response.projects };
} catch (err) {
console.error('Failed to fetch projects:', err);
return statePartArg.getState();
}
});
// ============================================================================
// Groups Actions
// ============================================================================
export const fetchGroupsAction = dataStatePart.createAction<{
connectionId: string;
search?: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetGroups
>('/typedrequest', 'getGroups');
const response = await typedRequest.fire({
identity: context.identity!,
connectionId: dataArg.connectionId,
search: dataArg.search,
});
return { ...statePartArg.getState(), groups: response.groups };
} catch (err) {
console.error('Failed to fetch groups:', err);
return statePartArg.getState();
}
});
// ============================================================================
// Secrets Actions
// ============================================================================
export const fetchSecretsAction = dataStatePart.createAction<{
connectionId: string;
scope: 'project' | 'group';
scopeId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSecrets
>('/typedrequest', 'getSecrets');
const response = await typedRequest.fire({
identity: context.identity!,
connectionId: dataArg.connectionId,
scope: dataArg.scope,
scopeId: dataArg.scopeId,
});
return { ...statePartArg.getState(), secrets: response.secrets };
} catch (err) {
console.error('Failed to fetch secrets:', err);
return statePartArg.getState();
}
});
export const fetchAllSecretsAction = dataStatePart.createAction<{
connectionId: string;
scope?: 'project' | 'group';
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
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<
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();
}
});
export const createSecretAction = dataStatePart.createAction<{
connectionId: string;
scope: 'project' | 'group';
scopeId: string;
key: string;
value: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateSecret
>('/typedrequest', 'createSecret');
await typedRequest.fire({
identity: context.identity!,
...dataArg,
});
// Re-fetch only the affected entity's secrets and merge
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSecrets
>('/typedrequest', 'getSecrets');
const listResp = await listReq.fire({
identity: context.identity!,
connectionId: dataArg.connectionId,
scope: dataArg.scope,
scopeId: dataArg.scopeId,
});
const state = statePartArg.getState();
const otherSecrets = state.secrets.filter(
(s) => !(s.scopeId === dataArg.scopeId && s.scope === dataArg.scope),
);
return { ...state, secrets: [...otherSecrets, ...listResp.secrets] };
} catch (err) {
console.error('Failed to create secret:', err);
return statePartArg.getState();
}
});
export const updateSecretAction = dataStatePart.createAction<{
connectionId: string;
scope: 'project' | 'group';
scopeId: string;
key: string;
value: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateSecret
>('/typedrequest', 'updateSecret');
await typedRequest.fire({
identity: context.identity!,
...dataArg,
});
// Re-fetch only the affected entity's secrets and merge
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSecrets
>('/typedrequest', 'getSecrets');
const listResp = await listReq.fire({
identity: context.identity!,
connectionId: dataArg.connectionId,
scope: dataArg.scope,
scopeId: dataArg.scopeId,
});
const state = statePartArg.getState();
const otherSecrets = state.secrets.filter(
(s) => !(s.scopeId === dataArg.scopeId && s.scope === dataArg.scope),
);
return { ...state, secrets: [...otherSecrets, ...listResp.secrets] };
} catch (err) {
console.error('Failed to update secret:', err);
return statePartArg.getState();
}
});
export const deleteSecretAction = dataStatePart.createAction<{
connectionId: string;
scope: 'project' | 'group';
scopeId: string;
key: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteSecret
>('/typedrequest', 'deleteSecret');
await typedRequest.fire({
identity: context.identity!,
...dataArg,
});
const state = statePartArg.getState();
return {
...state,
secrets: state.secrets.filter(
(s) => !(s.key === dataArg.key && s.scopeId === dataArg.scopeId && s.scope === dataArg.scope),
),
};
} catch (err) {
console.error('Failed to delete secret:', err);
return statePartArg.getState();
}
});
// ============================================================================
// Pipelines Actions
// ============================================================================
export const fetchPipelinesAction = dataStatePart.createAction<{
connectionId: string;
projectId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetPipelines
>('/typedrequest', 'getPipelines');
const response = await typedRequest.fire({
identity: context.identity!,
connectionId: dataArg.connectionId,
projectId: dataArg.projectId,
});
return { ...statePartArg.getState(), pipelines: response.pipelines };
} catch (err) {
console.error('Failed to fetch pipelines:', err);
return statePartArg.getState();
}
});
export const fetchPipelineJobsAction = dataStatePart.createAction<{
connectionId: string;
projectId: string;
pipelineId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetPipelineJobs
>('/typedrequest', 'getPipelineJobs');
const response = await typedRequest.fire({
identity: context.identity!,
connectionId: dataArg.connectionId,
projectId: dataArg.projectId,
pipelineId: dataArg.pipelineId,
});
return { ...statePartArg.getState(), pipelineJobs: response.jobs };
} catch (err) {
console.error('Failed to fetch pipeline jobs:', err);
return statePartArg.getState();
}
});
export const retryPipelineAction = dataStatePart.createAction<{
connectionId: string;
projectId: string;
pipelineId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RetryPipeline
>('/typedrequest', 'retryPipeline');
await typedRequest.fire({
identity: context.identity!,
...dataArg,
});
// Re-fetch pipelines
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetPipelines
>('/typedrequest', 'getPipelines');
const listResp = await listReq.fire({
identity: context.identity!,
connectionId: dataArg.connectionId,
projectId: dataArg.projectId,
});
return { ...statePartArg.getState(), pipelines: listResp.pipelines };
} catch (err) {
console.error('Failed to retry pipeline:', err);
return statePartArg.getState();
}
});
export const cancelPipelineAction = dataStatePart.createAction<{
connectionId: string;
projectId: string;
pipelineId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CancelPipeline
>('/typedrequest', 'cancelPipeline');
await typedRequest.fire({
identity: context.identity!,
...dataArg,
});
// Re-fetch pipelines
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetPipelines
>('/typedrequest', 'getPipelines');
const listResp = await listReq.fire({
identity: context.identity!,
connectionId: dataArg.connectionId,
projectId: dataArg.projectId,
});
return { ...statePartArg.getState(), pipelines: listResp.pipelines };
} catch (err) {
console.error('Failed to cancel pipeline:', err);
return statePartArg.getState();
}
});
// ============================================================================
// Logs Actions
// ============================================================================
export const fetchJobLogAction = dataStatePart.createAction<{
connectionId: string;
projectId: string;
jobId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetJobLog
>('/typedrequest', 'getJobLog');
const response = await typedRequest.fire({
identity: context.identity!,
...dataArg,
});
return { ...statePartArg.getState(), currentJobLog: response.log };
} catch (err) {
console.error('Failed to fetch job log:', err);
return statePartArg.getState();
}
});
// ============================================================================
// UI Actions
// ============================================================================
export const setActiveViewAction = uiStatePart.createAction<{ view: string }>(
async (statePartArg, dataArg) => {
return { ...statePartArg.getState(), activeView: dataArg.view };
},
);
export const toggleAutoRefreshAction = uiStatePart.createAction(async (statePartArg) => {
const state = statePartArg.getState();
return { ...state, autoRefresh: !state.autoRefresh };
});
export const setRefreshIntervalAction = uiStatePart.createAction<{ interval: number }>(
async (statePartArg, dataArg) => {
return { ...statePartArg.getState(), refreshInterval: dataArg.interval };
},
);