feat: Implement Cloudly Task Manager with predefined tasks and execution tracking

- Added CloudlyTaskManager class for managing tasks, including registration, execution, scheduling, and cancellation.
- Created predefined tasks: DNS Sync, Certificate Renewal, Cleanup, Health Check, Resource Report, Database Maintenance, Security Scan, and Docker Cleanup.
- Introduced ITaskExecution interface for tracking task execution details and outcomes.
- Developed API request interfaces for task management operations (getTasks, getTaskExecutions, triggerTask, cancelTask).
- Implemented CloudlyViewTasks web component for displaying tasks and their execution history, including filtering and detailed views.
This commit is contained in:
2025-09-10 16:37:03 +00:00
parent fd1da01a3f
commit 5b37bb5b11
18 changed files with 1770 additions and 47 deletions

View File

@@ -663,6 +663,89 @@ export const verifyExternalRegistryAction = dataState.createAction(
}
);
// Task Actions
export const taskActions = {
getTasks: dataState.createAction(
async (statePartArg, payloadArg: {}) => {
const currentState = statePartArg.getState();
const trGetTasks =
new domtools.plugins.typedrequest.TypedRequest<plugins.interfaces.requests.task.IRequest_Any_Cloudly_GetTasks>(
'/typedrequest',
'getTasks'
);
const response = await trGetTasks.fire({
identity: loginStatePart.getState().identity,
});
return response as any;
}
),
getTaskExecutions: dataState.createAction(
async (statePartArg, payloadArg: { filter?: any }) => {
const currentState = statePartArg.getState();
const trGetTaskExecutions =
new domtools.plugins.typedrequest.TypedRequest<plugins.interfaces.requests.task.IRequest_Any_Cloudly_GetTaskExecutions>(
'/typedrequest',
'getTaskExecutions'
);
const response = await trGetTaskExecutions.fire({
identity: loginStatePart.getState().identity,
filter: payloadArg.filter,
});
return response as any;
}
),
getTaskExecutionById: dataState.createAction(
async (statePartArg, payloadArg: { executionId: string }) => {
const currentState = statePartArg.getState();
const trGetTaskExecutionById =
new domtools.plugins.typedrequest.TypedRequest<plugins.interfaces.requests.task.IRequest_Any_Cloudly_GetTaskExecutionById>(
'/typedrequest',
'getTaskExecutionById'
);
const response = await trGetTaskExecutionById.fire({
identity: loginStatePart.getState().identity,
executionId: payloadArg.executionId,
});
return response as any;
}
),
triggerTask: dataState.createAction(
async (statePartArg, payloadArg: { taskName: string; userId?: string }) => {
const currentState = statePartArg.getState();
const trTriggerTask =
new domtools.plugins.typedrequest.TypedRequest<plugins.interfaces.requests.task.IRequest_Any_Cloudly_TriggerTask>(
'/typedrequest',
'triggerTask'
);
const response = await trTriggerTask.fire({
identity: loginStatePart.getState().identity,
taskName: payloadArg.taskName,
userId: payloadArg.userId,
});
return currentState;
}
),
cancelTask: dataState.createAction(
async (statePartArg, payloadArg: { executionId: string }) => {
const currentState = statePartArg.getState();
const trCancelTask =
new domtools.plugins.typedrequest.TypedRequest<plugins.interfaces.requests.task.IRequest_Any_Cloudly_CancelTask>(
'/typedrequest',
'cancelTask'
);
const response = await trCancelTask.fire({
identity: loginStatePart.getState().identity,
executionId: payloadArg.executionId,
});
return currentState;
}
),
};
// cluster
export const addClusterAction = dataState.createAction(
async (

View File

@@ -27,6 +27,7 @@ import { CloudlyViewSecretGroups } from './cloudly-view-secretgroups.js';
import { CloudlyViewServices } from './cloudly-view-services.js';
import { CloudlyViewExternalRegistries } from './cloudly-view-externalregistries.js';
import { CloudlyViewSettings } from './cloudly-view-settings.js';
import { CloudlyViewTasks } from './cloudly-view-tasks.js';
declare global {
interface HTMLElementTagNameMap {
@@ -126,6 +127,11 @@ export class CloudlyDashboard extends DeesElement {
iconName: 'lucide:Rocket',
element: CloudlyViewDeployments,
},
{
name: 'Tasks',
iconName: 'lucide:ListChecks',
element: CloudlyViewTasks,
},
{
name: 'Domains',
iconName: 'lucide:Globe2',

View File

@@ -0,0 +1,560 @@
import * as shared from '../elements/shared/index.js';
import * as plugins from '../plugins.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
property,
} from '@design.estate/dees-element';
import * as appstate from '../appstate.js';
@customElement('cloudly-view-tasks')
export class CloudlyViewTasks extends DeesElement {
@state()
private data: appstate.IDataState = {};
@state()
private tasks: any[] = [];
@state()
private executions: plugins.interfaces.data.ITaskExecution[] = [];
@state()
private selectedExecution: plugins.interfaces.data.ITaskExecution | null = null;
@state()
private loading = false;
@state()
private filterStatus: string = 'all';
constructor() {
super();
const subscription = appstate.dataState
.select((stateArg) => stateArg)
.subscribe((dataArg) => {
this.data = dataArg;
});
this.rxSubscriptions.push(subscription);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
css`
.task-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.task-card {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
}
.task-card:hover {
background: #222;
border-color: #555;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.task-name {
font-size: 1.1em;
font-weight: 600;
color: #fff;
}
.task-description {
color: #999;
font-size: 0.9em;
margin-bottom: 12px;
}
.task-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.category-badge, .status-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 500;
}
.category-maintenance {
background: #ff9800;
color: white;
}
.category-deployment {
background: #2196f3;
color: white;
}
.category-backup {
background: #4caf50;
color: white;
}
.category-monitoring {
background: #9c27b0;
color: white;
}
.category-cleanup {
background: #795548;
color: white;
}
.category-system {
background: #607d8b;
color: white;
}
.category-security {
background: #f44336;
color: white;
}
.status-running {
background: #2196f3;
color: white;
}
.status-completed {
background: #4caf50;
color: white;
}
.status-failed {
background: #f44336;
color: white;
}
.status-cancelled {
background: #ff9800;
color: white;
}
.trigger-button {
padding: 6px 12px;
background: #2196f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background 0.2s;
}
.trigger-button:hover {
background: #1976d2;
}
.trigger-button:disabled {
background: #666;
cursor: not-allowed;
}
.schedule-info {
color: #666;
font-size: 0.85em;
margin-top: 8px;
}
.last-run {
color: #888;
font-size: 0.85em;
margin-top: 4px;
}
.execution-logs {
background: #0a0a0a;
border: 1px solid #333;
border-radius: 4px;
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);
}
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.filter-button {
padding: 6px 12px;
background: #333;
color: #ccc;
border: 1px solid #555;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.filter-button.active {
background: #2196f3;
color: white;
border-color: #2196f3;
}
.filter-button:hover:not(.active) {
background: #444;
}
.metrics {
display: flex;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #333;
}
.metric {
display: flex;
flex-direction: column;
}
.metric-label {
color: #666;
font-size: 0.85em;
}
.metric-value {
color: #fff;
font-size: 1.1em;
font-weight: 600;
}
`,
];
async connectedCallback() {
super.connectedCallback();
await this.loadTasks();
await this.loadExecutions();
}
private async loadTasks() {
this.loading = true;
try {
const response: any = await appstate.dataState.dispatchAction(
appstate.taskActions.getTasks, {}
);
this.tasks = response.tasks || [];
} catch (error) {
console.error('Failed to load tasks:', error);
} finally {
this.loading = false;
}
}
private async loadExecutions() {
try {
const filter: any = {};
if (this.filterStatus !== 'all') {
filter.status = this.filterStatus;
}
const response: any = await appstate.dataState.dispatchAction(
appstate.taskActions.getTaskExecutions, { filter }
);
this.executions = response.executions || [];
} catch (error) {
console.error('Failed to load executions:', error);
}
}
private async triggerTask(taskName: string) {
try {
await appstate.dataState.dispatchAction(
appstate.taskActions.triggerTask, { taskName }
);
// Reload tasks and executions to show the new execution
await this.loadTasks();
await this.loadExecutions();
} catch (error) {
console.error('Failed to trigger task:', error);
}
}
private formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleString();
}
private formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`;
return `${(ms / 3600000).toFixed(1)}h`;
}
private getCategoryIcon(category: string): string {
switch (category) {
case 'maintenance':
return 'lucide:Wrench';
case 'deployment':
return 'lucide:Rocket';
case 'backup':
return 'lucide:Archive';
case 'monitoring':
return 'lucide:Activity';
case 'cleanup':
return 'lucide:Trash2';
case 'system':
return 'lucide:Settings';
case 'security':
return 'lucide:Shield';
default:
return 'lucide:Play';
}
}
private renderTaskCard(task: any) {
const lastExecution = this.executions
.filter(e => e.data.taskName === task.name)
.sort((a, b) => (b.data.startedAt || 0) - (a.data.startedAt || 0))[0];
const isRunning = lastExecution?.data.status === 'running';
return html`
<div class="task-card">
<div class="task-header">
<div class="task-name">
<dees-icon .iconName=${this.getCategoryIcon(task.category)}></dees-icon>
${task.name}
</div>
<button
class="trigger-button"
?disabled=${isRunning || !task.enabled}
@click=${() => this.triggerTask(task.name)}
>
${isRunning ? 'Running...' : 'Run'}
</button>
</div>
<div class="task-description">${task.description}</div>
<div class="task-meta">
<span class="category-badge category-${task.category}">${task.category}</span>
${!task.enabled ? html`<span class="status-badge status-cancelled">Disabled</span>` : ''}
</div>
${task.schedule ? html`
<div class="schedule-info">
<dees-icon .iconName=${'lucide:Clock'}></dees-icon>
Schedule: ${task.schedule}
</div>
` : ''}
${task.lastRun ? html`
<div class="last-run">
Last run: ${this.formatDate(task.lastRun)}
</div>
` : ''}
${lastExecution ? html`
<div class="metrics">
<div class="metric">
<span class="metric-label">Status</span>
<span class="metric-value">
<span class="status-badge status-${lastExecution.data.status}">
${lastExecution.data.status}
</span>
</span>
</div>
${lastExecution.data.duration ? html`
<div class="metric">
<span class="metric-label">Duration</span>
<span class="metric-value">${this.formatDuration(lastExecution.data.duration)}</span>
</div>
` : ''}
</div>
` : ''}
</div>
`;
}
private renderExecutionDetails(execution: plugins.interfaces.data.ITaskExecution) {
return html`
<div class="execution-details">
<h3>Execution Details: ${execution.data.taskName}</h3>
<div class="metrics">
<div class="metric">
<span class="metric-label">Started</span>
<span class="metric-value">${this.formatDate(execution.data.startedAt)}</span>
</div>
${execution.data.completedAt ? html`
<div class="metric">
<span class="metric-label">Completed</span>
<span class="metric-value">${this.formatDate(execution.data.completedAt)}</span>
</div>
` : ''}
${execution.data.duration ? html`
<div class="metric">
<span class="metric-label">Duration</span>
<span class="metric-value">${this.formatDuration(execution.data.duration)}</span>
</div>
` : ''}
<div class="metric">
<span class="metric-label">Triggered By</span>
<span class="metric-value">${execution.data.triggeredBy}</span>
</div>
</div>
${execution.data.logs && execution.data.logs.length > 0 ? html`
<h4>Logs</h4>
<div class="execution-logs">
${execution.data.logs.map(log => html`
<div class="log-entry log-${log.severity}">
<span>${this.formatDate(log.timestamp)}</span> -
${log.message}
</div>
`)}
</div>
` : ''}
${execution.data.metrics ? html`
<h4>Metrics</h4>
<div class="metrics">
${Object.entries(execution.data.metrics).map(([key, value]) => html`
<div class="metric">
<span class="metric-label">${key}</span>
<span class="metric-value">${typeof value === 'object' ? JSON.stringify(value) : value}</span>
</div>
`)}
</div>
` : ''}
${execution.data.error ? html`
<h4>Error</h4>
<div class="execution-logs">
<div class="log-entry log-error">
${execution.data.error.message}
${execution.data.error.stack ? html`<pre>${execution.data.error.stack}</pre>` : ''}
</div>
</div>
` : ''}
</div>
`;
}
public render() {
return html`
<cloudly-sectionheading>Tasks</cloudly-sectionheading>
<div class="filter-bar">
<button
class="filter-button ${this.filterStatus === 'all' ? 'active' : ''}"
@click=${() => { this.filterStatus = 'all'; this.loadExecutions(); }}
>
All
</button>
<button
class="filter-button ${this.filterStatus === 'running' ? 'active' : ''}"
@click=${() => { this.filterStatus = 'running'; this.loadExecutions(); }}
>
Running
</button>
<button
class="filter-button ${this.filterStatus === 'completed' ? 'active' : ''}"
@click=${() => { this.filterStatus = 'completed'; this.loadExecutions(); }}
>
Completed
</button>
<button
class="filter-button ${this.filterStatus === 'failed' ? 'active' : ''}"
@click=${() => { this.filterStatus = 'failed'; this.loadExecutions(); }}
>
Failed
</button>
</div>
<div class="task-grid">
${this.tasks.map(task => this.renderTaskCard(task))}
</div>
<cloudly-sectionheading>Execution History</cloudly-sectionheading>
<dees-table
.heading1=${'Task Executions'}
.heading2=${'History of task runs and their outcomes'}
.data=${this.executions}
.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': 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;
},
},
]}
></dees-table>
${this.selectedExecution ? html`
<cloudly-sectionheading>Execution Details</cloudly-sectionheading>
${this.renderExecutionDetails(this.selectedExecution)}
` : ''}
`;
}
}