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:
2025-09-14 17:28:21 +00:00
parent 5ef8621db7
commit bb313fd9dc
38 changed files with 2363 additions and 5088 deletions

View 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;
}
}

View File

@@ -0,0 +1,93 @@
import { DeesElement, customElement, html, css, cssManager, property } from '@design.estate/dees-element';
import { formatDate, formatDuration } from '../utils.js';
@customElement('cloudly-execution-details')
export class CloudlyExecutionDetails extends DeesElement {
@property({ type: Object }) execution: any;
public static styles = [
cssManager.defaultStyles,
css`
.execution-details h3, .execution-details h4 { margin: 8px 0; }
.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; }
.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); }
`,
];
public render() {
const execution = this.execution;
if (!execution) return html``;
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">${formatDate(execution.data.startedAt)}</span>
</div>
${execution.data.completedAt ? html`
<div class="metric">
<span class="metric-label">Completed</span>
<span class="metric-value">${formatDate(execution.data.completedAt)}</span>
</div>
` : ''}
${execution.data.duration ? html`
<div class="metric">
<span class="metric-label">Duration</span>
<span class="metric-value">${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: any) => html`
<div class="log-entry log-${log.severity}">
<span>${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>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'cloudly-execution-details': CloudlyExecutionDetails;
}
}

View File

@@ -0,0 +1,206 @@
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;
}
}

View File

@@ -0,0 +1,68 @@
export function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleString();
}
export function 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`;
}
export function 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`;
}
export function 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';
}
}
export function 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
}
}
export function 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;
}