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:
@@ -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 (
|
||||
|
@@ -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',
|
||||
|
560
ts_web/elements/cloudly-view-tasks.ts
Normal file
560
ts_web/elements/cloudly-view-tasks.ts
Normal 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)}
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user