- 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.
207 lines
8.4 KiB
TypeScript
207 lines
8.4 KiB
TypeScript
import { DeesElement, customElement, html, css, cssManager, property } from '@design.estate/dees-element';
|
|
import { formatCronFriendly, formatDuration, formatRelativeTime, getCategoryHue, getCategoryIcon } from '../utils.js';
|
|
|
|
@customElement('cloudly-task-panel')
|
|
export class CloudlyTaskPanel extends DeesElement {
|
|
@property({ type: Object }) task: any;
|
|
@property({ type: Array }) executions: any[] = [];
|
|
@property({ type: Object }) canceling: Record<string, boolean> = {};
|
|
|
|
// Callbacks provided by parent view
|
|
@property({ attribute: false }) onRun?: (taskName: string) => void;
|
|
@property({ attribute: false }) onCancel?: (taskName: string) => void;
|
|
@property({ attribute: false }) onOpenDetails?: (execution: any) => void;
|
|
@property({ attribute: false }) onOpenLogs?: (execution: any) => void;
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
css`
|
|
.task-panel {
|
|
background: #131313;
|
|
border: 1px solid #2a2a2a;
|
|
border-radius: 10px;
|
|
padding: 16px;
|
|
transition: border-color 0.2s, background 0.2s;
|
|
}
|
|
.task-panel:hover { border-color: #3a3a3a; }
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
.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; font-size: 28px; }
|
|
.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: #b5b5b5;
|
|
font-size: 0.95em;
|
|
margin-bottom: 12px;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.metrics-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 10px;
|
|
margin-top: 8px;
|
|
width: 100%;
|
|
max-width: 760px;
|
|
}
|
|
.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; }
|
|
|
|
.panel-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; }
|
|
|
|
.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; }
|
|
`,
|
|
];
|
|
|
|
private computeData() {
|
|
const task = this.task || {};
|
|
const executions = this.executions || [];
|
|
const lastExecution = executions
|
|
.filter((e: any) => e.data.taskName === task.name)
|
|
.sort((a: any, b: any) => (b.data.startedAt || 0) - (a.data.startedAt || 0))[0];
|
|
const isRunning = lastExecution?.data.status === 'running';
|
|
const executionsForTask = executions.filter((e: any) => e.data.taskName === task.name);
|
|
const now = Date.now();
|
|
const last24hCount = executionsForTask.filter((e: any) => (e.data.startedAt || 0) > now - 86_400_000).length;
|
|
const completed = executionsForTask.filter((e: any) => 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: number, e: any) => 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 subtitle = [
|
|
task.category,
|
|
task.schedule ? `⏱ ${formatCronFriendly(task.schedule)}` : null,
|
|
isRunning
|
|
? (lastExecution?.data.startedAt ? `Started ${formatRelativeTime(lastExecution.data.startedAt)}` : 'Running')
|
|
: (task.lastRun ? `Last ${formatRelativeTime(task.lastRun)}` : 'Never run')
|
|
].filter(Boolean).join(' • ');
|
|
return { lastExecution, isRunning, last24hCount, successRate, avgDuration, lastLog, subtitle };
|
|
}
|
|
|
|
public render() {
|
|
const task = this.task;
|
|
const { lastExecution, isRunning, last24hCount, successRate, avgDuration, lastLog, subtitle } = this.computeData();
|
|
|
|
return html`
|
|
<div class="task-panel">
|
|
<div class="panel-header">
|
|
<div class="header-left">
|
|
<dees-icon class="task-icon" .icon=${getCategoryIcon(task.category)}></dees-icon>
|
|
<div>
|
|
<div class="task-name" title=${task.name}>${task.name}</div>
|
|
<div class="task-subtitle" title=${task.schedule || ''}>${subtitle}</div>
|
|
</div>
|
|
</div>
|
|
<div class="header-right">
|
|
${lastExecution ? html`<span class="status-badge status-${lastExecution.data.status}">${lastExecution.data.status}</span>` : html`<span class="status-badge" style="background:#2e2e2e;color:#ddd;border:1px solid #3a3a3a;">idle</span>`}
|
|
${isRunning ? html`
|
|
<dees-spinner style="--size: 18px"></dees-spinner>
|
|
<dees-button
|
|
.text=${this.canceling[lastExecution!.id] ? 'Cancelling…' : 'Cancel'}
|
|
.type=${'secondary'}
|
|
.disabled=${!!this.canceling[lastExecution!.id]}
|
|
@click=${() => this.onCancel?.(task.name)}
|
|
></dees-button>
|
|
` : html`
|
|
<dees-button .text=${'Run'} .type=${'primary'} .disabled=${!task.enabled} @click=${() => this.onRun?.(task.name)}></dees-button>
|
|
`}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="task-description" title=${task.description || ''}>${task.description}</div>
|
|
|
|
${lastExecution ? html`
|
|
<div class="metrics-grid">
|
|
<div class="metric-item">
|
|
<div class="label">Last Status</div>
|
|
<div class="value">
|
|
<span class="status-badge status-${lastExecution.data.status}">${lastExecution.data.status}</span>
|
|
</div>
|
|
</div>
|
|
<div class="metric-item">
|
|
<div class="label">Avg Duration</div>
|
|
<div class="value">${avgDuration ? formatDuration(avgDuration) : '-'}</div>
|
|
</div>
|
|
<div class="metric-item">
|
|
<div class="label">24h Runs · Success</div>
|
|
<div class="value">${last24hCount} · ${successRate}%</div>
|
|
</div>
|
|
</div>
|
|
<div class="lastline">
|
|
${lastLog ? html`<span class="dot ${lastLog.severity}"></span> ${lastLog.message}` : 'No recent logs'}
|
|
</div>
|
|
<div class="panel-footer">
|
|
<button class="link-button" @click=${() => this.onOpenDetails?.(lastExecution)}>Details</button>
|
|
${lastExecution.data.logs?.length ? html`<button class="link-button" @click=${() => this.onOpenLogs?.(lastExecution)}>Logs</button>` : ''}
|
|
</div>
|
|
` : html`
|
|
<div class="metrics-grid">
|
|
<div class="metric-item">
|
|
<div class="label">Last Status</div>
|
|
<div class="value">—</div>
|
|
</div>
|
|
<div class="metric-item">
|
|
<div class="label">Avg Duration</div>
|
|
<div class="value">—</div>
|
|
</div>
|
|
<div class="metric-item">
|
|
<div class="label">24h Runs · Success</div>
|
|
<div class="value">0 · 0%</div>
|
|
</div>
|
|
</div>
|
|
`}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'cloudly-task-panel': CloudlyTaskPanel;
|
|
}
|
|
}
|
|
|