import { LitElement, html, css, unsafeCSS } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@shoelace-style/shoelace/dist/components/alert/alert.js';
import '@shoelace-style/shoelace/dist/components/badge/badge.js';
import '@shoelace-style/shoelace/dist/components/spinner/spinner.js';
import '@shoelace-style/shoelace/dist/components/divider/divider.js';
import { router } from '../../router';
import { Router } from '@vaadin/router';
import { unifiedWebSocketManager } from '../../services/unified-websocket-manager';
import {
getFlows,
getFlowPresets,
cloneFlowPreset,
deleteFlow,
getFlowExecutions,
triggerFlowExecution,
} from '../../api';
import { parseUTCDate, formatLocalDateTime } from '../../utils/date';
import consoleStyles from '../../styles/console-styles.css?inline';
interface Flow {
id: string;
name: string;
description?: string;
icon?: string;
account_id?: string;
}
interface FlowExecution {
id: string;
flow_id: string;
status: string;
start_time: string;
end_time?: string;
}
@customElement('flows-view')
export class FlowsView extends LitElement {
static styles = [
unsafeCSS(consoleStyles),
css`
:host {
display: block;
}
.flows-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 28px;
margin-bottom: 32px;
}
@media (max-width: 1400px) {
.flows-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 900px) {
.flows-grid {
grid-template-columns: 1fr;
}
}
.presets-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 28px;
}
@media (max-width: 1400px) {
.presets-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 900px) {
.presets-grid {
grid-template-columns: 1fr;
}
}
.flows-grid > sl-card,
.presets-grid > sl-card {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.flow-card {
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
}
.flow-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.flow-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.flow-title {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--sl-font-size-large);
font-weight: 600;
}
.flow-description {
color: var(--sl-color-neutral-600);
margin-bottom: 12px;
font-size: var(--sl-font-size-small);
}
.flow-stats {
display: flex;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--sl-color-neutral-200);
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--sl-font-size-small);
color: var(--sl-color-neutral-600);
}
.flow-footer {
display: flex;
gap: 8px;
justify-content: space-between;
align-items: center;
}
.flow-footer-actions {
display: flex;
gap: 8px;
}
.active-executions {
margin-bottom: 32px;
}
.executions-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.execution-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--sl-color-neutral-50);
border-radius: 4px;
transition: background 0.2s;
}
.execution-item:hover {
background: var(--sl-color-neutral-100);
}
.execution-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 24px 0 16px 0;
}
.presets-collapsed {
text-align: center;
padding: 24px 16px;
color: var(--sl-color-neutral-500);
font-size: var(--sl-font-size-medium);
background: var(--sl-color-neutral-50);
border-radius: 4px;
}
`,
];
@state()
private flows: Flow[] = [];
@state()
private presets: Flow[] = [];
@state()
private executions: FlowExecution[] = [];
@state()
private isAlertVisible = false;
@state()
private isLoading = true;
@state()
private triggeringFlowId: string | null = null;
@state()
private deletingFlowId: string | null = null;
@state()
private showPresets = true;
private unsubscribe?: () => void;
private hasInitializedPresetVisibility = false;
async connectedCallback() {
super.connectedCallback();
await this.loadData();
this.isAlertVisible = !localStorage.getItem('flows-alert-dismissed');
// Connect to WebSocket for real-time updates instead of polling
this.connectWebSocket();
}
disconnectedCallback() {
super.disconnectedCallback();
// Clean up WebSocket connection
this.unsubscribe?.();
}
async loadData() {
this.isLoading = true;
try {
const [flows, presets, executions] = await Promise.all([
getFlows(),
getFlowPresets(),
getFlowExecutions(),
]);
this.flows = flows;
this.presets = presets;
this.executions = executions;
if (this.flows.length === 0) {
this.showPresets = true;
} else if (!this.hasInitializedPresetVisibility) {
this.showPresets = false;
}
this.hasInitializedPresetVisibility = true;
} finally {
this.isLoading = false;
}
}
async refreshExecutions() {
this.executions = await getFlowExecutions();
}
private connectWebSocket() {
this.unsubscribe = unifiedWebSocketManager.subscribe(
'flow_executions',
(message: any) => this.handleWebSocketMessage(message)
);
// Track connection state
unifiedWebSocketManager.onStateChange((state) => {
console.log(`Flows view WebSocket state: ${state}`);
});
}
private handleWebSocketMessage(message: any) {
console.log('Flow updates message:', message);
// Handle status updates
if (message.type === 'status_update' && message.execution_id) {
const executionIndex = this.executions.findIndex(
(exec) => exec.id === message.execution_id
);
if (executionIndex >= 0) {
// Update existing execution
const updated = [...this.executions];
updated[executionIndex] = {
...updated[executionIndex],
status: message.payload.status,
...(message.payload.end_time && {
end_time: message.payload.end_time,
}),
};
this.executions = updated;
} else {
// New execution started, reload the list
this.refreshExecutions();
}
}
// Handle new executions
if (message.type === 'execution_started' && message.payload) {
this.refreshExecutions();
}
}
render() {
if (this.isLoading) {
return html`
<view-header headerText="Flows" width="extra-wide"></view-header>
<div style="display: flex; justify-content: center; padding: 48px;">
<sl-spinner style="font-size: 3rem;"></sl-spinner>
</div>
`;
}
const activeExecutions = this.executions.filter(
(e) => e.status === 'RUNNING' || e.status === 'STARTING'
);
return html`
<view-header headerText="Flows" width="extra-wide">
<div slot="main-column">
<sl-button
variant="primary"
@click=${() => Router.go('/console/flows/new')}
>
<sl-icon slot="prefix" name="plus-lg"></sl-icon>
Create New Flow
</sl-button>
</div>
</view-header>
<div class="column-layout extra-wide">
<div class="main-column">
<div class="proxy-notice">
<div class="proxy-notice-text">
Automate your workflows by creating intelligent agents that
respond to issue tracker events or external webhooks. Get started
by exploring the presets below, or create a new flow from scratch.
</div>
</div>
${activeExecutions.length > 0
? html`
<div class="active-executions">
<div class="section-header">
<h2>
<sl-icon name="lightning-fill"></sl-icon>
Active Executions
</h2>
<sl-button
size="small"
href=${router.urlForPath('/console/flows/executions')}
>
View All
</sl-button>
</div>
<div class="executions-list">
${activeExecutions
.slice(0, 5)
.map((exec) => this.renderExecutionItem(exec))}
</div>
</div>
`
: ''}
${this.flows.length > 0
? html`
<div class="flows-grid">
${this.flows.map((flow) => this.renderFlowCard(flow))}
</div>
`
: html`
<div class="empty-state">
<sl-icon
name="inbox"
style="font-size: 3rem; opacity: 0.3;"
></sl-icon>
<p>
No flows yet. Create your first flow or clone a preset
below.
</p>
</div>
`}
<sl-divider></sl-divider>
<div class="section-header">
<h2>Presets</h2>
${this.flows.length > 0
? html`
<sl-button size="small" @click=${this.togglePresets}>
<sl-icon
slot="prefix"
name=${this.showPresets ? 'chevron-up' : 'chevron-down'}
></sl-icon>
${this.showPresets ? 'Hide presets' : 'Show presets'}
</sl-button>
`
: ''}
</div>
${this.showPresets
? html`
<div class="presets-grid">
${this.presets.map((preset) => this.renderPresetCard(preset))}
</div>
`
: html`<div class="presets-collapsed">
Presets are hidden. Use "Show presets" to explore starter
workflows.
</div>`}
</div>
</div>
`;
}
renderFlowCard(flow: Flow) {
const flowExecutions = this.executions.filter((e) => e.flow_id === flow.id);
const activeCount = flowExecutions.filter(
(e) => e.status === 'RUNNING' || e.status === 'STARTING'
).length;
const totalCount = flowExecutions.length;
return html`
<sl-card
class="flow-card"
@click=${() => Router.go(`/console/flows/${flow.id}`)}
>
<div slot="header" class="flow-header">
<div class="flow-title">${flow.name}</div>
${activeCount > 0
? html`<sl-badge variant="primary" pulse
>${activeCount} active</sl-badge
>`
: ''}
</div>
${flow.description
? html`<div class="flow-description">${flow.description}</div>`
: ''}
<div class="flow-stats">
<div class="stat-item">
<sl-icon name="play-circle"></sl-icon>
<span>${totalCount} executions</span>
</div>
</div>
<div slot="footer" class="flow-footer">
<div class="flow-footer-actions">
<sl-button
size="small"
href=${`/console/flows/${flow.id}?edit=true`}
@click=${(e: Event) => e.stopPropagation()}
>
<sl-icon slot="prefix" name="pencil"></sl-icon>
Edit
</sl-button>
<sl-button
size="small"
variant="danger"
@click=${(e: Event) => {
e.stopPropagation();
this.deleteFlowHandler(flow.id, flow.name);
}}
?loading=${this.deletingFlowId === flow.id}
>
<sl-icon slot="prefix" name="trash"></sl-icon>
Delete
</sl-button>
</div>
<sl-button
size="small"
variant="primary"
@click=${(e: Event) => {
e.stopPropagation();
this.triggerTestRun(flow.id);
}}
?loading=${this.triggeringFlowId === flow.id}
>
<sl-icon slot="prefix" name="play-fill"></sl-icon>
Test Run
</sl-button>
</div>
</sl-card>
`;
}
renderPresetCard(preset: Flow) {
return html`
<sl-card class="flow-card">
<div slot="header" class="flow-header">
<div class="flow-title">
<sl-icon name=${preset.icon || 'gear'}></sl-icon>
${preset.name}
</div>
<div class="flow-footer-actions">
<sl-button size="small" @click=${() => this.clonePreset(preset.id)}>
Use Template
</sl-button>
${preset.account_id
? html`
<sl-button
size="small"
variant="danger"
@click=${() => this.removePreset(preset.id)}
>
Remove
</sl-button>
`
: ''}
</div>
</div>
${preset.description
? html`<div class="flow-description">${preset.description}</div>`
: ''}
</sl-card>
`;
}
renderExecutionItem(exec: FlowExecution) {
const flow = this.flows.find((f) => f.id === exec.flow_id);
return html`
<div
class="execution-item"
@click=${() => Router.go(`/console/flows/executions/${exec.id}`)}
style="cursor: pointer;"
>
<div class="execution-info">
<sl-badge variant=${this.getStatusVariant(exec.status)}>
${exec.status}
</sl-badge>
<div>
<strong>${flow?.name || 'Unknown Flow'}</strong>
<div
style="font-size: var(--sl-font-size-small); color: var(--sl-color-neutral-600);"
>
Started ${formatLocalDateTime(exec.start_time)}
</div>
</div>
</div>
<sl-button size="small">
<sl-icon name="arrow-right"></sl-icon>
</sl-button>
</div>
`;
}
getStatusVariant(status: string) {
switch (status) {
case 'SUCCEEDED':
return 'success';
case 'FAILED':
return 'danger';
case 'RUNNING':
case 'STARTING':
return 'primary';
default:
return 'neutral';
}
}
async triggerTestRun(flowId: string) {
this.triggeringFlowId = flowId;
try {
const execution = await triggerFlowExecution(flowId);
Router.go(`/console/flows/executions/${execution.id}`);
} catch (error) {
console.error('Failed to trigger test run:', error);
// TODO: Show error notification
} finally {
this.triggeringFlowId = null;
}
}
handleAlertDismiss() {
this.isAlertVisible = false;
localStorage.setItem('flows-alert-dismissed', 'true');
}
async clonePreset(presetId: string) {
Router.go(`/console/flows/new?preset_id=${presetId}`);
}
async removePreset(presetId: string) {
await deleteFlow(presetId);
this.presets = await getFlowPresets();
}
async deleteFlowHandler(flowId: string, flowName: string) {
const confirmed = confirm(
`Are you sure you want to delete the flow "${flowName}"? This action cannot be undone.`
);
if (!confirmed) return;
this.deletingFlowId = flowId;
try {
await deleteFlow(flowId);
// Reload flows list
this.flows = await getFlows();
} catch (error) {
console.error('Failed to delete flow:', error);
alert('Failed to delete flow. Please try again.');
} finally {
this.deletingFlowId = null;
}
}
private togglePresets() {
this.showPresets = !this.showPresets;
}
}