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

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