diff --git a/ts_web/elements/cloudly-view-tasks.ts b/ts_web/elements/cloudly-view-tasks.ts index 521038e..bb89fda 100644 --- a/ts_web/elements/cloudly-view-tasks.ts +++ b/ts_web/elements/cloudly-view-tasks.ts @@ -27,6 +27,17 @@ export class CloudlyViewTasks extends DeesElement { @state() private filterStatus: string = 'all'; + @state() + private searchQuery: string = ''; + + @state() + private categoryFilter: string = 'all'; + + @state() + private autoRefresh: boolean = true; + + private _refreshHandle: any = null; + constructor() { super(); const subscription = appstate.dataState @@ -38,6 +49,9 @@ export class CloudlyViewTasks extends DeesElement { // Load initial data (non-blocking) this.loadInitialData(); + + // Start periodic refresh (lightweight; executions only by default) + this.startAutoRefresh(); } private async loadInitialData() { @@ -49,48 +63,117 @@ export class CloudlyViewTasks extends DeesElement { } } + private startAutoRefresh() { + this.stopAutoRefresh(); + if (!this.autoRefresh) return; + this._refreshHandle = setInterval(async () => { + try { + await this.loadExecutionsWithFilter(); + } catch (err) { + // ignore transient errors during refresh + } + }, 5000); + } + + private stopAutoRefresh() { + if (this._refreshHandle) { + clearInterval(this._refreshHandle); + this._refreshHandle = null; + } + } + + public async disconnectedCallback(): Promise { + await (super.disconnectedCallback?.()); + this.stopAutoRefresh(); + } + public static styles = [ cssManager.defaultStyles, shared.viewHostCss, css` + .toolbar { + display: flex; + gap: 12px; + align-items: center; + margin: 4px 0 16px 0; + flex-wrap: wrap; + } + + .toolbar .spacer { + flex: 1 1 auto; + } + + .search-input { + background: #111; + color: #ddd; + border: 1px solid #333; + border-radius: 6px; + padding: 8px 10px; + min-width: 220px; + } + + .chipbar { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + .chip { + padding: 6px 10px; + background: #2a2a2a; + color: #bbb; + border: 1px solid #444; + border-radius: 16px; + cursor: pointer; + transition: all 0.2s; + user-select: none; + } + + .chip.active { + background: #2196f3; + border-color: #2196f3; + color: white; + } + .task-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; margin-bottom: 32px; } .task-card { - background: #1a1a1a; - border: 1px solid #333; - border-radius: 8px; + background: #131313; + border: 1px solid #2a2a2a; + border-radius: 10px; padding: 16px; - cursor: pointer; - transition: all 0.2s; + transition: border-color 0.2s, background 0.2s; } - .task-card:hover { - background: #222; - border-color: #555; - } + .task-card:hover { border-color: #3a3a3a; } - .task-header { + .card-header { display: flex; justify-content: space-between; align-items: center; + gap: 12px; margin-bottom: 12px; } - .task-name { - font-size: 1.1em; - font-weight: 600; - color: #fff; - } + .header-left { display: flex; align-items: center; gap: 12px; min-width: 0; } + .header-right { display: flex; align-items: center; gap: 8px; } + .task-icon { color: #cfcfcf; } + .task-name { font-size: 1.05em; font-weight: 650; color: #fff; letter-spacing: 0.1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .task-subtitle { color: #8c8c8c; font-size: 0.9em; } .task-description { - color: #999; - font-size: 0.9em; + color: #b5b5b5; + font-size: 0.95em; margin-bottom: 12px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } .task-meta { @@ -166,7 +249,7 @@ export class CloudlyViewTasks extends DeesElement { background: #2196f3; color: white; border: none; - border-radius: 4px; + border-radius: 6px; cursor: pointer; font-size: 0.9em; transition: background 0.2s; @@ -196,7 +279,7 @@ export class CloudlyViewTasks extends DeesElement { .execution-logs { background: #0a0a0a; border: 1px solid #333; - border-radius: 4px; + border-radius: 6px; padding: 16px; margin-top: 16px; max-height: 400px; @@ -279,21 +362,150 @@ export class CloudlyViewTasks extends DeesElement { font-size: 1.1em; font-weight: 600; } + + .card-actions, .task-header .right { + display: flex; + gap: 8px; + align-items: center; + } + + .metrics-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 8px; + } + + .metric-item { + background: #0f0f0f; + border: 1px solid #2c2c2c; + border-radius: 8px; + padding: 10px 12px; + } + + .metric-item .label { + color: #8d8d8d; + font-size: 0.8em; + } + + .metric-item .value { + color: #eaeaea; + font-weight: 600; + margin-top: 4px; + } + + .lastline { + display: flex; + align-items: center; + gap: 8px; + color: #a0a0a0; + font-size: 0.9em; + margin-top: 10px; + } + + .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } + .dot.info { background: #2196f3; } + .dot.success { background: #4caf50; } + .dot.warning { background: #ff9800; } + .dot.error { background: #f44336; } + + .card-footer { + display: flex; + gap: 12px; + margin-top: 12px; + } + + .link-button { + background: transparent; + border: none; + color: #8ab4ff; + cursor: pointer; + padding: 0; + font-size: 0.95em; + } + .link-button:hover { text-decoration: underline; } + + .secondary-button { + padding: 6px 12px; + background: #2b2b2b; + color: #ccc; + border: 1px solid #444; + border-radius: 6px; + cursor: pointer; + font-size: 0.9em; + transition: background 0.2s, border-color 0.2s; + } + + .secondary-button:hover { + background: #363636; + border-color: #555; + } `, ]; private async triggerTask(taskName: string) { try { - await appstate.dataState.dispatchAction( - appstate.taskActions.triggerTask, { taskName } - ); - - // Reload tasks and executions to show the new execution - await appstate.dataState.dispatchAction(appstate.taskActions.getTasks, {}); - await this.loadExecutionsWithFilter(); + const modal = await plugins.deesCatalog.DeesModal.createAndShow({ + heading: `Run Task: ${taskName}`, + content: html` +
+

Do you want to trigger this task now?

+
+ `, + menuOptions: [ + { + name: 'Run now', + action: async (modalArg: any) => { + await appstate.dataState.dispatchAction( + appstate.taskActions.triggerTask, { taskName } + ); + plugins.deesCatalog.DeesToast.createAndShow({ + message: `Task ${taskName} triggered`, + type: 'success', + }); + await modalArg.destroy(); + // Refresh executions to reflect the new run quickly + await this.loadExecutionsWithFilter(); + } + }, + { + name: 'Cancel', + action: async (modalArg: any) => modalArg.destroy() + } + ] + }); } catch (error) { console.error('Failed to trigger task:', error); + plugins.deesCatalog.DeesToast.createAndShow({ + message: `Failed to trigger: ${error.message}`, + type: 'error', + }); + } + } + + private async cancelTaskFor(taskName: string) { + try { + const executions = (this.data.taskExecutions || []) + .filter(e => e.data.taskName === taskName && e.data.status === 'running') + .sort((a, b) => (b.data.startedAt || 0) - (a.data.startedAt || 0)); + const running = executions[0]; + if (!running) return; + + await appstate.dataState.dispatchAction( + appstate.taskActions.cancelTask, { executionId: running.id } + ); + plugins.deesCatalog.DeesToast.createAndShow({ + message: `Cancelled ${taskName}`, + type: 'success', + }); + await this.loadExecutionsWithFilter(); + } catch (err) { + console.error('Failed to cancel task:', err); + plugins.deesCatalog.DeesToast.createAndShow({ + message: `Cancel failed: ${err.message}`, + type: 'error', + }); } } @@ -323,6 +535,16 @@ export class CloudlyViewTasks extends DeesElement { return `${(ms / 3600000).toFixed(1)}h`; } + private formatRelativeTime(ts?: number): string { + if (!ts) return '-'; + const diff = Date.now() - ts; + const abs = Math.abs(diff); + if (abs < 60_000) return `${Math.round(abs / 1000)}s ago`; + if (abs < 3_600_000) return `${Math.round(abs / 60_000)}m ago`; + if (abs < 86_400_000) return `${Math.round(abs / 3_600_000)}h ago`; + return `${Math.round(abs / 86_400_000)}d ago`; + } + private getCategoryIcon(category: string): string { switch (category) { case 'maintenance': @@ -344,6 +566,43 @@ export class CloudlyViewTasks extends DeesElement { } } + private getCategoryHue(category: string): number { + switch (category) { + case 'maintenance': return 28; // orange + case 'deployment': return 208; // blue + case 'backup': return 122; // green + case 'monitoring': return 280; // purple + case 'cleanup': return 20; // brownish + case 'system': return 200; // steel + case 'security': return 0; // red + default: return 210; // default blue + } + } + + private getStatusColor(status?: string): string { + switch (status) { + case 'running': return '#2196f3'; + case 'completed': return '#4caf50'; + case 'failed': return '#f44336'; + case 'cancelled': return '#ff9800'; + default: return '#3a3a3a'; + } + } + + private formatCronFriendly(cron?: string): string { + if (!cron) return ''; + const parts = cron.trim().split(/\s+/); + if (parts.length !== 5) return cron; // fallback + const [min, hour, dom, mon, dow] = parts; + if (min === '*/1' && hour === '*' && dom === '*' && mon === '*' && dow === '*') return 'every minute'; + if (min.startsWith('*/') && hour === '*' && dom === '*' && mon === '*' && dow === '*') return `every ${min.replace('*/','')} min`; + if (min === '0' && hour.startsWith('*/') && dom === '*' && mon === '*' && dow === '*') return `every ${hour.replace('*/','')} hours`; + if (min === '0' && hour === '*' && dom === '*' && mon === '*' && dow === '*') return 'hourly'; + if (min === '0' && hour === '0' && dom === '*' && mon === '*' && dow === '*') return 'daily'; + if (min === '0' && hour === '0' && dom === '1' && mon === '*' && dow === '*') return 'monthly'; + return cron; + } + private renderTaskCard(task: any) { const executions = this.data.taskExecutions || []; const lastExecution = executions @@ -351,65 +610,129 @@ export class CloudlyViewTasks extends DeesElement { .sort((a, b) => (b.data.startedAt || 0) - (a.data.startedAt || 0))[0]; const isRunning = lastExecution?.data.status === 'running'; + const executionsForTask = executions.filter(e => e.data.taskName === task.name); + const now = Date.now(); + const last24hCount = executionsForTask.filter(e => (e.data.startedAt || 0) > now - 86_400_000).length; + const completed = executionsForTask.filter(e => e.data.status === 'completed'); + const successRate = executionsForTask.length ? Math.round((completed.length * 100) / executionsForTask.length) : 0; + const avgDuration = completed.length ? Math.round(completed.reduce((acc, e) => acc + (e.data.duration || 0), 0) / completed.length) : undefined; + const lastLog = lastExecution?.data.logs && lastExecution.data.logs.length > 0 ? lastExecution.data.logs[lastExecution.data.logs.length - 1] : null; + const status = lastExecution?.data.status as ('running'|'completed'|'failed'|'cancelled'|undefined); + const hue = this.getCategoryHue(task.category); + const subtitle = [ + task.category, + task.schedule ? `⏱ ${this.formatCronFriendly(task.schedule)}` : null, + isRunning + ? (lastExecution?.data.startedAt ? `Started ${this.formatRelativeTime(lastExecution.data.startedAt)}` : 'Running') + : (task.lastRun ? `Last ${this.formatRelativeTime(task.lastRun)}` : 'Never run') + ].filter(Boolean).join(' • '); + return html`
-
-
- - ${task.name} +
+
+ +
+
${task.name}
+
${subtitle}
+
+
+
+ ${lastExecution ? html`${lastExecution.data.status}` : html`idle`} + ${isRunning ? html` + + this.cancelTaskFor(task.name)}> + ` : html` + this.triggerTask(task.name)}> + `}
-
-
${task.description}
- -
- ${task.category} - ${!task.enabled ? html`Disabled` : ''} -
- - ${task.schedule ? html` -
- - Schedule: ${task.schedule} -
- ` : ''} - - ${task.lastRun ? html` -
- Last run: ${this.formatDate(task.lastRun)} -
- ` : ''} +
${task.description}
${lastExecution ? html` -
-
- Status - - - ${lastExecution.data.status} - - -
- ${lastExecution.data.duration ? html` -
- Duration - ${this.formatDuration(lastExecution.data.duration)} +
+
+
Last Status
+
+ ${lastExecution.data.status}
- ` : ''} +
+
+
Avg Duration
+
${avgDuration ? this.formatDuration(avgDuration) : '-'}
+
+
+
24h Runs · Success
+
${last24hCount} · ${successRate}%
+
- ` : ''} +
+ ${lastLog ? html` ${lastLog.message}` : 'No recent logs'} +
+ + ` : html` +
+
+
Last Status
+
+
+
+
Avg Duration
+
+
+
+
24h Runs · Success
+
0 · 0%
+
+
+ `}
`; } + private async openExecutionDetails(execution: plugins.interfaces.data.ITaskExecution) { + this.selectedExecution = execution; + // Scroll into view of the details section + requestAnimationFrame(() => { + this.shadowRoot?.querySelector('cloudly-sectionheading + .execution-details')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + } + + private async openLogsModal(execution: plugins.interfaces.data.ITaskExecution) { + await plugins.deesCatalog.DeesModal.createAndShow({ + heading: `Logs: ${execution.data.taskName}`, + content: html` +
+ ${(execution.data.logs || []).map(log => html` +
+ ${this.formatDate(log.timestamp)} - ${log.message} +
+ `)} +
+ `, + menuOptions: [ + { + name: 'Copy All', + action: async (modalArg: any) => { + try { + await navigator.clipboard.writeText((execution.data.logs || []) + .map(l => `${new Date(l.timestamp).toISOString()} [${l.severity}] ${l.message}`).join('\n')); + plugins.deesCatalog.DeesToast.createAndShow({ message: 'Logs copied', type: 'success' }); + } catch (e) { + plugins.deesCatalog.DeesToast.createAndShow({ message: 'Copy failed', type: 'error' }); + } + } + }, + { name: 'Close', action: async (modalArg: any) => modalArg.destroy() } + ] + }); + } + private renderExecutionDetails(execution: plugins.interfaces.data.ITaskExecution) { return html`
@@ -476,72 +799,104 @@ export class CloudlyViewTasks extends DeesElement { } public render() { + const tasks = (this.data.tasks || []) as any[]; + const categories = Array.from(new Set(tasks.map(t => t.category))).sort(); + const filteredTasks = tasks + .filter(t => this.categoryFilter === 'all' || t.category === this.categoryFilter) + .filter(t => !this.searchQuery || t.name.toLowerCase().includes(this.searchQuery.toLowerCase()) || (t.description || '').toLowerCase().includes(this.searchQuery.toLowerCase())); + return html` Tasks - -
- - - - -
- -
- ${(this.data.tasks || []).map(task => this.renderTaskCard(task))} -
- + + +
+
+
{ this.categoryFilter = 'all'; }}> + All +
+ ${categories.map(cat => html` +
{ this.categoryFilter = cat; }}> + ${cat} +
+ `)} +
+
+ { this.searchQuery = e.target.value; }} /> + + +
+ +
+ ${filteredTasks.map(task => this.renderTaskCard(task))} +
+
+ Execution History - - { - return { - Task: itemArg.data.taskName, - Status: html`${itemArg.data.status}`, - 'Started At': this.formatDate(itemArg.data.startedAt), - Duration: itemArg.data.duration ? this.formatDuration(itemArg.data.duration) : '-', - 'Triggered By': itemArg.data.triggeredBy, - Logs: itemArg.data.logs?.length || 0, - }; - }} - .actionFunction=${async (itemArg) => [ - { - name: 'View Details', - iconName: 'lucide:Eye', - type: ['inRow'], - actionFunc: async () => { - this.selectedExecution = itemArg; - }, - }, - ]} - > - + + + { + return { + Task: itemArg.data.taskName, + Status: html`${itemArg.data.status}`, + 'Started At': this.formatDate(itemArg.data.startedAt), + Duration: itemArg.data.duration ? this.formatDuration(itemArg.data.duration) : '-', + 'Triggered By': itemArg.data.triggeredBy, + Logs: itemArg.data.logs?.length || 0, + }; + }} + .actionFunction=${async (itemArg: plugins.interfaces.data.ITaskExecution) => { + const actions: any[] = [ + { + name: 'View Details', + iconName: 'lucide:Eye', + type: ['inRow'], + actionFunc: async () => { + this.selectedExecution = itemArg; + }, + } + ]; + if (itemArg.data.status === 'running') { + actions.push({ + name: 'Cancel', + iconName: 'lucide:SquareX', + type: ['inRow'], + actionFunc: async () => { + await appstate.dataState.dispatchAction(appstate.taskActions.cancelTask, { executionId: itemArg.id }); + await this.loadExecutionsWithFilter(); + }, + }); + } + return actions; + }} + > + + ${this.selectedExecution ? html` Execution Details ${this.renderExecutionDetails(this.selectedExecution)} ` : ''} `; } -} \ No newline at end of file +}