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:
2026-02-28 16:33:53 +00:00
parent 2f050744bc
commit f7e16aa350
30 changed files with 2983 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,3 +5,4 @@ export * from './group.ts';
export * from './secret.ts';
export * from './pipeline.ts';
export * from './actionlog.ts';
export * from './sync.ts';

View 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'
}

View File

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

View File

@@ -8,3 +8,4 @@ export * from './logs.ts';
export * from './webhook.ts';
export * from './actions.ts';
export * from './actionlog.ts';
export * from './sync.ts';

View 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: {};
}

View File

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

View File

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

View File

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

View File

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

View 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">&rarr;</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(); } },
],
});
}
}

View File

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