7 Commits

33 changed files with 30064 additions and 166579 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,31 @@
# Changelog # Changelog
## 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.6.0",
"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.6.0",
"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.6.0',
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

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

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

@@ -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.6.0',
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

@@ -543,3 +543,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

@@ -38,6 +38,8 @@ export class GitopsViewSecrets extends DeesElement {
@state() @state()
accessor selectedScopeId: string = ''; accessor selectedScopeId: string = '';
private _autoRefreshHandler: () => void;
constructor() { constructor() {
super(); super();
const connSub = appstate.connectionsStatePart const connSub = appstate.connectionsStatePart
@@ -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 = [