feat: Add settings view for cloud provider configurations
- Implemented CloudlyViewSettings component for managing cloud provider settings including Hetzner, Cloudflare, AWS, DigitalOcean, Azure, and Google Cloud. - Added functionality to load, save, and test connections for each provider. - Enhanced UI with loading states and success/error notifications. feat: Create tasks view with execution history - Developed CloudlyViewTasks component to display and manage tasks and their executions. - Integrated auto-refresh functionality for task executions. - Added filtering and searching capabilities for tasks. feat: Implement execution details and task panel components - Created CloudlyExecutionDetails component to show detailed information about task executions including logs and metrics. - Developed CloudlyTaskPanel component to display individual tasks with execution status and actions to run or cancel tasks. feat: Utility functions for formatting and categorization - Added utility functions for formatting dates, durations, and cron expressions. - Implemented functions to retrieve category icons and hues for task categorization.
This commit is contained in:
308
ts_web/elements/views/tasks/index.ts
Normal file
308
ts_web/elements/views/tasks/index.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import * as shared from '../../shared/index.js';
|
||||
import * as plugins from '../../../plugins.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as appstate from '../../../appstate.js';
|
||||
import './parts/cloudly-task-panel.js';
|
||||
import './parts/cloudly-execution-details.js';
|
||||
import { formatCronFriendly, formatDate, formatDuration } from './utils.js';
|
||||
|
||||
@customElement('cloudly-view-tasks')
|
||||
export class CloudlyViewTasks extends DeesElement {
|
||||
@state()
|
||||
private data: appstate.IDataState = {} as any;
|
||||
|
||||
@state()
|
||||
private selectedExecution: plugins.interfaces.data.ITaskExecution | null = null;
|
||||
|
||||
@state()
|
||||
private loading = false;
|
||||
|
||||
@state()
|
||||
private filterStatus: string = 'all';
|
||||
|
||||
@state()
|
||||
private searchQuery: string = '';
|
||||
|
||||
@state()
|
||||
private categoryFilter: string = 'all';
|
||||
|
||||
@state()
|
||||
private autoRefresh: boolean = true;
|
||||
|
||||
private _refreshHandle: any = null;
|
||||
@state()
|
||||
private canceling: Record<string, boolean> = {};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const subscription = appstate.dataState
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((dataArg) => {
|
||||
this.data = dataArg;
|
||||
});
|
||||
this.rxSubscriptions.push(subscription);
|
||||
|
||||
// Load initial data (non-blocking)
|
||||
this.loadInitialData();
|
||||
|
||||
// Start periodic refresh (lightweight; executions only by default)
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
private async loadInitialData() {
|
||||
try {
|
||||
await appstate.dataState.dispatchAction(appstate.taskActions.getTasks, {});
|
||||
await appstate.dataState.dispatchAction(appstate.taskActions.getTaskExecutions, {});
|
||||
} catch (error) {
|
||||
console.error('Failed to load initial task data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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-list { display: flex; flex-direction: column; gap: 16px; margin-bottom: 32px; }
|
||||
|
||||
.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; }
|
||||
|
||||
/* Shared badge styles used within the table content */
|
||||
.status-badge { padding: 2px 8px; border-radius: 4px; font-size: 0.85em; font-weight: 500; }
|
||||
.status-running { background: #2196f3; color: white; }
|
||||
.status-completed { background: #4caf50; color: white; }
|
||||
.status-failed { background: #f44336; color: white; }
|
||||
.status-cancelled { background: #ff9800; color: white; }
|
||||
|
||||
.execution-logs { background: #0a0a0a; border: 1px solid #333; border-radius: 6px; padding: 16px; margin-top: 16px; max-height: 400px; overflow-y: auto; }
|
||||
.log-entry { font-family: monospace; font-size: 0.9em; margin-bottom: 8px; padding: 4px 8px; border-radius: 4px; }
|
||||
.log-info { color: #2196f3; }
|
||||
.log-warning { color: #ff9800; background: rgba(255, 152, 0, 0.1); }
|
||||
.log-error { color: #f44336; background: rgba(244, 67, 54, 0.1); }
|
||||
.log-success { color: #4caf50; background: rgba(76, 175, 80, 0.1); }
|
||||
`,
|
||||
];
|
||||
|
||||
private async triggerTask(taskName: string) {
|
||||
try {
|
||||
const modal = await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `Run Task: ${taskName}`,
|
||||
content: html`<div><p>Do you want to trigger this task now?</p></div>`,
|
||||
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();
|
||||
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: any) => e.data.taskName === taskName && e.data.status === 'running')
|
||||
.sort((a: any, b: any) => (b.data.startedAt || 0) - (a.data.startedAt || 0));
|
||||
const running = executions[0];
|
||||
if (!running) return;
|
||||
|
||||
this.canceling = { ...this.canceling, [running.id]: true };
|
||||
try {
|
||||
await appstate.dataState.dispatchAction(appstate.taskActions.cancelTask, { executionId: running.id });
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: `Cancelled ${taskName}`, type: 'success' });
|
||||
} finally {
|
||||
this.canceling = { ...this.canceling, [running.id]: false };
|
||||
await this.loadExecutionsWithFilter();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to cancel task:', err);
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: `Cancel failed: ${err.message}`, type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
private async loadExecutionsWithFilter() {
|
||||
try {
|
||||
const filter: any = {};
|
||||
if (this.filterStatus !== 'all') {
|
||||
filter.status = this.filterStatus;
|
||||
}
|
||||
await appstate.dataState.dispatchAction(appstate.taskActions.getTaskExecutions, { filter });
|
||||
} catch (error) {
|
||||
console.error('Failed to load executions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async openExecutionDetails(execution: plugins.interfaces.data.ITaskExecution) {
|
||||
this.selectedExecution = execution;
|
||||
requestAnimationFrame(() => {
|
||||
this.shadowRoot?.querySelector('cloudly-sectionheading + cloudly-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`
|
||||
<div class="execution-logs">
|
||||
${(execution.data.logs || []).map((log: any) => html`
|
||||
<div class="log-entry log-${log.severity}"><span>${formatDate(log.timestamp)}</span> - ${log.message}</div>
|
||||
`)}
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Copy All',
|
||||
action: async (modalArg: any) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText((execution.data.logs || [])
|
||||
.map((l: any) => `${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() }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
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`
|
||||
<cloudly-sectionheading>Tasks</cloudly-sectionheading>
|
||||
|
||||
<dees-panel .title=${'Task Library'} .subtitle=${'Run maintenance, monitoring and system tasks'} .variant=${'outline'}>
|
||||
<div class="toolbar">
|
||||
<div class="chipbar">
|
||||
<div class="chip ${this.categoryFilter === 'all' ? 'active' : ''}"
|
||||
@click=${() => { this.categoryFilter = 'all'; }}>
|
||||
All
|
||||
</div>
|
||||
${categories.map(cat => html`
|
||||
<div class="chip ${this.categoryFilter === cat ? 'active' : ''}"
|
||||
@click=${() => { this.categoryFilter = cat; }}>
|
||||
${cat}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<input class="search-input" placeholder="Search tasks" .value=${this.searchQuery}
|
||||
@input=${(e: any) => { this.searchQuery = e.target.value; }} />
|
||||
<button class="secondary-button" @click=${async () => { await this.loadExecutionsWithFilter(); }}>Refresh</button>
|
||||
<button class="secondary-button" @click=${() => { this.autoRefresh = !this.autoRefresh; this.autoRefresh ? this.startAutoRefresh() : this.stopAutoRefresh(); }}>
|
||||
${this.autoRefresh ? 'Auto-Refresh: On' : 'Auto-Refresh: Off'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="task-list">
|
||||
${filteredTasks.map(task => html`
|
||||
<cloudly-task-panel
|
||||
.task=${task}
|
||||
.executions=${this.data.taskExecutions || []}
|
||||
.canceling=${this.canceling}
|
||||
.onRun=${(name: string) => this.triggerTask(name)}
|
||||
.onCancel=${(name: string) => this.cancelTaskFor(name)}
|
||||
.onOpenDetails=${(exec: any) => this.openExecutionDetails(exec)}
|
||||
.onOpenLogs=${(exec: any) => this.openLogsModal(exec)}
|
||||
></cloudly-task-panel>
|
||||
`)}
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<cloudly-sectionheading>Execution History</cloudly-sectionheading>
|
||||
|
||||
<dees-panel .title=${'Recent Executions'} .subtitle=${'History of task runs and their outcomes'} .variant=${'outline'}>
|
||||
<dees-table
|
||||
.heading1=${'Task Executions'}
|
||||
.heading2=${'History of task runs and their outcomes'}
|
||||
.data=${this.data.taskExecutions || []}
|
||||
.displayFunction=${(itemArg: plugins.interfaces.data.ITaskExecution) => {
|
||||
return {
|
||||
Task: itemArg.data.taskName,
|
||||
Status: html`<span class="status-badge status-${itemArg.data.status}">${itemArg.data.status}</span>`,
|
||||
'Started At': formatDate(itemArg.data.startedAt),
|
||||
Duration: itemArg.data.duration ? formatDuration(itemArg.data.duration) : '-',
|
||||
'Triggered By': itemArg.data.triggeredBy,
|
||||
Logs: itemArg.data.logs?.length || 0,
|
||||
} as any;
|
||||
}}
|
||||
.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;
|
||||
}}
|
||||
></dees-table>
|
||||
</dees-panel>
|
||||
|
||||
${this.selectedExecution ? html`
|
||||
<cloudly-sectionheading>Execution Details</cloudly-sectionheading>
|
||||
<cloudly-execution-details .execution=${this.selectedExecution}></cloudly-execution-details>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'cloudly-view-tasks': CloudlyViewTasks;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user