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`
Sync
Mirror repositories between Gitea and GitLab instances
this.addSyncConfig()}>Add Sync this.refresh()}>Refresh
{ 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`

Run sync "${item.name}" now?${statusNote}

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

Are you sure you want to ${actionLabel.toLowerCase()} sync "${item.name}"?

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

Are you sure you want to delete sync config "${item.name}"? This will also remove all local mirror data.

`, 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(); }, }, ], }); }, }, ]} >
`; } 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`
`, 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`
`, 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`
${mappings.length} repositories will be synced
${mappings.map((m: any) => html`
${m.sourceFullPath} ${m.targetFullPath}
`)} ${mappings.length === 0 ? html`

No repositories found on source.

` : ''}
${deletions.length > 0 ? html`
${deletions.length} target repositor${deletions.length === 1 ? 'y' : 'ies'} will be moved to ${obsoletePath}
${deletions.map((d: string) => html`
${d}
`)}
` : ''} ${groupDeletions.length > 0 ? html`
${groupDeletions.length} target group${groupDeletions.length === 1 ? '' : 's'} will be moved to ${obsoletePath}
${groupDeletions.map((g: string) => html`
${g}
`)}
` : ''} `, menuOptions: [ { name: 'Close', action: async (modal: any) => { modal.destroy(); } }, ], }); } catch (err: any) { await plugins.deesCatalog.DeesModal.createAndShow({ heading: 'Preview Failed', content: html`

${err.message || String(err)}

`, 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`
${statuses.map((s: any) => html`
${s.sourceFullPath}
${s.lastSyncError ? html`
${s.lastSyncError}
` : ''}
${s.status}
${s.lastSyncAt ? new Date(s.lastSyncAt).toLocaleString() : ''}
`)} ${statuses.length === 0 ? html`

No repos synced yet.

` : ''}
`, menuOptions: [ { name: 'Close', action: async (modal: any) => { modal.destroy(); } }, ], }); } }