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'; type TViewMode = 'current' | 'project' | 'group' | 'error'; type TSortBy = 'created' | 'duration' | 'status'; type TTimeRange = '1h' | '6h' | '1d' | '3d' | '7d' | '30d'; @customElement('gitops-view-pipelines') export class GitopsViewPipelines extends DeesElement { @state() accessor connectionsState: appstate.IConnectionsState = { connections: [], activeConnectionId: null, }; @state() accessor dataState: appstate.IDataState = { projects: [], groups: [], secrets: [], pipelines: [], pipelineJobs: [], currentJobLog: '', }; @state() accessor selectedConnectionId: string = ''; @state() accessor selectedProjectId: string = ''; @state() accessor selectedGroupId: string = ''; @state() accessor viewMode: TViewMode = 'current'; @state() accessor sortBy: TSortBy = 'created'; @state() accessor timeRange: TTimeRange = '1d'; @state() accessor isLoading: boolean = false; private _autoRefreshHandler: () => void; private _logPollInterval: ReturnType | null = null; constructor() { super(); const connSub = appstate.connectionsStatePart .select((s) => s) .subscribe((s) => { this.connectionsState = s; }); this.rxSubscriptions.push(connSub); const dataSub = appstate.dataStatePart .select((s) => s) .subscribe((s) => { this.dataState = s; }); this.rxSubscriptions.push(dataSub); this._autoRefreshHandler = () => this.handleAutoRefresh(); document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler); } public override disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler); this.stopLogPolling(); } private handleAutoRefresh(): void { this.loadPipelines(); } 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-success { background: #1a3a1a; color: #00ff88; } .status-failed { background: #3a1a1a; color: #ff4444; } .status-running { background: #1a2a3a; color: #00acff; } .status-pending { background: #3a3a1a; color: #ffaa00; } .status-canceled { background: #2a2a2a; color: #999; } `, ]; public render(): TemplateResult { const connectionOptions = this.connectionsState.connections.map((c) => ({ option: `${c.name} (${c.providerType})`, key: c.id, })); const viewModeOptions = [ { option: 'Current', key: 'current' }, { option: 'Project', key: 'project' }, { option: 'Group', key: 'group' }, { option: 'Error', key: 'error' }, ]; const timeRangeOptions = [ { option: '1 hour', key: '1h' }, { option: '6 hours', key: '6h' }, { option: '1 day', key: '1d' }, { option: '3 days', key: '3d' }, { option: '7 days', key: '7d' }, { option: '30 days', key: '30d' }, ]; const sortByOptions = [ { option: 'Created', key: 'created' }, { option: 'Duration', key: 'duration' }, { option: 'Status', key: 'status' }, ]; const projectOptions = this.dataState.projects.map((p) => ({ option: p.fullPath || p.name, key: p.id, })); const groupOptions = this.dataState.groups.map((g) => ({ option: g.fullPath || g.name, key: g.id, })); const showMultiProjectColumns = this.viewMode !== 'project'; return html`
Pipelines
View and manage CI/CD pipelines
o.key === this.selectedConnectionId) || connectionOptions[0]} @selectedOption=${(e: CustomEvent) => { this.selectedConnectionId = e.detail.key; this.onConnectionChange(); }} > o.key === this.viewMode)} @selectedOption=${(e: CustomEvent) => { this.onViewModeChange(e.detail.key); }} > ${this.viewMode === 'project' ? html` o.key === this.selectedProjectId) || projectOptions[0]} @selectedOption=${(e: CustomEvent) => { this.selectedProjectId = e.detail.key; this.loadPipelines(); }} > ` : ''} ${this.viewMode === 'group' ? html` o.key === this.selectedGroupId) || groupOptions[0]} @selectedOption=${(e: CustomEvent) => { this.selectedGroupId = e.detail.key; this.loadPipelines(); }} > ` : ''} o.key === this.timeRange)} @selectedOption=${(e: CustomEvent) => { this.timeRange = e.detail.key; this.loadPipelines(); }} > o.key === this.sortBy)} @selectedOption=${(e: CustomEvent) => { this.sortBy = e.detail.key; this.loadPipelines(); }} > this.loadPipelines()}>Refresh
{ const row: any = {}; row['ID'] = item.id; row['Status'] = item.status; if (showMultiProjectColumns) { row['Project'] = item.projectName; } row['Ref'] = item.ref; row['Duration'] = item.duration ? `${Math.round(item.duration)}s` : '-'; row['Source'] = item.source; row['Created'] = item.createdAt ? new Date(item.createdAt).toLocaleString() : '-'; return row; }} .dataActions=${[ { name: 'View Logs', iconName: 'lucide:terminal', type: ['inRow', 'contextmenu'], actionFunc: async ({ item }: any) => { await this.openPipelineLogs(item); }, }, { name: 'View Jobs', iconName: 'lucide:list', type: ['contextmenu'], actionFunc: async ({ item }: any) => { await this.viewJobs(item); }, }, { name: 'Retry', iconName: 'lucide:refreshCw', type: ['inRow', 'contextmenu'], actionFunc: async ({ item }: any) => { await appstate.dataStatePart.dispatchAction(appstate.retryPipelineAction, { connectionId: item.connectionId || this.selectedConnectionId, projectId: item.projectId, pipelineId: item.id, }); await this.loadPipelines(); }, }, { name: 'Cancel', iconName: 'lucide:xCircle', type: ['contextmenu'], actionFunc: async ({ item }: any) => { await appstate.dataStatePart.dispatchAction(appstate.cancelPipelineAction, { connectionId: item.connectionId || this.selectedConnectionId, projectId: item.projectId, pipelineId: item.id, }); await this.loadPipelines(); }, }, ]} > `; } async firstUpdated() { await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null); // Check for navigation context from projects view const navCtx = appstate.uiStatePart.getState().navigationContext; if (navCtx?.connectionId && navCtx?.projectId) { this.selectedConnectionId = navCtx.connectionId; this.selectedProjectId = navCtx.projectId; this.viewMode = 'project'; appstate.uiStatePart.dispatchAction(appstate.clearNavigationContextAction, null); await this.loadProjects(); await this.loadPipelines(); return; } const conns = appstate.connectionsStatePart.getState().connections; if (conns.length > 0 && !this.selectedConnectionId) { this.selectedConnectionId = conns[0].id; // In 'current' mode, load pipelines immediately await this.loadPipelines(); } } // --------------------------------------------------------------------------- // Data loading // --------------------------------------------------------------------------- private async onConnectionChange() { this.selectedProjectId = ''; this.selectedGroupId = ''; if (this.viewMode === 'project') { await this.loadProjects(); } else if (this.viewMode === 'group') { await this.loadGroups(); } else { await this.loadPipelines(); } } private onViewModeChange(newMode: TViewMode) { this.stopLogPolling(); this.viewMode = newMode; this.selectedProjectId = ''; this.selectedGroupId = ''; if (newMode === 'current' || newMode === 'error') { this.loadPipelines(); } else if (newMode === 'project') { this.loadProjects(); } else if (newMode === 'group') { this.loadGroups(); } } private async loadProjects() { if (!this.selectedConnectionId) return; await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, { connectionId: this.selectedConnectionId, }); } private async loadGroups() { if (!this.selectedConnectionId) return; await appstate.dataStatePart.dispatchAction(appstate.fetchGroupsAction, { connectionId: this.selectedConnectionId, }); } private async loadPipelines() { if (!this.selectedConnectionId) return; // For project mode, require a project selection if (this.viewMode === 'project' && !this.selectedProjectId) return; // For group mode, require a group selection if (this.viewMode === 'group' && !this.selectedGroupId) return; this.isLoading = true; try { await appstate.dataStatePart.dispatchAction(appstate.fetchPipelinesAction, { connectionId: this.selectedConnectionId, projectId: this.viewMode === 'project' ? this.selectedProjectId : undefined, viewMode: this.viewMode, groupId: this.viewMode === 'group' ? this.selectedGroupId : undefined, sortBy: this.sortBy, timeRange: this.timeRange, }); } finally { this.isLoading = false; } } // --------------------------------------------------------------------------- // Pipeline log viewing // --------------------------------------------------------------------------- private async openPipelineLogs(pipeline: any) { await appstate.dataStatePart.dispatchAction(appstate.fetchPipelineJobsAction, { connectionId: pipeline.connectionId || this.selectedConnectionId, projectId: pipeline.projectId, pipelineId: pipeline.id, }); const jobs = appstate.dataStatePart.getState().pipelineJobs; let activeJobId: string | null = null; const modal = await plugins.deesCatalog.DeesModal.createAndShow({ heading: `Pipeline #${pipeline.id} - Logs`, content: html`
${jobs.map((job: any) => html`
{ // Update active state visually const container = (e.target as HTMLElement).closest('.log-container'); container?.querySelectorAll('.job-entry').forEach((el: Element) => el.classList.remove('active')); (e.target as HTMLElement).closest('.job-entry')?.classList.add('active'); activeJobId = job.id; await this.selectJobForLog(job, pipeline, container); }} >
${job.name}
${job.status} ${job.stage ? ` ${job.stage}` : ''} ${job.duration ? ` - ${Math.round(job.duration)}s` : ''}
`)} ${jobs.length === 0 ? html`
No jobs found.
` : ''}
Select a job to view its log.
`, menuOptions: [ { name: 'Close', action: async (modalRef: any) => { this.stopLogPolling(); modalRef.destroy(); }, }, ], }); } private async selectJobForLog(job: any, pipeline: any, container: Element | null) { this.stopLogPolling(); // Fetch initial log await this.fetchAndDisplayLog(job, pipeline, container); // If job is running/pending, poll every 3 seconds if (job.status === 'running' || job.status === 'pending' || job.status === 'waiting') { this._logPollInterval = setInterval(async () => { await this.fetchAndDisplayLog(job, pipeline, container); }, 3000); } } private async fetchAndDisplayLog(job: any, pipeline: any, container: Element | null) { try { await appstate.dataStatePart.dispatchAction(appstate.fetchJobLogAction, { connectionId: pipeline.connectionId || this.selectedConnectionId, projectId: pipeline.projectId, jobId: job.id, }); const log = appstate.dataStatePart.getState().currentJobLog; const pre = container?.querySelector('.job-log-pre'); if (pre) { pre.textContent = log || '(No output yet)'; // Auto-scroll to bottom const logOutput = pre.closest('.log-output'); if (logOutput) { logOutput.scrollTop = logOutput.scrollHeight; } } } catch (err) { console.error('Failed to fetch job log:', err); } } private stopLogPolling() { if (this._logPollInterval !== null) { clearInterval(this._logPollInterval); this._logPollInterval = null; } } // --------------------------------------------------------------------------- // Legacy job view (accessible via context menu) // --------------------------------------------------------------------------- private async viewJobs(pipeline: any) { await appstate.dataStatePart.dispatchAction(appstate.fetchPipelineJobsAction, { connectionId: pipeline.connectionId || this.selectedConnectionId, projectId: pipeline.projectId, pipelineId: pipeline.id, }); const jobs = appstate.dataStatePart.getState().pipelineJobs; await plugins.deesCatalog.DeesModal.createAndShow({ heading: `Pipeline #${pipeline.id} - Jobs`, content: html`
${jobs.map((job: any) => html`
${job.name} (${job.stage}) ${job.status} - ${job.duration ? `${Math.round(job.duration)}s` : '-'}
`)} ${jobs.length === 0 ? html`

No jobs found.

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