15 Commits

Author SHA1 Message Date
481b72b8fb v2.7.1 2026-02-24 21:10:05 +00:00
c9786591e3 fix(repo): update file metadata (mode/permissions) without content changes 2026-02-24 21:10:05 +00:00
c5834f3cd1 v2.7.0 2026-02-24 21:09:17 +00:00
179bb9223e feat(secrets): add ability to fetch and view all secrets across projects and groups, include scopeName, and improve frontend merging/filtering 2026-02-24 21:09:17 +00:00
ee3f01993f v2.6.2 2026-02-24 20:19:34 +00:00
15e845d5f8 fix(meta): update file metadata only (no source changes) 2026-02-24 20:19:34 +00:00
0815e4c8ae v2.6.1 2026-02-24 20:16:02 +00:00
7e6b774982 fix(package.json): apply metadata-only update (no functional changes) 2026-02-24 20:16:02 +00:00
768bd1ef53 v2.6.0 2026-02-24 19:41:52 +00:00
71176a1856 feat(webhook): add webhook endpoint and client push notifications, auto-refresh UI, and gitea id mapping fixes 2026-02-24 19:41:52 +00:00
b576056fa1 v2.5.0 2026-02-24 18:41:26 +00:00
57935d6388 feat(gitea-provider): auto-paginate Gitea repository and organization listing; respect explicit page option and default perPage to 50 2026-02-24 18:41:26 +00:00
5ca8c1fb60 v2.4.0 2026-02-24 18:18:40 +00:00
92b0ec179f feat(opsserver): serve embedded frontend bundle from committed ts_bundled instead of using external dist_serve directory 2026-02-24 18:18:40 +00:00
06f447459e feat(security): integrate @push.rocks/smartsecret for keychain-based token storage
Connection tokens are now stored in OS keychain (or encrypted file fallback) instead of plaintext JSON. Existing plaintext tokens auto-migrate on first load.
2026-02-24 16:37:13 +00:00
37 changed files with 30237 additions and 166599 deletions

4
.gitignore vendored
View File

@@ -6,7 +6,9 @@ deno.lock
node_modules/ node_modules/
# Build outputs # Build outputs
dist_serve/ # ts_bundled/ is committed (embedded frontend bundle)
ts_bundled/bundle.js
ts_bundled/bundle.js.map
# Development # Development
.nogit/ .nogit/

View File

@@ -1,5 +1,60 @@
# Changelog # 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
- getProjects and getGroups now auto-fetch all pages when opts.page is not provided
- When opts.page is provided, the provider respects it and does not auto-paginate
- Defaults perPage to 50 for paginated requests
- Dependency @design.estate/dees-catalog bumped from ^3.43.0 to ^3.43.3
## 2026-02-24 - 2.4.0 - feat(opsserver)
serve embedded frontend bundle from committed ts_bundled instead of using external dist_serve directory
- Switch server to use bundledContent from committed ts_bundled bundle (base64ts) instead of pointing at a serveDir
- Update bundler config to emit ./ts_bundled/bundle.ts with outputMode 'base64ts' and includeFiles mapping
- Remove dist_serve from .gitignore and commit ts_bundled (embedded frontend bundle)
- Bump devDependency @git.zone/tsbundle to ^2.8.4 and deno dependency @api.global/typedserver to ^8.3.1
## 2026-02-24 - 2.3.0 - feat(storage) ## 2026-02-24 - 2.3.0 - feat(storage)
add StorageManager and cache subsystem; integrate storage into ConnectionManager and GitopsApp, migrate legacy connections, and add tests add StorageManager and cache subsystem; integrate storage into ConnectionManager and GitopsApp, migrate legacy connections, and add tests

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/gitops", "name": "@serve.zone/gitops",
"version": "2.3.0", "version": "2.7.1",
"exports": "./mod.ts", "exports": "./mod.ts",
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"tasks": { "tasks": {
@@ -13,13 +13,14 @@
"@std/encoding": "jsr:@std/encoding@^1.0.10", "@std/encoding": "jsr:@std/encoding@^1.0.10",
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19", "@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6", "@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.0", "@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0", "@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1", "@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1",
"@apiclient.xyz/gitea": "npm:@apiclient.xyz/gitea@^1.0.3", "@apiclient.xyz/gitea": "npm:@apiclient.xyz/gitea@^1.0.3",
"@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.0.3", "@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.0.3",
"@push.rocks/smartmongo": "npm:@push.rocks/smartmongo@^5.1.0", "@push.rocks/smartmongo": "npm:@push.rocks/smartmongo@^5.1.0",
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.0.15" "@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.0.15",
"@push.rocks/smartsecret": "npm:@push.rocks/smartsecret@^1.0.1"
}, },
"compilerOptions": { "compilerOptions": {
"lib": [ "lib": [

29544
dist_serve/bundle.js Normal file

File diff suppressed because one or more lines are too long

33
dist_serve/index.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta
name="viewport"
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="theme-color" content="#000000" />
<title>GitOps</title>
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
<style>
html {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
body {
position: relative;
background: #000;
margin: 0px;
}
</style>
</head>
<body>
<noscript>
<p style="color: #fff; text-align: center; margin-top: 100px;">
JavaScript is required to run the GitOps dashboard.
</p>
</noscript>
</body>
<script defer type="module" src="/bundle.js"></script>
</html>

View File

@@ -3,11 +3,11 @@
"bundles": [ "bundles": [
{ {
"from": "./ts_web/index.ts", "from": "./ts_web/index.ts",
"to": "./dist_serve/bundle.js", "to": "./ts_bundled/bundle.ts",
"outputMode": "bundle", "outputMode": "base64ts",
"bundler": "esbuild", "bundler": "esbuild",
"production": true, "production": true,
"includeFiles": ["./html/index.html"] "includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
} }
] ]
}, },
@@ -15,7 +15,8 @@
"bundles": [ "bundles": [
{ {
"from": "./ts_web/index.ts", "from": "./ts_web/index.ts",
"to": "./dist_serve/bundle.js", "to": "./ts_bundled/bundle.ts",
"outputMode": "base64ts",
"watchPatterns": ["./ts_web/**/*"], "watchPatterns": ["./ts_web/**/*"],
"triggerReload": true "triggerReload": true
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/gitops", "name": "@serve.zone/gitops",
"version": "2.3.0", "version": "2.7.1",
"description": "GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs", "description": "GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs",
"main": "mod.ts", "main": "mod.ts",
"type": "module", "type": "module",
@@ -14,11 +14,11 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@api.global/typedrequest-interfaces": "^3.0.19", "@api.global/typedrequest-interfaces": "^3.0.19",
"@design.estate/dees-catalog": "^3.43.0", "@design.estate/dees-catalog": "^3.43.3",
"@design.estate/dees-element": "^2.1.6" "@design.estate/dees-element": "^2.1.6"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbundle": "^2.8.3", "@git.zone/tsbundle": "^2.9.0",
"@git.zone/tswatch": "^3.1.0" "@git.zone/tswatch": "^3.2.0"
} }
} }

3
readme.todo.md Normal file
View File

@@ -0,0 +1,3 @@
# GitOps TODOs
- [ ] Webhook HMAC signature verification (X-Gitea-Signature / X-Gitlab-Token) — currently accepts all POSTs

View File

@@ -3,6 +3,7 @@ import { BaseProvider, GiteaProvider, GitLabProvider } from '../ts/providers/ind
import { ConnectionManager } from '../ts/classes/connectionmanager.ts'; import { ConnectionManager } from '../ts/classes/connectionmanager.ts';
import { GitopsApp } from '../ts/classes/gitopsapp.ts'; import { GitopsApp } from '../ts/classes/gitopsapp.ts';
import { StorageManager } from '../ts/storage/index.ts'; import { StorageManager } from '../ts/storage/index.ts';
import * as smartsecret from '@push.rocks/smartsecret';
Deno.test('GiteaProvider instantiates correctly', () => { Deno.test('GiteaProvider instantiates correctly', () => {
const provider = new GiteaProvider('test-id', 'https://gitea.example.com', 'test-token'); const provider = new GiteaProvider('test-id', 'https://gitea.example.com', 'test-token');
@@ -20,7 +21,8 @@ Deno.test('GitLabProvider instantiates correctly', () => {
Deno.test('ConnectionManager instantiates correctly', () => { Deno.test('ConnectionManager instantiates correctly', () => {
const storage = new StorageManager({ backend: 'memory' }); const storage = new StorageManager({ backend: 'memory' });
const manager = new ConnectionManager(storage); const secret = new smartsecret.SmartSecret({ service: 'gitops-test' });
const manager = new ConnectionManager(storage, secret);
assertExists(manager); assertExists(manager);
}); });
@@ -28,6 +30,7 @@ Deno.test('GitopsApp instantiates correctly', () => {
const app = new GitopsApp(); const app = new GitopsApp();
assertExists(app); assertExists(app);
assertExists(app.storageManager); assertExists(app.storageManager);
assertExists(app.smartSecret);
assertExists(app.connectionManager); assertExists(app.connectionManager);
assertExists(app.opsServer); assertExists(app.opsServer);
}); });

View File

@@ -1,5 +1,6 @@
import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts'; import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts';
import { StorageManager } from '../ts/storage/index.ts'; import { StorageManager } from '../ts/storage/index.ts';
import * as smartsecret from '@push.rocks/smartsecret';
Deno.test('StorageManager memory: set and get', async () => { Deno.test('StorageManager memory: set and get', async () => {
const sm = new StorageManager({ backend: 'memory' }); const sm = new StorageManager({ backend: 'memory' });
@@ -114,7 +115,8 @@ Deno.test('StorageManager filesystem: list keys', async () => {
Deno.test('ConnectionManager with StorageManager: create and load', async () => { Deno.test('ConnectionManager with StorageManager: create and load', async () => {
const { ConnectionManager } = await import('../ts/classes/connectionmanager.ts'); const { ConnectionManager } = await import('../ts/classes/connectionmanager.ts');
const sm = new StorageManager({ backend: 'memory' }); const sm = new StorageManager({ backend: 'memory' });
const cm = new ConnectionManager(sm); const secret = new smartsecret.SmartSecret({ service: 'gitops-test' });
const cm = new ConnectionManager(sm, secret);
await cm.init(); await cm.init();
// Create a connection // Create a connection
@@ -129,7 +131,7 @@ Deno.test('ConnectionManager with StorageManager: create and load', async () =>
assertEquals(stored.id, conn.id); assertEquals(stored.id, conn.id);
// Create a new ConnectionManager and verify it loads the connection // Create a new ConnectionManager and verify it loads the connection
const cm2 = new ConnectionManager(sm); const cm2 = new ConnectionManager(sm, secret);
await cm2.init(); await cm2.init();
const conns = cm2.getConnections(); const conns = cm2.getConnections();
assertEquals(conns.length, 1); assertEquals(conns.length, 1);

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/gitops', name: '@serve.zone/gitops',
version: '2.3.0', version: '2.7.1',
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs' description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
} }

View File

@@ -6,17 +6,21 @@ import type { StorageManager } from '../storage/index.ts';
const LEGACY_CONNECTIONS_FILE = './.nogit/connections.json'; const LEGACY_CONNECTIONS_FILE = './.nogit/connections.json';
const CONNECTIONS_PREFIX = '/connections/'; const CONNECTIONS_PREFIX = '/connections/';
const KEYCHAIN_PREFIX = 'keychain:';
/** /**
* Manages provider connections — persists each connection as an * Manages provider connections — persists each connection as an
* individual JSON file via StorageManager. * individual JSON file via StorageManager. Tokens are stored in
* the OS keychain (or encrypted file fallback) via SmartSecret.
*/ */
export class ConnectionManager { export class ConnectionManager {
private connections: interfaces.data.IProviderConnection[] = []; private connections: interfaces.data.IProviderConnection[] = [];
private storageManager: StorageManager; private storageManager: StorageManager;
private smartSecret: plugins.smartsecret.SmartSecret;
constructor(storageManager: StorageManager) { constructor(storageManager: StorageManager, smartSecret: plugins.smartsecret.SmartSecret) {
this.storageManager = storageManager; this.storageManager = storageManager;
this.smartSecret = smartSecret;
} }
async init(): Promise<void> { async init(): Promise<void> {
@@ -51,6 +55,18 @@ export class ConnectionManager {
for (const key of keys) { for (const key of keys) {
const conn = await this.storageManager.getJSON<interfaces.data.IProviderConnection>(key); const conn = await this.storageManager.getJSON<interfaces.data.IProviderConnection>(key);
if (conn) { if (conn) {
if (conn.token.startsWith(KEYCHAIN_PREFIX)) {
// Token is in keychain — retrieve it
const realToken = await this.smartSecret.getSecret(conn.id);
if (realToken) {
conn.token = realToken;
} else {
logger.warn(`Could not retrieve token for connection ${conn.id} from keychain`);
}
} else if (conn.token && conn.token !== '***') {
// Plaintext token found — auto-migrate to keychain
await this.migrateTokenToKeychain(conn);
}
this.connections.push(conn); this.connections.push(conn);
} }
} }
@@ -61,11 +77,33 @@ export class ConnectionManager {
} }
} }
/**
* Migrates a plaintext token to keychain storage.
*/
private async migrateTokenToKeychain(
conn: interfaces.data.IProviderConnection,
): Promise<void> {
try {
await this.smartSecret.setSecret(conn.id, conn.token);
// Save sentinel to JSON file
const jsonConn = { ...conn, token: `${KEYCHAIN_PREFIX}${conn.id}` };
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn);
logger.info(`Migrated token for connection "${conn.name}" to keychain`);
} catch (err) {
logger.warn(`Failed to migrate token for ${conn.id} to keychain: ${err}`);
}
}
private async persistConnection(conn: interfaces.data.IProviderConnection): Promise<void> { private async persistConnection(conn: interfaces.data.IProviderConnection): Promise<void> {
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, conn); // Store real token in keychain
await this.smartSecret.setSecret(conn.id, conn.token);
// Save JSON with sentinel value
const jsonConn = { ...conn, token: `${KEYCHAIN_PREFIX}${conn.id}` };
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn);
} }
private async removeConnection(id: string): Promise<void> { private async removeConnection(id: string): Promise<void> {
await this.smartSecret.deleteSecret(id);
await this.storageManager.delete(`${CONNECTIONS_PREFIX}${id}.json`); await this.storageManager.delete(`${CONNECTIONS_PREFIX}${id}.json`);
} }

View File

@@ -1,3 +1,4 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts'; import { logger } from '../logging.ts';
import { ConnectionManager } from './connectionmanager.ts'; import { ConnectionManager } from './connectionmanager.ts';
import { OpsServer } from '../opsserver/index.ts'; import { OpsServer } from '../opsserver/index.ts';
@@ -10,6 +11,7 @@ import { resolvePaths } from '../paths.ts';
*/ */
export class GitopsApp { export class GitopsApp {
public storageManager: StorageManager; public storageManager: StorageManager;
public smartSecret: plugins.smartsecret.SmartSecret;
public connectionManager: ConnectionManager; public connectionManager: ConnectionManager;
public opsServer: OpsServer; public opsServer: OpsServer;
public cacheDb: CacheDb; public cacheDb: CacheDb;
@@ -21,7 +23,8 @@ export class GitopsApp {
backend: 'filesystem', backend: 'filesystem',
fsPath: paths.defaultStoragePath, fsPath: paths.defaultStoragePath,
}); });
this.connectionManager = new ConnectionManager(this.storageManager); this.smartSecret = new plugins.smartsecret.SmartSecret({ service: 'gitops' });
this.connectionManager = new ConnectionManager(this.storageManager, this.smartSecret);
this.cacheDb = CacheDb.getInstance({ this.cacheDb = CacheDb.getInstance({
storagePath: paths.defaultTsmDbPath, storagePath: paths.defaultTsmDbPath,

View File

@@ -2,6 +2,7 @@ import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts'; import { logger } from '../logging.ts';
import type { GitopsApp } from '../classes/gitopsapp.ts'; import type { GitopsApp } from '../classes/gitopsapp.ts';
import * as handlers from './handlers/index.ts'; import * as handlers from './handlers/index.ts';
import { files as bundledFiles } from '../../ts_bundled/bundle.ts';
export class OpsServer { export class OpsServer {
public gitopsAppRef: GitopsApp; public gitopsAppRef: GitopsApp;
@@ -16,17 +17,23 @@ export class OpsServer {
public secretsHandler!: handlers.SecretsHandler; public secretsHandler!: handlers.SecretsHandler;
public pipelinesHandler!: handlers.PipelinesHandler; public pipelinesHandler!: handlers.PipelinesHandler;
public logsHandler!: handlers.LogsHandler; public logsHandler!: handlers.LogsHandler;
public webhookHandler!: handlers.WebhookHandler;
constructor(gitopsAppRef: GitopsApp) { constructor(gitopsAppRef: GitopsApp) {
this.gitopsAppRef = gitopsAppRef; this.gitopsAppRef = gitopsAppRef;
} }
public async start(port = 3000) { public async start(port = 3000) {
const absoluteServeDir = plugins.path.resolve('./dist_serve'); // Create webhook handler before server so routes register via addCustomRoutes
this.webhookHandler = new handlers.WebhookHandler(this);
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({ this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
domain: 'localhost', domain: 'localhost',
feedMetadata: undefined, feedMetadata: undefined,
serveDir: absoluteServeDir, bundledContent: bundledFiles,
addCustomRoutes: async (typedserver) => {
this.webhookHandler.registerRoutes(typedserver);
},
}); });
// Chain typedrouters // Chain typedrouters

View File

@@ -5,3 +5,4 @@ export { GroupsHandler } from './groups.handler.ts';
export { SecretsHandler } from './secrets.handler.ts'; export { SecretsHandler } from './secrets.handler.ts';
export { PipelinesHandler } from './pipelines.handler.ts'; export { PipelinesHandler } from './pipelines.handler.ts';
export { LogsHandler } from './logs.handler.ts'; export { LogsHandler } from './logs.handler.ts';
export { WebhookHandler } from './webhook.handler.ts';

View File

@@ -12,6 +12,58 @@ export class SecretsHandler {
} }
private registerHandlers(): void { 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 // Get secrets
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecrets>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecrets>(

View 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');
}
}

View File

@@ -28,3 +28,7 @@ export { giteaClient, gitlabClient };
import * as smartmongo from '@push.rocks/smartmongo'; import * as smartmongo from '@push.rocks/smartmongo';
import * as smartdata from '@push.rocks/smartdata'; import * as smartdata from '@push.rocks/smartdata';
export { smartmongo, smartdata }; export { smartmongo, smartdata };
// Secrets
import * as smartsecret from '@push.rocks/smartsecret';
export { smartsecret };

View File

@@ -18,13 +18,45 @@ export class GiteaProvider extends BaseProvider {
} }
async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> { async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> {
const repos = await this.client.getRepos(opts); // If caller explicitly requests a specific page, respect it (no auto-pagination)
return repos.map((r) => this.mapProject(r)); if (opts?.page) {
const repos = await this.client.getRepos(opts);
return repos.map((r) => this.mapProject(r));
}
const allRepos: plugins.giteaClient.IGiteaRepository[] = [];
const perPage = opts?.perPage || 50;
let page = 1;
while (true) {
const repos = await this.client.getRepos({ ...opts, page, perPage });
allRepos.push(...repos);
if (repos.length < perPage) break;
page++;
}
return allRepos.map((r) => this.mapProject(r));
} }
async getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]> { async getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]> {
const orgs = await this.client.getOrgs(opts); // If caller explicitly requests a specific page, respect it (no auto-pagination)
return orgs.map((o) => this.mapGroup(o)); if (opts?.page) {
const orgs = await this.client.getOrgs(opts);
return orgs.map((o) => this.mapGroup(o));
}
const allOrgs: plugins.giteaClient.IGiteaOrganization[] = [];
const perPage = opts?.perPage || 50;
let page = 1;
while (true) {
const orgs = await this.client.getOrgs({ ...opts, page, perPage });
allOrgs.push(...orgs);
if (orgs.length < perPage) break;
page++;
}
return allOrgs.map((o) => this.mapGroup(o));
} }
// --- Project Secrets --- // --- Project Secrets ---
@@ -40,7 +72,7 @@ export class GiteaProvider extends BaseProvider {
value: string, value: string,
): Promise<interfaces.data.ISecret> { ): Promise<interfaces.data.ISecret> {
await this.client.setRepoSecret(projectId, key, value); 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( async updateProjectSecret(
@@ -68,7 +100,7 @@ export class GiteaProvider extends BaseProvider {
value: string, value: string,
): Promise<interfaces.data.ISecret> { ): Promise<interfaces.data.ISecret> {
await this.client.setOrgSecret(groupId, key, value); 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( async updateGroupSecret(
@@ -117,7 +149,7 @@ export class GiteaProvider extends BaseProvider {
private mapProject(r: plugins.giteaClient.IGiteaRepository): interfaces.data.IProject { private mapProject(r: plugins.giteaClient.IGiteaRepository): interfaces.data.IProject {
return { return {
id: String(r.id), id: r.full_name || String(r.id),
name: r.name || '', name: r.name || '',
fullPath: r.full_name || '', fullPath: r.full_name || '',
description: r.description || '', description: r.description || '',
@@ -132,7 +164,7 @@ export class GiteaProvider extends BaseProvider {
private mapGroup(o: plugins.giteaClient.IGiteaOrganization): interfaces.data.IGroup { private mapGroup(o: plugins.giteaClient.IGiteaOrganization): interfaces.data.IGroup {
return { return {
id: String(o.id || o.name), id: o.name || String(o.id),
name: o.name || '', name: o.name || '',
fullPath: o.name || '', fullPath: o.name || '',
description: o.description || '', description: o.description || '',
@@ -143,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 { return {
key: s.name || '', key: s.name || '',
value: '***', value: '***',
@@ -151,6 +183,7 @@ export class GiteaProvider extends BaseProvider {
masked: true, masked: true,
scope, scope,
scopeId, scopeId,
scopeName: scopeName || scopeId,
connectionId: this.connectionId, connectionId: this.connectionId,
environment: '*', environment: '*',
}; };

View File

@@ -149,6 +149,7 @@ export class GitLabProvider extends BaseProvider {
v: plugins.gitlabClient.IGitLabVariable, v: plugins.gitlabClient.IGitLabVariable,
scope: 'project' | 'group', scope: 'project' | 'group',
scopeId: string, scopeId: string,
scopeName?: string,
): interfaces.data.ISecret { ): interfaces.data.ISecret {
return { return {
key: v.key || '', key: v.key || '',
@@ -157,6 +158,7 @@ export class GitLabProvider extends BaseProvider {
masked: v.masked || false, masked: v.masked || false,
scope, scope,
scopeId, scopeId,
scopeName: scopeName || scopeId,
connectionId: this.connectionId, connectionId: this.connectionId,
environment: v.environment_scope || '*', environment: v.environment_scope || '*',
}; };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

11
ts_bundled/bundle.ts Normal file

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,7 @@ export interface ISecret {
masked: boolean; masked: boolean;
scope: 'project' | 'group'; scope: 'project' | 'group';
scopeId: string; scopeId: string;
scopeName: string;
connectionId: string; connectionId: string;
environment: string; environment: string;
} }

View File

@@ -5,3 +5,4 @@ export * from './groups.ts';
export * from './secrets.ts'; export * from './secrets.ts';
export * from './pipelines.ts'; export * from './pipelines.ts';
export * from './logs.ts'; export * from './logs.ts';
export * from './webhook.ts';

View File

@@ -1,6 +1,21 @@
import * as plugins from '../plugins.ts'; import * as plugins from '../plugins.ts';
import * as data from '../data/index.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< export interface IReq_GetSecrets extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSecrets IReq_GetSecrets

View 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;
};
}

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/gitops', name: '@serve.zone/gitops',
version: '2.3.0', version: '2.7.1',
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs' description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
} }

View File

@@ -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<{ export const createSecretAction = dataStatePart.createAction<{
connectionId: string; connectionId: string;
scope: 'project' | 'group'; scope: 'project' | 'group';
@@ -320,7 +341,7 @@ export const createSecretAction = dataStatePart.createAction<{
identity: context.identity!, identity: context.identity!,
...dataArg, ...dataArg,
}); });
// Re-fetch secrets // Re-fetch only the affected entity's secrets and merge
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest< const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSecrets interfaces.requests.IReq_GetSecrets
>('/typedrequest', 'getSecrets'); >('/typedrequest', 'getSecrets');
@@ -330,7 +351,11 @@ export const createSecretAction = dataStatePart.createAction<{
scope: dataArg.scope, scope: dataArg.scope,
scopeId: dataArg.scopeId, 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) { } catch (err) {
console.error('Failed to create secret:', err); console.error('Failed to create secret:', err);
return statePartArg.getState(); return statePartArg.getState();
@@ -353,7 +378,7 @@ export const updateSecretAction = dataStatePart.createAction<{
identity: context.identity!, identity: context.identity!,
...dataArg, ...dataArg,
}); });
// Re-fetch // Re-fetch only the affected entity's secrets and merge
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest< const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSecrets interfaces.requests.IReq_GetSecrets
>('/typedrequest', 'getSecrets'); >('/typedrequest', 'getSecrets');
@@ -363,7 +388,11 @@ export const updateSecretAction = dataStatePart.createAction<{
scope: dataArg.scope, scope: dataArg.scope,
scopeId: dataArg.scopeId, 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) { } catch (err) {
console.error('Failed to update secret:', err); console.error('Failed to update secret:', err);
return statePartArg.getState(); return statePartArg.getState();
@@ -388,7 +417,9 @@ export const deleteSecretAction = dataStatePart.createAction<{
const state = statePartArg.getState(); const state = statePartArg.getState();
return { return {
...state, ...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) { } catch (err) {
console.error('Failed to delete secret:', err); console.error('Failed to delete secret:', err);
@@ -543,3 +574,9 @@ export const toggleAutoRefreshAction = uiStatePart.createAction(async (statePart
const state = statePartArg.getState(); const state = statePartArg.getState();
return { ...state, autoRefresh: !state.autoRefresh }; return { ...state, autoRefresh: !state.autoRefresh };
}); });
export const setRefreshIntervalAction = uiStatePart.createAction<{ interval: number }>(
async (statePartArg, dataArg) => {
return { ...statePartArg.getState(), refreshInterval: dataArg.interval };
},
);

View File

@@ -43,6 +43,14 @@ export class GitopsDashboard extends DeesElement {
private resolvedViewTabs: Array<{ name: string; iconName: string; element: any }> = []; 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() { constructor() {
super(); super();
document.title = 'GitOps'; document.title = 'GitOps';
@@ -53,7 +61,11 @@ export class GitopsDashboard extends DeesElement {
this.loginState = loginState; this.loginState = loginState;
if (loginState.isLoggedIn) { if (loginState.isLoggedIn) {
appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null); appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
this.connectWebSocket();
} else {
this.disconnectWebSocket();
} }
this.manageAutoRefreshTimer();
}); });
this.rxSubscriptions.push(loginSubscription); this.rxSubscriptions.push(loginSubscription);
@@ -62,6 +74,7 @@ export class GitopsDashboard extends DeesElement {
.subscribe((uiState) => { .subscribe((uiState) => {
this.uiState = uiState; this.uiState = uiState;
this.syncAppdashView(uiState.activeView); this.syncAppdashView(uiState.activeView);
this.manageAutoRefreshTimer();
}); });
this.rxSubscriptions.push(uiSubscription); this.rxSubscriptions.push(uiSubscription);
} }
@@ -78,6 +91,36 @@ export class GitopsDashboard extends DeesElement {
width: 100%; width: 100%;
height: 100vh; 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-appdash>
</dees-simple-login> </dees-simple-login>
</div> </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) { private async login(username: string, password: string) {
const domtools = await this.domtoolsPromise; const domtools = await this.domtoolsPromise;
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any; const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;

View File

@@ -38,6 +38,8 @@ export class GitopsViewBuildlog extends DeesElement {
@state() @state()
accessor selectedJobId: string = ''; accessor selectedJobId: string = '';
private _autoRefreshHandler: () => void;
constructor() { constructor() {
super(); super();
const connSub = appstate.connectionsStatePart const connSub = appstate.connectionsStatePart
@@ -49,6 +51,18 @@ export class GitopsViewBuildlog extends DeesElement {
.select((s) => s) .select((s) => s)
.subscribe((s) => { this.dataState = s; }); .subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub); 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 = [ public static styles = [

View File

@@ -19,12 +19,26 @@ export class GitopsViewConnections extends DeesElement {
activeConnectionId: null, activeConnectionId: null,
}; };
private _autoRefreshHandler: () => void;
constructor() { constructor() {
super(); super();
const sub = appstate.connectionsStatePart const sub = appstate.connectionsStatePart
.select((s) => s) .select((s) => s)
.subscribe((s) => { this.connectionsState = s; }); .subscribe((s) => { this.connectionsState = s; });
this.rxSubscriptions.push(sub); 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 = [ public static styles = [

View File

@@ -32,6 +32,8 @@ export class GitopsViewGroups extends DeesElement {
@state() @state()
accessor selectedConnectionId: string = ''; accessor selectedConnectionId: string = '';
private _autoRefreshHandler: () => void;
constructor() { constructor() {
super(); super();
const connSub = appstate.connectionsStatePart const connSub = appstate.connectionsStatePart
@@ -43,6 +45,18 @@ export class GitopsViewGroups extends DeesElement {
.select((s) => s) .select((s) => s)
.subscribe((s) => { this.dataState = s; }); .subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub); 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 = [ public static styles = [

View File

@@ -30,6 +30,8 @@ export class GitopsViewOverview extends DeesElement {
currentJobLog: '', currentJobLog: '',
}; };
private _autoRefreshHandler: () => void;
constructor() { constructor() {
super(); super();
const connSub = appstate.connectionsStatePart const connSub = appstate.connectionsStatePart
@@ -41,6 +43,18 @@ export class GitopsViewOverview extends DeesElement {
.select((s) => s) .select((s) => s)
.subscribe((s) => { this.dataState = s; }); .subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub); 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 = [ public static styles = [

View File

@@ -35,6 +35,8 @@ export class GitopsViewPipelines extends DeesElement {
@state() @state()
accessor selectedProjectId: string = ''; accessor selectedProjectId: string = '';
private _autoRefreshHandler: () => void;
constructor() { constructor() {
super(); super();
const connSub = appstate.connectionsStatePart const connSub = appstate.connectionsStatePart
@@ -46,6 +48,18 @@ export class GitopsViewPipelines extends DeesElement {
.select((s) => s) .select((s) => s)
.subscribe((s) => { this.dataState = s; }); .subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub); 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 = [ public static styles = [

View File

@@ -32,6 +32,8 @@ export class GitopsViewProjects extends DeesElement {
@state() @state()
accessor selectedConnectionId: string = ''; accessor selectedConnectionId: string = '';
private _autoRefreshHandler: () => void;
constructor() { constructor() {
super(); super();
const connSub = appstate.connectionsStatePart const connSub = appstate.connectionsStatePart
@@ -43,6 +45,18 @@ export class GitopsViewProjects extends DeesElement {
.select((s) => s) .select((s) => s)
.subscribe((s) => { this.dataState = s; }); .subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub); 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 = [ public static styles = [

View File

@@ -36,7 +36,9 @@ export class GitopsViewSecrets extends DeesElement {
accessor selectedScope: 'project' | 'group' = 'project'; accessor selectedScope: 'project' | 'group' = 'project';
@state() @state()
accessor selectedScopeId: string = ''; accessor selectedScopeId: string = '__all__';
private _autoRefreshHandler: () => void;
constructor() { constructor() {
super(); super();
@@ -49,6 +51,18 @@ export class GitopsViewSecrets extends DeesElement {
.select((s) => s) .select((s) => s)
.subscribe((s) => { this.dataState = s; }); .subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub); 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 = [ public static styles = [
@@ -56,6 +70,13 @@ export class GitopsViewSecrets extends DeesElement {
viewHostCss, 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 { public render(): TemplateResult {
const connectionOptions = this.connectionsState.connections.map((c) => ({ const connectionOptions = this.connectionsState.connections.map((c) => ({
option: `${c.name} (${c.providerType})`, option: `${c.name} (${c.providerType})`,
@@ -67,10 +88,17 @@ export class GitopsViewSecrets extends DeesElement {
{ option: 'Group', key: 'group' }, { 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.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 }));
const entityOptions = [
{ option: 'All', key: '__all__' },
...entities,
];
const isAllSelected = this.selectedScopeId === '__all__';
return html` return html`
<div class="view-title">Secrets</div> <div class="view-title">Secrets</div>
<div class="view-description">Manage CI/CD secrets and variables</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=${connectionOptions.find((o) => o.key === this.selectedConnectionId) || connectionOptions[0]}
@selectedOption=${(e: CustomEvent) => { @selectedOption=${(e: CustomEvent) => {
this.selectedConnectionId = e.detail.key; this.selectedConnectionId = e.detail.key;
this.selectedScopeId = '__all__';
this.loadEntities(); this.loadEntities();
this.loadSecrets();
}} }}
></dees-input-dropdown> ></dees-input-dropdown>
<dees-input-dropdown <dees-input-dropdown
@@ -90,7 +120,9 @@ export class GitopsViewSecrets extends DeesElement {
.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 'project' | 'group';
this.selectedScopeId = '__all__';
this.loadEntities(); this.loadEntities();
this.loadSecrets();
}} }}
></dees-input-dropdown> ></dees-input-dropdown>
<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=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
@selectedOption=${(e: CustomEvent) => { @selectedOption=${(e: CustomEvent) => {
this.selectedScopeId = e.detail.key; this.selectedScopeId = e.detail.key;
this.loadSecrets();
}} }}
></dees-input-dropdown> ></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> <dees-button @click=${() => this.loadSecrets()}>Refresh</dees-button>
</div> </div>
<dees-table <dees-table
.heading1=${'Secrets'} .heading1=${'Secrets'}
.heading2=${'CI/CD variables for the selected entity'} .heading2=${'CI/CD variables for the selected entity'}
.data=${this.dataState.secrets} .data=${this.filteredSecrets}
.displayFunction=${(item: any) => ({ .displayFunction=${(item: any) => ({
Key: item.key, Key: item.key,
Scope: item.scopeName || item.scopeId,
Value: item.masked ? '******' : item.value, Value: item.masked ? '******' : item.value,
Protected: item.protected ? 'Yes' : 'No', Protected: item.protected ? 'Yes' : 'No',
Environment: item.environment || '*', Environment: item.environment || '*',
@@ -127,8 +162,8 @@ export class GitopsViewSecrets extends DeesElement {
action: async (item: any) => { action: async (item: any) => {
await appstate.dataStatePart.dispatchAction(appstate.deleteSecretAction, { await appstate.dataStatePart.dispatchAction(appstate.deleteSecretAction, {
connectionId: this.selectedConnectionId, connectionId: this.selectedConnectionId,
scope: this.selectedScope, scope: item.scope,
scopeId: this.selectedScopeId, scopeId: item.scopeId,
key: item.key, key: item.key,
}); });
}, },
@@ -144,6 +179,7 @@ export class GitopsViewSecrets extends DeesElement {
if (conns.length > 0 && !this.selectedConnectionId) { if (conns.length > 0 && !this.selectedConnectionId) {
this.selectedConnectionId = conns[0].id; this.selectedConnectionId = conns[0].id;
await this.loadEntities(); await this.loadEntities();
await this.loadSecrets();
} }
} }
@@ -161,15 +197,15 @@ export class GitopsViewSecrets extends DeesElement {
} }
private async loadSecrets() { private async loadSecrets() {
if (!this.selectedConnectionId || !this.selectedScopeId) return; if (!this.selectedConnectionId) return;
await appstate.dataStatePart.dispatchAction(appstate.fetchSecretsAction, { await appstate.dataStatePart.dispatchAction(appstate.fetchAllSecretsAction, {
connectionId: this.selectedConnectionId, connectionId: this.selectedConnectionId,
scope: this.selectedScope, scope: this.selectedScope,
scopeId: this.selectedScopeId,
}); });
} }
private async addSecret() { private async addSecret() {
if (this.selectedScopeId === '__all__') return;
await plugins.deesCatalog.DeesModal.createAndShow({ await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Add Secret', heading: 'Add Secret',
content: html` content: html`
@@ -220,8 +256,8 @@ export class GitopsViewSecrets extends DeesElement {
const input = modal.shadowRoot.querySelector('dees-input-text'); const input = modal.shadowRoot.querySelector('dees-input-text');
await appstate.dataStatePart.dispatchAction(appstate.updateSecretAction, { await appstate.dataStatePart.dispatchAction(appstate.updateSecretAction, {
connectionId: this.selectedConnectionId, connectionId: this.selectedConnectionId,
scope: this.selectedScope, scope: item.scope,
scopeId: this.selectedScopeId, scopeId: item.scopeId,
key: item.key, key: item.key,
value: input?.value || '', value: input?.value || '',
}); });