feat(sync): add sync subsystem: SyncManager, OpsServer sync handlers, Sync UI and state, provider groupFilter support, and realtime sync log streaming via TypedSocket
This commit is contained in:
13
changelog.md
13
changelog.md
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-28 - 2.8.0 - feat(sync)
|
||||
add sync subsystem: SyncManager, OpsServer sync handlers, Sync UI and state, provider groupFilter support, and realtime sync log streaming via TypedSocket
|
||||
|
||||
- Introduce SyncManager and wire it into GitopsApp (init/stop) with a new syncMirrorsPath
|
||||
- Add typedrequest SyncHandler with endpoints to create/update/delete/pause/trigger/preview sync configs and fetch repo statuses/logs
|
||||
- Add sync data interfaces (ISyncConfig, ISyncRepoStatus, ISyncLogEntry) and action log integration for sync operations
|
||||
- Add web UI: gitops-view-sync, appstate sync actions/selectors, and preview/status/modals for sync configs
|
||||
- Add groupFilter and groupFilterId to connection model; migrate legacy baseGroup/baseGroupId to groupFilter fields on load
|
||||
- Providers (Gitea/GitLab) and BaseProvider now accept groupFilterId and scope project/group listings accordingly (auto-pagination applies)
|
||||
- Logging: add sync log buffer, getSyncLogs API, and broadcast sync log entries to connected clients via TypedSocket; web client listens and displays entries
|
||||
- Update dependencies: bump @apiclient.xyz/gitea and gitlab versions and add @api.global/typedsocket
|
||||
- Connections UI: expose Group Filter field and pass through on create/update
|
||||
|
||||
## 2026-02-24 - 2.7.1 - fix(repo)
|
||||
update file metadata (mode/permissions) without content changes
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
|
||||
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
|
||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1",
|
||||
"@apiclient.xyz/gitea": "npm:@apiclient.xyz/gitea@^1.0.3",
|
||||
"@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.0.3",
|
||||
"@apiclient.xyz/gitea": "npm:@apiclient.xyz/gitea@^1.2.0",
|
||||
"@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.2.0",
|
||||
"@push.rocks/smartmongo": "npm:@push.rocks/smartmongo@^5.1.0",
|
||||
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.0.15",
|
||||
"@push.rocks/smartsecret": "npm:@push.rocks/smartsecret@^1.0.1"
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "8.4.0",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@apiclient.xyz/gitea": "1.2.0",
|
||||
"@apiclient.xyz/gitlab": "2.2.0",
|
||||
"@design.estate/dees-catalog": "^3.43.3",
|
||||
"@design.estate/dees-element": "^2.1.6"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/gitops',
|
||||
version: '2.7.1',
|
||||
version: '2.8.0',
|
||||
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
|
||||
}
|
||||
|
||||
@@ -77,6 +77,15 @@ export class ConnectionManager {
|
||||
for (const key of keys) {
|
||||
const conn = await this.storageManager.getJSON<interfaces.data.IProviderConnection>(key);
|
||||
if (conn) {
|
||||
// Migrate legacy baseGroup/baseGroupId property names
|
||||
if ((conn as any).baseGroup !== undefined && conn.groupFilter === undefined) {
|
||||
conn.groupFilter = (conn as any).baseGroup;
|
||||
delete (conn as any).baseGroup;
|
||||
}
|
||||
if ((conn as any).baseGroupId !== undefined && conn.groupFilterId === undefined) {
|
||||
conn.groupFilterId = (conn as any).baseGroupId;
|
||||
delete (conn as any).baseGroupId;
|
||||
}
|
||||
if (conn.token.startsWith(KEYCHAIN_PREFIX)) {
|
||||
// Token is in keychain — retrieve it
|
||||
const realToken = await this.smartSecret.getSecret(conn.id);
|
||||
@@ -142,6 +151,7 @@ export class ConnectionManager {
|
||||
providerType: interfaces.data.TProviderType,
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
groupFilter?: string,
|
||||
): Promise<interfaces.data.IProviderConnection> {
|
||||
const connection: interfaces.data.IProviderConnection = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -151,6 +161,7 @@ export class ConnectionManager {
|
||||
token,
|
||||
createdAt: Date.now(),
|
||||
status: 'disconnected',
|
||||
groupFilter: groupFilter || undefined,
|
||||
};
|
||||
this.connections.push(connection);
|
||||
await this.persistConnection(connection);
|
||||
@@ -160,13 +171,17 @@ export class ConnectionManager {
|
||||
|
||||
async updateConnection(
|
||||
id: string,
|
||||
updates: { name?: string; baseUrl?: string; token?: string },
|
||||
updates: { name?: string; baseUrl?: string; token?: string; groupFilter?: 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;
|
||||
if (updates.groupFilter !== undefined) {
|
||||
conn.groupFilter = updates.groupFilter || undefined;
|
||||
conn.groupFilterId = undefined; // Will be re-resolved on next test
|
||||
}
|
||||
await this.persistConnection(conn);
|
||||
return { ...conn, token: '***' };
|
||||
}
|
||||
@@ -196,10 +211,39 @@ export class ConnectionManager {
|
||||
const provider = this.getProvider(id);
|
||||
const result = await provider.testConnection();
|
||||
conn.status = result.ok ? 'connected' : 'error';
|
||||
// Resolve group filter ID if connection has a groupFilter
|
||||
if (result.ok && conn.groupFilter) {
|
||||
await this.resolveGroupFilterId(conn);
|
||||
}
|
||||
await this.persistConnection(conn);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a human-readable groupFilter to the provider-specific group ID.
|
||||
*/
|
||||
private async resolveGroupFilterId(conn: interfaces.data.IProviderConnection): Promise<void> {
|
||||
if (!conn.groupFilter) {
|
||||
conn.groupFilterId = undefined;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (conn.providerType === 'gitlab') {
|
||||
const gitlabClient = new plugins.gitlabClient.GitLabClient(conn.baseUrl, conn.token);
|
||||
const group = await gitlabClient.getGroupByPath(conn.groupFilter);
|
||||
conn.groupFilterId = String(group.id);
|
||||
logger.info(`Resolved group filter "${conn.groupFilter}" to ID ${conn.groupFilterId}`);
|
||||
} else {
|
||||
// For Gitea, the org name IS the ID
|
||||
conn.groupFilterId = conn.groupFilter;
|
||||
logger.info(`Group filter for Gitea connection set to org "${conn.groupFilterId}"`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to resolve group filter "${conn.groupFilter}": ${err}`);
|
||||
conn.groupFilterId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: returns the correct provider instance for a connection ID
|
||||
*/
|
||||
@@ -209,9 +253,9 @@ export class ConnectionManager {
|
||||
|
||||
switch (conn.providerType) {
|
||||
case 'gitea':
|
||||
return new GiteaProvider(conn.id, conn.baseUrl, conn.token);
|
||||
return new GiteaProvider(conn.id, conn.baseUrl, conn.token, conn.groupFilterId);
|
||||
case 'gitlab':
|
||||
return new GitLabProvider(conn.id, conn.baseUrl, conn.token);
|
||||
return new GitLabProvider(conn.id, conn.baseUrl, conn.token, conn.groupFilterId);
|
||||
default:
|
||||
throw new Error(`Unknown provider type: ${conn.providerType}`);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { ConnectionManager } from './connectionmanager.ts';
|
||||
import { ActionLog } from './actionlog.ts';
|
||||
import { SyncManager } from './syncmanager.ts';
|
||||
import { OpsServer } from '../opsserver/index.ts';
|
||||
import { StorageManager } from '../storage/index.ts';
|
||||
import { CacheDb, CacheCleaner, CachedProject, CachedSecret, SecretsScanService } from '../cache/index.ts';
|
||||
@@ -18,11 +19,14 @@ export class GitopsApp {
|
||||
public opsServer: OpsServer;
|
||||
public cacheDb: CacheDb;
|
||||
public cacheCleaner: CacheCleaner;
|
||||
public syncManager!: SyncManager;
|
||||
public secretsScanService!: SecretsScanService;
|
||||
private scanIntervalId: number | null = null;
|
||||
private paths: ReturnType<typeof resolvePaths>;
|
||||
|
||||
constructor() {
|
||||
const paths = resolvePaths();
|
||||
this.paths = paths;
|
||||
this.storageManager = new StorageManager({
|
||||
backend: 'filesystem',
|
||||
fsPath: paths.defaultStoragePath,
|
||||
@@ -51,6 +55,15 @@ export class GitopsApp {
|
||||
// Initialize connection manager (loads saved connections)
|
||||
await this.connectionManager.init();
|
||||
|
||||
// Initialize sync manager
|
||||
this.syncManager = new SyncManager(
|
||||
this.storageManager,
|
||||
this.connectionManager,
|
||||
this.actionLog,
|
||||
this.paths.syncMirrorsPath,
|
||||
);
|
||||
await this.syncManager.init();
|
||||
|
||||
// Initialize secrets scan service with 24h auto-scan
|
||||
this.secretsScanService = new SecretsScanService(this.connectionManager);
|
||||
const SCAN_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
@@ -80,6 +93,7 @@ export class GitopsApp {
|
||||
clearInterval(this.scanIntervalId);
|
||||
this.scanIntervalId = null;
|
||||
}
|
||||
await this.syncManager.stop();
|
||||
await this.opsServer.stop();
|
||||
this.cacheCleaner.stop();
|
||||
await this.cacheDb.stop();
|
||||
|
||||
1600
ts/classes/syncmanager.ts
Normal file
1600
ts/classes/syncmanager.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,60 @@
|
||||
* Logging utilities for GitOps
|
||||
*/
|
||||
|
||||
import type { ISyncLogEntry } from '../ts_interfaces/data/sync.ts';
|
||||
|
||||
type LogLevel = 'info' | 'success' | 'warn' | 'error' | 'debug';
|
||||
|
||||
const SYNC_LOG_MAX = 500;
|
||||
|
||||
class Logger {
|
||||
private debugMode = false;
|
||||
private syncLogBuffer: ISyncLogEntry[] = [];
|
||||
private broadcastFn?: (entry: ISyncLogEntry) => void;
|
||||
|
||||
constructor() {
|
||||
this.debugMode = Deno.args.includes('--debug') || Deno.env.get('DEBUG') === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the broadcast function used to push sync log entries to connected clients.
|
||||
*/
|
||||
setBroadcastFn(fn: (entry: ISyncLogEntry) => void): void {
|
||||
this.broadcastFn = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a sync-related message to both the console and the ring buffer.
|
||||
* Also broadcasts to connected frontends via TypedSocket if available.
|
||||
*/
|
||||
syncLog(level: ISyncLogEntry['level'], message: string, source?: string): void {
|
||||
// Also log to console
|
||||
this.log(level, message);
|
||||
|
||||
const entry: ISyncLogEntry = {
|
||||
timestamp: Date.now(),
|
||||
level,
|
||||
message,
|
||||
source,
|
||||
};
|
||||
|
||||
this.syncLogBuffer.push(entry);
|
||||
if (this.syncLogBuffer.length > SYNC_LOG_MAX) {
|
||||
this.syncLogBuffer.splice(0, this.syncLogBuffer.length - SYNC_LOG_MAX);
|
||||
}
|
||||
|
||||
if (this.broadcastFn) {
|
||||
this.broadcastFn(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent sync log entries.
|
||||
*/
|
||||
getSyncLogs(limit = 100): ISyncLogEntry[] {
|
||||
return this.syncLogBuffer.slice(-limit);
|
||||
}
|
||||
|
||||
log(level: LogLevel, message: string, ...args: unknown[]): void {
|
||||
const prefix = this.getPrefix(level);
|
||||
const formattedMessage = `${prefix} ${message}`;
|
||||
|
||||
@@ -20,6 +20,7 @@ export class OpsServer {
|
||||
public webhookHandler!: handlers.WebhookHandler;
|
||||
public actionsHandler!: handlers.ActionsHandler;
|
||||
public actionLogHandler!: handlers.ActionLogHandler;
|
||||
public syncHandler!: handlers.SyncHandler;
|
||||
|
||||
constructor(gitopsAppRef: GitopsApp) {
|
||||
this.gitopsAppRef = gitopsAppRef;
|
||||
@@ -63,6 +64,7 @@ export class OpsServer {
|
||||
this.logsHandler = new handlers.LogsHandler(this);
|
||||
this.actionsHandler = new handlers.ActionsHandler(this);
|
||||
this.actionLogHandler = new handlers.ActionLogHandler(this);
|
||||
this.syncHandler = new handlers.SyncHandler(this);
|
||||
|
||||
logger.success('OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export class ConnectionsHandler {
|
||||
dataArg.providerType,
|
||||
dataArg.baseUrl,
|
||||
dataArg.token,
|
||||
dataArg.groupFilter,
|
||||
);
|
||||
this.actionLog.append({
|
||||
actionType: 'create',
|
||||
@@ -65,12 +66,14 @@ export class ConnectionsHandler {
|
||||
name: dataArg.name,
|
||||
baseUrl: dataArg.baseUrl,
|
||||
token: dataArg.token,
|
||||
groupFilter: dataArg.groupFilter,
|
||||
},
|
||||
);
|
||||
const fields = [
|
||||
dataArg.name && 'name',
|
||||
dataArg.baseUrl && 'baseUrl',
|
||||
dataArg.token && 'token',
|
||||
dataArg.groupFilter !== undefined && 'groupFilter',
|
||||
].filter(Boolean).join(', ');
|
||||
this.actionLog.append({
|
||||
actionType: 'update',
|
||||
|
||||
@@ -8,3 +8,4 @@ export { LogsHandler } from './logs.handler.ts';
|
||||
export { WebhookHandler } from './webhook.handler.ts';
|
||||
export { ActionsHandler } from './actions.handler.ts';
|
||||
export { ActionLogHandler } from './actionlog.handler.ts';
|
||||
export { SyncHandler } from './sync.handler.ts';
|
||||
|
||||
222
ts/opsserver/handlers/sync.handler.ts
Normal file
222
ts/opsserver/handlers/sync.handler.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
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';
|
||||
import { logger } from '../../logging.ts';
|
||||
|
||||
export class SyncHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
this.setupBroadcast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up the logger's broadcast function to push sync log entries
|
||||
* to all connected frontends via TypedSocket.
|
||||
*/
|
||||
private setupBroadcast(): void {
|
||||
logger.setBroadcastFn((entry) => {
|
||||
try {
|
||||
const typedsocket = this.opsServerRef.server?.typedserver?.typedsocket;
|
||||
if (!typedsocket) return;
|
||||
typedsocket.findAllTargetConnectionsByTag('allClients').then((connections) => {
|
||||
for (const conn of connections) {
|
||||
typedsocket
|
||||
.createTypedRequest<interfaces.requests.IReq_PushSyncLog>('pushSyncLog', conn)
|
||||
.fire({ entry })
|
||||
.catch(() => {});
|
||||
}
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
// Server may not be ready yet — ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private get syncManager() {
|
||||
return this.opsServerRef.gitopsAppRef.syncManager;
|
||||
}
|
||||
|
||||
private get actionLog() {
|
||||
return this.opsServerRef.gitopsAppRef.actionLog;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get all sync configs
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSyncConfigs>(
|
||||
'getSyncConfigs',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
return { configs: this.syncManager.getConfigs() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create sync config
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSyncConfig>(
|
||||
'createSyncConfig',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const config = await this.syncManager.createConfig({
|
||||
name: dataArg.name,
|
||||
sourceConnectionId: dataArg.sourceConnectionId,
|
||||
targetConnectionId: dataArg.targetConnectionId,
|
||||
targetGroupOffset: dataArg.targetGroupOffset,
|
||||
intervalMinutes: dataArg.intervalMinutes,
|
||||
enforceDelete: dataArg.enforceDelete,
|
||||
enforceGroupDelete: dataArg.enforceGroupDelete,
|
||||
addMirrorHint: dataArg.addMirrorHint,
|
||||
});
|
||||
this.actionLog.append({
|
||||
actionType: 'create',
|
||||
entityType: 'sync',
|
||||
entityId: config.id,
|
||||
entityName: config.name,
|
||||
details: `Created sync config "${config.name}" (${config.intervalMinutes}m interval)`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { config };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update sync config
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSyncConfig>(
|
||||
'updateSyncConfig',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const config = await this.syncManager.updateConfig(dataArg.syncConfigId, {
|
||||
name: dataArg.name,
|
||||
targetGroupOffset: dataArg.targetGroupOffset,
|
||||
intervalMinutes: dataArg.intervalMinutes,
|
||||
enforceDelete: dataArg.enforceDelete,
|
||||
enforceGroupDelete: dataArg.enforceGroupDelete,
|
||||
addMirrorHint: dataArg.addMirrorHint,
|
||||
});
|
||||
this.actionLog.append({
|
||||
actionType: 'update',
|
||||
entityType: 'sync',
|
||||
entityId: config.id,
|
||||
entityName: config.name,
|
||||
details: `Updated sync config "${config.name}"`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { config };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete sync config
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSyncConfig>(
|
||||
'deleteSyncConfig',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const config = this.syncManager.getConfig(dataArg.syncConfigId);
|
||||
await this.syncManager.deleteConfig(dataArg.syncConfigId);
|
||||
this.actionLog.append({
|
||||
actionType: 'delete',
|
||||
entityType: 'sync',
|
||||
entityId: dataArg.syncConfigId,
|
||||
entityName: config?.name || dataArg.syncConfigId,
|
||||
details: `Deleted sync config "${config?.name || dataArg.syncConfigId}"`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Pause/resume sync config
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PauseSyncConfig>(
|
||||
'pauseSyncConfig',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const config = await this.syncManager.pauseConfig(
|
||||
dataArg.syncConfigId,
|
||||
dataArg.paused,
|
||||
);
|
||||
this.actionLog.append({
|
||||
actionType: dataArg.paused ? 'pause' : 'resume',
|
||||
entityType: 'sync',
|
||||
entityId: config.id,
|
||||
entityName: config.name,
|
||||
details: `${dataArg.paused ? 'Paused' : 'Resumed'} sync config "${config.name}"`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { config };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Trigger sync manually
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TriggerSync>(
|
||||
'triggerSync',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const config = this.syncManager.getConfig(dataArg.syncConfigId);
|
||||
if (!config) {
|
||||
return { ok: false, message: 'Sync config not found' };
|
||||
}
|
||||
// Fire and forget — force=true bypasses paused check for manual triggers
|
||||
this.syncManager.executeSync(dataArg.syncConfigId, true).catch((err) => {
|
||||
console.error(`Manual sync trigger failed: ${err}`);
|
||||
});
|
||||
this.actionLog.append({
|
||||
actionType: 'sync',
|
||||
entityType: 'sync',
|
||||
entityId: config.id,
|
||||
entityName: config.name,
|
||||
details: `Manually triggered sync "${config.name}"`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { ok: true, message: 'Sync triggered' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Preview sync (dry run — shows source → target mappings)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PreviewSync>(
|
||||
'previewSync',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const result = await this.syncManager.previewSync(dataArg.syncConfigId);
|
||||
return { mappings: result.mappings, deletions: result.deletions, groupDeletions: result.groupDeletions };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get repo statuses for a sync config
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSyncRepoStatuses>(
|
||||
'getSyncRepoStatuses',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const statuses = await this.syncManager.getRepoStatuses(dataArg.syncConfigId);
|
||||
return { statuses };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get recent sync log entries
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSyncLogs>(
|
||||
'getSyncLogs',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const logs = logger.getSyncLogs(dataArg.limit || 200);
|
||||
return { logs };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export interface IGitopsPaths {
|
||||
gitopsHomeDir: string;
|
||||
defaultStoragePath: string;
|
||||
defaultTsmDbPath: string;
|
||||
syncMirrorsPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -15,5 +16,6 @@ export function resolvePaths(baseDir?: string): IGitopsPaths {
|
||||
gitopsHomeDir: home,
|
||||
defaultStoragePath: path.join(home, 'storage'),
|
||||
defaultTsmDbPath: path.join(home, 'tsmdb'),
|
||||
syncMirrorsPath: path.join(home, 'mirrors'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,11 +16,16 @@ export interface IListOptions {
|
||||
* Subclasses implement Gitea API v1 or GitLab API v4.
|
||||
*/
|
||||
export abstract class BaseProvider {
|
||||
public readonly groupFilterId?: string;
|
||||
|
||||
constructor(
|
||||
public readonly connectionId: string,
|
||||
public readonly baseUrl: string,
|
||||
protected readonly token: string,
|
||||
) {}
|
||||
groupFilterId?: string,
|
||||
) {
|
||||
this.groupFilterId = groupFilterId;
|
||||
}
|
||||
|
||||
// Connection
|
||||
abstract testConnection(): Promise<ITestConnectionResult>;
|
||||
|
||||
@@ -8,8 +8,8 @@ import { BaseProvider, type ITestConnectionResult, type IListOptions } from './c
|
||||
export class GiteaProvider extends BaseProvider {
|
||||
private client: plugins.giteaClient.GiteaClient;
|
||||
|
||||
constructor(connectionId: string, baseUrl: string, token: string) {
|
||||
super(connectionId, baseUrl, token);
|
||||
constructor(connectionId: string, baseUrl: string, token: string, groupFilterId?: string) {
|
||||
super(connectionId, baseUrl, token, groupFilterId);
|
||||
this.client = new plugins.giteaClient.GiteaClient(baseUrl, token);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,14 @@ export class GiteaProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> {
|
||||
// Use org-scoped listing when groupFilterId is set
|
||||
const fetchFn = this.groupFilterId
|
||||
? (o: IListOptions) => this.client.getOrgRepos(this.groupFilterId!, o)
|
||||
: (o: IListOptions) => this.client.getRepos(o);
|
||||
|
||||
// If caller explicitly requests a specific page, respect it (no auto-pagination)
|
||||
if (opts?.page) {
|
||||
const repos = await this.client.getRepos(opts);
|
||||
const repos = await fetchFn(opts);
|
||||
return repos.map((r) => this.mapProject(r));
|
||||
}
|
||||
|
||||
@@ -29,7 +34,7 @@ export class GiteaProvider extends BaseProvider {
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const repos = await this.client.getRepos({ ...opts, page, perPage });
|
||||
const repos = await fetchFn({ ...opts, page, perPage });
|
||||
allRepos.push(...repos);
|
||||
if (repos.length < perPage) break;
|
||||
page++;
|
||||
@@ -39,6 +44,12 @@ export class GiteaProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
async getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]> {
|
||||
// When groupFilterId is set, return only that single org
|
||||
if (this.groupFilterId) {
|
||||
const org = await this.client.getOrg(this.groupFilterId);
|
||||
return [this.mapGroup(org)];
|
||||
}
|
||||
|
||||
// If caller explicitly requests a specific page, respect it (no auto-pagination)
|
||||
if (opts?.page) {
|
||||
const orgs = await this.client.getOrgs(opts);
|
||||
|
||||
@@ -8,8 +8,8 @@ import { BaseProvider, type ITestConnectionResult, type IListOptions } from './c
|
||||
export class GitLabProvider extends BaseProvider {
|
||||
private client: plugins.gitlabClient.GitLabClient;
|
||||
|
||||
constructor(connectionId: string, baseUrl: string, token: string) {
|
||||
super(connectionId, baseUrl, token);
|
||||
constructor(connectionId: string, baseUrl: string, token: string, groupFilterId?: string) {
|
||||
super(connectionId, baseUrl, token, groupFilterId);
|
||||
this.client = new plugins.gitlabClient.GitLabClient(baseUrl, token);
|
||||
}
|
||||
|
||||
@@ -18,13 +18,71 @@ export class GitLabProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> {
|
||||
const projects = await this.client.getProjects(opts);
|
||||
return projects.map((p) => this.mapProject(p));
|
||||
if (this.groupFilterId) {
|
||||
// Auto-paginate group-scoped project listing
|
||||
if (opts?.page) {
|
||||
const projects = await this.client.getGroupProjects(this.groupFilterId, opts);
|
||||
return projects.map((p) => this.mapProject(p));
|
||||
}
|
||||
const allProjects: plugins.gitlabClient.IGitLabProject[] = [];
|
||||
const perPage = opts?.perPage || 50;
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const projects = await this.client.getGroupProjects(this.groupFilterId, { ...opts, page, perPage });
|
||||
allProjects.push(...projects);
|
||||
if (projects.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
return allProjects.map((p) => this.mapProject(p));
|
||||
}
|
||||
if (opts?.page) {
|
||||
const projects = await this.client.getProjects(opts);
|
||||
return projects.map((p) => this.mapProject(p));
|
||||
}
|
||||
const allProjects: plugins.gitlabClient.IGitLabProject[] = [];
|
||||
const perPage = opts?.perPage || 50;
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const projects = await this.client.getProjects({ ...opts, page, perPage });
|
||||
allProjects.push(...projects);
|
||||
if (projects.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
return allProjects.map((p) => this.mapProject(p));
|
||||
}
|
||||
|
||||
async getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]> {
|
||||
const groups = await this.client.getGroups(opts);
|
||||
return groups.map((g) => this.mapGroup(g));
|
||||
if (this.groupFilterId) {
|
||||
// Auto-paginate descendant groups listing
|
||||
if (opts?.page) {
|
||||
const groups = await this.client.getDescendantGroups(this.groupFilterId, opts);
|
||||
return groups.map((g) => this.mapGroup(g));
|
||||
}
|
||||
const allGroups: plugins.gitlabClient.IGitLabGroup[] = [];
|
||||
const perPage = opts?.perPage || 50;
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const groups = await this.client.getDescendantGroups(this.groupFilterId, { ...opts, page, perPage });
|
||||
allGroups.push(...groups);
|
||||
if (groups.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
return allGroups.map((g) => this.mapGroup(g));
|
||||
}
|
||||
if (opts?.page) {
|
||||
const groups = await this.client.getGroups(opts);
|
||||
return groups.map((g) => this.mapGroup(g));
|
||||
}
|
||||
const allGroups: plugins.gitlabClient.IGitLabGroup[] = [];
|
||||
const perPage = opts?.perPage || 50;
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const groups = await this.client.getGroups({ ...opts, page, perPage });
|
||||
allGroups.push(...groups);
|
||||
if (groups.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
return allGroups.map((g) => this.mapGroup(g));
|
||||
}
|
||||
|
||||
// --- Project Secrets (CI/CD Variables) ---
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
export type TActionType = 'create' | 'update' | 'delete' | 'pause' | 'resume' | 'test' | 'scan';
|
||||
export type TActionEntity = 'connection' | 'secret' | 'pipeline';
|
||||
export type TActionType = 'create' | 'update' | 'delete' | 'pause' | 'resume' | 'test' | 'scan' | 'sync' | 'obsolete';
|
||||
export type TActionEntity = 'connection' | 'secret' | 'pipeline' | 'sync';
|
||||
|
||||
export interface IActionLogEntry {
|
||||
id: string;
|
||||
|
||||
@@ -8,4 +8,6 @@ export interface IProviderConnection {
|
||||
token: string;
|
||||
createdAt: number;
|
||||
status: 'connected' | 'disconnected' | 'error' | 'paused';
|
||||
groupFilter?: string; // Restricts which repos this connection can see (e.g. "foss.global")
|
||||
groupFilterId?: string; // Resolved filter group ID (numeric for GitLab, org name for Gitea)
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './group.ts';
|
||||
export * from './secret.ts';
|
||||
export * from './pipeline.ts';
|
||||
export * from './actionlog.ts';
|
||||
export * from './sync.ts';
|
||||
|
||||
36
ts_interfaces/data/sync.ts
Normal file
36
ts_interfaces/data/sync.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type TSyncStatus = 'active' | 'paused' | 'error';
|
||||
|
||||
export interface ISyncConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceConnectionId: string;
|
||||
targetConnectionId: string;
|
||||
targetGroupOffset?: string; // Path prefix for target repos (e.g. "mirror/gitlab")
|
||||
intervalMinutes: number; // Default 5
|
||||
status: TSyncStatus;
|
||||
lastSyncAt: number;
|
||||
lastSyncError?: string;
|
||||
lastSyncDurationMs?: number;
|
||||
reposSynced: number;
|
||||
enforceDelete: boolean; // When true, stale target repos are moved to obsolete
|
||||
enforceGroupDelete: boolean; // When true, stale target groups/orgs are moved to obsolete
|
||||
addMirrorHint?: boolean; // When true, target descriptions get "(This is a mirror of ...)" appended
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ISyncRepoStatus {
|
||||
id: string;
|
||||
syncConfigId: string;
|
||||
sourceFullPath: string; // e.g. "push.rocks/smartstate"
|
||||
targetFullPath: string; // e.g. "foss.global/push.rocks/smartstate"
|
||||
lastSyncAt: number;
|
||||
lastSyncError?: string;
|
||||
status: 'synced' | 'error' | 'pending';
|
||||
}
|
||||
|
||||
export interface ISyncLogEntry {
|
||||
timestamp: number;
|
||||
level: 'info' | 'warn' | 'error' | 'success' | 'debug';
|
||||
message: string;
|
||||
source?: string; // e.g. 'preview', 'sync', 'git', 'api'
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export interface IReq_CreateConnection extends plugins.typedrequestInterfaces.im
|
||||
providerType: data.TProviderType;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
groupFilter?: string;
|
||||
};
|
||||
response: {
|
||||
connection: data.IProviderConnection;
|
||||
@@ -42,6 +43,7 @@ export interface IReq_UpdateConnection extends plugins.typedrequestInterfaces.im
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
groupFilter?: string;
|
||||
};
|
||||
response: {
|
||||
connection: data.IProviderConnection;
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './logs.ts';
|
||||
export * from './webhook.ts';
|
||||
export * from './actions.ts';
|
||||
export * from './actionlog.ts';
|
||||
export * from './sync.ts';
|
||||
|
||||
155
ts_interfaces/requests/sync.ts
Normal file
155
ts_interfaces/requests/sync.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetSyncConfigs extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSyncConfigs
|
||||
> {
|
||||
method: 'getSyncConfigs';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
configs: data.ISyncConfig[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_CreateSyncConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateSyncConfig
|
||||
> {
|
||||
method: 'createSyncConfig';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
name: string;
|
||||
sourceConnectionId: string;
|
||||
targetConnectionId: string;
|
||||
targetGroupOffset?: string;
|
||||
intervalMinutes?: number;
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
};
|
||||
response: {
|
||||
config: data.ISyncConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_UpdateSyncConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateSyncConfig
|
||||
> {
|
||||
method: 'updateSyncConfig';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
syncConfigId: string;
|
||||
name?: string;
|
||||
targetGroupOffset?: string;
|
||||
intervalMinutes?: number;
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
};
|
||||
response: {
|
||||
config: data.ISyncConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_DeleteSyncConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteSyncConfig
|
||||
> {
|
||||
method: 'deleteSyncConfig';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
syncConfigId: string;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_PauseSyncConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PauseSyncConfig
|
||||
> {
|
||||
method: 'pauseSyncConfig';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
syncConfigId: string;
|
||||
paused: boolean;
|
||||
};
|
||||
response: {
|
||||
config: data.ISyncConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_TriggerSync extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_TriggerSync
|
||||
> {
|
||||
method: 'triggerSync';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
syncConfigId: string;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_PreviewSync extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PreviewSync
|
||||
> {
|
||||
method: 'previewSync';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
syncConfigId: string;
|
||||
};
|
||||
response: {
|
||||
mappings: Array<{ sourceFullPath: string; targetFullPath: string }>;
|
||||
deletions: string[];
|
||||
groupDeletions: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetSyncRepoStatuses extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSyncRepoStatuses
|
||||
> {
|
||||
method: 'getSyncRepoStatuses';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
syncConfigId: string;
|
||||
};
|
||||
response: {
|
||||
statuses: data.ISyncRepoStatus[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetSyncLogs extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSyncLogs
|
||||
> {
|
||||
method: 'getSyncLogs';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
limit?: number;
|
||||
};
|
||||
response: {
|
||||
logs: data.ISyncLogEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_PushSyncLog extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PushSyncLog
|
||||
> {
|
||||
method: 'pushSyncLog';
|
||||
request: {
|
||||
entry: data.ISyncLogEntry;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/gitops',
|
||||
version: '2.7.1',
|
||||
version: '2.8.0',
|
||||
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ export const createConnectionAction = connectionsStatePart.createAction<{
|
||||
providerType: interfaces.data.TProviderType;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
groupFilter?: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
@@ -279,6 +280,7 @@ export const updateConnectionAction = connectionsStatePart.createAction<{
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
groupFilter?: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
@@ -701,3 +703,225 @@ export const setRefreshIntervalAction = uiStatePart.createAction<{ interval: num
|
||||
return { ...statePartArg.getState(), refreshInterval: dataArg.interval };
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Sync State
|
||||
// ============================================================================
|
||||
|
||||
export interface ISyncState {
|
||||
configs: interfaces.data.ISyncConfig[];
|
||||
repoStatuses: interfaces.data.ISyncRepoStatus[];
|
||||
}
|
||||
|
||||
export const syncStatePart = await appState.getStatePart<ISyncState>(
|
||||
'sync',
|
||||
{ configs: [], repoStatuses: [] },
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const fetchSyncConfigsAction = syncStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSyncConfigs
|
||||
>('/typedrequest', 'getSyncConfigs');
|
||||
const response = await typedRequest.fire({ identity: context.identity! });
|
||||
return { ...statePartArg.getState(), configs: response.configs };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch sync configs:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const createSyncConfigAction = syncStatePart.createAction<{
|
||||
name: string;
|
||||
sourceConnectionId: string;
|
||||
targetConnectionId: string;
|
||||
targetGroupOffset?: string;
|
||||
intervalMinutes?: number;
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateSyncConfig
|
||||
>('/typedrequest', 'createSyncConfig');
|
||||
await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
// Re-fetch
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSyncConfigs
|
||||
>('/typedrequest', 'getSyncConfigs');
|
||||
const listResp = await listReq.fire({ identity: context.identity! });
|
||||
return { ...statePartArg.getState(), configs: listResp.configs };
|
||||
} catch (err) {
|
||||
console.error('Failed to create sync config:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const updateSyncConfigAction = syncStatePart.createAction<{
|
||||
syncConfigId: string;
|
||||
name?: string;
|
||||
targetGroupOffset?: string;
|
||||
intervalMinutes?: number;
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpdateSyncConfig
|
||||
>('/typedrequest', 'updateSyncConfig');
|
||||
await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSyncConfigs
|
||||
>('/typedrequest', 'getSyncConfigs');
|
||||
const listResp = await listReq.fire({ identity: context.identity! });
|
||||
return { ...statePartArg.getState(), configs: listResp.configs };
|
||||
} catch (err) {
|
||||
console.error('Failed to update sync config:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteSyncConfigAction = syncStatePart.createAction<{
|
||||
syncConfigId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_DeleteSyncConfig
|
||||
>('/typedrequest', 'deleteSyncConfig');
|
||||
await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
const state = statePartArg.getState();
|
||||
return { ...state, configs: state.configs.filter((c) => c.id !== dataArg.syncConfigId) };
|
||||
} catch (err) {
|
||||
console.error('Failed to delete sync config:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const pauseSyncConfigAction = syncStatePart.createAction<{
|
||||
syncConfigId: string;
|
||||
paused: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_PauseSyncConfig
|
||||
>('/typedrequest', 'pauseSyncConfig');
|
||||
await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSyncConfigs
|
||||
>('/typedrequest', 'getSyncConfigs');
|
||||
const listResp = await listReq.fire({ identity: context.identity! });
|
||||
return { ...statePartArg.getState(), configs: listResp.configs };
|
||||
} catch (err) {
|
||||
console.error('Failed to pause/resume sync config:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const triggerSyncAction = syncStatePart.createAction<{
|
||||
syncConfigId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_TriggerSync
|
||||
>('/typedrequest', 'triggerSync');
|
||||
await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
return statePartArg.getState();
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger sync:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const fetchSyncRepoStatusesAction = syncStatePart.createAction<{
|
||||
syncConfigId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSyncRepoStatuses
|
||||
>('/typedrequest', 'getSyncRepoStatuses');
|
||||
const response = await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
return { ...statePartArg.getState(), repoStatuses: response.statuses };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch sync repo statuses:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Sync Log — TypedSocket client for server-push entries
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchSyncLogs(limit = 200): Promise<interfaces.data.ISyncLogEntry[]> {
|
||||
const identity = loginStatePart.getState().identity;
|
||||
if (!identity) throw new Error('Not logged in');
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSyncLogs
|
||||
>('/typedrequest', 'getSyncLogs');
|
||||
const response = await typedRequest.fire({ identity, limit });
|
||||
return response.logs;
|
||||
}
|
||||
|
||||
let syncLogSocketInitialized = false;
|
||||
|
||||
/**
|
||||
* Create a TypedSocket client that handles server-push sync log entries.
|
||||
* Dispatches 'gitops-sync-log-entry' custom events on document.
|
||||
* Call once after login.
|
||||
*/
|
||||
export async function initSyncLogSocket(): Promise<void> {
|
||||
if (syncLogSocketInitialized) return;
|
||||
syncLogSocketInitialized = true;
|
||||
|
||||
try {
|
||||
const typedrouter = new plugins.domtools.plugins.typedrequest.TypedRouter();
|
||||
|
||||
typedrouter.addTypedHandler(
|
||||
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushSyncLog>(
|
||||
'pushSyncLog',
|
||||
async (dataArg) => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('gitops-sync-log-entry', { detail: dataArg.entry }),
|
||||
);
|
||||
return {};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await plugins.typedsocket.TypedSocket.createClient(
|
||||
typedrouter,
|
||||
plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl(),
|
||||
{ autoReconnect: true },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to init sync log TypedSocket client:', err);
|
||||
syncLogSocketInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Preview Helper
|
||||
// ============================================================================
|
||||
|
||||
export async function previewSync(syncConfigId: string): Promise<{
|
||||
mappings: Array<{ sourceFullPath: string; targetFullPath: string }>;
|
||||
deletions: string[];
|
||||
groupDeletions: string[];
|
||||
}> {
|
||||
const identity = loginStatePart.getState().identity;
|
||||
if (!identity) throw new Error('Not logged in');
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_PreviewSync
|
||||
>('/typedrequest', 'previewSync');
|
||||
const response = await typedRequest.fire({ identity, syncConfigId });
|
||||
return { mappings: response.mappings, deletions: response.deletions, groupDeletions: response.groupDeletions };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { GitopsViewPipelines } from './views/pipelines/index.js';
|
||||
import type { GitopsViewBuildlog } from './views/buildlog/index.js';
|
||||
import type { GitopsViewActions } from './views/actions/index.js';
|
||||
import type { GitopsViewActionlog } from './views/actionlog/index.js';
|
||||
import type { GitopsViewSync } from './views/sync/index.js';
|
||||
|
||||
@customElement('gitops-dashboard')
|
||||
export class GitopsDashboard extends DeesElement {
|
||||
@@ -43,6 +44,7 @@ export class GitopsDashboard extends DeesElement {
|
||||
{ name: 'Build Log', iconName: 'lucide:scrollText', element: (async () => (await import('./views/buildlog/index.js')).GitopsViewBuildlog)() },
|
||||
{ name: 'Actions', iconName: 'lucide:zap', element: (async () => (await import('./views/actions/index.js')).GitopsViewActions)() },
|
||||
{ name: 'Action Log', iconName: 'lucide:scroll', element: (async () => (await import('./views/actionlog/index.js')).GitopsViewActionlog)() },
|
||||
{ name: 'Sync', iconName: 'lucide:refreshCw', element: (async () => (await import('./views/sync/index.js')).GitopsViewSync)() },
|
||||
];
|
||||
|
||||
private resolvedViewTabs: Array<{ name: string; iconName: string; element: any }> = [];
|
||||
|
||||
@@ -62,6 +62,7 @@ export class GitopsViewConnections extends DeesElement {
|
||||
Name: item.name,
|
||||
Type: item.providerType,
|
||||
URL: item.baseUrl,
|
||||
'Group Filter': item.groupFilter || '-',
|
||||
Status: item.status,
|
||||
Created: new Date(item.createdAt).toLocaleDateString(),
|
||||
})}
|
||||
@@ -164,6 +165,9 @@ export class GitopsViewConnections extends DeesElement {
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'API Token (leave empty to keep current)'} .key=${'token'} type="password"></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Group Filter (optional)'} .key=${'groupFilter'} .value=${item.groupFilter || ''} .description=${'Restricts which repos this connection can see (e.g. an org name or GitLab group path). Does not affect where synced repos are placed.'}></dees-input-text>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
@@ -181,6 +185,7 @@ export class GitopsViewConnections extends DeesElement {
|
||||
connectionId: item.id,
|
||||
name: data.name,
|
||||
baseUrl: data.baseUrl,
|
||||
groupFilter: data.groupFilter,
|
||||
...(data.token ? { token: data.token } : {}),
|
||||
},
|
||||
);
|
||||
@@ -218,6 +223,9 @@ export class GitopsViewConnections extends DeesElement {
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'API Token'} .key=${'token'} type="password"></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Group Filter (optional)'} .key=${'groupFilter'} .description=${'Restricts which repos this connection can see (e.g. an org name or GitLab group path). Does not affect where synced repos are placed.'}></dees-input-text>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
@@ -240,6 +248,7 @@ export class GitopsViewConnections extends DeesElement {
|
||||
providerType: data.providerType,
|
||||
baseUrl: data.baseUrl,
|
||||
token: data.token,
|
||||
groupFilter: data.groupFilter || undefined,
|
||||
},
|
||||
);
|
||||
modal.destroy();
|
||||
|
||||
503
ts_web/elements/views/sync/index.ts
Normal file
503
ts_web/elements/views/sync/index.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
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-sync')
|
||||
export class GitopsViewSync extends DeesElement {
|
||||
@state()
|
||||
accessor syncState: appstate.ISyncState = { configs: [], repoStatuses: [] };
|
||||
|
||||
@state()
|
||||
accessor connectionsState: appstate.IConnectionsState = {
|
||||
connections: [],
|
||||
activeConnectionId: null,
|
||||
};
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
private _syncLogHandler: (e: Event) => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const syncSub = appstate.syncStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.syncState = s; });
|
||||
this.rxSubscriptions.push(syncSub);
|
||||
|
||||
const connSub = appstate.connectionsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.connectionsState = s; });
|
||||
this.rxSubscriptions.push(connSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.refresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
|
||||
// Listen for server-push sync log entries via TypedSocket
|
||||
this._syncLogHandler = (e: Event) => {
|
||||
const entry = (e as CustomEvent).detail;
|
||||
if (!entry) return;
|
||||
const chartLog = this.shadowRoot?.querySelector('dees-chart-log') as any;
|
||||
if (chartLog?.addLog) {
|
||||
chartLog.addLog(entry.level, entry.message, entry.source);
|
||||
}
|
||||
};
|
||||
document.addEventListener('gitops-sync-log-entry', this._syncLogHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
document.removeEventListener('gitops-sync-log-entry', this._syncLogHandler);
|
||||
}
|
||||
|
||||
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-active { background: #1a3a1a; color: #00ff88; }
|
||||
.status-paused { background: #3a3a1a; color: #ffaa00; }
|
||||
.status-error { background: #3a1a1a; color: #ff4444; }
|
||||
|
||||
dees-chart-log {
|
||||
margin-top: 24px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="view-title">Sync</div>
|
||||
<div class="view-description">Mirror repositories between Gitea and GitLab instances</div>
|
||||
<div class="toolbar">
|
||||
<dees-button @click=${() => this.addSyncConfig()}>Add Sync</dees-button>
|
||||
<dees-button @click=${() => this.refresh()}>Refresh</dees-button>
|
||||
</div>
|
||||
<dees-table
|
||||
.heading1=${'Sync Configurations'}
|
||||
.heading2=${'Automatic repository mirroring between instances'}
|
||||
.data=${this.syncState.configs}
|
||||
.displayFunction=${(item: any) => {
|
||||
const sourceConn = this.connectionsState.connections.find((c) => c.id === item.sourceConnectionId);
|
||||
const targetConn = this.connectionsState.connections.find((c) => c.id === item.targetConnectionId);
|
||||
return {
|
||||
Name: item.name,
|
||||
Source: sourceConn?.name || item.sourceConnectionId,
|
||||
'Target': `${targetConn?.name || item.targetConnectionId}${item.targetGroupOffset ? ` → ${item.targetGroupOffset}/` : ''}`,
|
||||
Interval: `${item.intervalMinutes}m`,
|
||||
Status: item.status,
|
||||
'Enforce Delete': item.enforceDelete ? 'Yes' : 'No',
|
||||
'Enforce Group Delete': item.enforceGroupDelete ? 'Yes' : 'No',
|
||||
'Mirror Hint': item.addMirrorHint ? 'Yes' : 'No',
|
||||
'Last Sync': item.lastSyncAt ? new Date(item.lastSyncAt).toLocaleString() : 'Never',
|
||||
Repos: String(item.reposSynced),
|
||||
};
|
||||
}}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Preview',
|
||||
iconName: 'lucide:eye',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.previewSync(item); },
|
||||
},
|
||||
{
|
||||
name: 'Trigger Now',
|
||||
iconName: 'lucide:play',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
const statusNote = item.status === 'paused' ? ' (config is paused — this is a one-off run)' : '';
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Trigger Sync',
|
||||
content: html`<p style="color: #fff;">Run sync "${item.name}" now?${statusNote}</p>`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
{
|
||||
name: 'Trigger',
|
||||
action: async (modal: any) => {
|
||||
await appstate.syncStatePart.dispatchAction(appstate.triggerSyncAction, {
|
||||
syncConfigId: item.id,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'View Repos',
|
||||
iconName: 'lucide:list',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.viewRepoStatuses(item); },
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:edit',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.editSyncConfig(item); },
|
||||
},
|
||||
{
|
||||
name: 'Pause/Resume',
|
||||
iconName: 'lucide:pauseCircle',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
const isPaused = item.status === 'paused';
|
||||
const actionLabel = isPaused ? 'Resume' : 'Pause';
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `${actionLabel} Sync`,
|
||||
content: html`<p style="color: #fff;">Are you sure you want to ${actionLabel.toLowerCase()} sync "${item.name}"?</p>`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
{
|
||||
name: actionLabel,
|
||||
action: async (modal: any) => {
|
||||
await appstate.syncStatePart.dispatchAction(appstate.pauseSyncConfigAction, {
|
||||
syncConfigId: item.id,
|
||||
paused: !isPaused,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Delete Sync Config',
|
||||
content: html`<p style="color: #fff;">Are you sure you want to delete sync config "${item.name}"? This will also remove all local mirror data.</p>`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
{
|
||||
name: 'Delete',
|
||||
action: async (modal: any) => {
|
||||
await appstate.syncStatePart.dispatchAction(appstate.deleteSyncConfigAction, {
|
||||
syncConfigId: item.id,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
<dees-chart-log
|
||||
.label=${'Sync Activity Log'}
|
||||
.autoScroll=${true}
|
||||
.maxEntries=${500}
|
||||
></dees-chart-log>
|
||||
`;
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
||||
await this.refresh();
|
||||
// Initialize TypedSocket for server-push sync log entries
|
||||
await appstate.initSyncLogSocket();
|
||||
// Load existing log entries
|
||||
await this.loadExistingLogs();
|
||||
}
|
||||
|
||||
private async loadExistingLogs() {
|
||||
try {
|
||||
const logs = await appstate.fetchSyncLogs(200);
|
||||
const chartLog = this.shadowRoot?.querySelector('dees-chart-log') as any;
|
||||
if (chartLog?.updateLog && logs.length > 0) {
|
||||
chartLog.updateLog(
|
||||
logs.map((entry) => ({
|
||||
timestamp: new Date(entry.timestamp).toISOString(),
|
||||
level: entry.level,
|
||||
message: entry.message,
|
||||
source: entry.source,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load sync logs:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
await appstate.syncStatePart.dispatchAction(appstate.fetchSyncConfigsAction, null);
|
||||
}
|
||||
|
||||
private async addSyncConfig() {
|
||||
const connectionOptions = this.connectionsState.connections.map((c) => ({
|
||||
option: `${c.name} (${c.providerType})`,
|
||||
key: c.id,
|
||||
}));
|
||||
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Add Sync Configuration',
|
||||
content: html`
|
||||
<style>.form-row { margin-bottom: 16px; }</style>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Name'} .key=${'name'} .description=${'A human-readable name for this sync configuration'}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-dropdown
|
||||
.label=${'Source Connection'}
|
||||
.key=${'sourceConnectionId'}
|
||||
.description=${'The connection to read repositories from (filtered by its group filter)'}
|
||||
.options=${connectionOptions}
|
||||
.selectedOption=${connectionOptions[0]}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-dropdown
|
||||
.label=${'Target Connection'}
|
||||
.key=${'targetConnectionId'}
|
||||
.description=${'The connection to push repositories to'}
|
||||
.options=${connectionOptions}
|
||||
.selectedOption=${connectionOptions[1] || connectionOptions[0]}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Target Group Offset'} .key=${'targetGroupOffset'} .description=${'Path prefix for target repos (e.g. "mirror/gitlab"). Leave empty for no prefix — repos land at their relative path.'}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Interval (minutes)'} .key=${'intervalMinutes'} .value=${'5'} .description=${'How often to run this sync automatically'}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Enforce Deletion'} .key=${'enforceDelete'} .value=${false} .description=${'When enabled, repos on the target not present on the source will be moved to an obsolete group (private).'}></dees-input-checkbox>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Enforce Group Deletion'} .key=${'enforceGroupDelete'} .value=${false} .description=${'When enabled, groups/orgs on the target not present on the source will be moved to obsolete.'}></dees-input-checkbox>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Add Mirror Hint'} .key=${'addMirrorHint'} .value=${false} .description=${'When enabled, target descriptions get "(This is a mirror of ...)" appended.'}></dees-input-checkbox>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
{
|
||||
name: 'Create',
|
||||
action: async (modal: any) => {
|
||||
const inputs = modal.shadowRoot.querySelectorAll('dees-input-text, dees-input-dropdown, dees-input-checkbox');
|
||||
const data: any = {};
|
||||
for (const input of inputs) {
|
||||
if (input.key === 'sourceConnectionId' || input.key === 'targetConnectionId') {
|
||||
data[input.key] = input.selectedOption?.key || '';
|
||||
} else if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint') {
|
||||
data[input.key] = input.getValue();
|
||||
} else {
|
||||
data[input.key] = input.value || '';
|
||||
}
|
||||
}
|
||||
await appstate.syncStatePart.dispatchAction(appstate.createSyncConfigAction, {
|
||||
name: data.name,
|
||||
sourceConnectionId: data.sourceConnectionId,
|
||||
targetConnectionId: data.targetConnectionId,
|
||||
targetGroupOffset: data.targetGroupOffset || undefined,
|
||||
intervalMinutes: parseInt(data.intervalMinutes) || 5,
|
||||
enforceDelete: !!data.enforceDelete,
|
||||
enforceGroupDelete: !!data.enforceGroupDelete,
|
||||
addMirrorHint: !!data.addMirrorHint,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async editSyncConfig(item: any) {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `Edit Sync: ${item.name}`,
|
||||
content: html`
|
||||
<style>.form-row { margin-bottom: 16px; }</style>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Name'} .key=${'name'} .value=${item.name} .description=${'A human-readable name for this sync configuration'}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Target Group Offset'} .key=${'targetGroupOffset'} .value=${item.targetGroupOffset || ''} .description=${'Path prefix for target repos (e.g. "mirror/gitlab"). Leave empty for no prefix — repos land at their relative path.'}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Interval (minutes)'} .key=${'intervalMinutes'} .value=${String(item.intervalMinutes)} .description=${'How often to run this sync automatically'}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Enforce Deletion'} .key=${'enforceDelete'} .value=${!!item.enforceDelete} .description=${'When enabled, repos on the target not present on the source will be moved to an obsolete group (private).'}></dees-input-checkbox>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Enforce Group Deletion'} .key=${'enforceGroupDelete'} .value=${!!item.enforceGroupDelete} .description=${'When enabled, groups/orgs on the target not present on the source will be moved to obsolete.'}></dees-input-checkbox>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Add Mirror Hint'} .key=${'addMirrorHint'} .value=${!!item.addMirrorHint} .description=${'When enabled, target descriptions get "(This is a mirror of ...)" appended.'}></dees-input-checkbox>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
{
|
||||
name: 'Save',
|
||||
action: async (modal: any) => {
|
||||
const inputs = modal.shadowRoot.querySelectorAll('dees-input-text, dees-input-checkbox');
|
||||
const data: any = {};
|
||||
for (const input of inputs) {
|
||||
if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint') {
|
||||
data[input.key] = input.getValue();
|
||||
} else {
|
||||
data[input.key] = input.value || '';
|
||||
}
|
||||
}
|
||||
await appstate.syncStatePart.dispatchAction(appstate.updateSyncConfigAction, {
|
||||
syncConfigId: item.id,
|
||||
name: data.name,
|
||||
targetGroupOffset: data.targetGroupOffset || undefined,
|
||||
intervalMinutes: parseInt(data.intervalMinutes) || 5,
|
||||
enforceDelete: !!data.enforceDelete,
|
||||
enforceGroupDelete: !!data.enforceGroupDelete,
|
||||
addMirrorHint: !!data.addMirrorHint,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async previewSync(item: any) {
|
||||
try {
|
||||
const { mappings, deletions, groupDeletions } = await appstate.previewSync(item.id);
|
||||
|
||||
// Compute the full obsolete group path for display
|
||||
const targetConn = this.connectionsState.connections.find((c: any) => c.id === item.targetConnectionId);
|
||||
let obsoletePath: string;
|
||||
if (targetConn?.providerType === 'gitea') {
|
||||
const segments = item.targetGroupOffset ? item.targetGroupOffset.split('/') : [];
|
||||
const orgName = segments[0] || targetConn?.groupFilter || 'default';
|
||||
obsoletePath = `${orgName}-obsolete`;
|
||||
} else {
|
||||
obsoletePath = item.targetGroupOffset ? `${item.targetGroupOffset}/obsolete` : 'obsolete';
|
||||
}
|
||||
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `Preview Sync: "${item.name}"`,
|
||||
content: html`
|
||||
<style>
|
||||
.preview-list { color: #fff; max-height: 400px; overflow-y: auto; }
|
||||
.preview-item { display: flex; align-items: center; gap: 12px; padding: 6px 0; border-bottom: 1px solid #333; font-size: 13px; }
|
||||
.preview-source { color: #aaa; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.preview-arrow { color: #666; flex-shrink: 0; }
|
||||
.preview-target { color: #00ff88; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.preview-count { color: #888; font-size: 12px; margin-bottom: 12px; }
|
||||
.preview-delete { color: #ff4444; padding: 6px 0; border-bottom: 1px solid #333; font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||
.preview-delete-marker { flex-shrink: 0; }
|
||||
.preview-delete-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.preview-section { margin-top: 16px; }
|
||||
.preview-section-header { color: #ff4444; font-size: 12px; font-weight: 600; margin-bottom: 8px; }
|
||||
</style>
|
||||
<div class="preview-count">${mappings.length} repositories will be synced</div>
|
||||
<div class="preview-list">
|
||||
${mappings.map((m: any) => html`
|
||||
<div class="preview-item">
|
||||
<span class="preview-source">${m.sourceFullPath}</span>
|
||||
<span class="preview-arrow">→</span>
|
||||
<span class="preview-target">${m.targetFullPath}</span>
|
||||
</div>
|
||||
`)}
|
||||
${mappings.length === 0 ? html`<p style="color: #888;">No repositories found on source.</p>` : ''}
|
||||
</div>
|
||||
${deletions.length > 0 ? html`
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-header">${deletions.length} target repositor${deletions.length === 1 ? 'y' : 'ies'} will be moved to ${obsoletePath}</div>
|
||||
<div class="preview-list">
|
||||
${deletions.map((d: string) => html`
|
||||
<div class="preview-delete">
|
||||
<span class="preview-delete-marker">→</span>
|
||||
<span class="preview-delete-path">${d}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${groupDeletions.length > 0 ? html`
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-header">${groupDeletions.length} target group${groupDeletions.length === 1 ? '' : 's'} will be moved to ${obsoletePath}</div>
|
||||
<div class="preview-list">
|
||||
${groupDeletions.map((g: string) => html`
|
||||
<div class="preview-delete">
|
||||
<span class="preview-delete-marker">→</span>
|
||||
<span class="preview-delete-path">${g}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Close', action: async (modal: any) => { modal.destroy(); } },
|
||||
],
|
||||
});
|
||||
} catch (err: any) {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Preview Failed',
|
||||
content: html`<p style="color: #ff4444;">${err.message || String(err)}</p>`,
|
||||
menuOptions: [
|
||||
{ name: 'Close', action: async (modal: any) => { modal.destroy(); } },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async viewRepoStatuses(item: any) {
|
||||
await appstate.syncStatePart.dispatchAction(appstate.fetchSyncRepoStatusesAction, {
|
||||
syncConfigId: item.id,
|
||||
});
|
||||
|
||||
const statuses = appstate.syncStatePart.getState().repoStatuses;
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `Sync "${item.name}" - Repo Statuses`,
|
||||
content: html`
|
||||
<style>
|
||||
.repo-list { color: #fff; max-height: 400px; overflow-y: auto; }
|
||||
.repo-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #333; }
|
||||
.repo-path { font-weight: 600; font-size: 13px; }
|
||||
.repo-status { font-size: 12px; text-transform: uppercase; }
|
||||
.repo-status.synced { color: #00ff88; }
|
||||
.repo-status.error { color: #ff4444; }
|
||||
.repo-status.pending { color: #ffaa00; }
|
||||
.repo-error { font-size: 11px; color: #ff6666; margin-top: 4px; }
|
||||
</style>
|
||||
<div class="repo-list">
|
||||
${statuses.map((s: any) => html`
|
||||
<div class="repo-item">
|
||||
<div>
|
||||
<div class="repo-path">${s.sourceFullPath}</div>
|
||||
${s.lastSyncError ? html`<div class="repo-error">${s.lastSyncError}</div>` : ''}
|
||||
</div>
|
||||
<div>
|
||||
<span class="repo-status ${s.status}">${s.status}</span>
|
||||
<div style="font-size: 11px; color: #888;">${s.lastSyncAt ? new Date(s.lastSyncAt).toLocaleString() : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
${statuses.length === 0 ? html`<p style="color: #888;">No repos synced yet.</p>` : ''}
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Close', action: async (modal: any) => { modal.destroy(); } },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
import * as deesElement from '@design.estate/dees-element';
|
||||
import * as deesCatalog from '@design.estate/dees-catalog';
|
||||
|
||||
// @api.global scope
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export {
|
||||
deesElement,
|
||||
deesCatalog,
|
||||
typedsocket,
|
||||
};
|
||||
|
||||
// domtools gives us TypedRequest, smartstate, smartrouter, and other utilities
|
||||
|
||||
Reference in New Issue
Block a user