commit 3fad287a29b43c95c91e11c444acd2f461e777b0 Author: Juergen Kunz Date: Tue Feb 24 12:29:58 2026 +0000 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eadd5f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Deno +.deno/ +deno.lock + +# Node modules +node_modules/ + +# Build outputs +dist_serve/ + +# Development +.nogit/ +*.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +coverage/ + +# Config with secrets +.env +.env.local + +# Playwright +.playwright-mcp + +# Lock file +pnpm-lock.yaml diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..4031765 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +onlyBuiltDependencies: + - "@design.estate/dees-catalog" + - "esbuild" diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..4ab5b42 --- /dev/null +++ b/deno.json @@ -0,0 +1,46 @@ +{ + "name": "@serve.zone/gitops", + "version": "1.0.0", + "exports": "./mod.ts", + "nodeModulesDir": "auto", + "tasks": { + "test": "deno test --allow-all test/", + "dev": "pnpm run watch" + }, + "imports": { + "@std/path": "jsr:@std/path@^1.1.2", + "@std/fs": "jsr:@std/fs@^1.0.19", + "@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", + "@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0", + "@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1" + }, + "compilerOptions": { + "lib": [ + "deno.window", + "deno.ns" + ], + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "fmt": { + "useTabs": false, + "lineWidth": 100, + "indentWidth": 2, + "semiColons": true, + "singleQuote": true, + "proseWrap": "preserve" + }, + "lint": { + "rules": { + "tags": [ + "recommended" + ] + } + } +} diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..4a38fab --- /dev/null +++ b/mod.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env -S deno run --allow-all + +/** + * GitOps - Manage Gitea & GitLab from a single dashboard + * + * Entry point for the GitOps server. + */ + +import { runCli } from './ts/index.ts'; + +if (import.meta.main) { + try { + await runCli(); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + Deno.exit(1); + } +} diff --git a/npmextra.json b/npmextra.json new file mode 100644 index 0000000..c692318 --- /dev/null +++ b/npmextra.json @@ -0,0 +1,24 @@ +{ + "@git.zone/tsbundle": { + "bundles": [ + { + "from": "./ts_web/index.ts", + "to": "./dist_serve/bundle.js", + "outputMode": "bundle", + "bundler": "esbuild", + "production": true + } + ] + }, + "@git.zone/tswatch": { + "watchers": [ + { + "name": "ui-bundle", + "watch": "./ts_web/**/*", + "command": "tsbundle", + "debounce": 500, + "runOnStart": true + } + ] + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f25a936 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "@serve.zone/gitops", + "version": "1.0.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": { + "build": "tsbundle", + "startTs": "deno run --allow-all mod.ts server", + "watch": "tswatch website" + }, + "author": "Lossless GmbH", + "license": "MIT", + "dependencies": { + "@api.global/typedrequest-interfaces": "^3.0.19", + "@design.estate/dees-catalog": "^3.43.0", + "@design.estate/dees-element": "^2.1.6" + }, + "devDependencies": { + "@git.zone/tsbundle": "^2.8.3", + "@git.zone/tswatch": "^2.3.13" + } +} diff --git a/test/test.basic.ts b/test/test.basic.ts new file mode 100644 index 0000000..d8a765e --- /dev/null +++ b/test/test.basic.ts @@ -0,0 +1,30 @@ +import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts'; +import { BaseProvider, GiteaProvider, GitLabProvider } from '../ts/providers/index.ts'; +import { ConnectionManager } from '../ts/classes/connectionmanager.ts'; +import { GitopsApp } from '../ts/classes/gitopsapp.ts'; + +Deno.test('GiteaProvider instantiates correctly', () => { + const provider = new GiteaProvider('test-id', 'https://gitea.example.com', 'test-token'); + assertExists(provider); + assertEquals(provider.connectionId, 'test-id'); + assertEquals(provider.baseUrl, 'https://gitea.example.com'); +}); + +Deno.test('GitLabProvider instantiates correctly', () => { + const provider = new GitLabProvider('test-id', 'https://gitlab.example.com', 'test-token'); + assertExists(provider); + assertEquals(provider.connectionId, 'test-id'); + assertEquals(provider.baseUrl, 'https://gitlab.example.com'); +}); + +Deno.test('ConnectionManager instantiates correctly', () => { + const manager = new ConnectionManager(); + assertExists(manager); +}); + +Deno.test('GitopsApp instantiates correctly', () => { + const app = new GitopsApp(); + assertExists(app); + assertExists(app.connectionManager); + assertExists(app.opsServer); +}); diff --git a/ts/classes/connectionmanager.ts b/ts/classes/connectionmanager.ts new file mode 100644 index 0000000..1652fd3 --- /dev/null +++ b/ts/classes/connectionmanager.ts @@ -0,0 +1,114 @@ +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'; + +const CONNECTIONS_FILE = './.nogit/connections.json'; + +/** + * Manages provider connections - persists to .nogit/connections.json + * and creates provider instances on demand. + */ +export class ConnectionManager { + private connections: interfaces.data.IProviderConnection[] = []; + + async init(): Promise { + await this.loadConnections(); + } + + private async loadConnections(): Promise { + try { + const text = await Deno.readTextFile(CONNECTIONS_FILE); + this.connections = JSON.parse(text); + logger.info(`Loaded ${this.connections.length} connection(s)`); + } catch { + this.connections = []; + logger.debug('No existing connections file found, starting fresh'); + } + } + + private async saveConnections(): Promise { + // Ensure .nogit directory exists + try { + await Deno.mkdir('./.nogit', { recursive: true }); + } catch { /* already exists */ } + await Deno.writeTextFile(CONNECTIONS_FILE, JSON.stringify(this.connections, null, 2)); + } + + getConnections(): interfaces.data.IProviderConnection[] { + // Return connections without exposing tokens + return this.connections.map((c) => ({ ...c, token: '***' })); + } + + getConnection(id: string): interfaces.data.IProviderConnection | undefined { + return this.connections.find((c) => c.id === id); + } + + async createConnection( + name: string, + providerType: interfaces.data.TProviderType, + baseUrl: string, + token: string, + ): Promise { + const connection: interfaces.data.IProviderConnection = { + id: crypto.randomUUID(), + name, + providerType, + baseUrl: baseUrl.replace(/\/+$/, ''), + token, + createdAt: Date.now(), + status: 'disconnected', + }; + this.connections.push(connection); + await this.saveConnections(); + logger.success(`Connection created: ${name} (${providerType})`); + return { ...connection, token: '***' }; + } + + async updateConnection( + id: string, + updates: { name?: string; baseUrl?: string; token?: string }, + ): Promise { + const conn = this.connections.find((c) => c.id === id); + if (!conn) throw new Error(`Connection not found: ${id}`); + 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(); + return { ...conn, token: '***' }; + } + + async deleteConnection(id: string): Promise { + 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(); + logger.info(`Connection deleted: ${id}`); + } + + async testConnection(id: string): Promise<{ ok: boolean; error?: string }> { + const provider = this.getProvider(id); + const result = await provider.testConnection(); + const conn = this.connections.find((c) => c.id === id)!; + conn.status = result.ok ? 'connected' : 'error'; + await this.saveConnections(); + return result; + } + + /** + * Factory: returns the correct provider instance for a connection ID + */ + getProvider(connectionId: string): BaseProvider { + const conn = this.connections.find((c) => c.id === connectionId); + if (!conn) throw new Error(`Connection not found: ${connectionId}`); + + switch (conn.providerType) { + case 'gitea': + return new GiteaProvider(conn.id, conn.baseUrl, conn.token); + case 'gitlab': + return new GitLabProvider(conn.id, conn.baseUrl, conn.token); + default: + throw new Error(`Unknown provider type: ${conn.providerType}`); + } + } +} diff --git a/ts/classes/gitopsapp.ts b/ts/classes/gitopsapp.ts new file mode 100644 index 0000000..f455621 --- /dev/null +++ b/ts/classes/gitopsapp.ts @@ -0,0 +1,34 @@ +import { logger } from '../logging.ts'; +import { ConnectionManager } from './connectionmanager.ts'; +import { OpsServer } from '../opsserver/index.ts'; + +/** + * Main GitOps application orchestrator + */ +export class GitopsApp { + public connectionManager: ConnectionManager; + public opsServer: OpsServer; + + constructor() { + this.connectionManager = new ConnectionManager(); + this.opsServer = new OpsServer(this); + } + + async start(port = 3000): Promise { + logger.info('Initializing GitOps...'); + + // Initialize connection manager (loads saved connections) + await this.connectionManager.init(); + + // Start OpsServer + await this.opsServer.start(port); + + logger.success('GitOps initialized successfully'); + } + + async stop(): Promise { + logger.info('Shutting down GitOps...'); + await this.opsServer.stop(); + logger.success('GitOps shutdown complete'); + } +} diff --git a/ts/index.ts b/ts/index.ts new file mode 100644 index 0000000..656b990 --- /dev/null +++ b/ts/index.ts @@ -0,0 +1,37 @@ +/** + * Main exports and CLI entry point for GitOps + */ + +export { GitopsApp } from './classes/gitopsapp.ts'; +export { logger } from './logging.ts'; + +import { GitopsApp } from './classes/gitopsapp.ts'; +import { logger } from './logging.ts'; + +export async function runCli(): Promise { + const args = Deno.args; + const command = args[0] || 'server'; + + switch (command) { + case 'server': { + const port = parseInt(Deno.env.get('GITOPS_PORT') || '3000', 10); + const app = new GitopsApp(); + await app.start(port); + + // Handle graceful shutdown + const shutdown = async () => { + logger.info('Shutting down...'); + await app.stop(); + Deno.exit(0); + }; + + Deno.addSignalListener('SIGINT', shutdown); + Deno.addSignalListener('SIGTERM', shutdown); + break; + } + default: + logger.error(`Unknown command: ${command}`); + logger.info('Usage: gitops [server]'); + Deno.exit(1); + } +} diff --git a/ts/logging.ts b/ts/logging.ts new file mode 100644 index 0000000..49df4a9 --- /dev/null +++ b/ts/logging.ts @@ -0,0 +1,75 @@ +/** + * Logging utilities for GitOps + */ + +type LogLevel = 'info' | 'success' | 'warn' | 'error' | 'debug'; + +class Logger { + private debugMode = false; + + constructor() { + this.debugMode = Deno.args.includes('--debug') || Deno.env.get('DEBUG') === 'true'; + } + + log(level: LogLevel, message: string, ...args: unknown[]): void { + const prefix = this.getPrefix(level); + const formattedMessage = `${prefix} ${message}`; + + switch (level) { + case 'error': + console.error(formattedMessage, ...args); + break; + case 'warn': + console.warn(formattedMessage, ...args); + break; + case 'debug': + if (this.debugMode) { + console.log(formattedMessage, ...args); + } + break; + default: + console.log(formattedMessage, ...args); + } + } + + info(message: string, ...args: unknown[]): void { + this.log('info', message, ...args); + } + + success(message: string, ...args: unknown[]): void { + this.log('success', message, ...args); + } + + warn(message: string, ...args: unknown[]): void { + this.log('warn', message, ...args); + } + + error(message: string, ...args: unknown[]): void { + this.log('error', message, ...args); + } + + debug(message: string, ...args: unknown[]): void { + this.log('debug', message, ...args); + } + + private getPrefix(level: LogLevel): string { + const colors: Record = { + info: '\x1b[36m', + success: '\x1b[32m', + warn: '\x1b[33m', + error: '\x1b[31m', + debug: '\x1b[90m', + }; + const reset = '\x1b[0m'; + const icons: Record = { + info: 'i', + success: '+', + warn: '!', + error: 'x', + debug: '*', + }; + return `${colors[level]}[${icons[level]}]${reset}`; + } +} + +export const logger = new Logger(); diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts new file mode 100644 index 0000000..f702df3 --- /dev/null +++ b/ts/opsserver/classes.opsserver.ts @@ -0,0 +1,64 @@ +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'; + +export class OpsServer { + public gitopsAppRef: GitopsApp; + public typedrouter = new plugins.typedrequest.TypedRouter(); + public server!: plugins.typedserver.utilityservers.UtilityWebsiteServer; + + // Handler instances + public adminHandler!: handlers.AdminHandler; + public connectionsHandler!: handlers.ConnectionsHandler; + public projectsHandler!: handlers.ProjectsHandler; + public groupsHandler!: handlers.GroupsHandler; + public secretsHandler!: handlers.SecretsHandler; + public pipelinesHandler!: handlers.PipelinesHandler; + public logsHandler!: handlers.LogsHandler; + + constructor(gitopsAppRef: GitopsApp) { + this.gitopsAppRef = gitopsAppRef; + } + + public async start(port = 3000) { + const absoluteServeDir = plugins.path.resolve('./dist_serve'); + this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({ + domain: 'localhost', + feedMetadata: undefined, + serveDir: absoluteServeDir, + }); + + // Chain typedrouters + this.server.typedrouter.addTypedRouter(this.typedrouter); + + // Set up all handlers + await this.setupHandlers(); + + await this.server.start(port); + logger.success(`OpsServer started on http://localhost:${port}`); + } + + private async setupHandlers(): Promise { + // AdminHandler requires async initialization for JWT key generation + this.adminHandler = new handlers.AdminHandler(this); + await this.adminHandler.initialize(); + + // All other handlers self-register in their constructors + this.connectionsHandler = new handlers.ConnectionsHandler(this); + this.projectsHandler = new handlers.ProjectsHandler(this); + this.groupsHandler = new handlers.GroupsHandler(this); + this.secretsHandler = new handlers.SecretsHandler(this); + this.pipelinesHandler = new handlers.PipelinesHandler(this); + this.logsHandler = new handlers.LogsHandler(this); + + logger.success('OpsServer TypedRequest handlers initialized'); + } + + public async stop() { + if (this.server) { + await this.server.stop(); + logger.success('OpsServer stopped'); + } + } +} diff --git a/ts/opsserver/handlers/admin.handler.ts b/ts/opsserver/handlers/admin.handler.ts new file mode 100644 index 0000000..9895730 --- /dev/null +++ b/ts/opsserver/handlers/admin.handler.ts @@ -0,0 +1,122 @@ +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 interface IJwtData { + userId: string; + status: 'loggedIn' | 'loggedOut'; + expiresAt: number; +} + +export class AdminHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + public smartjwtInstance!: plugins.smartjwt.SmartJwt; + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + } + + public async initialize(): Promise { + this.smartjwtInstance = new plugins.smartjwt.SmartJwt(); + await this.smartjwtInstance.init(); + await this.smartjwtInstance.createNewKeyPair(); + this.registerHandlers(); + } + + private registerHandlers(): void { + // Login + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'adminLogin', + async (dataArg) => { + const expectedUsername = Deno.env.get('GITOPS_ADMIN_USERNAME') || 'admin'; + const expectedPassword = Deno.env.get('GITOPS_ADMIN_PASSWORD') || 'admin'; + + if (dataArg.username !== expectedUsername || dataArg.password !== expectedPassword) { + throw new plugins.typedrequest.TypedResponseError('Invalid credentials'); + } + + const expiresAt = Date.now() + 24 * 3600 * 1000; + const userId = 'admin'; + const jwt = await this.smartjwtInstance.createJWT({ + userId, + status: 'loggedIn', + expiresAt, + }); + + logger.info(`User logged in: ${dataArg.username}`); + + return { + identity: { + jwt, + userId, + username: dataArg.username, + expiresAt, + role: 'admin' as const, + }, + }; + }, + ), + ); + + // Logout + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'adminLogout', + async (_dataArg) => { + return { ok: true }; + }, + ), + ); + + // Verify Identity + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'verifyIdentity', + async (dataArg) => { + if (!dataArg.identity?.jwt) { + return { valid: false }; + } + try { + const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt); + if (jwtData.expiresAt < Date.now()) return { valid: false }; + if (jwtData.status !== 'loggedIn') return { valid: false }; + return { + valid: true, + identity: { + jwt: dataArg.identity.jwt, + userId: jwtData.userId, + username: dataArg.identity.username, + expiresAt: jwtData.expiresAt, + role: dataArg.identity.role, + }, + }; + } catch { + return { valid: false }; + } + }, + ), + ); + } + + // Guard for valid identity + public validIdentityGuard = new plugins.smartguard.Guard<{ + identity: interfaces.data.IIdentity; + }>( + async (dataArg) => { + if (!dataArg.identity?.jwt) return false; + try { + const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt); + if (jwtData.expiresAt < Date.now()) return false; + if (jwtData.status !== 'loggedIn') return false; + if (dataArg.identity.expiresAt !== jwtData.expiresAt) return false; + if (dataArg.identity.userId !== jwtData.userId) return false; + return true; + } catch { + return false; + } + }, + { failedHint: 'identity is not valid', name: 'validIdentityGuard' }, + ); +} diff --git a/ts/opsserver/handlers/connections.handler.ts b/ts/opsserver/handlers/connections.handler.ts new file mode 100644 index 0000000..9f4b74f --- /dev/null +++ b/ts/opsserver/handlers/connections.handler.ts @@ -0,0 +1,91 @@ +import * as plugins from '../../plugins.ts'; +import type { OpsServer } from '../classes.opsserver.ts'; +import * as interfaces from '../../../ts_interfaces/index.ts'; +import { requireValidIdentity } from '../helpers/guards.ts'; + +export class ConnectionsHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private registerHandlers(): void { + // Get all connections + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getConnections', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const connections = this.opsServerRef.gitopsAppRef.connectionManager.getConnections(); + return { connections }; + }, + ), + ); + + // Create connection + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createConnection', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const connection = await this.opsServerRef.gitopsAppRef.connectionManager.createConnection( + dataArg.name, + dataArg.providerType, + dataArg.baseUrl, + dataArg.token, + ); + return { connection }; + }, + ), + ); + + // Update connection + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateConnection', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const connection = await this.opsServerRef.gitopsAppRef.connectionManager.updateConnection( + dataArg.connectionId, + { + name: dataArg.name, + baseUrl: dataArg.baseUrl, + token: dataArg.token, + }, + ); + return { connection }; + }, + ), + ); + + // Test connection + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'testConnection', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const result = await this.opsServerRef.gitopsAppRef.connectionManager.testConnection( + dataArg.connectionId, + ); + return result; + }, + ), + ); + + // Delete connection + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteConnection', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + await this.opsServerRef.gitopsAppRef.connectionManager.deleteConnection( + dataArg.connectionId, + ); + return { ok: true }; + }, + ), + ); + } +} diff --git a/ts/opsserver/handlers/groups.handler.ts b/ts/opsserver/handlers/groups.handler.ts new file mode 100644 index 0000000..2c8e6f3 --- /dev/null +++ b/ts/opsserver/handlers/groups.handler.ts @@ -0,0 +1,32 @@ +import * as plugins from '../../plugins.ts'; +import type { OpsServer } from '../classes.opsserver.ts'; +import * as interfaces from '../../../ts_interfaces/index.ts'; +import { requireValidIdentity } from '../helpers/guards.ts'; + +export class GroupsHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private registerHandlers(): void { + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getGroups', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider( + dataArg.connectionId, + ); + const groups = await provider.getGroups({ + search: dataArg.search, + page: dataArg.page, + }); + return { groups }; + }, + ), + ); + } +} diff --git a/ts/opsserver/handlers/index.ts b/ts/opsserver/handlers/index.ts new file mode 100644 index 0000000..a778c41 --- /dev/null +++ b/ts/opsserver/handlers/index.ts @@ -0,0 +1,7 @@ +export { AdminHandler } from './admin.handler.ts'; +export { ConnectionsHandler } from './connections.handler.ts'; +export { ProjectsHandler } from './projects.handler.ts'; +export { GroupsHandler } from './groups.handler.ts'; +export { SecretsHandler } from './secrets.handler.ts'; +export { PipelinesHandler } from './pipelines.handler.ts'; +export { LogsHandler } from './logs.handler.ts'; diff --git a/ts/opsserver/handlers/logs.handler.ts b/ts/opsserver/handlers/logs.handler.ts new file mode 100644 index 0000000..f7eb0de --- /dev/null +++ b/ts/opsserver/handlers/logs.handler.ts @@ -0,0 +1,29 @@ +import * as plugins from '../../plugins.ts'; +import type { OpsServer } from '../classes.opsserver.ts'; +import * as interfaces from '../../../ts_interfaces/index.ts'; +import { requireValidIdentity } from '../helpers/guards.ts'; + +export class LogsHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private registerHandlers(): void { + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getJobLog', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider( + dataArg.connectionId, + ); + const log = await provider.getJobLog(dataArg.projectId, dataArg.jobId); + return { log }; + }, + ), + ); + } +} diff --git a/ts/opsserver/handlers/pipelines.handler.ts b/ts/opsserver/handlers/pipelines.handler.ts new file mode 100644 index 0000000..ae4a015 --- /dev/null +++ b/ts/opsserver/handlers/pipelines.handler.ts @@ -0,0 +1,77 @@ +import * as plugins from '../../plugins.ts'; +import type { OpsServer } from '../classes.opsserver.ts'; +import * as interfaces from '../../../ts_interfaces/index.ts'; +import { requireValidIdentity } from '../helpers/guards.ts'; + +export class PipelinesHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private registerHandlers(): void { + // Get pipelines + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getPipelines', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider( + dataArg.connectionId, + ); + const pipelines = await provider.getPipelines(dataArg.projectId, { + page: dataArg.page, + }); + return { pipelines }; + }, + ), + ); + + // Get pipeline jobs + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getPipelineJobs', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider( + dataArg.connectionId, + ); + const jobs = await provider.getPipelineJobs(dataArg.projectId, dataArg.pipelineId); + return { jobs }; + }, + ), + ); + + // Retry pipeline + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'retryPipeline', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider( + dataArg.connectionId, + ); + await provider.retryPipeline(dataArg.projectId, dataArg.pipelineId); + return { ok: true }; + }, + ), + ); + + // Cancel pipeline + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'cancelPipeline', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider( + dataArg.connectionId, + ); + await provider.cancelPipeline(dataArg.projectId, dataArg.pipelineId); + return { ok: true }; + }, + ), + ); + } +} diff --git a/ts/opsserver/handlers/projects.handler.ts b/ts/opsserver/handlers/projects.handler.ts new file mode 100644 index 0000000..e743aec --- /dev/null +++ b/ts/opsserver/handlers/projects.handler.ts @@ -0,0 +1,32 @@ +import * as plugins from '../../plugins.ts'; +import type { OpsServer } from '../classes.opsserver.ts'; +import * as interfaces from '../../../ts_interfaces/index.ts'; +import { requireValidIdentity } from '../helpers/guards.ts'; + +export class ProjectsHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private registerHandlers(): void { + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getProjects', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider( + dataArg.connectionId, + ); + const projects = await provider.getProjects({ + search: dataArg.search, + page: dataArg.page, + }); + return { projects }; + }, + ), + ); + } +} diff --git a/ts/opsserver/handlers/secrets.handler.ts b/ts/opsserver/handlers/secrets.handler.ts new file mode 100644 index 0000000..4a656ff --- /dev/null +++ b/ts/opsserver/handlers/secrets.handler.ts @@ -0,0 +1,85 @@ +import * as plugins from '../../plugins.ts'; +import type { OpsServer } from '../classes.opsserver.ts'; +import * as interfaces from '../../../ts_interfaces/index.ts'; +import { requireValidIdentity } from '../helpers/guards.ts'; + +export class SecretsHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private registerHandlers(): void { + // Get secrets + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getSecrets', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider( + dataArg.connectionId, + ); + const secrets = dataArg.scope === 'project' + ? await provider.getProjectSecrets(dataArg.scopeId) + : await provider.getGroupSecrets(dataArg.scopeId); + return { secrets }; + }, + ), + ); + + // Create secret + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createSecret', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider( + dataArg.connectionId, + ); + const secret = dataArg.scope === 'project' + ? await provider.createProjectSecret(dataArg.scopeId, dataArg.key, dataArg.value) + : await provider.createGroupSecret(dataArg.scopeId, dataArg.key, dataArg.value); + return { secret }; + }, + ), + ); + + // Update secret + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateSecret', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider( + dataArg.connectionId, + ); + const secret = dataArg.scope === 'project' + ? await provider.updateProjectSecret(dataArg.scopeId, dataArg.key, dataArg.value) + : await provider.updateGroupSecret(dataArg.scopeId, dataArg.key, dataArg.value); + return { secret }; + }, + ), + ); + + // Delete secret + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteSecret', + async (dataArg) => { + await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); + const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider( + dataArg.connectionId, + ); + if (dataArg.scope === 'project') { + await provider.deleteProjectSecret(dataArg.scopeId, dataArg.key); + } else { + await provider.deleteGroupSecret(dataArg.scopeId, dataArg.key); + } + return { ok: true }; + }, + ), + ); + } +} diff --git a/ts/opsserver/helpers/guards.ts b/ts/opsserver/helpers/guards.ts new file mode 100644 index 0000000..5cc0c39 --- /dev/null +++ b/ts/opsserver/helpers/guards.ts @@ -0,0 +1,16 @@ +import * as plugins from '../../plugins.ts'; +import type { AdminHandler } from '../handlers/admin.handler.ts'; +import * as interfaces from '../../../ts_interfaces/index.ts'; + +export async function requireValidIdentity( + adminHandler: AdminHandler, + dataArg: T, +): Promise { + if (!dataArg.identity) { + throw new plugins.typedrequest.TypedResponseError('No identity provided'); + } + const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity }); + if (!passed) { + throw new plugins.typedrequest.TypedResponseError('Valid identity required'); + } +} diff --git a/ts/opsserver/index.ts b/ts/opsserver/index.ts new file mode 100644 index 0000000..247526f --- /dev/null +++ b/ts/opsserver/index.ts @@ -0,0 +1 @@ +export { OpsServer } from './classes.opsserver.ts'; diff --git a/ts/plugins.ts b/ts/plugins.ts new file mode 100644 index 0000000..5b770a9 --- /dev/null +++ b/ts/plugins.ts @@ -0,0 +1,20 @@ +/** + * Centralized dependency imports for GitOps + */ + +// Deno Standard Library +import * as path from '@std/path'; +import * as fs from '@std/fs'; +import * as encoding from '@std/encoding'; + +export { path, fs, encoding }; + +// TypedRequest/TypedServer infrastructure +import * as typedrequest from '@api.global/typedrequest'; +import * as typedserver from '@api.global/typedserver'; +export { typedrequest, typedserver }; + +// Auth & Guards +import * as smartguard from '@push.rocks/smartguard'; +import * as smartjwt from '@push.rocks/smartjwt'; +export { smartguard, smartjwt }; diff --git a/ts/providers/classes.baseprovider.ts b/ts/providers/classes.baseprovider.ts new file mode 100644 index 0000000..6dc07d9 --- /dev/null +++ b/ts/providers/classes.baseprovider.ts @@ -0,0 +1,89 @@ +import type * as interfaces from '../../ts_interfaces/index.ts'; + +export interface ITestConnectionResult { + ok: boolean; + error?: string; +} + +export interface IListOptions { + search?: string; + page?: number; + perPage?: number; +} + +/** + * Abstract base class for Git provider implementations. + * Subclasses implement Gitea API v1 or GitLab API v4. + */ +export abstract class BaseProvider { + constructor( + public readonly connectionId: string, + public readonly baseUrl: string, + protected readonly token: string, + ) {} + + // Connection + abstract testConnection(): Promise; + + // Projects + abstract getProjects(opts?: IListOptions): Promise; + + // Groups / Orgs + abstract getGroups(opts?: IListOptions): Promise; + + // Secrets — project scope + abstract getProjectSecrets(projectId: string): Promise; + abstract createProjectSecret( + projectId: string, + key: string, + value: string, + ): Promise; + abstract updateProjectSecret( + projectId: string, + key: string, + value: string, + ): Promise; + abstract deleteProjectSecret(projectId: string, key: string): Promise; + + // Secrets — group scope + abstract getGroupSecrets(groupId: string): Promise; + abstract createGroupSecret( + groupId: string, + key: string, + value: string, + ): Promise; + abstract updateGroupSecret( + groupId: string, + key: string, + value: string, + ): Promise; + abstract deleteGroupSecret(groupId: string, key: string): Promise; + + // Pipelines / CI + abstract getPipelines( + projectId: string, + opts?: IListOptions, + ): Promise; + abstract getPipelineJobs( + projectId: string, + pipelineId: string, + ): Promise; + abstract getJobLog(projectId: string, jobId: string): Promise; + abstract retryPipeline(projectId: string, pipelineId: string): Promise; + abstract cancelPipeline(projectId: string, pipelineId: string): Promise; + + /** + * Helper for making authenticated fetch requests + */ + protected async apiFetch( + path: string, + options: RequestInit = {}, + ): Promise { + const url = `${this.baseUrl.replace(/\/+$/, '')}${path}`; + const headers = new Headers(options.headers); + this.setAuthHeader(headers); + return fetch(url, { ...options, headers }); + } + + protected abstract setAuthHeader(headers: Headers): void; +} diff --git a/ts/providers/classes.giteaprovider.ts b/ts/providers/classes.giteaprovider.ts new file mode 100644 index 0000000..0f2e7cb --- /dev/null +++ b/ts/providers/classes.giteaprovider.ts @@ -0,0 +1,263 @@ +import type * as interfaces from '../../ts_interfaces/index.ts'; +import { BaseProvider, type ITestConnectionResult, type IListOptions } from './classes.baseprovider.ts'; + +/** + * Gitea API v1 provider implementation + */ +export class GiteaProvider extends BaseProvider { + protected setAuthHeader(headers: Headers): void { + headers.set('Authorization', `token ${this.token}`); + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + } + + async testConnection(): Promise { + try { + const resp = await this.apiFetch('/api/v1/user'); + if (!resp.ok) { + return { ok: false, error: `HTTP ${resp.status}: ${resp.statusText}` }; + } + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + async getProjects(opts?: IListOptions): Promise { + const page = opts?.page || 1; + const limit = opts?.perPage || 50; + let url = `/api/v1/repos/search?page=${page}&limit=${limit}&sort=updated`; + if (opts?.search) { + url += `&q=${encodeURIComponent(opts.search)}`; + } + const resp = await this.apiFetch(url); + if (!resp.ok) throw new Error(`Gitea getProjects failed: ${resp.status}`); + const body = await resp.json(); + const repos = body.data || body; + return (repos as any[]).map((r) => this.mapProject(r)); + } + + async getGroups(opts?: IListOptions): Promise { + const page = opts?.page || 1; + const limit = opts?.perPage || 50; + const resp = await this.apiFetch(`/api/v1/orgs?page=${page}&limit=${limit}`); + if (!resp.ok) throw new Error(`Gitea getGroups failed: ${resp.status}`); + const orgs = await resp.json() as any[]; + return orgs.map((o) => this.mapGroup(o)); + } + + // --- Project Secrets --- + + async getProjectSecrets(projectId: string): Promise { + const resp = await this.apiFetch(`/api/v1/repos/${projectId}/actions/secrets`); + if (!resp.ok) throw new Error(`Gitea getProjectSecrets failed: ${resp.status}`); + const secrets = await resp.json() as any[]; + return secrets.map((s) => this.mapSecret(s, 'project', projectId)); + } + + async createProjectSecret( + projectId: string, + key: string, + value: string, + ): Promise { + const resp = await this.apiFetch(`/api/v1/repos/${projectId}/actions/secrets/${key}`, { + method: 'PUT', + body: JSON.stringify({ data: value }), + }); + if (!resp.ok) throw new Error(`Gitea createProjectSecret failed: ${resp.status}`); + return { key, value: '***', protected: false, masked: true, scope: 'project', scopeId: projectId, connectionId: this.connectionId, environment: '*' }; + } + + async updateProjectSecret( + projectId: string, + key: string, + value: string, + ): Promise { + return this.createProjectSecret(projectId, key, value); + } + + async deleteProjectSecret(projectId: string, key: string): Promise { + const resp = await this.apiFetch(`/api/v1/repos/${projectId}/actions/secrets/${key}`, { + method: 'DELETE', + }); + if (!resp.ok) throw new Error(`Gitea deleteProjectSecret failed: ${resp.status}`); + } + + // --- Group Secrets --- + + async getGroupSecrets(groupId: string): Promise { + const resp = await this.apiFetch(`/api/v1/orgs/${groupId}/actions/secrets`); + if (!resp.ok) throw new Error(`Gitea getGroupSecrets failed: ${resp.status}`); + const secrets = await resp.json() as any[]; + return secrets.map((s) => this.mapSecret(s, 'group', groupId)); + } + + async createGroupSecret( + groupId: string, + key: string, + value: string, + ): Promise { + const resp = await this.apiFetch(`/api/v1/orgs/${groupId}/actions/secrets/${key}`, { + method: 'PUT', + body: JSON.stringify({ data: value }), + }); + if (!resp.ok) throw new Error(`Gitea createGroupSecret failed: ${resp.status}`); + return { key, value: '***', protected: false, masked: true, scope: 'group', scopeId: groupId, connectionId: this.connectionId, environment: '*' }; + } + + async updateGroupSecret( + groupId: string, + key: string, + value: string, + ): Promise { + return this.createGroupSecret(groupId, key, value); + } + + async deleteGroupSecret(groupId: string, key: string): Promise { + const resp = await this.apiFetch(`/api/v1/orgs/${groupId}/actions/secrets/${key}`, { + method: 'DELETE', + }); + if (!resp.ok) throw new Error(`Gitea deleteGroupSecret failed: ${resp.status}`); + } + + // --- Pipelines (Action Runs) --- + + async getPipelines( + projectId: string, + opts?: IListOptions, + ): Promise { + const page = opts?.page || 1; + const limit = opts?.perPage || 30; + const resp = await this.apiFetch( + `/api/v1/repos/${projectId}/actions/runs?page=${page}&limit=${limit}`, + ); + if (!resp.ok) throw new Error(`Gitea getPipelines failed: ${resp.status}`); + const body = await resp.json(); + const runs = body.workflow_runs || body; + return (runs as any[]).map((r) => this.mapPipeline(r, projectId)); + } + + async getPipelineJobs( + projectId: string, + pipelineId: string, + ): Promise { + const resp = await this.apiFetch( + `/api/v1/repos/${projectId}/actions/runs/${pipelineId}/jobs`, + ); + if (!resp.ok) throw new Error(`Gitea getPipelineJobs failed: ${resp.status}`); + const body = await resp.json(); + const jobs = body.jobs || body; + return (jobs as any[]).map((j) => this.mapJob(j, pipelineId)); + } + + async getJobLog(projectId: string, jobId: string): Promise { + const resp = await this.apiFetch( + `/api/v1/repos/${projectId}/actions/jobs/${jobId}/logs`, + ); + if (!resp.ok) throw new Error(`Gitea getJobLog failed: ${resp.status}`); + return resp.text(); + } + + async retryPipeline(projectId: string, pipelineId: string): Promise { + // Gitea doesn't have a native retry — we re-run the workflow + const resp = await this.apiFetch( + `/api/v1/repos/${projectId}/actions/runs/${pipelineId}/rerun`, + { method: 'POST' }, + ); + if (!resp.ok) throw new Error(`Gitea retryPipeline failed: ${resp.status}`); + } + + async cancelPipeline(projectId: string, pipelineId: string): Promise { + const resp = await this.apiFetch( + `/api/v1/repos/${projectId}/actions/runs/${pipelineId}/cancel`, + { method: 'POST' }, + ); + if (!resp.ok) throw new Error(`Gitea cancelPipeline failed: ${resp.status}`); + } + + // --- Mappers --- + + private mapProject(r: any): interfaces.data.IProject { + return { + id: String(r.id), + name: r.name || '', + fullPath: r.full_name || '', + description: r.description || '', + defaultBranch: r.default_branch || 'main', + webUrl: r.html_url || '', + connectionId: this.connectionId, + visibility: r.private ? 'private' : (r.internal ? 'internal' : 'public'), + topics: r.topics || [], + lastActivity: r.updated_at || '', + }; + } + + private mapGroup(o: any): interfaces.data.IGroup { + return { + id: String(o.id || o.name), + name: o.name || o.username || '', + fullPath: o.name || o.username || '', + description: o.description || '', + webUrl: `${this.baseUrl}/${o.name || o.username}`, + connectionId: this.connectionId, + visibility: o.visibility || 'public', + projectCount: o.repo_count || 0, + }; + } + + private mapSecret(s: any, scope: 'project' | 'group', scopeId: string): interfaces.data.ISecret { + return { + key: s.name || s.key || '', + value: '***', + protected: false, + masked: true, + scope, + scopeId, + connectionId: this.connectionId, + environment: '*', + }; + } + + private mapPipeline(r: any, projectId: string): interfaces.data.IPipeline { + return { + id: String(r.id), + projectId, + projectName: projectId, + connectionId: this.connectionId, + status: this.mapStatus(r.status || r.conclusion), + ref: r.head_branch || '', + sha: r.head_sha || '', + webUrl: r.html_url || '', + duration: r.run_duration || 0, + createdAt: r.created_at || '', + source: r.event || 'push', + }; + } + + private mapJob(j: any, pipelineId: string): interfaces.data.IPipelineJob { + return { + id: String(j.id), + pipelineId, + name: j.name || '', + stage: j.name || 'default', + status: this.mapStatus(j.status || j.conclusion), + duration: j.run_duration || 0, + }; + } + + private mapStatus(status: string): interfaces.data.TPipelineStatus { + const map: Record = { + success: 'success', + completed: 'success', + failure: 'failed', + cancelled: 'canceled', + waiting: 'waiting', + running: 'running', + queued: 'pending', + in_progress: 'running', + skipped: 'skipped', + }; + return map[status?.toLowerCase()] || 'pending'; + } +} diff --git a/ts/providers/classes.gitlabprovider.ts b/ts/providers/classes.gitlabprovider.ts new file mode 100644 index 0000000..1e24a73 --- /dev/null +++ b/ts/providers/classes.gitlabprovider.ts @@ -0,0 +1,275 @@ +import type * as interfaces from '../../ts_interfaces/index.ts'; +import { BaseProvider, type ITestConnectionResult, type IListOptions } from './classes.baseprovider.ts'; + +/** + * GitLab API v4 provider implementation + */ +export class GitLabProvider extends BaseProvider { + protected setAuthHeader(headers: Headers): void { + headers.set('PRIVATE-TOKEN', this.token); + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + } + + async testConnection(): Promise { + try { + const resp = await this.apiFetch('/api/v4/user'); + if (!resp.ok) { + return { ok: false, error: `HTTP ${resp.status}: ${resp.statusText}` }; + } + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + async getProjects(opts?: IListOptions): Promise { + const page = opts?.page || 1; + const perPage = opts?.perPage || 50; + let url = `/api/v4/projects?page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc&membership=true`; + if (opts?.search) { + url += `&search=${encodeURIComponent(opts.search)}`; + } + const resp = await this.apiFetch(url); + if (!resp.ok) throw new Error(`GitLab getProjects failed: ${resp.status}`); + const projects = await resp.json() as any[]; + return projects.map((p) => this.mapProject(p)); + } + + async getGroups(opts?: IListOptions): Promise { + const page = opts?.page || 1; + const perPage = opts?.perPage || 50; + let url = `/api/v4/groups?page=${page}&per_page=${perPage}&order_by=name&sort=asc`; + if (opts?.search) { + url += `&search=${encodeURIComponent(opts.search)}`; + } + const resp = await this.apiFetch(url); + if (!resp.ok) throw new Error(`GitLab getGroups failed: ${resp.status}`); + const groups = await resp.json() as any[]; + return groups.map((g) => this.mapGroup(g)); + } + + // --- Project Secrets (CI/CD Variables) --- + + async getProjectSecrets(projectId: string): Promise { + const resp = await this.apiFetch(`/api/v4/projects/${encodeURIComponent(projectId)}/variables`); + if (!resp.ok) throw new Error(`GitLab getProjectSecrets failed: ${resp.status}`); + const vars = await resp.json() as any[]; + return vars.map((v) => this.mapVariable(v, 'project', projectId)); + } + + async createProjectSecret( + projectId: string, + key: string, + value: string, + ): Promise { + const resp = await this.apiFetch(`/api/v4/projects/${encodeURIComponent(projectId)}/variables`, { + method: 'POST', + body: JSON.stringify({ key, value, protected: false, masked: false }), + }); + if (!resp.ok) throw new Error(`GitLab createProjectSecret failed: ${resp.status}`); + const v = await resp.json(); + return this.mapVariable(v, 'project', projectId); + } + + async updateProjectSecret( + projectId: string, + key: string, + value: string, + ): Promise { + const resp = await this.apiFetch( + `/api/v4/projects/${encodeURIComponent(projectId)}/variables/${encodeURIComponent(key)}`, + { + method: 'PUT', + body: JSON.stringify({ value }), + }, + ); + if (!resp.ok) throw new Error(`GitLab updateProjectSecret failed: ${resp.status}`); + const v = await resp.json(); + return this.mapVariable(v, 'project', projectId); + } + + async deleteProjectSecret(projectId: string, key: string): Promise { + const resp = await this.apiFetch( + `/api/v4/projects/${encodeURIComponent(projectId)}/variables/${encodeURIComponent(key)}`, + { method: 'DELETE' }, + ); + if (!resp.ok) throw new Error(`GitLab deleteProjectSecret failed: ${resp.status}`); + } + + // --- Group Secrets (CI/CD Variables) --- + + async getGroupSecrets(groupId: string): Promise { + const resp = await this.apiFetch(`/api/v4/groups/${encodeURIComponent(groupId)}/variables`); + if (!resp.ok) throw new Error(`GitLab getGroupSecrets failed: ${resp.status}`); + const vars = await resp.json() as any[]; + return vars.map((v) => this.mapVariable(v, 'group', groupId)); + } + + async createGroupSecret( + groupId: string, + key: string, + value: string, + ): Promise { + const resp = await this.apiFetch(`/api/v4/groups/${encodeURIComponent(groupId)}/variables`, { + method: 'POST', + body: JSON.stringify({ key, value, protected: false, masked: false }), + }); + if (!resp.ok) throw new Error(`GitLab createGroupSecret failed: ${resp.status}`); + const v = await resp.json(); + return this.mapVariable(v, 'group', groupId); + } + + async updateGroupSecret( + groupId: string, + key: string, + value: string, + ): Promise { + const resp = await this.apiFetch( + `/api/v4/groups/${encodeURIComponent(groupId)}/variables/${encodeURIComponent(key)}`, + { + method: 'PUT', + body: JSON.stringify({ value }), + }, + ); + if (!resp.ok) throw new Error(`GitLab updateGroupSecret failed: ${resp.status}`); + const v = await resp.json(); + return this.mapVariable(v, 'group', groupId); + } + + async deleteGroupSecret(groupId: string, key: string): Promise { + const resp = await this.apiFetch( + `/api/v4/groups/${encodeURIComponent(groupId)}/variables/${encodeURIComponent(key)}`, + { method: 'DELETE' }, + ); + if (!resp.ok) throw new Error(`GitLab deleteGroupSecret failed: ${resp.status}`); + } + + // --- Pipelines --- + + async getPipelines( + projectId: string, + opts?: IListOptions, + ): Promise { + const page = opts?.page || 1; + const perPage = opts?.perPage || 30; + const resp = await this.apiFetch( + `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines?page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc`, + ); + if (!resp.ok) throw new Error(`GitLab getPipelines failed: ${resp.status}`); + const pipelines = await resp.json() as any[]; + return pipelines.map((p) => this.mapPipeline(p, projectId)); + } + + async getPipelineJobs( + projectId: string, + pipelineId: string, + ): Promise { + const resp = await this.apiFetch( + `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/jobs`, + ); + if (!resp.ok) throw new Error(`GitLab getPipelineJobs failed: ${resp.status}`); + const jobs = await resp.json() as any[]; + return jobs.map((j) => this.mapJob(j, pipelineId)); + } + + async getJobLog(projectId: string, jobId: string): Promise { + const resp = await this.apiFetch( + `/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}/trace`, + { headers: { Accept: 'text/plain' } }, + ); + if (!resp.ok) throw new Error(`GitLab getJobLog failed: ${resp.status}`); + return resp.text(); + } + + async retryPipeline(projectId: string, pipelineId: string): Promise { + const resp = await this.apiFetch( + `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/retry`, + { method: 'POST' }, + ); + if (!resp.ok) throw new Error(`GitLab retryPipeline failed: ${resp.status}`); + } + + async cancelPipeline(projectId: string, pipelineId: string): Promise { + const resp = await this.apiFetch( + `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/cancel`, + { method: 'POST' }, + ); + if (!resp.ok) throw new Error(`GitLab cancelPipeline failed: ${resp.status}`); + } + + // --- Mappers --- + + private mapProject(p: any): interfaces.data.IProject { + return { + id: String(p.id), + name: p.name || '', + fullPath: p.path_with_namespace || '', + description: p.description || '', + defaultBranch: p.default_branch || 'main', + webUrl: p.web_url || '', + connectionId: this.connectionId, + visibility: p.visibility || 'private', + topics: p.topics || p.tag_list || [], + lastActivity: p.last_activity_at || '', + }; + } + + private mapGroup(g: any): interfaces.data.IGroup { + return { + id: String(g.id), + name: g.name || '', + fullPath: g.full_path || '', + description: g.description || '', + webUrl: g.web_url || '', + connectionId: this.connectionId, + visibility: g.visibility || 'private', + projectCount: g.projects?.length || 0, + }; + } + + private mapVariable( + v: any, + scope: 'project' | 'group', + scopeId: string, + ): interfaces.data.ISecret { + return { + key: v.key || '', + value: v.value || '***', + protected: v.protected || false, + masked: v.masked || false, + scope, + scopeId, + connectionId: this.connectionId, + environment: v.environment_scope || '*', + }; + } + + private mapPipeline(p: any, projectId: string): interfaces.data.IPipeline { + return { + id: String(p.id), + projectId, + projectName: projectId, + connectionId: this.connectionId, + status: (p.status || 'pending') as interfaces.data.TPipelineStatus, + ref: p.ref || '', + sha: p.sha || '', + webUrl: p.web_url || '', + duration: p.duration || 0, + createdAt: p.created_at || '', + source: p.source || 'push', + }; + } + + private mapJob(j: any, pipelineId: string): interfaces.data.IPipelineJob { + return { + id: String(j.id), + pipelineId: String(pipelineId), + name: j.name || '', + stage: j.stage || '', + status: (j.status || 'pending') as interfaces.data.TPipelineStatus, + duration: j.duration || 0, + }; + } +} diff --git a/ts/providers/index.ts b/ts/providers/index.ts new file mode 100644 index 0000000..876f157 --- /dev/null +++ b/ts/providers/index.ts @@ -0,0 +1,3 @@ +export { BaseProvider } from './classes.baseprovider.ts'; +export { GiteaProvider } from './classes.giteaprovider.ts'; +export { GitLabProvider } from './classes.gitlabprovider.ts'; diff --git a/ts_interfaces/data/connection.ts b/ts_interfaces/data/connection.ts new file mode 100644 index 0000000..2cc767d --- /dev/null +++ b/ts_interfaces/data/connection.ts @@ -0,0 +1,11 @@ +export type TProviderType = 'gitea' | 'gitlab'; + +export interface IProviderConnection { + id: string; + name: string; + providerType: TProviderType; + baseUrl: string; + token: string; + createdAt: number; + status: 'connected' | 'disconnected' | 'error'; +} diff --git a/ts_interfaces/data/group.ts b/ts_interfaces/data/group.ts new file mode 100644 index 0000000..cdf523f --- /dev/null +++ b/ts_interfaces/data/group.ts @@ -0,0 +1,10 @@ +export interface IGroup { + id: string; + name: string; + fullPath: string; + description: string; + webUrl: string; + connectionId: string; + visibility: string; + projectCount: number; +} diff --git a/ts_interfaces/data/identity.ts b/ts_interfaces/data/identity.ts new file mode 100644 index 0000000..5d49e62 --- /dev/null +++ b/ts_interfaces/data/identity.ts @@ -0,0 +1,7 @@ +export interface IIdentity { + jwt: string; + userId: string; + username: string; + expiresAt: number; + role: 'admin' | 'user'; +} diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts new file mode 100644 index 0000000..032088c --- /dev/null +++ b/ts_interfaces/data/index.ts @@ -0,0 +1,6 @@ +export * from './identity.ts'; +export * from './connection.ts'; +export * from './project.ts'; +export * from './group.ts'; +export * from './secret.ts'; +export * from './pipeline.ts'; diff --git a/ts_interfaces/data/pipeline.ts b/ts_interfaces/data/pipeline.ts new file mode 100644 index 0000000..61b83e3 --- /dev/null +++ b/ts_interfaces/data/pipeline.ts @@ -0,0 +1,32 @@ +export type TPipelineStatus = + | 'pending' + | 'running' + | 'success' + | 'failed' + | 'canceled' + | 'skipped' + | 'waiting' + | 'manual'; + +export interface IPipeline { + id: string; + projectId: string; + projectName: string; + connectionId: string; + status: TPipelineStatus; + ref: string; + sha: string; + webUrl: string; + duration: number; + createdAt: string; + source: string; +} + +export interface IPipelineJob { + id: string; + pipelineId: string; + name: string; + stage: string; + status: TPipelineStatus; + duration: number; +} diff --git a/ts_interfaces/data/project.ts b/ts_interfaces/data/project.ts new file mode 100644 index 0000000..9fe7250 --- /dev/null +++ b/ts_interfaces/data/project.ts @@ -0,0 +1,12 @@ +export interface IProject { + id: string; + name: string; + fullPath: string; + description: string; + defaultBranch: string; + webUrl: string; + connectionId: string; + visibility: string; + topics: string[]; + lastActivity: string; +} diff --git a/ts_interfaces/data/secret.ts b/ts_interfaces/data/secret.ts new file mode 100644 index 0000000..8037213 --- /dev/null +++ b/ts_interfaces/data/secret.ts @@ -0,0 +1,10 @@ +export interface ISecret { + key: string; + value: string; + protected: boolean; + masked: boolean; + scope: 'project' | 'group'; + scopeId: string; + connectionId: string; + environment: string; +} diff --git a/ts_interfaces/index.ts b/ts_interfaces/index.ts new file mode 100644 index 0000000..a416cf3 --- /dev/null +++ b/ts_interfaces/index.ts @@ -0,0 +1,9 @@ +export * from './plugins.ts'; + +// Data types +import * as data from './data/index.ts'; +export { data }; + +// Request interfaces +import * as requests from './requests/index.ts'; +export { requests }; diff --git a/ts_interfaces/plugins.ts b/ts_interfaces/plugins.ts new file mode 100644 index 0000000..20d7d1c --- /dev/null +++ b/ts_interfaces/plugins.ts @@ -0,0 +1,6 @@ +// @apiglobal scope +import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces'; + +export { + typedrequestInterfaces, +}; diff --git a/ts_interfaces/requests/admin.ts b/ts_interfaces/requests/admin.ts new file mode 100644 index 0000000..3f2476b --- /dev/null +++ b/ts_interfaces/requests/admin.ts @@ -0,0 +1,43 @@ +import * as plugins from '../plugins.ts'; +import * as data from '../data/index.ts'; + +export interface IReq_AdminLogin extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_AdminLogin +> { + method: 'adminLogin'; + request: { + username: string; + password: string; + }; + response: { + identity?: data.IIdentity; + }; +} + +export interface IReq_AdminLogout extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_AdminLogout +> { + method: 'adminLogout'; + request: { + identity: data.IIdentity; + }; + response: { + ok: boolean; + }; +} + +export interface IReq_VerifyIdentity extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_VerifyIdentity +> { + method: 'verifyIdentity'; + request: { + identity: data.IIdentity; + }; + response: { + valid: boolean; + identity?: data.IIdentity; + }; +} diff --git a/ts_interfaces/requests/connections.ts b/ts_interfaces/requests/connections.ts new file mode 100644 index 0000000..6a4cbaf --- /dev/null +++ b/ts_interfaces/requests/connections.ts @@ -0,0 +1,78 @@ +import * as plugins from '../plugins.ts'; +import * as data from '../data/index.ts'; + +export interface IReq_GetConnections extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetConnections +> { + method: 'getConnections'; + request: { + identity: data.IIdentity; + }; + response: { + connections: data.IProviderConnection[]; + }; +} + +export interface IReq_CreateConnection extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CreateConnection +> { + method: 'createConnection'; + request: { + identity: data.IIdentity; + name: string; + providerType: data.TProviderType; + baseUrl: string; + token: string; + }; + response: { + connection: data.IProviderConnection; + }; +} + +export interface IReq_UpdateConnection extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_UpdateConnection +> { + method: 'updateConnection'; + request: { + identity: data.IIdentity; + connectionId: string; + name?: string; + baseUrl?: string; + token?: string; + }; + response: { + connection: data.IProviderConnection; + }; +} + +export interface IReq_TestConnection extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_TestConnection +> { + method: 'testConnection'; + request: { + identity: data.IIdentity; + connectionId: string; + }; + response: { + ok: boolean; + error?: string; + }; +} + +export interface IReq_DeleteConnection extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DeleteConnection +> { + method: 'deleteConnection'; + request: { + identity: data.IIdentity; + connectionId: string; + }; + response: { + ok: boolean; + }; +} diff --git a/ts_interfaces/requests/groups.ts b/ts_interfaces/requests/groups.ts new file mode 100644 index 0000000..2e82a0a --- /dev/null +++ b/ts_interfaces/requests/groups.ts @@ -0,0 +1,18 @@ +import * as plugins from '../plugins.ts'; +import * as data from '../data/index.ts'; + +export interface IReq_GetGroups extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetGroups +> { + method: 'getGroups'; + request: { + identity: data.IIdentity; + connectionId: string; + search?: string; + page?: number; + }; + response: { + groups: data.IGroup[]; + }; +} diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts new file mode 100644 index 0000000..2b48a76 --- /dev/null +++ b/ts_interfaces/requests/index.ts @@ -0,0 +1,7 @@ +export * from './admin.ts'; +export * from './connections.ts'; +export * from './projects.ts'; +export * from './groups.ts'; +export * from './secrets.ts'; +export * from './pipelines.ts'; +export * from './logs.ts'; diff --git a/ts_interfaces/requests/logs.ts b/ts_interfaces/requests/logs.ts new file mode 100644 index 0000000..b834e98 --- /dev/null +++ b/ts_interfaces/requests/logs.ts @@ -0,0 +1,18 @@ +import * as plugins from '../plugins.ts'; +import * as data from '../data/index.ts'; + +export interface IReq_GetJobLog extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetJobLog +> { + method: 'getJobLog'; + request: { + identity: data.IIdentity; + connectionId: string; + projectId: string; + jobId: string; + }; + response: { + log: string; + }; +} diff --git a/ts_interfaces/requests/pipelines.ts b/ts_interfaces/requests/pipelines.ts new file mode 100644 index 0000000..ebe790c --- /dev/null +++ b/ts_interfaces/requests/pipelines.ts @@ -0,0 +1,66 @@ +import * as plugins from '../plugins.ts'; +import * as data from '../data/index.ts'; + +export interface IReq_GetPipelines extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetPipelines +> { + method: 'getPipelines'; + request: { + identity: data.IIdentity; + connectionId: string; + projectId: string; + page?: number; + }; + response: { + pipelines: data.IPipeline[]; + }; +} + +export interface IReq_GetPipelineJobs extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetPipelineJobs +> { + method: 'getPipelineJobs'; + request: { + identity: data.IIdentity; + connectionId: string; + projectId: string; + pipelineId: string; + }; + response: { + jobs: data.IPipelineJob[]; + }; +} + +export interface IReq_RetryPipeline extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_RetryPipeline +> { + method: 'retryPipeline'; + request: { + identity: data.IIdentity; + connectionId: string; + projectId: string; + pipelineId: string; + }; + response: { + ok: boolean; + }; +} + +export interface IReq_CancelPipeline extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CancelPipeline +> { + method: 'cancelPipeline'; + request: { + identity: data.IIdentity; + connectionId: string; + projectId: string; + pipelineId: string; + }; + response: { + ok: boolean; + }; +} diff --git a/ts_interfaces/requests/projects.ts b/ts_interfaces/requests/projects.ts new file mode 100644 index 0000000..5cf332f --- /dev/null +++ b/ts_interfaces/requests/projects.ts @@ -0,0 +1,18 @@ +import * as plugins from '../plugins.ts'; +import * as data from '../data/index.ts'; + +export interface IReq_GetProjects extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetProjects +> { + method: 'getProjects'; + request: { + identity: data.IIdentity; + connectionId: string; + search?: string; + page?: number; + }; + response: { + projects: data.IProject[]; + }; +} diff --git a/ts_interfaces/requests/secrets.ts b/ts_interfaces/requests/secrets.ts new file mode 100644 index 0000000..1fc1430 --- /dev/null +++ b/ts_interfaces/requests/secrets.ts @@ -0,0 +1,77 @@ +import * as plugins from '../plugins.ts'; +import * as data from '../data/index.ts'; + +export interface IReq_GetSecrets extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetSecrets +> { + method: 'getSecrets'; + request: { + identity: data.IIdentity; + connectionId: string; + scope: 'project' | 'group'; + scopeId: string; + }; + response: { + secrets: data.ISecret[]; + }; +} + +export interface IReq_CreateSecret extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CreateSecret +> { + method: 'createSecret'; + request: { + identity: data.IIdentity; + connectionId: string; + scope: 'project' | 'group'; + scopeId: string; + key: string; + value: string; + protected?: boolean; + masked?: boolean; + environment?: string; + }; + response: { + secret: data.ISecret; + }; +} + +export interface IReq_UpdateSecret extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_UpdateSecret +> { + method: 'updateSecret'; + request: { + identity: data.IIdentity; + connectionId: string; + scope: 'project' | 'group'; + scopeId: string; + key: string; + value: string; + protected?: boolean; + masked?: boolean; + environment?: string; + }; + response: { + secret: data.ISecret; + }; +} + +export interface IReq_DeleteSecret extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DeleteSecret +> { + method: 'deleteSecret'; + request: { + identity: data.IIdentity; + connectionId: string; + scope: 'project' | 'group'; + scopeId: string; + key: string; + }; + response: { + ok: boolean; + }; +} diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts new file mode 100644 index 0000000..10292c8 --- /dev/null +++ b/ts_web/appstate.ts @@ -0,0 +1,545 @@ +import * as plugins from './plugins.js'; +import * as interfaces from '../ts_interfaces/index.js'; + +// ============================================================================ +// Smartstate instance +// ============================================================================ +export const appState = new plugins.domtools.plugins.smartstate.Smartstate(); + +// ============================================================================ +// State Part Interfaces +// ============================================================================ + +export interface ILoginState { + identity: interfaces.data.IIdentity | null; + isLoggedIn: boolean; +} + +export interface IConnectionsState { + connections: interfaces.data.IProviderConnection[]; + activeConnectionId: string | null; +} + +export interface IDataState { + projects: interfaces.data.IProject[]; + groups: interfaces.data.IGroup[]; + secrets: interfaces.data.ISecret[]; + pipelines: interfaces.data.IPipeline[]; + pipelineJobs: interfaces.data.IPipelineJob[]; + currentJobLog: string; +} + +export interface IUiState { + activeView: string; + autoRefresh: boolean; + refreshInterval: number; +} + +// ============================================================================ +// State Parts +// ============================================================================ + +export const loginStatePart = await appState.getStatePart( + 'login', + { + identity: null, + isLoggedIn: false, + }, + 'persistent', +); + +export const connectionsStatePart = await appState.getStatePart( + 'connections', + { + connections: [], + activeConnectionId: null, + }, + 'soft', +); + +export const dataStatePart = await appState.getStatePart( + 'data', + { + projects: [], + groups: [], + secrets: [], + pipelines: [], + pipelineJobs: [], + currentJobLog: '', + }, + 'soft', +); + +export const uiStatePart = await appState.getStatePart( + 'ui', + { + activeView: 'overview', + autoRefresh: true, + refreshInterval: 30000, + }, +); + +// ============================================================================ +// Helpers +// ============================================================================ + +interface IActionContext { + identity: interfaces.data.IIdentity | null; +} + +const getActionContext = (): IActionContext => { + return { identity: loginStatePart.getState().identity }; +}; + +// ============================================================================ +// Login Actions +// ============================================================================ + +export const loginAction = loginStatePart.createAction<{ + username: string; + password: string; +}>(async (statePartArg, dataArg) => { + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_AdminLogin + >('/typedrequest', 'adminLogin'); + + const response = await typedRequest.fire({ + username: dataArg.username, + password: dataArg.password, + }); + + return { + identity: response.identity || null, + isLoggedIn: !!response.identity, + }; + } catch (err) { + console.error('Login failed:', err); + return { identity: null, isLoggedIn: false }; + } +}); + +export const logoutAction = loginStatePart.createAction(async (_statePartArg) => { + const context = getActionContext(); + try { + if (context.identity) { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_AdminLogout + >('/typedrequest', 'adminLogout'); + await typedRequest.fire({ identity: context.identity }); + } + } catch (err) { + console.error('Logout error:', err); + } + return { identity: null, isLoggedIn: false }; +}); + +// ============================================================================ +// Connections Actions +// ============================================================================ + +export const fetchConnectionsAction = connectionsStatePart.createAction(async (statePartArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetConnections + >('/typedrequest', 'getConnections'); + const response = await typedRequest.fire({ identity: context.identity! }); + return { ...statePartArg.getState(), connections: response.connections }; + } catch (err) { + console.error('Failed to fetch connections:', err); + return statePartArg.getState(); + } +}); + +export const createConnectionAction = connectionsStatePart.createAction<{ + name: string; + providerType: interfaces.data.TProviderType; + baseUrl: string; + token: string; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateConnection + >('/typedrequest', 'createConnection'); + await typedRequest.fire({ + identity: context.identity!, + ...dataArg, + }); + // Re-fetch + const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetConnections + >('/typedrequest', 'getConnections'); + const listResp = await listReq.fire({ identity: context.identity! }); + return { ...statePartArg.getState(), connections: listResp.connections }; + } catch (err) { + console.error('Failed to create connection:', err); + return statePartArg.getState(); + } +}); + +export const testConnectionAction = connectionsStatePart.createAction<{ + connectionId: string; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_TestConnection + >('/typedrequest', 'testConnection'); + const result = await typedRequest.fire({ + identity: context.identity!, + connectionId: dataArg.connectionId, + }); + // Re-fetch to get updated status + const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetConnections + >('/typedrequest', 'getConnections'); + const listResp = await listReq.fire({ identity: context.identity! }); + return { ...statePartArg.getState(), connections: listResp.connections }; + } catch (err) { + console.error('Failed to test connection:', err); + return statePartArg.getState(); + } +}); + +export const deleteConnectionAction = connectionsStatePart.createAction<{ + connectionId: string; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_DeleteConnection + >('/typedrequest', 'deleteConnection'); + await typedRequest.fire({ + identity: context.identity!, + connectionId: dataArg.connectionId, + }); + const state = statePartArg.getState(); + return { + ...state, + connections: state.connections.filter((c) => c.id !== dataArg.connectionId), + activeConnectionId: state.activeConnectionId === dataArg.connectionId ? null : state.activeConnectionId, + }; + } catch (err) { + console.error('Failed to delete connection:', err); + return statePartArg.getState(); + } +}); + +// ============================================================================ +// Projects Actions +// ============================================================================ + +export const fetchProjectsAction = dataStatePart.createAction<{ + connectionId: string; + search?: string; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetProjects + >('/typedrequest', 'getProjects'); + const response = await typedRequest.fire({ + identity: context.identity!, + connectionId: dataArg.connectionId, + search: dataArg.search, + }); + return { ...statePartArg.getState(), projects: response.projects }; + } catch (err) { + console.error('Failed to fetch projects:', err); + return statePartArg.getState(); + } +}); + +// ============================================================================ +// Groups Actions +// ============================================================================ + +export const fetchGroupsAction = dataStatePart.createAction<{ + connectionId: string; + search?: string; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetGroups + >('/typedrequest', 'getGroups'); + const response = await typedRequest.fire({ + identity: context.identity!, + connectionId: dataArg.connectionId, + search: dataArg.search, + }); + return { ...statePartArg.getState(), groups: response.groups }; + } catch (err) { + console.error('Failed to fetch groups:', err); + return statePartArg.getState(); + } +}); + +// ============================================================================ +// Secrets Actions +// ============================================================================ + +export const fetchSecretsAction = dataStatePart.createAction<{ + connectionId: string; + scope: 'project' | 'group'; + scopeId: string; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetSecrets + >('/typedrequest', 'getSecrets'); + const response = await typedRequest.fire({ + identity: context.identity!, + connectionId: dataArg.connectionId, + scope: dataArg.scope, + scopeId: dataArg.scopeId, + }); + return { ...statePartArg.getState(), secrets: response.secrets }; + } catch (err) { + console.error('Failed to fetch secrets:', err); + return statePartArg.getState(); + } +}); + +export const createSecretAction = dataStatePart.createAction<{ + connectionId: string; + scope: 'project' | 'group'; + scopeId: string; + key: string; + value: string; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateSecret + >('/typedrequest', 'createSecret'); + await typedRequest.fire({ + identity: context.identity!, + ...dataArg, + }); + // Re-fetch secrets + const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetSecrets + >('/typedrequest', 'getSecrets'); + const listResp = await listReq.fire({ + identity: context.identity!, + connectionId: dataArg.connectionId, + scope: dataArg.scope, + scopeId: dataArg.scopeId, + }); + return { ...statePartArg.getState(), secrets: listResp.secrets }; + } catch (err) { + console.error('Failed to create secret:', err); + return statePartArg.getState(); + } +}); + +export const updateSecretAction = dataStatePart.createAction<{ + connectionId: string; + scope: 'project' | 'group'; + scopeId: string; + key: string; + value: string; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_UpdateSecret + >('/typedrequest', 'updateSecret'); + await typedRequest.fire({ + identity: context.identity!, + ...dataArg, + }); + // Re-fetch + const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetSecrets + >('/typedrequest', 'getSecrets'); + const listResp = await listReq.fire({ + identity: context.identity!, + connectionId: dataArg.connectionId, + scope: dataArg.scope, + scopeId: dataArg.scopeId, + }); + return { ...statePartArg.getState(), secrets: listResp.secrets }; + } catch (err) { + console.error('Failed to update secret:', err); + return statePartArg.getState(); + } +}); + +export const deleteSecretAction = dataStatePart.createAction<{ + connectionId: string; + scope: 'project' | 'group'; + scopeId: string; + key: string; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_DeleteSecret + >('/typedrequest', 'deleteSecret'); + await typedRequest.fire({ + identity: context.identity!, + ...dataArg, + }); + const state = statePartArg.getState(); + return { + ...state, + secrets: state.secrets.filter((s) => s.key !== dataArg.key), + }; + } catch (err) { + console.error('Failed to delete secret:', err); + return statePartArg.getState(); + } +}); + +// ============================================================================ +// Pipelines Actions +// ============================================================================ + +export const fetchPipelinesAction = dataStatePart.createAction<{ + connectionId: string; + projectId: string; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetPipelines + >('/typedrequest', 'getPipelines'); + const response = await typedRequest.fire({ + identity: context.identity!, + connectionId: dataArg.connectionId, + projectId: dataArg.projectId, + }); + return { ...statePartArg.getState(), pipelines: response.pipelines }; + } catch (err) { + console.error('Failed to fetch pipelines:', err); + return statePartArg.getState(); + } +}); + +export const fetchPipelineJobsAction = dataStatePart.createAction<{ + connectionId: string; + projectId: string; + pipelineId: string; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetPipelineJobs + >('/typedrequest', 'getPipelineJobs'); + const response = await typedRequest.fire({ + identity: context.identity!, + connectionId: dataArg.connectionId, + projectId: dataArg.projectId, + pipelineId: dataArg.pipelineId, + }); + return { ...statePartArg.getState(), pipelineJobs: response.jobs }; + } catch (err) { + console.error('Failed to fetch pipeline jobs:', err); + return statePartArg.getState(); + } +}); + +export const retryPipelineAction = dataStatePart.createAction<{ + connectionId: string; + projectId: string; + pipelineId: string; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_RetryPipeline + >('/typedrequest', 'retryPipeline'); + await typedRequest.fire({ + identity: context.identity!, + ...dataArg, + }); + // Re-fetch pipelines + const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetPipelines + >('/typedrequest', 'getPipelines'); + const listResp = await listReq.fire({ + identity: context.identity!, + connectionId: dataArg.connectionId, + projectId: dataArg.projectId, + }); + return { ...statePartArg.getState(), pipelines: listResp.pipelines }; + } catch (err) { + console.error('Failed to retry pipeline:', err); + return statePartArg.getState(); + } +}); + +export const cancelPipelineAction = dataStatePart.createAction<{ + connectionId: string; + projectId: string; + pipelineId: string; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CancelPipeline + >('/typedrequest', 'cancelPipeline'); + await typedRequest.fire({ + identity: context.identity!, + ...dataArg, + }); + // Re-fetch pipelines + const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetPipelines + >('/typedrequest', 'getPipelines'); + const listResp = await listReq.fire({ + identity: context.identity!, + connectionId: dataArg.connectionId, + projectId: dataArg.projectId, + }); + return { ...statePartArg.getState(), pipelines: listResp.pipelines }; + } catch (err) { + console.error('Failed to cancel pipeline:', err); + return statePartArg.getState(); + } +}); + +// ============================================================================ +// Logs Actions +// ============================================================================ + +export const fetchJobLogAction = dataStatePart.createAction<{ + connectionId: string; + projectId: string; + jobId: string; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetJobLog + >('/typedrequest', 'getJobLog'); + const response = await typedRequest.fire({ + identity: context.identity!, + ...dataArg, + }); + return { ...statePartArg.getState(), currentJobLog: response.log }; + } catch (err) { + console.error('Failed to fetch job log:', err); + return statePartArg.getState(); + } +}); + +// ============================================================================ +// UI Actions +// ============================================================================ + +export const setActiveViewAction = uiStatePart.createAction<{ view: string }>( + async (statePartArg, dataArg) => { + return { ...statePartArg.getState(), activeView: dataArg.view }; + }, +); + +export const toggleAutoRefreshAction = uiStatePart.createAction(async (statePartArg) => { + const state = statePartArg.getState(); + return { ...state, autoRefresh: !state.autoRefresh }; +}); diff --git a/ts_web/elements/gitops-dashboard.ts b/ts_web/elements/gitops-dashboard.ts new file mode 100644 index 0000000..76c35dc --- /dev/null +++ b/ts_web/elements/gitops-dashboard.ts @@ -0,0 +1,199 @@ +import * as plugins from '../plugins.js'; +import * as appstate from '../appstate.js'; +import * as interfaces from '../../ts_interfaces/index.js'; +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; + +import type { GitopsViewOverview } from './views/overview/index.js'; +import type { GitopsViewConnections } from './views/connections/index.js'; +import type { GitopsViewProjects } from './views/projects/index.js'; +import type { GitopsViewGroups } from './views/groups/index.js'; +import type { GitopsViewSecrets } from './views/secrets/index.js'; +import type { GitopsViewPipelines } from './views/pipelines/index.js'; +import type { GitopsViewBuildlog } from './views/buildlog/index.js'; + +@customElement('gitops-dashboard') +export class GitopsDashboard extends DeesElement { + @state() + accessor loginState: appstate.ILoginState = { identity: null, isLoggedIn: false }; + + @state() + accessor uiState: appstate.IUiState = { + activeView: 'overview', + autoRefresh: true, + refreshInterval: 30000, + }; + + 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)() }, + ]; + + private resolvedViewTabs: Array<{ name: string; element: any }> = []; + + constructor() { + super(); + document.title = 'GitOps'; + + const loginSubscription = appstate.loginStatePart + .select((stateArg) => stateArg) + .subscribe((loginState) => { + this.loginState = loginState; + if (loginState.isLoggedIn) { + appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null); + } + }); + this.rxSubscriptions.push(loginSubscription); + + const uiSubscription = appstate.uiStatePart + .select((stateArg) => stateArg) + .subscribe((uiState) => { + this.uiState = uiState; + this.syncAppdashView(uiState.activeView); + }); + this.rxSubscriptions.push(uiSubscription); + } + + public static styles = [ + cssManager.defaultStyles, + css` + :host { + display: block; + width: 100%; + height: 100%; + } + .maincontainer { + width: 100%; + height: 100vh; + } + `, + ]; + + public render(): TemplateResult { + return html` +
+ + + + +
+ `; + } + + public async firstUpdated() { + // Resolve async view tab imports + this.resolvedViewTabs = await Promise.all( + this.viewTabs.map(async (tab) => ({ + name: tab.name, + element: await tab.element, + })), + ); + this.requestUpdate(); + await this.updateComplete; + + const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any; + if (simpleLogin) { + simpleLogin.addEventListener('login', (e: CustomEvent) => { + this.login(e.detail.data.username, e.detail.data.password); + }); + } + + const appDash = this.shadowRoot!.querySelector('dees-simple-appdash') as any; + if (appDash) { + appDash.addEventListener('view-select', (e: CustomEvent) => { + const viewName = e.detail.view.name.toLowerCase(); + appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: viewName }); + }); + appDash.addEventListener('logout', async () => { + await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); + }); + } + + // Load initial view on appdash + if (appDash && this.resolvedViewTabs.length > 0) { + const initialView = this.resolvedViewTabs.find( + (t) => t.name.toLowerCase() === this.uiState.activeView, + ) || this.resolvedViewTabs[0]; + await appDash.loadView(initialView); + } + + // Check for stored session (persistent login state) + const loginState = appstate.loginStatePart.getState(); + if (loginState.identity?.jwt) { + if (loginState.identity.expiresAt > Date.now()) { + try { + const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_VerifyIdentity + >('/typedrequest', 'verifyIdentity'); + const response = await typedRequest.fire({ identity: loginState.identity }); + if (response.valid) { + this.loginState = loginState; + if (simpleLogin) { + await simpleLogin.switchToSlottedContent(); + } + } else { + await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); + } + } catch (err) { + console.warn('Stored session invalid, returning to login:', err); + await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); + } + } else { + await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); + } + } + } + + private async login(username: string, password: string) { + const domtools = await this.domtoolsPromise; + const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any; + const form = simpleLogin?.shadowRoot?.querySelector('dees-form') as any; + + if (form) { + form.setStatus('pending', 'Logging in...'); + } + + const newState = await appstate.loginStatePart.dispatchAction(appstate.loginAction, { + username, + password, + }); + + if (newState.identity) { + if (form) { + form.setStatus('success', 'Logged in!'); + } + if (simpleLogin) { + await simpleLogin.switchToSlottedContent(); + } + } else { + if (form) { + form.setStatus('error', 'Login failed!'); + await domtools.convenience.smartdelay.delayFor(2000); + form.reset(); + } + } + } + + private syncAppdashView(viewName: string): void { + const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any; + if (!appDash || this.resolvedViewTabs.length === 0) return; + const targetTab = this.resolvedViewTabs.find((t) => t.name.toLowerCase() === viewName); + if (!targetTab) return; + appDash.loadView(targetTab); + } +} diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts new file mode 100644 index 0000000..fba796f --- /dev/null +++ b/ts_web/elements/index.ts @@ -0,0 +1,8 @@ +import './gitops-dashboard.js'; +import './views/overview/index.js'; +import './views/connections/index.js'; +import './views/projects/index.js'; +import './views/groups/index.js'; +import './views/secrets/index.js'; +import './views/pipelines/index.js'; +import './views/buildlog/index.js'; diff --git a/ts_web/elements/shared/css.ts b/ts_web/elements/shared/css.ts new file mode 100644 index 0000000..71bd27d --- /dev/null +++ b/ts_web/elements/shared/css.ts @@ -0,0 +1,29 @@ +import { css } from '@design.estate/dees-element'; + +export const viewHostCss = css` + :host { + display: block; + width: 100%; + height: 100%; + padding: 24px; + box-sizing: border-box; + color: #fff; + } + .view-title { + font-size: 24px; + font-weight: 600; + margin-bottom: 24px; + } + .view-description { + font-size: 14px; + color: #999; + margin-bottom: 24px; + } + .toolbar { + display: flex; + gap: 16px; + align-items: center; + margin-bottom: 24px; + flex-wrap: wrap; + } +`; diff --git a/ts_web/elements/shared/index.ts b/ts_web/elements/shared/index.ts new file mode 100644 index 0000000..8a6d6c8 --- /dev/null +++ b/ts_web/elements/shared/index.ts @@ -0,0 +1 @@ +export * from './css.js'; diff --git a/ts_web/elements/views/buildlog/index.ts b/ts_web/elements/views/buildlog/index.ts new file mode 100644 index 0000000..e5dcab5 --- /dev/null +++ b/ts_web/elements/views/buildlog/index.ts @@ -0,0 +1,182 @@ +import * as plugins from '../../../plugins.js'; +import * as appstate from '../../../appstate.js'; +import { viewHostCss } from '../../shared/index.js'; +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; + +@customElement('gitops-view-buildlog') +export class GitopsViewBuildlog extends DeesElement { + @state() + accessor connectionsState: appstate.IConnectionsState = { + connections: [], + activeConnectionId: null, + }; + + @state() + accessor dataState: appstate.IDataState = { + projects: [], + groups: [], + secrets: [], + pipelines: [], + pipelineJobs: [], + currentJobLog: '', + }; + + @state() + accessor selectedConnectionId: string = ''; + + @state() + accessor selectedProjectId: string = ''; + + @state() + accessor selectedJobId: string = ''; + + constructor() { + super(); + const connSub = appstate.connectionsStatePart + .select((s) => s) + .subscribe((s) => { this.connectionsState = s; }); + this.rxSubscriptions.push(connSub); + + const dataSub = appstate.dataStatePart + .select((s) => s) + .subscribe((s) => { this.dataState = s; }); + this.rxSubscriptions.push(dataSub); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + .log-container { + background: #0d0d0d; + border: 1px solid #333; + border-radius: 8px; + padding: 16px; + font-family: 'Fira Code', 'Courier New', monospace; + font-size: 13px; + line-height: 1.6; + color: #ccc; + max-height: 600px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; + } + .log-empty { + color: #666; + text-align: center; + padding: 40px; + } + .job-meta { + display: flex; + gap: 16px; + margin-bottom: 16px; + padding: 12px; + background: #1a1a2e; + border-radius: 8px; + font-size: 14px; + } + .job-meta-item { + color: #999; + } + .job-meta-item strong { + color: #fff; + } + `, + ]; + + public render(): TemplateResult { + const connectionOptions = this.connectionsState.connections.map((c) => ({ + option: `${c.name} (${c.providerType})`, + key: c.id, + })); + + const projectOptions = this.dataState.projects.map((p) => ({ + option: p.fullPath || p.name, + key: p.id, + })); + + const jobOptions = this.dataState.pipelineJobs.map((j) => ({ + option: `${j.name} (${j.status})`, + key: j.id, + })); + + return html` +
Build Log
+
View raw build logs for CI/CD jobs
+
+ o.key === this.selectedConnectionId) || connectionOptions[0]} + @selectedOption=${(e: CustomEvent) => { + this.selectedConnectionId = e.detail.key; + this.loadProjects(); + }} + > + o.key === this.selectedProjectId) || projectOptions[0]} + @selectedOption=${(e: CustomEvent) => { + this.selectedProjectId = e.detail.key; + }} + > + o.key === this.selectedJobId) || jobOptions[0]} + @selectedOption=${(e: CustomEvent) => { + this.selectedJobId = e.detail.key; + }} + > + this.fetchLog()}>Fetch Log + this.fetchLog()}>Refresh +
+ ${this.selectedJobId ? html` +
+ Job: ${this.selectedJobId} + Project: ${this.selectedProjectId} +
+ ` : ''} +
+ ${this.dataState.currentJobLog + ? this.dataState.currentJobLog + : html`
Select a connection, project, and job, then click "Fetch Log" to view build output.
` + } +
+ `; + } + + async firstUpdated() { + await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null); + const conns = appstate.connectionsStatePart.getState().connections; + if (conns.length > 0 && !this.selectedConnectionId) { + this.selectedConnectionId = conns[0].id; + await this.loadProjects(); + } + } + + private async loadProjects() { + if (!this.selectedConnectionId) return; + await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, { + connectionId: this.selectedConnectionId, + }); + } + + private async fetchLog() { + if (!this.selectedConnectionId || !this.selectedProjectId || !this.selectedJobId) return; + await appstate.dataStatePart.dispatchAction(appstate.fetchJobLogAction, { + connectionId: this.selectedConnectionId, + projectId: this.selectedProjectId, + jobId: this.selectedJobId, + }); + } +} diff --git a/ts_web/elements/views/connections/index.ts b/ts_web/elements/views/connections/index.ts new file mode 100644 index 0000000..2e0121a --- /dev/null +++ b/ts_web/elements/views/connections/index.ts @@ -0,0 +1,158 @@ +import * as plugins from '../../../plugins.js'; +import * as appstate from '../../../appstate.js'; +import { viewHostCss } from '../../shared/index.js'; +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; + +@customElement('gitops-view-connections') +export class GitopsViewConnections extends DeesElement { + @state() + accessor connectionsState: appstate.IConnectionsState = { + connections: [], + activeConnectionId: null, + }; + + constructor() { + super(); + const sub = appstate.connectionsStatePart + .select((s) => s) + .subscribe((s) => { this.connectionsState = s; }); + this.rxSubscriptions.push(sub); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + ]; + + public render(): TemplateResult { + return html` +
Connections
+
Manage your Gitea and GitLab provider connections
+
+ this.addConnection()}>Add Connection + this.refresh()}>Refresh +
+ ({ + Name: item.name, + Type: item.providerType, + URL: item.baseUrl, + Status: item.status, + Created: new Date(item.createdAt).toLocaleDateString(), + })} + .dataActions=${[ + { + name: 'Test', + iconName: 'lucide:plug', + action: async (item: any) => { + await appstate.connectionsStatePart.dispatchAction( + appstate.testConnectionAction, + { connectionId: item.id }, + ); + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash2', + action: async (item: any) => { + const confirmed = await plugins.deesCatalog.DeesModal.createAndShow({ + heading: 'Delete Connection', + content: html`

Are you sure you want to delete connection "${item.name}"?

`, + menuOptions: [ + { name: 'Cancel', action: async (modal: any) => { modal.destroy(); } }, + { + name: 'Delete', + action: async (modal: any) => { + await appstate.connectionsStatePart.dispatchAction( + appstate.deleteConnectionAction, + { connectionId: item.id }, + ); + modal.destroy(); + }, + }, + ], + }); + }, + }, + ]} + >
+ `; + } + + async firstUpdated() { + await this.refresh(); + } + + private async refresh() { + await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null); + } + + private async addConnection() { + await plugins.deesCatalog.DeesModal.createAndShow({ + heading: 'Add Connection', + content: html` + +
+ +
+
+ +
+
+ +
+
+ +
+ `, + menuOptions: [ + { name: 'Cancel', action: async (modal: any) => { modal.destroy(); } }, + { + name: 'Add', + action: async (modal: any) => { + const inputs = modal.shadowRoot.querySelectorAll('dees-input-text, dees-input-dropdown'); + const data: any = {}; + for (const input of inputs) { + if (input.key === 'providerType') { + data[input.key] = input.selectedOption?.key || 'gitea'; + } else { + data[input.key] = input.value || ''; + } + } + await appstate.connectionsStatePart.dispatchAction( + appstate.createConnectionAction, + { + name: data.name, + providerType: data.providerType, + baseUrl: data.baseUrl, + token: data.token, + }, + ); + modal.destroy(); + }, + }, + ], + }); + } +} diff --git a/ts_web/elements/views/groups/index.ts b/ts_web/elements/views/groups/index.ts new file mode 100644 index 0000000..34fee99 --- /dev/null +++ b/ts_web/elements/views/groups/index.ts @@ -0,0 +1,112 @@ +import * as plugins from '../../../plugins.js'; +import * as appstate from '../../../appstate.js'; +import { viewHostCss } from '../../shared/index.js'; +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; + +@customElement('gitops-view-groups') +export class GitopsViewGroups extends DeesElement { + @state() + accessor connectionsState: appstate.IConnectionsState = { + connections: [], + activeConnectionId: null, + }; + + @state() + accessor dataState: appstate.IDataState = { + projects: [], + groups: [], + secrets: [], + pipelines: [], + pipelineJobs: [], + currentJobLog: '', + }; + + @state() + accessor selectedConnectionId: string = ''; + + constructor() { + super(); + const connSub = appstate.connectionsStatePart + .select((s) => s) + .subscribe((s) => { this.connectionsState = s; }); + this.rxSubscriptions.push(connSub); + + const dataSub = appstate.dataStatePart + .select((s) => s) + .subscribe((s) => { this.dataState = s; }); + this.rxSubscriptions.push(dataSub); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + ]; + + public render(): TemplateResult { + const connectionOptions = this.connectionsState.connections.map((c) => ({ + option: `${c.name} (${c.providerType})`, + key: c.id, + })); + + return html` +
Groups
+
Browse organizations and groups from your connected providers
+
+ o.key === this.selectedConnectionId) || connectionOptions[0]} + @selectedOption=${(e: CustomEvent) => { + this.selectedConnectionId = e.detail.key; + this.loadGroups(); + }} + > + this.loadGroups()}>Refresh +
+ ({ + Name: item.name, + Path: item.fullPath, + Visibility: item.visibility, + Projects: String(item.projectCount), + })} + .dataActions=${[ + { + name: 'View Secrets', + iconName: 'lucide:key', + action: async (item: any) => { + appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'secrets' }); + }, + }, + ]} + > + `; + } + + async firstUpdated() { + await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null); + const conns = appstate.connectionsStatePart.getState().connections; + if (conns.length > 0 && !this.selectedConnectionId) { + this.selectedConnectionId = conns[0].id; + await this.loadGroups(); + } + } + + private async loadGroups() { + if (!this.selectedConnectionId) return; + await appstate.dataStatePart.dispatchAction(appstate.fetchGroupsAction, { + connectionId: this.selectedConnectionId, + }); + } +} diff --git a/ts_web/elements/views/overview/index.ts b/ts_web/elements/views/overview/index.ts new file mode 100644 index 0000000..da7a4e2 --- /dev/null +++ b/ts_web/elements/views/overview/index.ts @@ -0,0 +1,111 @@ +import * as plugins from '../../../plugins.js'; +import * as appstate from '../../../appstate.js'; +import { viewHostCss } from '../../shared/index.js'; +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; + +@customElement('gitops-view-overview') +export class GitopsViewOverview extends DeesElement { + @state() + accessor connectionsState: appstate.IConnectionsState = { + connections: [], + activeConnectionId: null, + }; + + @state() + accessor dataState: appstate.IDataState = { + projects: [], + groups: [], + secrets: [], + pipelines: [], + pipelineJobs: [], + currentJobLog: '', + }; + + constructor() { + super(); + const connSub = appstate.connectionsStatePart + .select((s) => s) + .subscribe((s) => { this.connectionsState = s; }); + this.rxSubscriptions.push(connSub); + + const dataSub = appstate.dataStatePart + .select((s) => s) + .subscribe((s) => { this.dataState = s; }); + this.rxSubscriptions.push(dataSub); + } + + 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 { + const connCount = this.connectionsState.connections.length; + const projCount = this.dataState.projects.length; + const groupCount = this.dataState.groups.length; + const pipelineCount = this.dataState.pipelines.length; + const failedPipelines = this.dataState.pipelines.filter((p) => p.status === 'failed').length; + + return html` +
Overview
+
GitOps dashboard - manage your Gitea and GitLab instances
+
+
+
${connCount}
+
Connections
+
+
+
${projCount}
+
Projects
+
+
+
${groupCount}
+
Groups
+
+
+
${pipelineCount}
+
Pipelines
+
+
+
${failedPipelines}
+
Failed Pipelines
+
+
+ `; + } +} diff --git a/ts_web/elements/views/pipelines/index.ts b/ts_web/elements/views/pipelines/index.ts new file mode 100644 index 0000000..ec47465 --- /dev/null +++ b/ts_web/elements/views/pipelines/index.ts @@ -0,0 +1,207 @@ +import * as plugins from '../../../plugins.js'; +import * as appstate from '../../../appstate.js'; +import { viewHostCss } from '../../shared/index.js'; +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; + +@customElement('gitops-view-pipelines') +export class GitopsViewPipelines extends DeesElement { + @state() + accessor connectionsState: appstate.IConnectionsState = { + connections: [], + activeConnectionId: null, + }; + + @state() + accessor dataState: appstate.IDataState = { + projects: [], + groups: [], + secrets: [], + pipelines: [], + pipelineJobs: [], + currentJobLog: '', + }; + + @state() + accessor selectedConnectionId: string = ''; + + @state() + accessor selectedProjectId: string = ''; + + constructor() { + super(); + const connSub = appstate.connectionsStatePart + .select((s) => s) + .subscribe((s) => { this.connectionsState = s; }); + this.rxSubscriptions.push(connSub); + + const dataSub = appstate.dataStatePart + .select((s) => s) + .subscribe((s) => { this.dataState = s; }); + this.rxSubscriptions.push(dataSub); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + .status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + } + .status-success { background: #1a3a1a; color: #00ff88; } + .status-failed { background: #3a1a1a; color: #ff4444; } + .status-running { background: #1a2a3a; color: #00acff; } + .status-pending { background: #3a3a1a; color: #ffaa00; } + .status-canceled { background: #2a2a2a; color: #999; } + `, + ]; + + public render(): TemplateResult { + const connectionOptions = this.connectionsState.connections.map((c) => ({ + option: `${c.name} (${c.providerType})`, + key: c.id, + })); + + const projectOptions = this.dataState.projects.map((p) => ({ + option: p.fullPath || p.name, + key: p.id, + })); + + return html` +
Pipelines
+
View and manage CI/CD pipelines
+
+ o.key === this.selectedConnectionId) || connectionOptions[0]} + @selectedOption=${(e: CustomEvent) => { + this.selectedConnectionId = e.detail.key; + this.loadProjects(); + }} + > + o.key === this.selectedProjectId) || projectOptions[0]} + @selectedOption=${(e: CustomEvent) => { + this.selectedProjectId = e.detail.key; + this.loadPipelines(); + }} + > + this.loadPipelines()}>Refresh +
+ ({ + ID: item.id, + Status: item.status, + Ref: item.ref, + Duration: item.duration ? `${Math.round(item.duration)}s` : '-', + Source: item.source, + Created: item.createdAt ? new Date(item.createdAt).toLocaleString() : '-', + })} + .dataActions=${[ + { + name: 'View Jobs', + iconName: 'lucide:list', + action: async (item: any) => { await this.viewJobs(item); }, + }, + { + name: 'Retry', + iconName: 'lucide:refresh-cw', + action: async (item: any) => { + await appstate.dataStatePart.dispatchAction(appstate.retryPipelineAction, { + connectionId: this.selectedConnectionId, + projectId: this.selectedProjectId, + pipelineId: item.id, + }); + }, + }, + { + name: 'Cancel', + iconName: 'lucide:x-circle', + action: async (item: any) => { + await appstate.dataStatePart.dispatchAction(appstate.cancelPipelineAction, { + connectionId: this.selectedConnectionId, + projectId: this.selectedProjectId, + pipelineId: item.id, + }); + }, + }, + ]} + > + `; + } + + async firstUpdated() { + await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null); + const conns = appstate.connectionsStatePart.getState().connections; + if (conns.length > 0 && !this.selectedConnectionId) { + this.selectedConnectionId = conns[0].id; + await this.loadProjects(); + } + } + + private async loadProjects() { + if (!this.selectedConnectionId) return; + await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, { + connectionId: this.selectedConnectionId, + }); + } + + private async loadPipelines() { + if (!this.selectedConnectionId || !this.selectedProjectId) return; + await appstate.dataStatePart.dispatchAction(appstate.fetchPipelinesAction, { + connectionId: this.selectedConnectionId, + projectId: this.selectedProjectId, + }); + } + + private async viewJobs(pipeline: any) { + await appstate.dataStatePart.dispatchAction(appstate.fetchPipelineJobsAction, { + connectionId: this.selectedConnectionId, + projectId: this.selectedProjectId, + pipelineId: pipeline.id, + }); + + const jobs = appstate.dataStatePart.getState().pipelineJobs; + await plugins.deesCatalog.DeesModal.createAndShow({ + heading: `Pipeline #${pipeline.id} - Jobs`, + content: html` + +
+ ${jobs.map((job: any) => html` +
+ ${job.name} (${job.stage}) + ${job.status} - ${job.duration ? `${Math.round(job.duration)}s` : '-'} +
+ `)} + ${jobs.length === 0 ? html`

No jobs found.

` : ''} +
+ `, + menuOptions: [ + { name: 'Close', action: async (modal: any) => { modal.destroy(); } }, + ], + }); + } +} diff --git a/ts_web/elements/views/projects/index.ts b/ts_web/elements/views/projects/index.ts new file mode 100644 index 0000000..22b986d --- /dev/null +++ b/ts_web/elements/views/projects/index.ts @@ -0,0 +1,120 @@ +import * as plugins from '../../../plugins.js'; +import * as appstate from '../../../appstate.js'; +import { viewHostCss } from '../../shared/index.js'; +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; + +@customElement('gitops-view-projects') +export class GitopsViewProjects extends DeesElement { + @state() + accessor connectionsState: appstate.IConnectionsState = { + connections: [], + activeConnectionId: null, + }; + + @state() + accessor dataState: appstate.IDataState = { + projects: [], + groups: [], + secrets: [], + pipelines: [], + pipelineJobs: [], + currentJobLog: '', + }; + + @state() + accessor selectedConnectionId: string = ''; + + constructor() { + super(); + const connSub = appstate.connectionsStatePart + .select((s) => s) + .subscribe((s) => { this.connectionsState = s; }); + this.rxSubscriptions.push(connSub); + + const dataSub = appstate.dataStatePart + .select((s) => s) + .subscribe((s) => { this.dataState = s; }); + this.rxSubscriptions.push(dataSub); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + ]; + + public render(): TemplateResult { + const connectionOptions = this.connectionsState.connections.map((c) => ({ + option: `${c.name} (${c.providerType})`, + key: c.id, + })); + + return html` +
Projects
+
Browse projects from your connected providers
+
+ o.key === this.selectedConnectionId) || connectionOptions[0]} + @selectedOption=${(e: CustomEvent) => { + this.selectedConnectionId = e.detail.key; + this.loadProjects(); + }} + > + this.loadProjects()}>Refresh +
+ ({ + Name: item.name, + Path: item.fullPath, + Visibility: item.visibility, + Branch: item.defaultBranch, + 'Last Activity': item.lastActivity ? new Date(item.lastActivity).toLocaleDateString() : '-', + })} + .dataActions=${[ + { + name: 'View Secrets', + iconName: 'lucide:key', + action: async (item: any) => { + appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'secrets' }); + }, + }, + { + name: 'View Pipelines', + iconName: 'lucide:play', + action: async (item: any) => { + appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'pipelines' }); + }, + }, + ]} + > + `; + } + + async firstUpdated() { + await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null); + const conns = appstate.connectionsStatePart.getState().connections; + if (conns.length > 0 && !this.selectedConnectionId) { + this.selectedConnectionId = conns[0].id; + await this.loadProjects(); + } + } + + private async loadProjects() { + if (!this.selectedConnectionId) return; + await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, { + connectionId: this.selectedConnectionId, + }); + } +} diff --git a/ts_web/elements/views/secrets/index.ts b/ts_web/elements/views/secrets/index.ts new file mode 100644 index 0000000..77143c3 --- /dev/null +++ b/ts_web/elements/views/secrets/index.ts @@ -0,0 +1,234 @@ +import * as plugins from '../../../plugins.js'; +import * as appstate from '../../../appstate.js'; +import { viewHostCss } from '../../shared/index.js'; +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; + +@customElement('gitops-view-secrets') +export class GitopsViewSecrets extends DeesElement { + @state() + accessor connectionsState: appstate.IConnectionsState = { + connections: [], + activeConnectionId: null, + }; + + @state() + accessor dataState: appstate.IDataState = { + projects: [], + groups: [], + secrets: [], + pipelines: [], + pipelineJobs: [], + currentJobLog: '', + }; + + @state() + accessor selectedConnectionId: string = ''; + + @state() + accessor selectedScope: 'project' | 'group' = 'project'; + + @state() + accessor selectedScopeId: string = ''; + + constructor() { + super(); + const connSub = appstate.connectionsStatePart + .select((s) => s) + .subscribe((s) => { this.connectionsState = s; }); + this.rxSubscriptions.push(connSub); + + const dataSub = appstate.dataStatePart + .select((s) => s) + .subscribe((s) => { this.dataState = s; }); + this.rxSubscriptions.push(dataSub); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + ]; + + public render(): TemplateResult { + const connectionOptions = this.connectionsState.connections.map((c) => ({ + option: `${c.name} (${c.providerType})`, + key: c.id, + })); + + const scopeOptions = [ + { option: 'Project', key: 'project' }, + { option: 'Group', key: 'group' }, + ]; + + const entityOptions = 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 })); + + return html` +
Secrets
+
Manage CI/CD secrets and variables
+
+ o.key === this.selectedConnectionId) || connectionOptions[0]} + @selectedOption=${(e: CustomEvent) => { + this.selectedConnectionId = e.detail.key; + this.loadEntities(); + }} + > + o.key === this.selectedScope)} + @selectedOption=${(e: CustomEvent) => { + this.selectedScope = e.detail.key as 'project' | 'group'; + this.loadEntities(); + }} + > + o.key === this.selectedScopeId) || entityOptions[0]} + @selectedOption=${(e: CustomEvent) => { + this.selectedScopeId = e.detail.key; + this.loadSecrets(); + }} + > + this.addSecret()}>Add Secret + this.loadSecrets()}>Refresh +
+ ({ + Key: item.key, + Value: item.masked ? '******' : item.value, + Protected: item.protected ? 'Yes' : 'No', + Environment: item.environment || '*', + })} + .dataActions=${[ + { + name: 'Edit', + iconName: 'lucide:edit', + action: async (item: any) => { await this.editSecret(item); }, + }, + { + name: 'Delete', + iconName: 'lucide:trash2', + action: async (item: any) => { + await appstate.dataStatePart.dispatchAction(appstate.deleteSecretAction, { + connectionId: this.selectedConnectionId, + scope: this.selectedScope, + scopeId: this.selectedScopeId, + key: item.key, + }); + }, + }, + ]} + > + `; + } + + async firstUpdated() { + await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null); + const conns = appstate.connectionsStatePart.getState().connections; + if (conns.length > 0 && !this.selectedConnectionId) { + this.selectedConnectionId = conns[0].id; + await this.loadEntities(); + } + } + + private async loadEntities() { + if (!this.selectedConnectionId) return; + if (this.selectedScope === 'project') { + await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, { + connectionId: this.selectedConnectionId, + }); + } else { + await appstate.dataStatePart.dispatchAction(appstate.fetchGroupsAction, { + connectionId: this.selectedConnectionId, + }); + } + } + + private async loadSecrets() { + if (!this.selectedConnectionId || !this.selectedScopeId) return; + await appstate.dataStatePart.dispatchAction(appstate.fetchSecretsAction, { + connectionId: this.selectedConnectionId, + scope: this.selectedScope, + scopeId: this.selectedScopeId, + }); + } + + private async addSecret() { + await plugins.deesCatalog.DeesModal.createAndShow({ + heading: 'Add Secret', + content: html` + +
+ +
+
+ +
+ `, + menuOptions: [ + { name: 'Cancel', action: async (modal: any) => { modal.destroy(); } }, + { + name: 'Create', + action: async (modal: any) => { + const inputs = modal.shadowRoot.querySelectorAll('dees-input-text'); + const data: any = {}; + for (const input of inputs) { data[input.key] = input.value || ''; } + await appstate.dataStatePart.dispatchAction(appstate.createSecretAction, { + connectionId: this.selectedConnectionId, + scope: this.selectedScope, + scopeId: this.selectedScopeId, + key: data.key, + value: data.value, + }); + modal.destroy(); + }, + }, + ], + }); + } + + private async editSecret(item: any) { + await plugins.deesCatalog.DeesModal.createAndShow({ + heading: `Edit Secret: ${item.key}`, + content: html` + +
+ +
+ `, + menuOptions: [ + { name: 'Cancel', action: async (modal: any) => { modal.destroy(); } }, + { + name: 'Update', + action: async (modal: any) => { + const input = modal.shadowRoot.querySelector('dees-input-text'); + await appstate.dataStatePart.dispatchAction(appstate.updateSecretAction, { + connectionId: this.selectedConnectionId, + scope: this.selectedScope, + scopeId: this.selectedScopeId, + key: item.key, + value: input?.value || '', + }); + modal.destroy(); + }, + }, + ], + }); + } +} diff --git a/ts_web/index.ts b/ts_web/index.ts new file mode 100644 index 0000000..cdcd474 --- /dev/null +++ b/ts_web/index.ts @@ -0,0 +1,7 @@ +import * as plugins from './plugins.js'; +import { html } from '@design.estate/dees-element'; +import './elements/index.js'; + +plugins.deesElement.render(html` + +`, document.body); diff --git a/ts_web/plugins.ts b/ts_web/plugins.ts new file mode 100644 index 0000000..f434924 --- /dev/null +++ b/ts_web/plugins.ts @@ -0,0 +1,11 @@ +// @design.estate scope +import * as deesElement from '@design.estate/dees-element'; +import * as deesCatalog from '@design.estate/dees-catalog'; + +export { + deesElement, + deesCatalog, +}; + +// domtools gives us TypedRequest, smartstate, smartrouter, and other utilities +export const domtools = deesElement.domtools;