21 Commits

Author SHA1 Message Date
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
6889b81159 v2.3.0 2026-02-24 15:22:56 +00:00
43321c35d6 feat(storage): add StorageManager and cache subsystem; integrate storage into ConnectionManager and GitopsApp, migrate legacy connections, and add tests 2026-02-24 15:22:56 +00:00
e8e45d5371 v2.2.1 2026-02-24 14:38:31 +00:00
0417702340 fix(ts_bundled): add generated bundled JavaScript and source map for ts build (bundle.js and bundle.js.map) 2026-02-24 14:38:31 +00:00
37d1db9ccf v2.2.0 2026-02-24 14:36:29 +00:00
f78cab7dd8 feat(opsserver): Serve bundled frontend from a dedicated dist_serve directory and update frontend UI/packaging 2026-02-24 14:36:29 +00:00
29922004ea v2.1.0 2026-02-24 13:47:44 +00:00
0d9e76bf94 feat(opsserver): switch to TypedServer and serve bundled UI assets; add index.html; update bundling output and dev watch configuration 2026-02-24 13:47:44 +00:00
47 changed files with 31020 additions and 126 deletions

4
.gitignore vendored
View File

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

View File

@@ -1,5 +1,89 @@
# Changelog
## 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)
add StorageManager and cache subsystem; integrate storage into ConnectionManager and GitopsApp, migrate legacy connections, and add tests
- Add StorageManager with filesystem and memory backends, key normalization, atomic writes and JSON helpers (getJSON/setJSON).
- ConnectionManager now depends on StorageManager, persists each connection as /connections/<id>.json, and includes a one-time migration from legacy .nogit/connections.json.
- Introduce cache subsystem: CacheDb (LocalTsmDb + Smartdata), CacheCleaner, CachedDocument and CachedProject for TTL'd cached provider data, plus lifecycle management in GitopsApp.
- GitopsApp now initializes StorageManager, wires ConnectionManager to storage, starts/stops CacheDb and CacheCleaner, and uses resolved default paths via resolvePaths.
- Export smartmongo and smartdata in plugins and add corresponding deps to deno.json.
- Add comprehensive tests: storage unit tests, connection manager integration using StorageManager, and a tsmdb + smartdata spike test.
## 2026-02-24 - 2.2.1 - fix(ts_bundled)
add generated bundled JavaScript and source map for ts build (bundle.js and bundle.js.map)
- Added ts_bundled/bundle.js (≈168 KB) - compiled/bundled output from ts sources
- Added ts_bundled/bundle.js.map (≈309 KB) - source map referencing ../ts/index.ts and ../ts_web/index.ts
- This is generated build output (deno bundle) and does not change runtime API
## 2026-02-24 - 2.2.0 - feat(opsserver)
Serve bundled frontend from a dedicated dist_serve directory and update frontend UI/packaging
- Serve static site using UtilityWebsiteServer with serveDir set to ./dist_serve and pass port into server.start()
- Update bundler config: output bundle to ./dist_serve/bundle.js, change outputMode to 'bundle', and include html/index.html
- Move root index.html into html/index.html and update .gitignore to ignore dist_serve/ (replace ts_bundled)
- Frontend enhancements: add iconName to view tabs and resolvedViewTabs, add Lucide icons for each tab, replace manual stats markup with dees-statsgrid using IStatsTile tiles
- Adjust shared CSS: center content, set max-width 1280px and adjust padding
- Add npm test script and rename/update tests (test.basic.ts -> test.basic_test.ts)
## 2026-02-24 - 2.1.0 - feat(opsserver)
switch to TypedServer and serve bundled UI assets; add index.html; update bundling output and dev watch configuration
- Replace UtilityWebsiteServer with TypedServer and load bundledContent from ts_bundled/bundle.ts; enable cors, spaFallback, injectReload, watch, and compression
- Add a minimal index.html SPA entry and include it in the bundle so it is served from the bundled assets
- Change tsbundle output to ./ts_bundled/bundle.ts with outputMode 'base64ts' and includeFiles ['./index.html']
- Add a tswatch bundle config and replace the previous watcher with a backend watcher that runs the server via 'deno run --allow-all mod.ts server' (restart enabled)
- Bump devDependency @git.zone/tswatch from ^2.3.13 to ^3.1.0 and update .gitignore to ignore ts_bundled/
## 2026-02-24 - 2.0.0 - BREAKING CHANGE(providers)
switch GitLab and Gitea providers to use @apiclient.xyz client libraries and export clients via plugins

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/gitops",
"version": "2.0.0",
"version": "2.7.0",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {
@@ -13,11 +13,14 @@
"@std/encoding": "jsr:@std/encoding@^1.0.10",
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@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/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1",
"@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/smartdata": "npm:@push.rocks/smartdata@^7.0.15",
"@push.rocks/smartsecret": "npm:@push.rocks/smartsecret@^1.0.1"
},
"compilerOptions": {
"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>

33
html/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,19 +3,30 @@
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./dist_serve/bundle.js",
"outputMode": "bundle",
"to": "./ts_bundled/bundle.ts",
"outputMode": "base64ts",
"bundler": "esbuild",
"production": true
"production": true,
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
}
]
},
"@git.zone/tswatch": {
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./ts_bundled/bundle.ts",
"outputMode": "base64ts",
"watchPatterns": ["./ts_web/**/*"],
"triggerReload": true
}
],
"watchers": [
{
"name": "ui-bundle",
"watch": "./ts_web/**/*",
"command": "tsbundle",
"name": "backend",
"watch": "./ts/**/*",
"command": "deno run --allow-all mod.ts server",
"restart": true,
"debounce": 500,
"runOnStart": true
}

View File

@@ -1,10 +1,11 @@
{
"name": "@serve.zone/gitops",
"version": "2.0.0",
"version": "2.7.0",
"description": "GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs",
"main": "mod.ts",
"type": "module",
"scripts": {
"test": "deno task test",
"build": "tsbundle",
"startTs": "deno run --allow-all mod.ts server",
"watch": "tswatch"
@@ -13,11 +14,11 @@
"license": "MIT",
"dependencies": {
"@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"
},
"devDependencies": {
"@git.zone/tsbundle": "^2.8.3",
"@git.zone/tswatch": "^2.3.13"
"@git.zone/tsbundle": "^2.9.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

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

139
test/test.storage_test.ts Normal file
View File

@@ -0,0 +1,139 @@
import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts';
import { StorageManager } from '../ts/storage/index.ts';
import * as smartsecret from '@push.rocks/smartsecret';
Deno.test('StorageManager memory: set and get', async () => {
const sm = new StorageManager({ backend: 'memory' });
await sm.set('/test/key1', 'hello');
const result = await sm.get('/test/key1');
assertEquals(result, 'hello');
});
Deno.test('StorageManager memory: get nonexistent returns null', async () => {
const sm = new StorageManager({ backend: 'memory' });
const result = await sm.get('/missing');
assertEquals(result, null);
});
Deno.test('StorageManager memory: delete', async () => {
const sm = new StorageManager({ backend: 'memory' });
await sm.set('/test/key1', 'hello');
const deleted = await sm.delete('/test/key1');
assertEquals(deleted, true);
const result = await sm.get('/test/key1');
assertEquals(result, null);
});
Deno.test('StorageManager memory: delete nonexistent returns false', async () => {
const sm = new StorageManager({ backend: 'memory' });
const deleted = await sm.delete('/missing');
assertEquals(deleted, false);
});
Deno.test('StorageManager memory: exists', async () => {
const sm = new StorageManager({ backend: 'memory' });
assertEquals(await sm.exists('/test/key1'), false);
await sm.set('/test/key1', 'hello');
assertEquals(await sm.exists('/test/key1'), true);
});
Deno.test('StorageManager memory: list keys under prefix', async () => {
const sm = new StorageManager({ backend: 'memory' });
await sm.set('/connections/a.json', '{}');
await sm.set('/connections/b.json', '{}');
await sm.set('/other/c.json', '{}');
const keys = await sm.list('/connections/');
assertEquals(keys, ['/connections/a.json', '/connections/b.json']);
});
Deno.test('StorageManager memory: getJSON and setJSON roundtrip', async () => {
const sm = new StorageManager({ backend: 'memory' });
const data = { id: '123', name: 'test', nested: { value: 42 } };
await sm.setJSON('/data/item.json', data);
const result = await sm.getJSON<typeof data>('/data/item.json');
assertEquals(result, data);
});
Deno.test('StorageManager memory: getJSON nonexistent returns null', async () => {
const sm = new StorageManager({ backend: 'memory' });
const result = await sm.getJSON('/missing.json');
assertEquals(result, null);
});
Deno.test('StorageManager: key validation requires leading slash', async () => {
const sm = new StorageManager({ backend: 'memory' });
let threw = false;
try {
await sm.get('no-slash');
} catch {
threw = true;
}
assertEquals(threw, true);
});
Deno.test('StorageManager: key normalization strips ..', async () => {
const sm = new StorageManager({ backend: 'memory' });
await sm.set('/test/../actual/key', 'value');
// '..' segments are stripped, so key becomes /test/actual/key — wait,
// the normalizer filters out '..' segments entirely
// /test/../actual/key -> segments: ['test', 'actual', 'key'] (.. filtered)
const result = await sm.get('/test/actual/key');
assertEquals(result, 'value');
});
Deno.test('StorageManager filesystem: set, get, delete roundtrip', async () => {
const tmpDir = await Deno.makeTempDir();
const sm = new StorageManager({ backend: 'filesystem', fsPath: tmpDir });
try {
await sm.set('/test/file.txt', 'filesystem content');
const result = await sm.get('/test/file.txt');
assertEquals(result, 'filesystem content');
assertEquals(await sm.exists('/test/file.txt'), true);
const deleted = await sm.delete('/test/file.txt');
assertEquals(deleted, true);
assertEquals(await sm.get('/test/file.txt'), null);
} finally {
await Deno.remove(tmpDir, { recursive: true });
}
});
Deno.test('StorageManager filesystem: list keys', async () => {
const tmpDir = await Deno.makeTempDir();
const sm = new StorageManager({ backend: 'filesystem', fsPath: tmpDir });
try {
await sm.setJSON('/items/a.json', { id: 'a' });
await sm.setJSON('/items/b.json', { id: 'b' });
const keys = await sm.list('/items/');
assertEquals(keys, ['/items/a.json', '/items/b.json']);
} finally {
await Deno.remove(tmpDir, { recursive: true });
}
});
Deno.test('ConnectionManager with StorageManager: create and load', async () => {
const { ConnectionManager } = await import('../ts/classes/connectionmanager.ts');
const sm = new StorageManager({ backend: 'memory' });
const secret = new smartsecret.SmartSecret({ service: 'gitops-test' });
const cm = new ConnectionManager(sm, secret);
await cm.init();
// Create a connection
const conn = await cm.createConnection('test', 'gitea', 'https://gitea.example.com', 'token');
assertExists(conn.id);
assertEquals(conn.name, 'test');
assertEquals(conn.token, '***');
// Verify it's stored in StorageManager
const stored = await sm.getJSON<{ id: string }>(`/connections/${conn.id}.json`);
assertExists(stored);
assertEquals(stored.id, conn.id);
// Create a new ConnectionManager and verify it loads the connection
const cm2 = new ConnectionManager(sm, secret);
await cm2.init();
const conns = cm2.getConnections();
assertEquals(conns.length, 1);
assertEquals(conns[0].id, conn.id);
});

View File

@@ -0,0 +1,59 @@
import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts';
import { LocalTsmDb } from '@push.rocks/smartmongo';
import { SmartdataDb, SmartDataDbDoc, Collection, svDb, unI } from '@push.rocks/smartdata';
Deno.test({
name: 'TsmDb spike: LocalTsmDb + SmartdataDb roundtrip',
sanitizeOps: false,
sanitizeResources: false,
fn: async () => {
const tmpDir = await Deno.makeTempDir();
// 1. Start local MongoDB-compatible server
const localDb = new LocalTsmDb({ folderPath: tmpDir });
const { connectionUri } = await localDb.start();
assertExists(connectionUri);
// 2. Connect smartdata
const smartDb = new SmartdataDb({
mongoDbUrl: connectionUri,
mongoDbName: 'gitops_spike_test',
});
await smartDb.init();
assertEquals(smartDb.status, 'connected');
// 3. Define a simple document class
@Collection(() => smartDb)
class TestDoc extends SmartDataDbDoc<TestDoc, TestDoc> {
@unI()
public id: string = '';
@svDb()
public label: string = '';
@svDb()
public value: number = 0;
constructor() {
super();
}
}
// 4. Insert a document
const doc = new TestDoc();
doc.id = 'test-1';
doc.label = 'spike';
doc.value = 42;
await doc.save();
// 5. Query it back
const found = await TestDoc.getInstance({ id: 'test-1' });
assertExists(found);
assertEquals(found.label, 'spike');
assertEquals(found.value, 42);
// 6. Cleanup — smartDb closes; localDb.stop() hangs under Deno, so fire-and-forget
await smartDb.close();
localDb.stop().catch(() => {});
},
});

View File

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

68
ts/cache/classes.cache.cleaner.ts vendored Normal file
View File

@@ -0,0 +1,68 @@
import { logger } from '../logging.ts';
import type { CacheDb } from './classes.cachedb.ts';
// deno-lint-ignore no-explicit-any
type DocumentClass = { getInstances: (filter: any) => Promise<{ delete: () => Promise<void> }[]> };
const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
/**
* Periodically cleans up expired cached documents.
*/
export class CacheCleaner {
private intervalId: number | null = null;
private intervalMs: number;
private documentClasses: DocumentClass[] = [];
private cacheDb: CacheDb;
constructor(cacheDb: CacheDb, intervalMs = DEFAULT_INTERVAL_MS) {
this.cacheDb = cacheDb;
this.intervalMs = intervalMs;
}
/** Register a document class for cleanup */
registerClass(cls: DocumentClass): void {
this.documentClasses.push(cls);
}
start(): void {
if (this.intervalId !== null) return;
this.intervalId = setInterval(() => {
this.clean().catch((err) => {
logger.error(`CacheCleaner error: ${err}`);
});
}, this.intervalMs);
// Unref so the interval doesn't prevent process exit
Deno.unrefTimer(this.intervalId);
logger.debug(`CacheCleaner started (interval: ${this.intervalMs}ms)`);
}
stop(): void {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
logger.debug('CacheCleaner stopped');
}
}
/** Run a single cleanup pass */
async clean(): Promise<number> {
const now = Date.now();
let totalDeleted = 0;
for (const cls of this.documentClasses) {
try {
const expired = await cls.getInstances({ expiresAt: { $lt: now } });
for (const doc of expired) {
await doc.delete();
totalDeleted++;
}
} catch (err) {
logger.error(`CacheCleaner: failed to clean class: ${err}`);
}
}
if (totalDeleted > 0) {
logger.debug(`CacheCleaner: deleted ${totalDeleted} expired document(s)`);
}
return totalDeleted;
}
}

57
ts/cache/classes.cached.document.ts vendored Normal file
View File

@@ -0,0 +1,57 @@
import * as plugins from '../plugins.ts';
/** TTL duration constants in milliseconds */
export const TTL = {
MINUTES_5: 5 * 60 * 1000,
HOURS_1: 60 * 60 * 1000,
HOURS_24: 24 * 60 * 60 * 1000,
DAYS_7: 7 * 24 * 60 * 60 * 1000,
DAYS_30: 30 * 24 * 60 * 60 * 1000,
DAYS_90: 90 * 24 * 60 * 60 * 1000,
} as const;
/**
* Abstract base class for cached documents with TTL support.
* Extend this class and add @Collection decorator pointing to your CacheDb.
*/
export abstract class CachedDocument<
T extends CachedDocument<T>,
> extends plugins.smartdata.SmartDataDbDoc<T, T> {
@plugins.smartdata.svDb()
public createdAt: number = Date.now();
@plugins.smartdata.svDb()
public expiresAt: number = Date.now() + TTL.HOURS_1;
@plugins.smartdata.svDb()
public lastAccessedAt: number = Date.now();
constructor() {
super();
}
/** Set TTL in milliseconds from now */
setTTL(ms: number): void {
this.expiresAt = Date.now() + ms;
}
/** Set TTL in days from now */
setTTLDays(days: number): void {
this.setTTL(days * 24 * 60 * 60 * 1000);
}
/** Set TTL in hours from now */
setTTLHours(hours: number): void {
this.setTTL(hours * 60 * 60 * 1000);
}
/** Check if this document has expired */
isExpired(): boolean {
return Date.now() > this.expiresAt;
}
/** Update last accessed timestamp */
touch(): void {
this.lastAccessedAt = Date.now();
}
}

82
ts/cache/classes.cachedb.ts vendored Normal file
View File

@@ -0,0 +1,82 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
export interface ICacheDbOptions {
storagePath?: string;
dbName?: string;
debug?: boolean;
}
/**
* Singleton wrapper around LocalTsmDb + SmartdataDb.
* Provides a managed MongoDB-compatible cache database.
*/
export class CacheDb {
private static instance: CacheDb | null = null;
private localTsmDb: InstanceType<typeof plugins.smartmongo.LocalTsmDb> | null = null;
private smartdataDb: InstanceType<typeof plugins.smartdata.SmartdataDb> | null = null;
private options: Required<ICacheDbOptions>;
private constructor(options: ICacheDbOptions = {}) {
this.options = {
storagePath: options.storagePath ?? './.nogit/cachedb',
dbName: options.dbName ?? 'gitops_cache',
debug: options.debug ?? false,
};
}
static getInstance(options?: ICacheDbOptions): CacheDb {
if (!CacheDb.instance) {
CacheDb.instance = new CacheDb(options);
}
return CacheDb.instance;
}
static resetInstance(): void {
CacheDb.instance = null;
}
async start(): Promise<void> {
logger.info('Starting CacheDb...');
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
folderPath: this.options.storagePath,
});
const { connectionUri } = await this.localTsmDb.start();
this.smartdataDb = new plugins.smartdata.SmartdataDb({
mongoDbUrl: connectionUri,
mongoDbName: this.options.dbName,
});
await this.smartdataDb.init();
logger.success(`CacheDb started (db: ${this.options.dbName})`);
}
async stop(): Promise<void> {
logger.info('Stopping CacheDb...');
if (this.smartdataDb) {
await this.smartdataDb.close();
this.smartdataDb = null;
}
if (this.localTsmDb) {
// localDb.stop() may hang under Deno — fire-and-forget with timeout
const stopPromise = this.localTsmDb.stop().catch(() => {});
await Promise.race([
stopPromise,
new Promise<void>((resolve) => {
const id = setTimeout(resolve, 3000);
Deno.unrefTimer(id);
}),
]);
this.localTsmDb = null;
}
logger.success('CacheDb stopped');
}
getDb(): InstanceType<typeof plugins.smartdata.SmartdataDb> {
if (!this.smartdataDb) {
throw new Error('CacheDb not started. Call start() first.');
}
return this.smartdataDb;
}
}

View File

@@ -0,0 +1,32 @@
import * as plugins from '../../plugins.ts';
import { CacheDb } from '../classes.cachedb.ts';
import { CachedDocument, TTL } from '../classes.cached.document.ts';
/**
* Cached project data from git providers. TTL: 5 minutes.
*/
@plugins.smartdata.Collection(() => CacheDb.getInstance().getDb())
export class CachedProject extends CachedDocument<CachedProject> {
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
public connectionId: string = '';
@plugins.smartdata.svDb()
public projectName: string = '';
@plugins.smartdata.svDb()
public projectUrl: string = '';
@plugins.smartdata.svDb()
public description: string = '';
@plugins.smartdata.svDb()
public defaultBranch: string = '';
constructor() {
super();
this.setTTL(TTL.MINUTES_5);
}
}

1
ts/cache/documents/index.ts vendored Normal file
View File

@@ -0,0 +1 @@
export { CachedProject } from './classes.cached.project.ts';

5
ts/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
export { CacheDb } from './classes.cachedb.ts';
export type { ICacheDbOptions } from './classes.cachedb.ts';
export { CachedDocument, TTL } from './classes.cached.document.ts';
export { CacheCleaner } from './classes.cache.cleaner.ts';
export * from './documents/index.ts';

View File

@@ -2,41 +2,112 @@ import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import type * as interfaces from '../../ts_interfaces/index.ts';
import { BaseProvider, GiteaProvider, GitLabProvider } from '../providers/index.ts';
import type { StorageManager } from '../storage/index.ts';
const CONNECTIONS_FILE = './.nogit/connections.json';
const LEGACY_CONNECTIONS_FILE = './.nogit/connections.json';
const CONNECTIONS_PREFIX = '/connections/';
const KEYCHAIN_PREFIX = 'keychain:';
/**
* Manages provider connections - persists to .nogit/connections.json
* and creates provider instances on demand.
* Manages provider connections persists each connection as an
* individual JSON file via StorageManager. Tokens are stored in
* the OS keychain (or encrypted file fallback) via SmartSecret.
*/
export class ConnectionManager {
private connections: interfaces.data.IProviderConnection[] = [];
private storageManager: StorageManager;
private smartSecret: plugins.smartsecret.SmartSecret;
constructor(storageManager: StorageManager, smartSecret: plugins.smartsecret.SmartSecret) {
this.storageManager = storageManager;
this.smartSecret = smartSecret;
}
async init(): Promise<void> {
await this.migrateLegacyFile();
await this.loadConnections();
}
private async loadConnections(): Promise<void> {
/**
* One-time migration from the legacy .nogit/connections.json file.
*/
private async migrateLegacyFile(): Promise<void> {
try {
const text = await Deno.readTextFile(CONNECTIONS_FILE);
this.connections = JSON.parse(text);
logger.info(`Loaded ${this.connections.length} connection(s)`);
const text = await Deno.readTextFile(LEGACY_CONNECTIONS_FILE);
const legacy: interfaces.data.IProviderConnection[] = JSON.parse(text);
if (legacy.length > 0) {
logger.info(`Migrating ${legacy.length} connection(s) from legacy file...`);
for (const conn of legacy) {
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, conn);
}
// Rename legacy file so migration doesn't repeat
await Deno.rename(LEGACY_CONNECTIONS_FILE, LEGACY_CONNECTIONS_FILE + '.migrated');
logger.success('Legacy connections migrated successfully');
}
} catch {
this.connections = [];
logger.debug('No existing connections file found, starting fresh');
// No legacy file or already migrated — nothing to do
}
}
private async saveConnections(): Promise<void> {
// Ensure .nogit directory exists
private async loadConnections(): Promise<void> {
const keys = await this.storageManager.list(CONNECTIONS_PREFIX);
this.connections = [];
for (const key of keys) {
const conn = await this.storageManager.getJSON<interfaces.data.IProviderConnection>(key);
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);
}
}
if (this.connections.length > 0) {
logger.info(`Loaded ${this.connections.length} connection(s)`);
} else {
logger.debug('No existing connections found, starting fresh');
}
}
/**
* Migrates a plaintext token to keychain storage.
*/
private async migrateTokenToKeychain(
conn: interfaces.data.IProviderConnection,
): Promise<void> {
try {
await Deno.mkdir('./.nogit', { recursive: true });
} catch { /* already exists */ }
await Deno.writeTextFile(CONNECTIONS_FILE, JSON.stringify(this.connections, null, 2));
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> {
// 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> {
await this.smartSecret.deleteSecret(id);
await this.storageManager.delete(`${CONNECTIONS_PREFIX}${id}.json`);
}
getConnections(): interfaces.data.IProviderConnection[] {
// Return connections without exposing tokens
return this.connections.map((c) => ({ ...c, token: '***' }));
}
@@ -60,7 +131,7 @@ export class ConnectionManager {
status: 'disconnected',
};
this.connections.push(connection);
await this.saveConnections();
await this.persistConnection(connection);
logger.success(`Connection created: ${name} (${providerType})`);
return { ...connection, token: '***' };
}
@@ -74,7 +145,7 @@ export class ConnectionManager {
if (updates.name) conn.name = updates.name;
if (updates.baseUrl) conn.baseUrl = updates.baseUrl.replace(/\/+$/, '');
if (updates.token) conn.token = updates.token;
await this.saveConnections();
await this.persistConnection(conn);
return { ...conn, token: '***' };
}
@@ -82,7 +153,7 @@ export class ConnectionManager {
const idx = this.connections.findIndex((c) => c.id === id);
if (idx === -1) throw new Error(`Connection not found: ${id}`);
this.connections.splice(idx, 1);
await this.saveConnections();
await this.removeConnection(id);
logger.info(`Connection deleted: ${id}`);
}
@@ -91,7 +162,7 @@ export class ConnectionManager {
const result = await provider.testConnection();
const conn = this.connections.find((c) => c.id === id)!;
conn.status = result.ok ? 'connected' : 'error';
await this.saveConnections();
await this.persistConnection(conn);
return result;
}

View File

@@ -1,25 +1,53 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { ConnectionManager } from './connectionmanager.ts';
import { OpsServer } from '../opsserver/index.ts';
import { StorageManager } from '../storage/index.ts';
import { CacheDb, CacheCleaner, CachedProject } from '../cache/index.ts';
import { resolvePaths } from '../paths.ts';
/**
* Main GitOps application orchestrator
*/
export class GitopsApp {
public storageManager: StorageManager;
public smartSecret: plugins.smartsecret.SmartSecret;
public connectionManager: ConnectionManager;
public opsServer: OpsServer;
public cacheDb: CacheDb;
public cacheCleaner: CacheCleaner;
constructor() {
this.connectionManager = new ConnectionManager();
const paths = resolvePaths();
this.storageManager = new StorageManager({
backend: 'filesystem',
fsPath: paths.defaultStoragePath,
});
this.smartSecret = new plugins.smartsecret.SmartSecret({ service: 'gitops' });
this.connectionManager = new ConnectionManager(this.storageManager, this.smartSecret);
this.cacheDb = CacheDb.getInstance({
storagePath: paths.defaultTsmDbPath,
dbName: 'gitops_cache',
});
this.cacheCleaner = new CacheCleaner(this.cacheDb);
this.cacheCleaner.registerClass(CachedProject);
this.opsServer = new OpsServer(this);
}
async start(port = 3000): Promise<void> {
logger.info('Initializing GitOps...');
// Start CacheDb
await this.cacheDb.start();
// Initialize connection manager (loads saved connections)
await this.connectionManager.init();
// Start CacheCleaner
this.cacheCleaner.start();
// Start OpsServer
await this.opsServer.start(port);
@@ -29,6 +57,8 @@ export class GitopsApp {
async stop(): Promise<void> {
logger.info('Shutting down GitOps...');
await this.opsServer.stop();
this.cacheCleaner.stop();
await this.cacheDb.stop();
logger.success('GitOps shutdown complete');
}
}

View File

@@ -2,6 +2,7 @@ import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import type { GitopsApp } from '../classes/gitopsapp.ts';
import * as handlers from './handlers/index.ts';
import { files as bundledFiles } from '../../ts_bundled/bundle.ts';
export class OpsServer {
public gitopsAppRef: GitopsApp;
@@ -16,17 +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) {
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({
domain: 'localhost',
feedMetadata: undefined,
serveDir: absoluteServeDir,
bundledContent: bundledFiles,
addCustomRoutes: async (typedserver) => {
this.webhookHandler.registerRoutes(typedserver);
},
});
// Chain typedrouters

View File

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

View File

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

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

19
ts/paths.ts Normal file
View File

@@ -0,0 +1,19 @@
import * as path from '@std/path';
export interface IGitopsPaths {
gitopsHomeDir: string;
defaultStoragePath: string;
defaultTsmDbPath: string;
}
/**
* Resolve gitops paths. Accepts optional baseDir for test isolation.
*/
export function resolvePaths(baseDir?: string): IGitopsPaths {
const home = baseDir ?? path.join(Deno.env.get('HOME') ?? '/tmp', '.serve.zone', 'gitops');
return {
gitopsHomeDir: home,
defaultStoragePath: path.join(home, 'storage'),
defaultTsmDbPath: path.join(home, 'tsmdb'),
};
}

View File

@@ -23,3 +23,12 @@ export { smartguard, smartjwt };
import * as giteaClient from '@apiclient.xyz/gitea';
import * as gitlabClient from '@apiclient.xyz/gitlab';
export { giteaClient, gitlabClient };
// Database
import * as smartmongo from '@push.rocks/smartmongo';
import * as smartdata from '@push.rocks/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[]> {
const repos = await this.client.getRepos(opts);
return repos.map((r) => this.mapProject(r));
// If caller explicitly requests a specific page, respect it (no auto-pagination)
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[]> {
const orgs = await this.client.getOrgs(opts);
return orgs.map((o) => this.mapGroup(o));
// If caller explicitly requests a specific page, respect it (no auto-pagination)
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 ---
@@ -40,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(
@@ -68,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(
@@ -117,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 || '',
@@ -132,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 || '',
@@ -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 {
key: s.name || '',
value: '***',
@@ -151,6 +183,7 @@ export class GiteaProvider extends BaseProvider {
masked: true,
scope,
scopeId,
scopeName: scopeName || scopeId,
connectionId: this.connectionId,
environment: '*',
};

View File

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

View File

@@ -0,0 +1,139 @@
import * as path from '@std/path';
export type TStorageBackend = 'filesystem' | 'memory';
export interface IStorageConfig {
backend?: TStorageBackend;
fsPath?: string;
}
/**
* Key-value storage abstraction with filesystem and memory backends.
* Keys must start with '/' and are normalized (no '..', no double slashes).
*/
export class StorageManager {
private backend: TStorageBackend;
private fsPath: string;
private memoryStore: Map<string, string>;
constructor(config: IStorageConfig = {}) {
this.backend = config.backend ?? 'filesystem';
this.fsPath = config.fsPath ?? './storage';
this.memoryStore = new Map();
}
/**
* Normalize and validate a storage key.
*/
private normalizeKey(key: string): string {
if (!key.startsWith('/')) {
throw new Error(`Storage key must start with '/': ${key}`);
}
// Strip '..' segments and normalize double slashes
const segments = key.split('/').filter((s) => s !== '' && s !== '..');
return '/' + segments.join('/');
}
/**
* Resolve a key to a filesystem path.
*/
private keyToPath(key: string): string {
const normalized = this.normalizeKey(key);
return path.join(this.fsPath, ...normalized.split('/').filter(Boolean));
}
async get(key: string): Promise<string | null> {
const normalized = this.normalizeKey(key);
if (this.backend === 'memory') {
return this.memoryStore.get(normalized) ?? null;
}
try {
return await Deno.readTextFile(this.keyToPath(normalized));
} catch (err) {
if (err instanceof Deno.errors.NotFound) return null;
throw err;
}
}
async set(key: string, value: string): Promise<void> {
const normalized = this.normalizeKey(key);
if (this.backend === 'memory') {
this.memoryStore.set(normalized, value);
return;
}
const filePath = this.keyToPath(normalized);
const dir = path.dirname(filePath);
await Deno.mkdir(dir, { recursive: true });
// Atomic write: write to temp then rename
const tmpPath = filePath + '.tmp';
await Deno.writeTextFile(tmpPath, value);
await Deno.rename(tmpPath, filePath);
}
async delete(key: string): Promise<boolean> {
const normalized = this.normalizeKey(key);
if (this.backend === 'memory') {
return this.memoryStore.delete(normalized);
}
try {
await Deno.remove(this.keyToPath(normalized));
return true;
} catch (err) {
if (err instanceof Deno.errors.NotFound) return false;
throw err;
}
}
async exists(key: string): Promise<boolean> {
const normalized = this.normalizeKey(key);
if (this.backend === 'memory') {
return this.memoryStore.has(normalized);
}
try {
await Deno.stat(this.keyToPath(normalized));
return true;
} catch (err) {
if (err instanceof Deno.errors.NotFound) return false;
throw err;
}
}
/**
* List keys under a given prefix.
*/
async list(prefix: string): Promise<string[]> {
const normalized = this.normalizeKey(prefix);
if (this.backend === 'memory') {
const keys: string[] = [];
for (const key of this.memoryStore.keys()) {
if (key.startsWith(normalized)) {
keys.push(key);
}
}
return keys.sort();
}
const dirPath = this.keyToPath(normalized);
const keys: string[] = [];
try {
for await (const entry of Deno.readDir(dirPath)) {
if (entry.isFile) {
keys.push(normalized.replace(/\/$/, '') + '/' + entry.name);
}
}
} catch (err) {
if (err instanceof Deno.errors.NotFound) return [];
throw err;
}
return keys.sort();
}
async getJSON<T>(key: string): Promise<T | null> {
const raw = await this.get(key);
if (raw === null) return null;
return JSON.parse(raw) as T;
}
async setJSON(key: string, value: unknown): Promise<void> {
await this.set(key, JSON.stringify(value, null, 2));
}
}

2
ts/storage/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { StorageManager } from './classes.storagemanager.ts';
export type { IStorageConfig, TStorageBackend } from './classes.storagemanager.ts';

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;
scope: 'project' | 'group';
scopeId: string;
scopeName: string;
connectionId: string;
environment: string;
}

View File

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

View File

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

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 = {
name: '@serve.zone/gitops',
version: '2.0.0',
version: '2.7.0',
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<{
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 };
},
);

View File

@@ -32,16 +32,24 @@ export class GitopsDashboard extends DeesElement {
};
private viewTabs = [
{ name: 'Overview', element: (async () => (await import('./views/overview/index.js')).GitopsViewOverview)() },
{ name: 'Connections', element: (async () => (await import('./views/connections/index.js')).GitopsViewConnections)() },
{ name: 'Projects', element: (async () => (await import('./views/projects/index.js')).GitopsViewProjects)() },
{ name: 'Groups', element: (async () => (await import('./views/groups/index.js')).GitopsViewGroups)() },
{ name: 'Secrets', element: (async () => (await import('./views/secrets/index.js')).GitopsViewSecrets)() },
{ name: 'Pipelines', element: (async () => (await import('./views/pipelines/index.js')).GitopsViewPipelines)() },
{ name: 'Build Log', element: (async () => (await import('./views/buildlog/index.js')).GitopsViewBuildlog)() },
{ name: 'Overview', iconName: 'lucide:layoutDashboard', element: (async () => (await import('./views/overview/index.js')).GitopsViewOverview)() },
{ name: 'Connections', iconName: 'lucide:plug', element: (async () => (await import('./views/connections/index.js')).GitopsViewConnections)() },
{ name: 'Projects', iconName: 'lucide:folderGit2', element: (async () => (await import('./views/projects/index.js')).GitopsViewProjects)() },
{ name: 'Groups', iconName: 'lucide:users', element: (async () => (await import('./views/groups/index.js')).GitopsViewGroups)() },
{ name: 'Secrets', iconName: 'lucide:key', element: (async () => (await import('./views/secrets/index.js')).GitopsViewSecrets)() },
{ name: 'Pipelines', iconName: 'lucide:play', element: (async () => (await import('./views/pipelines/index.js')).GitopsViewPipelines)() },
{ name: 'Build Log', iconName: 'lucide:scrollText', element: (async () => (await import('./views/buildlog/index.js')).GitopsViewBuildlog)() },
];
private resolvedViewTabs: Array<{ name: 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() {
super();
@@ -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>
` : ''}
`;
}
@@ -100,6 +152,7 @@ export class GitopsDashboard extends DeesElement {
this.resolvedViewTabs = await Promise.all(
this.viewTabs.map(async (tab) => ({
name: tab.name,
iconName: tab.iconName,
element: await tab.element,
})),
);
@@ -159,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;

View File

@@ -3,11 +3,11 @@ import { css } from '@design.estate/dees-element';
export const viewHostCss = css`
:host {
display: block;
width: 100%;
height: 100%;
padding: 24px;
box-sizing: border-box;
margin: auto;
max-width: 1280px;
padding: 16px 16px;
color: #fff;
box-sizing: border-box;
}
.view-title {
font-size: 24px;

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import {
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
import { type IStatsTile } from '@design.estate/dees-catalog';
@customElement('gitops-view-overview')
export class GitopsViewOverview extends DeesElement {
@@ -29,6 +30,8 @@ export class GitopsViewOverview extends DeesElement {
currentJobLog: '',
};
private _autoRefreshHandler: () => void;
constructor() {
super();
const connSub = appstate.connectionsStatePart
@@ -40,38 +43,23 @@ 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 = [
cssManager.defaultStyles,
viewHostCss,
css`
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #1a1a2e;
border: 1px solid #333;
border-radius: 8px;
padding: 20px;
text-align: center;
}
.stat-value {
font-size: 36px;
font-weight: 700;
color: #00acff;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #999;
text-transform: uppercase;
letter-spacing: 1px;
}
`,
];
public render(): TemplateResult {
@@ -81,31 +69,18 @@ export class GitopsViewOverview extends DeesElement {
const pipelineCount = this.dataState.pipelines.length;
const failedPipelines = this.dataState.pipelines.filter((p) => p.status === 'failed').length;
const tiles: IStatsTile[] = [
{ id: 'connections', title: 'Connections', value: connCount, type: 'number', icon: 'lucide:plug', color: '#00acff' },
{ id: 'projects', title: 'Projects', value: projCount, type: 'number', icon: 'lucide:folderGit2', color: '#00acff' },
{ id: 'groups', title: 'Groups', value: groupCount, type: 'number', icon: 'lucide:users', color: '#00acff' },
{ id: 'pipelines', title: 'Pipelines', value: pipelineCount, type: 'number', icon: 'lucide:play', color: '#00acff' },
{ id: 'failed', title: 'Failed Pipelines', value: failedPipelines, type: 'number', icon: 'lucide:triangleAlert', color: failedPipelines > 0 ? '#ff4444' : '#00ff88' },
];
return html`
<div class="view-title">Overview</div>
<div class="view-description">GitOps dashboard - manage your Gitea and GitLab instances</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${connCount}</div>
<div class="stat-label">Connections</div>
</div>
<div class="stat-card">
<div class="stat-value">${projCount}</div>
<div class="stat-label">Projects</div>
</div>
<div class="stat-card">
<div class="stat-value">${groupCount}</div>
<div class="stat-label">Groups</div>
</div>
<div class="stat-card">
<div class="stat-value">${pipelineCount}</div>
<div class="stat-label">Pipelines</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: ${failedPipelines > 0 ? '#ff4444' : '#00ff88'}">${failedPipelines}</div>
<div class="stat-label">Failed Pipelines</div>
</div>
</div>
<dees-statsgrid .tiles=${tiles}></dees-statsgrid>
`;
}
}

View File

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

View File

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

View File

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