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',
'Group Avatars': item.useGroupAvatarsForProjects ? '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' || input.key === 'useGroupAvatarsForProjects') {
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,
useGroupAvatarsForProjects: !!data.useGroupAvatarsForProjects,
});
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' || input.key === 'useGroupAvatarsForProjects') {
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,
useGroupAvatarsForProjects: !!data.useGroupAvatarsForProjects,
});
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.map((d: string) => html`
→
${d}
`)}
` : ''}
${groupDeletions.length > 0 ? html`
${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(); } },
],
});
}
}