This commit is contained in:
2026-02-24 12:29:58 +00:00
commit 3fad287a29
58 changed files with 3999 additions and 0 deletions

37
.gitignore vendored Normal file
View File

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

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- "@design.estate/dees-catalog"
- "esbuild"

46
deno.json Normal file
View File

@@ -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"
]
}
}
}

18
mod.ts Normal file
View File

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

24
npmextra.json Normal file
View File

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

23
package.json Normal file
View File

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

30
test/test.basic.ts Normal file
View File

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

View File

@@ -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<void> {
await this.loadConnections();
}
private async loadConnections(): Promise<void> {
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<void> {
// 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<interfaces.data.IProviderConnection> {
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<interfaces.data.IProviderConnection> {
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<void> {
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}`);
}
}
}

34
ts/classes/gitopsapp.ts Normal file
View File

@@ -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<void> {
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<void> {
logger.info('Shutting down GitOps...');
await this.opsServer.stop();
logger.success('GitOps shutdown complete');
}
}

37
ts/index.ts Normal file
View File

@@ -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<void> {
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);
}
}

75
ts/logging.ts Normal file
View File

@@ -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<LogLevel, string> = {
info: '\x1b[36m',
success: '\x1b[32m',
warn: '\x1b[33m',
error: '\x1b[31m',
debug: '\x1b[90m',
};
const reset = '\x1b[0m';
const icons: Record<LogLevel, string> = {
info: 'i',
success: '+',
warn: '!',
error: 'x',
debug: '*',
};
return `${colors[level]}[${icons[level]}]${reset}`;
}
}
export const logger = new Logger();

View File

@@ -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<void> {
// 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');
}
}
}

View File

@@ -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<IJwtData>;
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
}
public async initialize(): Promise<void> {
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<interfaces.requests.IReq_AdminLogin>(
'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<interfaces.requests.IReq_AdminLogout>(
'adminLogout',
async (_dataArg) => {
return { ok: true };
},
),
);
// Verify Identity
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
'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' },
);
}

View File

@@ -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<interfaces.requests.IReq_GetConnections>(
'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<interfaces.requests.IReq_CreateConnection>(
'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<interfaces.requests.IReq_UpdateConnection>(
'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<interfaces.requests.IReq_TestConnection>(
'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<interfaces.requests.IReq_DeleteConnection>(
'deleteConnection',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.gitopsAppRef.connectionManager.deleteConnection(
dataArg.connectionId,
);
return { ok: true };
},
),
);
}
}

View File

@@ -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<interfaces.requests.IReq_GetGroups>(
'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 };
},
),
);
}
}

View File

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

View File

@@ -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<interfaces.requests.IReq_GetJobLog>(
'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 };
},
),
);
}
}

View File

@@ -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<interfaces.requests.IReq_GetPipelines>(
'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<interfaces.requests.IReq_GetPipelineJobs>(
'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<interfaces.requests.IReq_RetryPipeline>(
'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<interfaces.requests.IReq_CancelPipeline>(
'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 };
},
),
);
}
}

View File

@@ -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<interfaces.requests.IReq_GetProjects>(
'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 };
},
),
);
}
}

View File

@@ -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<interfaces.requests.IReq_GetSecrets>(
'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<interfaces.requests.IReq_CreateSecret>(
'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<interfaces.requests.IReq_UpdateSecret>(
'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<interfaces.requests.IReq_DeleteSecret>(
'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 };
},
),
);
}
}

View File

@@ -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<T extends { identity?: interfaces.data.IIdentity }>(
adminHandler: AdminHandler,
dataArg: T,
): Promise<void> {
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');
}
}

1
ts/opsserver/index.ts Normal file
View File

@@ -0,0 +1 @@
export { OpsServer } from './classes.opsserver.ts';

20
ts/plugins.ts Normal file
View File

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

View File

@@ -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<ITestConnectionResult>;
// Projects
abstract getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]>;
// Groups / Orgs
abstract getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]>;
// Secrets — project scope
abstract getProjectSecrets(projectId: string): Promise<interfaces.data.ISecret[]>;
abstract createProjectSecret(
projectId: string,
key: string,
value: string,
): Promise<interfaces.data.ISecret>;
abstract updateProjectSecret(
projectId: string,
key: string,
value: string,
): Promise<interfaces.data.ISecret>;
abstract deleteProjectSecret(projectId: string, key: string): Promise<void>;
// Secrets — group scope
abstract getGroupSecrets(groupId: string): Promise<interfaces.data.ISecret[]>;
abstract createGroupSecret(
groupId: string,
key: string,
value: string,
): Promise<interfaces.data.ISecret>;
abstract updateGroupSecret(
groupId: string,
key: string,
value: string,
): Promise<interfaces.data.ISecret>;
abstract deleteGroupSecret(groupId: string, key: string): Promise<void>;
// Pipelines / CI
abstract getPipelines(
projectId: string,
opts?: IListOptions,
): Promise<interfaces.data.IPipeline[]>;
abstract getPipelineJobs(
projectId: string,
pipelineId: string,
): Promise<interfaces.data.IPipelineJob[]>;
abstract getJobLog(projectId: string, jobId: string): Promise<string>;
abstract retryPipeline(projectId: string, pipelineId: string): Promise<void>;
abstract cancelPipeline(projectId: string, pipelineId: string): Promise<void>;
/**
* Helper for making authenticated fetch requests
*/
protected async apiFetch(
path: string,
options: RequestInit = {},
): Promise<Response> {
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;
}

View File

@@ -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<ITestConnectionResult> {
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<interfaces.data.IProject[]> {
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<interfaces.data.IGroup[]> {
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<interfaces.data.ISecret[]> {
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<interfaces.data.ISecret> {
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<interfaces.data.ISecret> {
return this.createProjectSecret(projectId, key, value);
}
async deleteProjectSecret(projectId: string, key: string): Promise<void> {
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<interfaces.data.ISecret[]> {
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<interfaces.data.ISecret> {
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<interfaces.data.ISecret> {
return this.createGroupSecret(groupId, key, value);
}
async deleteGroupSecret(groupId: string, key: string): Promise<void> {
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<interfaces.data.IPipeline[]> {
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<interfaces.data.IPipelineJob[]> {
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<string> {
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<void> {
// 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<void> {
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<string, interfaces.data.TPipelineStatus> = {
success: 'success',
completed: 'success',
failure: 'failed',
cancelled: 'canceled',
waiting: 'waiting',
running: 'running',
queued: 'pending',
in_progress: 'running',
skipped: 'skipped',
};
return map[status?.toLowerCase()] || 'pending';
}
}

View File

@@ -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<ITestConnectionResult> {
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<interfaces.data.IProject[]> {
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<interfaces.data.IGroup[]> {
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<interfaces.data.ISecret[]> {
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<interfaces.data.ISecret> {
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<interfaces.data.ISecret> {
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<void> {
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<interfaces.data.ISecret[]> {
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<interfaces.data.ISecret> {
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<interfaces.data.ISecret> {
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<void> {
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<interfaces.data.IPipeline[]> {
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<interfaces.data.IPipelineJob[]> {
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<string> {
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<void> {
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<void> {
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,
};
}
}

3
ts/providers/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { BaseProvider } from './classes.baseprovider.ts';
export { GiteaProvider } from './classes.giteaprovider.ts';
export { GitLabProvider } from './classes.gitlabprovider.ts';

View File

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

View File

@@ -0,0 +1,10 @@
export interface IGroup {
id: string;
name: string;
fullPath: string;
description: string;
webUrl: string;
connectionId: string;
visibility: string;
projectCount: number;
}

View File

@@ -0,0 +1,7 @@
export interface IIdentity {
jwt: string;
userId: string;
username: string;
expiresAt: number;
role: 'admin' | 'user';
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
export interface ISecret {
key: string;
value: string;
protected: boolean;
masked: boolean;
scope: 'project' | 'group';
scopeId: string;
connectionId: string;
environment: string;
}

9
ts_interfaces/index.ts Normal file
View File

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

6
ts_interfaces/plugins.ts Normal file
View File

@@ -0,0 +1,6 @@
// @apiglobal scope
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
export {
typedrequestInterfaces,
};

View File

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

View File

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

View File

@@ -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[];
};
}

View File

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

View File

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

View File

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

View File

@@ -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[];
};
}

View File

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

545
ts_web/appstate.ts Normal file
View File

@@ -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<ILoginState>(
'login',
{
identity: null,
isLoggedIn: false,
},
'persistent',
);
export const connectionsStatePart = await appState.getStatePart<IConnectionsState>(
'connections',
{
connections: [],
activeConnectionId: null,
},
'soft',
);
export const dataStatePart = await appState.getStatePart<IDataState>(
'data',
{
projects: [],
groups: [],
secrets: [],
pipelines: [],
pipelineJobs: [],
currentJobLog: '',
},
'soft',
);
export const uiStatePart = await appState.getStatePart<IUiState>(
'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 };
});

View File

@@ -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`
<div class="maincontainer">
<dees-simple-login name="GitOps">
<dees-simple-appdash
name="GitOps"
.viewTabs=${this.resolvedViewTabs}
>
</dees-simple-appdash>
</dees-simple-login>
</div>
`;
}
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);
}
}

8
ts_web/elements/index.ts Normal file
View File

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

View File

@@ -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;
}
`;

View File

@@ -0,0 +1 @@
export * from './css.js';

View File

@@ -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`
<div class="view-title">Build Log</div>
<div class="view-description">View raw build logs for CI/CD jobs</div>
<div class="toolbar">
<dees-input-dropdown
.label=${'Connection'}
.options=${connectionOptions}
.selectedOption=${connectionOptions.find((o) => o.key === this.selectedConnectionId) || connectionOptions[0]}
@selectedOption=${(e: CustomEvent) => {
this.selectedConnectionId = e.detail.key;
this.loadProjects();
}}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Project'}
.options=${projectOptions}
.selectedOption=${projectOptions.find((o) => o.key === this.selectedProjectId) || projectOptions[0]}
@selectedOption=${(e: CustomEvent) => {
this.selectedProjectId = e.detail.key;
}}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Job'}
.options=${jobOptions}
.selectedOption=${jobOptions.find((o) => o.key === this.selectedJobId) || jobOptions[0]}
@selectedOption=${(e: CustomEvent) => {
this.selectedJobId = e.detail.key;
}}
></dees-input-dropdown>
<dees-button @click=${() => this.fetchLog()}>Fetch Log</dees-button>
<dees-button @click=${() => this.fetchLog()}>Refresh</dees-button>
</div>
${this.selectedJobId ? html`
<div class="job-meta">
<span class="job-meta-item">Job: <strong>${this.selectedJobId}</strong></span>
<span class="job-meta-item">Project: <strong>${this.selectedProjectId}</strong></span>
</div>
` : ''}
<div class="log-container">
${this.dataState.currentJobLog
? this.dataState.currentJobLog
: html`<div class="log-empty">Select a connection, project, and job, then click "Fetch Log" to view build output.</div>`
}
</div>
`;
}
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,
});
}
}

View File

@@ -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`
<div class="view-title">Connections</div>
<div class="view-description">Manage your Gitea and GitLab provider connections</div>
<div class="toolbar">
<dees-button @click=${() => this.addConnection()}>Add Connection</dees-button>
<dees-button @click=${() => this.refresh()}>Refresh</dees-button>
</div>
<dees-table
.heading1=${'Provider Connections'}
.heading2=${'Configure connections to Gitea and GitLab instances'}
.data=${this.connectionsState.connections}
.displayFunction=${(item: any) => ({
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`<p style="color: #fff;">Are you sure you want to delete connection "${item.name}"?</p>`,
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();
},
},
],
});
},
},
]}
></dees-table>
`;
}
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`
<style>
.form-row { margin-bottom: 16px; }
</style>
<div class="form-row">
<dees-input-text .label=${'Name'} .key=${'name'}></dees-input-text>
</div>
<div class="form-row">
<dees-input-dropdown
.label=${'Provider Type'}
.key=${'providerType'}
.options=${[
{ option: 'gitea', key: 'gitea' },
{ option: 'gitlab', key: 'gitlab' },
]}
.selectedOption=${{ option: 'gitea', key: 'gitea' }}
></dees-input-dropdown>
</div>
<div class="form-row">
<dees-input-text .label=${'Base URL'} .key=${'baseUrl'}></dees-input-text>
</div>
<div class="form-row">
<dees-input-text .label=${'API Token'} .key=${'token'} type="password"></dees-input-text>
</div>
`,
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();
},
},
],
});
}
}

View File

@@ -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`
<div class="view-title">Groups</div>
<div class="view-description">Browse organizations and groups from your connected providers</div>
<div class="toolbar">
<dees-input-dropdown
.label=${'Connection'}
.options=${connectionOptions}
.selectedOption=${connectionOptions.find((o) => o.key === this.selectedConnectionId) || connectionOptions[0]}
@selectedOption=${(e: CustomEvent) => {
this.selectedConnectionId = e.detail.key;
this.loadGroups();
}}
></dees-input-dropdown>
<dees-button @click=${() => this.loadGroups()}>Refresh</dees-button>
</div>
<dees-table
.heading1=${'Groups / Organizations'}
.heading2=${'Groups from the selected connection'}
.data=${this.dataState.groups}
.displayFunction=${(item: any) => ({
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' });
},
},
]}
></dees-table>
`;
}
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,
});
}
}

View File

@@ -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`
<div class="view-title">Overview</div>
<div class="view-description">GitOps dashboard - manage your Gitea and GitLab instances</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${connCount}</div>
<div class="stat-label">Connections</div>
</div>
<div class="stat-card">
<div class="stat-value">${projCount}</div>
<div class="stat-label">Projects</div>
</div>
<div class="stat-card">
<div class="stat-value">${groupCount}</div>
<div class="stat-label">Groups</div>
</div>
<div class="stat-card">
<div class="stat-value">${pipelineCount}</div>
<div class="stat-label">Pipelines</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: ${failedPipelines > 0 ? '#ff4444' : '#00ff88'}">${failedPipelines}</div>
<div class="stat-label">Failed Pipelines</div>
</div>
</div>
`;
}
}

View File

@@ -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`
<div class="view-title">Pipelines</div>
<div class="view-description">View and manage CI/CD pipelines</div>
<div class="toolbar">
<dees-input-dropdown
.label=${'Connection'}
.options=${connectionOptions}
.selectedOption=${connectionOptions.find((o) => o.key === this.selectedConnectionId) || connectionOptions[0]}
@selectedOption=${(e: CustomEvent) => {
this.selectedConnectionId = e.detail.key;
this.loadProjects();
}}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Project'}
.options=${projectOptions}
.selectedOption=${projectOptions.find((o) => o.key === this.selectedProjectId) || projectOptions[0]}
@selectedOption=${(e: CustomEvent) => {
this.selectedProjectId = e.detail.key;
this.loadPipelines();
}}
></dees-input-dropdown>
<dees-button @click=${() => this.loadPipelines()}>Refresh</dees-button>
</div>
<dees-table
.heading1=${'CI/CD Pipelines'}
.heading2=${'Pipeline runs for the selected project'}
.data=${this.dataState.pipelines}
.displayFunction=${(item: any) => ({
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,
});
},
},
]}
></dees-table>
`;
}
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`
<style>
.jobs-list { color: #fff; }
.job-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #333; }
.job-name { font-weight: 600; }
.job-status { text-transform: uppercase; font-size: 12px; }
</style>
<div class="jobs-list">
${jobs.map((job: any) => html`
<div class="job-item">
<span class="job-name">${job.name} (${job.stage})</span>
<span class="job-status">${job.status} - ${job.duration ? `${Math.round(job.duration)}s` : '-'}</span>
</div>
`)}
${jobs.length === 0 ? html`<p>No jobs found.</p>` : ''}
</div>
`,
menuOptions: [
{ name: 'Close', action: async (modal: any) => { modal.destroy(); } },
],
});
}
}

View File

@@ -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`
<div class="view-title">Projects</div>
<div class="view-description">Browse projects from your connected providers</div>
<div class="toolbar">
<dees-input-dropdown
.label=${'Connection'}
.options=${connectionOptions}
.selectedOption=${connectionOptions.find((o) => o.key === this.selectedConnectionId) || connectionOptions[0]}
@selectedOption=${(e: CustomEvent) => {
this.selectedConnectionId = e.detail.key;
this.loadProjects();
}}
></dees-input-dropdown>
<dees-button @click=${() => this.loadProjects()}>Refresh</dees-button>
</div>
<dees-table
.heading1=${'Projects'}
.heading2=${'Repositories from the selected connection'}
.data=${this.dataState.projects}
.displayFunction=${(item: any) => ({
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' });
},
},
]}
></dees-table>
`;
}
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,
});
}
}

View File

@@ -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`
<div class="view-title">Secrets</div>
<div class="view-description">Manage CI/CD secrets and variables</div>
<div class="toolbar">
<dees-input-dropdown
.label=${'Connection'}
.options=${connectionOptions}
.selectedOption=${connectionOptions.find((o) => o.key === this.selectedConnectionId) || connectionOptions[0]}
@selectedOption=${(e: CustomEvent) => {
this.selectedConnectionId = e.detail.key;
this.loadEntities();
}}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Scope'}
.options=${scopeOptions}
.selectedOption=${scopeOptions.find((o) => o.key === this.selectedScope)}
@selectedOption=${(e: CustomEvent) => {
this.selectedScope = e.detail.key as 'project' | 'group';
this.loadEntities();
}}
></dees-input-dropdown>
<dees-input-dropdown
.label=${this.selectedScope === 'project' ? 'Project' : 'Group'}
.options=${entityOptions}
.selectedOption=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
@selectedOption=${(e: CustomEvent) => {
this.selectedScopeId = e.detail.key;
this.loadSecrets();
}}
></dees-input-dropdown>
<dees-button @click=${() => this.addSecret()}>Add Secret</dees-button>
<dees-button @click=${() => this.loadSecrets()}>Refresh</dees-button>
</div>
<dees-table
.heading1=${'Secrets'}
.heading2=${'CI/CD variables for the selected entity'}
.data=${this.dataState.secrets}
.displayFunction=${(item: any) => ({
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,
});
},
},
]}
></dees-table>
`;
}
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`
<style>.form-row { margin-bottom: 16px; }</style>
<div class="form-row">
<dees-input-text .label=${'Key'} .key=${'key'}></dees-input-text>
</div>
<div class="form-row">
<dees-input-text .label=${'Value'} .key=${'value'} type="password"></dees-input-text>
</div>
`,
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`
<style>.form-row { margin-bottom: 16px; }</style>
<div class="form-row">
<dees-input-text .label=${'Value'} .key=${'value'} type="password"></dees-input-text>
</div>
`,
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();
},
},
],
});
}
}

7
ts_web/index.ts Normal file
View File

@@ -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`
<gitops-dashboard></gitops-dashboard>
`, document.body);

11
ts_web/plugins.ts Normal file
View File

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