Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 481b72b8fb | |||
| c9786591e3 | |||
| c5834f3cd1 | |||
| 179bb9223e | |||
| ee3f01993f | |||
| 15e845d5f8 | |||
| 0815e4c8ae | |||
| 7e6b774982 | |||
| 768bd1ef53 | |||
| 71176a1856 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,6 +7,8 @@ node_modules/
|
||||
|
||||
# Build outputs
|
||||
# ts_bundled/ is committed (embedded frontend bundle)
|
||||
ts_bundled/bundle.js
|
||||
ts_bundled/bundle.js.map
|
||||
|
||||
# Development
|
||||
.nogit/
|
||||
|
||||
39
changelog.md
39
changelog.md
@@ -1,5 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-24 - 2.7.1 - fix(repo)
|
||||
update file metadata (mode/permissions) without content changes
|
||||
|
||||
- One file changed: metadata-only (+1,-1).
|
||||
- No source or behavior changes — safe to bump patch version.
|
||||
- Change likely involves file mode/permission or metadata update only.
|
||||
|
||||
## 2026-02-24 - 2.7.0 - feat(secrets)
|
||||
add ability to fetch and view all secrets across projects and groups, include scopeName, and improve frontend merging/filtering
|
||||
|
||||
- Add new typed request and handler getAllSecrets to opsserver to bulk-fetch secrets across projects or groups (batched and using Promise.allSettled for performance).
|
||||
- Extend ISecret with scopeName and update provider mappings (Gitea/GitLab) and secret return values to include scopeName.
|
||||
- Frontend: add fetchAllSecretsAction, add an "All" option in the Secrets view, filter table by selected entity or show all, and disable "Add Secret" when "All" is selected.
|
||||
- Create/update actions now merge only the affected entity's secrets into state instead of replacing the entire list; delete now filters by key+scope+scopeId to avoid removing unrelated secrets.
|
||||
- UI: table now shows a Scope column using scopeName (or fallback to scopeId), selection changes trigger reloading of entities and secrets.
|
||||
|
||||
## 2026-02-24 - 2.6.2 - fix(meta)
|
||||
update file metadata only (no source changes)
|
||||
|
||||
- One file changed: metadata-only (e.g. permissions/mode) with no content modifications.
|
||||
- No code, dependency, or API changes detected; safe patch release recommended.
|
||||
- Bump patch version from 2.6.1 to 2.6.2.
|
||||
|
||||
## 2026-02-24 - 2.6.1 - fix(package.json)
|
||||
apply metadata-only update (no functional changes)
|
||||
|
||||
- Change is metadata-only (+1 -1) in a single file — no code or behavior changes
|
||||
- Current package.json version is 2.6.0; recommend a patch bump to 2.6.1
|
||||
|
||||
## 2026-02-24 - 2.6.0 - feat(webhook)
|
||||
add webhook endpoint and client push notifications, auto-refresh UI, and gitea id mapping fixes
|
||||
|
||||
- Add WebhookHandler with POST /webhook/:connectionId that parses provider-specific headers and broadcasts webhookNotification via TypedSocket to connected clients
|
||||
- Frontend: add auto-refresh toggle, refresh-interval action, dashboard auto-refresh timer, and views subscribing to gitops-auto-refresh events to refresh data
|
||||
- Frontend: add WebSocket client with reconnect logic to receive push notifications and trigger auto-refresh on webhook events
|
||||
- Gitea provider: prefer repository full_name and organization name when mapping project and group ids to ensure stable identifiers
|
||||
- Bump devDependencies: @git.zone/tsbundle ^2.9.0 and @git.zone/tswatch ^3.2.0
|
||||
- Add ts_bundled/bundle.js and bundle.js.map to .gitignore
|
||||
|
||||
## 2026-02-24 - 2.5.0 - feat(gitea-provider)
|
||||
auto-paginate Gitea repository and organization listing; respect explicit page option and default perPage to 50
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/gitops",
|
||||
"version": "2.5.0",
|
||||
"version": "2.7.1",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/gitops",
|
||||
"version": "2.5.0",
|
||||
"version": "2.7.1",
|
||||
"description": "GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs",
|
||||
"main": "mod.ts",
|
||||
"type": "module",
|
||||
@@ -18,7 +18,7 @@
|
||||
"@design.estate/dees-element": "^2.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbundle": "^2.8.4",
|
||||
"@git.zone/tswatch": "^3.1.0"
|
||||
"@git.zone/tsbundle": "^2.9.0",
|
||||
"@git.zone/tswatch": "^3.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
3
readme.todo.md
Normal file
3
readme.todo.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# GitOps TODOs
|
||||
|
||||
- [ ] Webhook HMAC signature verification (X-Gitea-Signature / X-Gitlab-Token) — currently accepts all POSTs
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/gitops',
|
||||
version: '2.5.0',
|
||||
version: '2.7.1',
|
||||
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
|
||||
}
|
||||
|
||||
@@ -17,16 +17,23 @@ export class OpsServer {
|
||||
public secretsHandler!: handlers.SecretsHandler;
|
||||
public pipelinesHandler!: handlers.PipelinesHandler;
|
||||
public logsHandler!: handlers.LogsHandler;
|
||||
public webhookHandler!: handlers.WebhookHandler;
|
||||
|
||||
constructor(gitopsAppRef: GitopsApp) {
|
||||
this.gitopsAppRef = gitopsAppRef;
|
||||
}
|
||||
|
||||
public async start(port = 3000) {
|
||||
// Create webhook handler before server so routes register via addCustomRoutes
|
||||
this.webhookHandler = new handlers.WebhookHandler(this);
|
||||
|
||||
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
||||
domain: 'localhost',
|
||||
feedMetadata: undefined,
|
||||
bundledContent: bundledFiles,
|
||||
addCustomRoutes: async (typedserver) => {
|
||||
this.webhookHandler.registerRoutes(typedserver);
|
||||
},
|
||||
});
|
||||
|
||||
// Chain typedrouters
|
||||
|
||||
@@ -5,3 +5,4 @@ export { GroupsHandler } from './groups.handler.ts';
|
||||
export { SecretsHandler } from './secrets.handler.ts';
|
||||
export { PipelinesHandler } from './pipelines.handler.ts';
|
||||
export { LogsHandler } from './logs.handler.ts';
|
||||
export { WebhookHandler } from './webhook.handler.ts';
|
||||
|
||||
@@ -12,6 +12,58 @@ export class SecretsHandler {
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get all secrets (bulk fetch across all entities)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllSecrets>(
|
||||
'getAllSecrets',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider(
|
||||
dataArg.connectionId,
|
||||
);
|
||||
|
||||
const allSecrets: interfaces.data.ISecret[] = [];
|
||||
|
||||
if (dataArg.scope === 'project') {
|
||||
const projects = await provider.getProjects();
|
||||
// Fetch in batches of 5 for performance
|
||||
for (let i = 0; i < projects.length; i += 5) {
|
||||
const batch = projects.slice(i, i + 5);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (p) => {
|
||||
const secrets = await provider.getProjectSecrets(p.id);
|
||||
return secrets.map((s) => ({ ...s, scopeName: p.fullPath || p.name }));
|
||||
}),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
allSecrets.push(...result.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const groups = await provider.getGroups();
|
||||
for (let i = 0; i < groups.length; i += 5) {
|
||||
const batch = groups.slice(i, i + 5);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (g) => {
|
||||
const secrets = await provider.getGroupSecrets(g.id);
|
||||
return secrets.map((s) => ({ ...s, scopeName: g.fullPath || g.name }));
|
||||
}),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
allSecrets.push(...result.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { secrets: allSecrets };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get secrets
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecrets>(
|
||||
|
||||
62
ts/opsserver/handlers/webhook.handler.ts
Normal file
62
ts/opsserver/handlers/webhook.handler.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import { logger } from '../../logging.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
|
||||
export class WebhookHandler {
|
||||
constructor(private opsServerRef: OpsServer) {}
|
||||
|
||||
public registerRoutes(typedserver: plugins.typedserver.TypedServer): void {
|
||||
typedserver.addRoute('/webhook/:connectionId', 'POST', async (ctx) => {
|
||||
const connectionId = ctx.params.connectionId;
|
||||
|
||||
// Validate connection exists
|
||||
const connection = this.opsServerRef.gitopsAppRef.connectionManager.getConnection(connectionId);
|
||||
if (!connection) {
|
||||
return new Response(JSON.stringify({ error: 'Connection not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Parse event type from provider-specific headers
|
||||
const giteaEvent = ctx.headers.get('X-Gitea-Event');
|
||||
const gitlabEvent = ctx.headers.get('X-Gitlab-Event');
|
||||
const event = giteaEvent || gitlabEvent || 'unknown';
|
||||
const provider = giteaEvent ? 'gitea' : gitlabEvent ? 'gitlab' : 'unknown';
|
||||
|
||||
logger.info(`Webhook received: ${provider}/${event} for connection ${connection.name} (${connectionId})`);
|
||||
|
||||
// Broadcast to all connected frontends via TypedSocket
|
||||
try {
|
||||
const typedsocket = this.opsServerRef.server.typedserver.typedsocket;
|
||||
if (typedsocket) {
|
||||
const connections = await typedsocket.findAllTargetConnectionsByTag('allClients');
|
||||
for (const conn of connections) {
|
||||
const req = typedsocket.createTypedRequest<interfaces.requests.IReq_WebhookNotification>(
|
||||
'webhookNotification',
|
||||
conn,
|
||||
);
|
||||
req.fire({
|
||||
connectionId,
|
||||
provider,
|
||||
event,
|
||||
timestamp: Date.now(),
|
||||
}).catch((err: any) => {
|
||||
logger.warn(`Failed to notify client: ${err.message || err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(`Failed to broadcast webhook event: ${err.message || err}`);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('WebhookHandler routes registered');
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export class GiteaProvider extends BaseProvider {
|
||||
value: string,
|
||||
): Promise<interfaces.data.ISecret> {
|
||||
await this.client.setRepoSecret(projectId, key, value);
|
||||
return { key, value: '***', protected: false, masked: true, scope: 'project', scopeId: projectId, connectionId: this.connectionId, environment: '*' };
|
||||
return { key, value: '***', protected: false, masked: true, scope: 'project', scopeId: projectId, scopeName: projectId, connectionId: this.connectionId, environment: '*' };
|
||||
}
|
||||
|
||||
async updateProjectSecret(
|
||||
@@ -100,7 +100,7 @@ export class GiteaProvider extends BaseProvider {
|
||||
value: string,
|
||||
): Promise<interfaces.data.ISecret> {
|
||||
await this.client.setOrgSecret(groupId, key, value);
|
||||
return { key, value: '***', protected: false, masked: true, scope: 'group', scopeId: groupId, connectionId: this.connectionId, environment: '*' };
|
||||
return { key, value: '***', protected: false, masked: true, scope: 'group', scopeId: groupId, scopeName: groupId, connectionId: this.connectionId, environment: '*' };
|
||||
}
|
||||
|
||||
async updateGroupSecret(
|
||||
@@ -149,7 +149,7 @@ export class GiteaProvider extends BaseProvider {
|
||||
|
||||
private mapProject(r: plugins.giteaClient.IGiteaRepository): interfaces.data.IProject {
|
||||
return {
|
||||
id: String(r.id),
|
||||
id: r.full_name || String(r.id),
|
||||
name: r.name || '',
|
||||
fullPath: r.full_name || '',
|
||||
description: r.description || '',
|
||||
@@ -164,7 +164,7 @@ export class GiteaProvider extends BaseProvider {
|
||||
|
||||
private mapGroup(o: plugins.giteaClient.IGiteaOrganization): interfaces.data.IGroup {
|
||||
return {
|
||||
id: String(o.id || o.name),
|
||||
id: o.name || String(o.id),
|
||||
name: o.name || '',
|
||||
fullPath: o.name || '',
|
||||
description: o.description || '',
|
||||
@@ -175,7 +175,7 @@ export class GiteaProvider extends BaseProvider {
|
||||
};
|
||||
}
|
||||
|
||||
private mapSecret(s: plugins.giteaClient.IGiteaSecret, scope: 'project' | 'group', scopeId: string): interfaces.data.ISecret {
|
||||
private mapSecret(s: plugins.giteaClient.IGiteaSecret, scope: 'project' | 'group', scopeId: string, scopeName?: string): interfaces.data.ISecret {
|
||||
return {
|
||||
key: s.name || '',
|
||||
value: '***',
|
||||
@@ -183,6 +183,7 @@ export class GiteaProvider extends BaseProvider {
|
||||
masked: true,
|
||||
scope,
|
||||
scopeId,
|
||||
scopeName: scopeName || scopeId,
|
||||
connectionId: this.connectionId,
|
||||
environment: '*',
|
||||
};
|
||||
|
||||
@@ -149,6 +149,7 @@ export class GitLabProvider extends BaseProvider {
|
||||
v: plugins.gitlabClient.IGitLabVariable,
|
||||
scope: 'project' | 'group',
|
||||
scopeId: string,
|
||||
scopeName?: string,
|
||||
): interfaces.data.ISecret {
|
||||
return {
|
||||
key: v.key || '',
|
||||
@@ -157,6 +158,7 @@ export class GitLabProvider extends BaseProvider {
|
||||
masked: v.masked || false,
|
||||
scope,
|
||||
scopeId,
|
||||
scopeName: scopeName || scopeId,
|
||||
connectionId: this.connectionId,
|
||||
environment: v.environment_scope || '*',
|
||||
};
|
||||
|
||||
166543
ts_bundled/bundle.js
166543
ts_bundled/bundle.js
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,6 +5,7 @@ export interface ISecret {
|
||||
masked: boolean;
|
||||
scope: 'project' | 'group';
|
||||
scopeId: string;
|
||||
scopeName: string;
|
||||
connectionId: string;
|
||||
environment: string;
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './groups.ts';
|
||||
export * from './secrets.ts';
|
||||
export * from './pipelines.ts';
|
||||
export * from './logs.ts';
|
||||
export * from './webhook.ts';
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetAllSecrets extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetAllSecrets
|
||||
> {
|
||||
method: 'getAllSecrets';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
connectionId: string;
|
||||
scope: 'project' | 'group';
|
||||
};
|
||||
response: {
|
||||
secrets: data.ISecret[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetSecrets extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSecrets
|
||||
|
||||
18
ts_interfaces/requests/webhook.ts
Normal file
18
ts_interfaces/requests/webhook.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_WebhookNotification extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_WebhookNotification
|
||||
> {
|
||||
method: 'webhookNotification';
|
||||
request: {
|
||||
connectionId: string;
|
||||
provider: string;
|
||||
event: string;
|
||||
timestamp: number;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/gitops',
|
||||
version: '2.5.0',
|
||||
version: '2.7.1',
|
||||
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
|
||||
}
|
||||
|
||||
@@ -304,6 +304,27 @@ export const fetchSecretsAction = dataStatePart.createAction<{
|
||||
}
|
||||
});
|
||||
|
||||
export const fetchAllSecretsAction = dataStatePart.createAction<{
|
||||
connectionId: string;
|
||||
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 };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch all secrets:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const createSecretAction = dataStatePart.createAction<{
|
||||
connectionId: string;
|
||||
scope: 'project' | 'group';
|
||||
@@ -320,7 +341,7 @@ export const createSecretAction = dataStatePart.createAction<{
|
||||
identity: context.identity!,
|
||||
...dataArg,
|
||||
});
|
||||
// Re-fetch secrets
|
||||
// Re-fetch only the affected entity's secrets and merge
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSecrets
|
||||
>('/typedrequest', 'getSecrets');
|
||||
@@ -330,7 +351,11 @@ export const createSecretAction = dataStatePart.createAction<{
|
||||
scope: dataArg.scope,
|
||||
scopeId: dataArg.scopeId,
|
||||
});
|
||||
return { ...statePartArg.getState(), secrets: listResp.secrets };
|
||||
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();
|
||||
@@ -353,7 +378,7 @@ export const updateSecretAction = dataStatePart.createAction<{
|
||||
identity: context.identity!,
|
||||
...dataArg,
|
||||
});
|
||||
// Re-fetch
|
||||
// Re-fetch only the affected entity's secrets and merge
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSecrets
|
||||
>('/typedrequest', 'getSecrets');
|
||||
@@ -363,7 +388,11 @@ export const updateSecretAction = dataStatePart.createAction<{
|
||||
scope: dataArg.scope,
|
||||
scopeId: dataArg.scopeId,
|
||||
});
|
||||
return { ...statePartArg.getState(), secrets: listResp.secrets };
|
||||
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();
|
||||
@@ -388,7 +417,9 @@ export const deleteSecretAction = dataStatePart.createAction<{
|
||||
const state = statePartArg.getState();
|
||||
return {
|
||||
...state,
|
||||
secrets: state.secrets.filter((s) => s.key !== dataArg.key),
|
||||
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);
|
||||
@@ -543,3 +574,9 @@ export const toggleAutoRefreshAction = uiStatePart.createAction(async (statePart
|
||||
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 };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -43,6 +43,14 @@ export class GitopsDashboard extends DeesElement {
|
||||
|
||||
private resolvedViewTabs: Array<{ name: string; iconName: string; element: any }> = [];
|
||||
|
||||
// Auto-refresh timer
|
||||
private autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// WebSocket client
|
||||
private ws: WebSocket | null = null;
|
||||
private wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private wsIntentionalClose = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
document.title = 'GitOps';
|
||||
@@ -53,7 +61,11 @@ export class GitopsDashboard extends DeesElement {
|
||||
this.loginState = loginState;
|
||||
if (loginState.isLoggedIn) {
|
||||
appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
||||
this.connectWebSocket();
|
||||
} else {
|
||||
this.disconnectWebSocket();
|
||||
}
|
||||
this.manageAutoRefreshTimer();
|
||||
});
|
||||
this.rxSubscriptions.push(loginSubscription);
|
||||
|
||||
@@ -62,6 +74,7 @@ export class GitopsDashboard extends DeesElement {
|
||||
.subscribe((uiState) => {
|
||||
this.uiState = uiState;
|
||||
this.syncAppdashView(uiState.activeView);
|
||||
this.manageAutoRefreshTimer();
|
||||
});
|
||||
this.rxSubscriptions.push(uiSubscription);
|
||||
}
|
||||
@@ -78,6 +91,36 @@ export class GitopsDashboard extends DeesElement {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
.auto-refresh-toggle {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 1000;
|
||||
background: rgba(30, 30, 50, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.auto-refresh-toggle:hover {
|
||||
background: rgba(40, 40, 70, 0.95);
|
||||
}
|
||||
.auto-refresh-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
}
|
||||
.auto-refresh-dot.active {
|
||||
background: #00ff88;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -92,6 +135,15 @@ export class GitopsDashboard extends DeesElement {
|
||||
</dees-simple-appdash>
|
||||
</dees-simple-login>
|
||||
</div>
|
||||
${this.loginState.isLoggedIn ? html`
|
||||
<div
|
||||
class="auto-refresh-toggle"
|
||||
@click=${() => appstate.uiStatePart.dispatchAction(appstate.toggleAutoRefreshAction, null)}
|
||||
>
|
||||
<span class="auto-refresh-dot ${this.uiState.autoRefresh ? 'active' : ''}"></span>
|
||||
Auto-Refresh: ${this.uiState.autoRefresh ? 'ON' : 'OFF'}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -160,6 +212,93 @@ export class GitopsDashboard extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.clearAutoRefreshTimer();
|
||||
this.disconnectWebSocket();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auto-refresh timer management
|
||||
// ============================================================================
|
||||
|
||||
private manageAutoRefreshTimer(): void {
|
||||
this.clearAutoRefreshTimer();
|
||||
const { autoRefresh, refreshInterval } = this.uiState;
|
||||
if (autoRefresh && this.loginState.isLoggedIn) {
|
||||
this.autoRefreshTimer = setInterval(() => {
|
||||
document.dispatchEvent(new CustomEvent('gitops-auto-refresh'));
|
||||
}, refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private clearAutoRefreshTimer(): void {
|
||||
if (this.autoRefreshTimer) {
|
||||
clearInterval(this.autoRefreshTimer);
|
||||
this.autoRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket client for webhook push notifications
|
||||
// ============================================================================
|
||||
|
||||
private connectWebSocket(): void {
|
||||
if (this.ws) return;
|
||||
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${location.host}`;
|
||||
|
||||
try {
|
||||
this.wsIntentionalClose = false;
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
// TypedSocket wraps messages; look for webhookNotification method
|
||||
if (data?.method === 'webhookNotification' || data?.type === 'webhookEvent') {
|
||||
console.log('Webhook event received:', data);
|
||||
document.dispatchEvent(new CustomEvent('gitops-auto-refresh'));
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('close', () => {
|
||||
this.ws = null;
|
||||
if (!this.wsIntentionalClose && this.loginState.isLoggedIn) {
|
||||
this.wsReconnectTimer = setTimeout(() => {
|
||||
this.connectWebSocket();
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('error', () => {
|
||||
// Will trigger close event
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('WebSocket connection failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private disconnectWebSocket(): void {
|
||||
this.wsIntentionalClose = true;
|
||||
if (this.wsReconnectTimer) {
|
||||
clearTimeout(this.wsReconnectTimer);
|
||||
this.wsReconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Login
|
||||
// ============================================================================
|
||||
|
||||
private async login(username: string, password: string) {
|
||||
const domtools = await this.domtoolsPromise;
|
||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||
|
||||
@@ -38,6 +38,8 @@ export class GitopsViewBuildlog extends DeesElement {
|
||||
@state()
|
||||
accessor selectedJobId: string = '';
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const connSub = appstate.connectionsStatePart
|
||||
@@ -49,6 +51,18 @@ export class GitopsViewBuildlog extends DeesElement {
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.dataState = s; });
|
||||
this.rxSubscriptions.push(dataSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.handleAutoRefresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
this.fetchLog();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
|
||||
@@ -19,12 +19,26 @@ export class GitopsViewConnections extends DeesElement {
|
||||
activeConnectionId: null,
|
||||
};
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.connectionsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.connectionsState = s; });
|
||||
this.rxSubscriptions.push(sub);
|
||||
|
||||
this._autoRefreshHandler = () => this.handleAutoRefresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
|
||||
@@ -32,6 +32,8 @@ export class GitopsViewGroups extends DeesElement {
|
||||
@state()
|
||||
accessor selectedConnectionId: string = '';
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const connSub = appstate.connectionsStatePart
|
||||
@@ -43,6 +45,18 @@ export class GitopsViewGroups extends DeesElement {
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.dataState = s; });
|
||||
this.rxSubscriptions.push(dataSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.handleAutoRefresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
this.loadGroups();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
|
||||
@@ -30,6 +30,8 @@ export class GitopsViewOverview extends DeesElement {
|
||||
currentJobLog: '',
|
||||
};
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const connSub = appstate.connectionsStatePart
|
||||
@@ -41,6 +43,18 @@ export class GitopsViewOverview extends DeesElement {
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.dataState = s; });
|
||||
this.rxSubscriptions.push(dataSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.handleAutoRefresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
|
||||
@@ -35,6 +35,8 @@ export class GitopsViewPipelines extends DeesElement {
|
||||
@state()
|
||||
accessor selectedProjectId: string = '';
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const connSub = appstate.connectionsStatePart
|
||||
@@ -46,6 +48,18 @@ export class GitopsViewPipelines extends DeesElement {
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.dataState = s; });
|
||||
this.rxSubscriptions.push(dataSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.handleAutoRefresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
this.loadPipelines();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
|
||||
@@ -32,6 +32,8 @@ export class GitopsViewProjects extends DeesElement {
|
||||
@state()
|
||||
accessor selectedConnectionId: string = '';
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const connSub = appstate.connectionsStatePart
|
||||
@@ -43,6 +45,18 @@ export class GitopsViewProjects extends DeesElement {
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.dataState = s; });
|
||||
this.rxSubscriptions.push(dataSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.handleAutoRefresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
this.loadProjects();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
|
||||
@@ -36,7 +36,9 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
accessor selectedScope: 'project' | 'group' = 'project';
|
||||
|
||||
@state()
|
||||
accessor selectedScopeId: string = '';
|
||||
accessor selectedScopeId: string = '__all__';
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -49,6 +51,18 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.dataState = s; });
|
||||
this.rxSubscriptions.push(dataSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.handleAutoRefresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
this.loadSecrets();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -56,6 +70,13 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
viewHostCss,
|
||||
];
|
||||
|
||||
private get filteredSecrets() {
|
||||
if (this.selectedScopeId === '__all__') {
|
||||
return this.dataState.secrets;
|
||||
}
|
||||
return this.dataState.secrets.filter((s) => s.scopeId === this.selectedScopeId);
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const connectionOptions = this.connectionsState.connections.map((c) => ({
|
||||
option: `${c.name} (${c.providerType})`,
|
||||
@@ -67,10 +88,17 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
{ option: 'Group', key: 'group' },
|
||||
];
|
||||
|
||||
const entityOptions = this.selectedScope === 'project'
|
||||
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 entityOptions = [
|
||||
{ option: 'All', key: '__all__' },
|
||||
...entities,
|
||||
];
|
||||
|
||||
const isAllSelected = this.selectedScopeId === '__all__';
|
||||
|
||||
return html`
|
||||
<div class="view-title">Secrets</div>
|
||||
<div class="view-description">Manage CI/CD secrets and variables</div>
|
||||
@@ -81,7 +109,9 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
.selectedOption=${connectionOptions.find((o) => o.key === this.selectedConnectionId) || connectionOptions[0]}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedConnectionId = e.detail.key;
|
||||
this.selectedScopeId = '__all__';
|
||||
this.loadEntities();
|
||||
this.loadSecrets();
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-dropdown
|
||||
@@ -90,7 +120,9 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
.selectedOption=${scopeOptions.find((o) => o.key === this.selectedScope)}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedScope = e.detail.key as 'project' | 'group';
|
||||
this.selectedScopeId = '__all__';
|
||||
this.loadEntities();
|
||||
this.loadSecrets();
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-dropdown
|
||||
@@ -99,18 +131,21 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
.selectedOption=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedScopeId = e.detail.key;
|
||||
this.loadSecrets();
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<dees-button @click=${() => this.addSecret()}>Add Secret</dees-button>
|
||||
<dees-button
|
||||
.disabled=${isAllSelected}
|
||||
@click=${() => this.addSecret()}
|
||||
>Add Secret</dees-button>
|
||||
<dees-button @click=${() => this.loadSecrets()}>Refresh</dees-button>
|
||||
</div>
|
||||
<dees-table
|
||||
.heading1=${'Secrets'}
|
||||
.heading2=${'CI/CD variables for the selected entity'}
|
||||
.data=${this.dataState.secrets}
|
||||
.data=${this.filteredSecrets}
|
||||
.displayFunction=${(item: any) => ({
|
||||
Key: item.key,
|
||||
Scope: item.scopeName || item.scopeId,
|
||||
Value: item.masked ? '******' : item.value,
|
||||
Protected: item.protected ? 'Yes' : 'No',
|
||||
Environment: item.environment || '*',
|
||||
@@ -127,8 +162,8 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
action: async (item: any) => {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.deleteSecretAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
scope: this.selectedScope,
|
||||
scopeId: this.selectedScopeId,
|
||||
scope: item.scope,
|
||||
scopeId: item.scopeId,
|
||||
key: item.key,
|
||||
});
|
||||
},
|
||||
@@ -144,6 +179,7 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
if (conns.length > 0 && !this.selectedConnectionId) {
|
||||
this.selectedConnectionId = conns[0].id;
|
||||
await this.loadEntities();
|
||||
await this.loadSecrets();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,15 +197,15 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
}
|
||||
|
||||
private async loadSecrets() {
|
||||
if (!this.selectedConnectionId || !this.selectedScopeId) return;
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchSecretsAction, {
|
||||
if (!this.selectedConnectionId) return;
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchAllSecretsAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
scope: this.selectedScope,
|
||||
scopeId: this.selectedScopeId,
|
||||
});
|
||||
}
|
||||
|
||||
private async addSecret() {
|
||||
if (this.selectedScopeId === '__all__') return;
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Add Secret',
|
||||
content: html`
|
||||
@@ -220,8 +256,8 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
const input = modal.shadowRoot.querySelector('dees-input-text');
|
||||
await appstate.dataStatePart.dispatchAction(appstate.updateSecretAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
scope: this.selectedScope,
|
||||
scopeId: this.selectedScopeId,
|
||||
scope: item.scope,
|
||||
scopeId: item.scopeId,
|
||||
key: item.key,
|
||||
value: input?.value || '',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user