Skip to content

Commit 307075e

Browse files
committed
Human decision plugin support implemented.
1 parent 07d029a commit 307075e

5 files changed

Lines changed: 395 additions & 42 deletions

File tree

internal/server/server.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/jackc/pgx/v5/pgxpool"
99
"github.com/rom8726/floxy"
1010
"github.com/rom8726/floxy/api"
11+
human_decision "github.com/rom8726/floxy/plugins/api/human-decision"
1112

1213
"github.com/rom8726/floxy-ui/internal/config"
1314
)
@@ -36,7 +37,12 @@ func New(cfg *config.Config) (*Server, error) {
3637

3738
// Create a floxy server
3839
store := floxy.NewStore(pool)
39-
floxyServer := api.New(nil, store)
40+
txManager := floxy.NewTxManager(pool)
41+
engine := floxy.NewEngine(txManager, store)
42+
humanDecisionPlugin := human_decision.New(engine, store, func(*http.Request) (string, error) {
43+
return "admin", nil
44+
})
45+
floxyServer := api.New(engine, store, api.WithPlugins(humanDecisionPlugin))
4046

4147
return &Server{
4248
config: cfg,
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import React, { useState } from 'react';
2+
3+
interface DecisionModalProps {
4+
isOpen: boolean;
5+
onClose: () => void;
6+
onConfirm: (message: string) => void;
7+
onReject: (message: string) => void;
8+
instanceId: string;
9+
}
10+
11+
export const DecisionModal: React.FC<DecisionModalProps> = ({
12+
isOpen,
13+
onClose,
14+
onConfirm,
15+
onReject,
16+
instanceId
17+
}) => {
18+
const [message, setMessage] = useState('');
19+
const [isSubmitting, setIsSubmitting] = useState(false);
20+
21+
const handleSubmit = async (action: 'confirm' | 'reject') => {
22+
if (isSubmitting) return;
23+
24+
setIsSubmitting(true);
25+
try {
26+
const response = await fetch(`/api/instances/${instanceId}/make-decision/${action}`, {
27+
method: 'POST',
28+
headers: {
29+
'Content-Type': 'application/json',
30+
},
31+
body: JSON.stringify({ message }),
32+
});
33+
34+
// Check if response is successful (2xx status codes)
35+
if (response.status >= 200 && response.status < 300) {
36+
// Call the parent handlers to refresh the page
37+
if (action === 'confirm') {
38+
onConfirm(message);
39+
} else {
40+
onReject(message);
41+
}
42+
43+
setMessage('');
44+
onClose();
45+
} else {
46+
// Handle error responses (404, 409, 422, 500, etc.)
47+
let errorMessage = `Failed to ${action} decision`;
48+
49+
try {
50+
const errorData = await response.json();
51+
if (errorData.message) {
52+
errorMessage = errorData.message;
53+
}
54+
} catch (parseError) {
55+
// If we can't parse the error response, use the status text
56+
errorMessage = response.statusText || errorMessage;
57+
}
58+
59+
throw new Error(errorMessage);
60+
}
61+
} catch (error) {
62+
console.error(`Error ${action}ing decision:`, error);
63+
alert(`Error ${action === 'confirm' ? 'confirming' : 'rejecting'} decision: ${error instanceof Error ? error.message : 'Unknown error'}`);
64+
} finally {
65+
setIsSubmitting(false);
66+
}
67+
};
68+
69+
if (!isOpen) return null;
70+
71+
return (
72+
<div className="modal-overlay" onClick={onClose}>
73+
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
74+
<div className="modal-header">
75+
<h3>Make Decision</h3>
76+
<button className="btn btn-close" onClick={onClose}>×</button>
77+
</div>
78+
79+
<div className="modal-body">
80+
<p>The instance is waiting for your decision. Please choose an action and add a comment (optional):</p>
81+
82+
<div className="form-group">
83+
<label htmlFor="decision-message">Message:</label>
84+
<textarea
85+
id="decision-message"
86+
value={message}
87+
onChange={(e) => setMessage(e.target.value)}
88+
placeholder="Enter a comment for your decision..."
89+
rows={3}
90+
style={{ width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
91+
/>
92+
</div>
93+
</div>
94+
95+
<div className="modal-footer">
96+
<button
97+
className="btn btn-secondary"
98+
onClick={onClose}
99+
disabled={isSubmitting}
100+
>
101+
Cancel
102+
</button>
103+
<button
104+
className="btn btn-danger"
105+
onClick={() => handleSubmit('reject')}
106+
disabled={isSubmitting}
107+
style={{ marginLeft: '0.5rem' }}
108+
>
109+
{isSubmitting ? 'Rejecting...' : 'Reject'}
110+
</button>
111+
<button
112+
className="btn btn-success"
113+
onClick={() => handleSubmit('confirm')}
114+
disabled={isSubmitting}
115+
style={{ marginLeft: '0.5rem' }}
116+
>
117+
{isSubmitting ? 'Confirming...' : 'Confirm'}
118+
</button>
119+
</div>
120+
</div>
121+
</div>
122+
);
123+
};

web/src/pages/InstanceDetail.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useState, useEffect } from 'react';
22
import { useParams, Link } from 'react-router-dom';
33
import { WorkflowGraph } from '../components/WorkflowGraph';
4+
import { DecisionModal } from '../components/DecisionModal';
45

56
interface WorkflowInstance {
67
id: number;
@@ -58,6 +59,7 @@ export const InstanceDetail: React.FC = () => {
5859
const [loading, setLoading] = useState(true);
5960
const [error, setError] = useState<string | null>(null);
6061
const [activeTab, setActiveTab] = useState<'steps' | 'events' | 'graph'>('steps');
62+
const [showDecisionModal, setShowDecisionModal] = useState(false);
6163

6264
useEffect(() => {
6365
const fetchData = async () => {
@@ -103,6 +105,32 @@ export const InstanceDetail: React.FC = () => {
103105
}
104106
}, [id]);
105107

108+
// Function to determine if decision buttons are needed
109+
const needsDecision = () => {
110+
if (!instance || !steps.length) return false;
111+
112+
// Look for the current step (the one that's currently running or waiting)
113+
// that is in waiting_decision status with type "human"
114+
const currentStep = steps.find(step =>
115+
instance.status === 'running' &&
116+
step.step_type === 'human' &&
117+
step.status === 'waiting_decision'
118+
);
119+
120+
return !!currentStep;
121+
};
122+
123+
// Functions for handling decisions (called after successful API call)
124+
const handleDecisionConfirm = (message: string) => {
125+
// Refresh data after successful decision
126+
window.location.reload();
127+
};
128+
129+
const handleDecisionReject = (message: string) => {
130+
// Refresh data after successful decision
131+
window.location.reload();
132+
};
133+
106134
if (loading) {
107135
return <div className="loading">Loading instance details...</div>;
108136
}
@@ -153,6 +181,24 @@ export const InstanceDetail: React.FC = () => {
153181
</div>
154182
)}
155183
</div>
184+
185+
{/* Decision buttons */}
186+
{needsDecision() && (
187+
<div className="decision-buttons">
188+
<div style={{ flex: 1 }}>
189+
<strong>Decision Required:</strong>
190+
<p style={{ margin: '0.5rem 0', color: '#6b7280', fontSize: '0.9rem' }}>
191+
The instance is waiting for your decision to continue execution.
192+
</p>
193+
</div>
194+
<button
195+
className="btn btn-primary"
196+
onClick={() => setShowDecisionModal(true)}
197+
>
198+
Make Decision
199+
</button>
200+
</div>
201+
)}
156202
</div>
157203

158204
<div className="card">
@@ -309,6 +355,15 @@ export const InstanceDetail: React.FC = () => {
309355
</div>
310356
)}
311357
</div>
358+
359+
{/* Decision modal */}
360+
<DecisionModal
361+
isOpen={showDecisionModal}
362+
onClose={() => setShowDecisionModal(false)}
363+
onConfirm={handleDecisionConfirm}
364+
onReject={handleDecisionReject}
365+
instanceId={id || ''}
366+
/>
312367
</div>
313368
);
314369
};

web/src/pages/Stats.tsx

Lines changed: 29 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import React, { useState, useEffect } from 'react';
22

33
interface WorkflowStats {
4-
name: string;
4+
workflow_name: string;
55
version: number;
6-
total_instances: string | number;
7-
completed: string | number;
8-
failed: string | number;
9-
running: string | number;
10-
avg_duration_seconds: string | number | null;
6+
total_instances: number;
7+
completed_instances: number;
8+
failed_instances: number;
9+
running_instances: number;
10+
average_duration: number;
1111
}
1212

1313
export const Stats: React.FC = () => {
@@ -42,20 +42,20 @@ export const Stats: React.FC = () => {
4242
return <div className="error">Error: {error}</div>;
4343
}
4444

45-
const formatDuration = (seconds: string | number | null) => {
46-
if (seconds === null || seconds === '') return '-';
47-
const numSeconds = typeof seconds === 'string' ? parseFloat(seconds) : seconds;
48-
if (isNaN(numSeconds)) return '-';
49-
if (numSeconds < 60) return `${numSeconds.toFixed(1)}s`;
50-
if (numSeconds < 3600) return `${(numSeconds / 60).toFixed(1)}m`;
51-
return `${(numSeconds / 3600).toFixed(1)}h`;
45+
const formatDuration = (nanoseconds: number) => {
46+
if (nanoseconds === 0 || nanoseconds === null) return '-';
47+
48+
// Convert nanoseconds to seconds
49+
const seconds = nanoseconds / 1000000000;
50+
51+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
52+
if (seconds < 3600) return `${(seconds / 60).toFixed(1)}m`;
53+
return `${(seconds / 3600).toFixed(1)}h`;
5254
};
5355

54-
const getSuccessRate = (completed: string | number, total: string | number) => {
55-
const numCompleted = typeof completed === 'string' ? parseInt(completed) : completed;
56-
const numTotal = typeof total === 'string' ? parseInt(total) : total;
57-
if (numTotal === 0) return '0.0';
58-
return ((numCompleted / numTotal) * 100).toFixed(1);
56+
const getSuccessRate = (completed: number, total: number) => {
57+
if (total === 0) return '0.0';
58+
return ((completed / total) * 100).toFixed(1);
5959
};
6060

6161
return (
@@ -81,17 +81,17 @@ export const Stats: React.FC = () => {
8181
</thead>
8282
<tbody>
8383
{stats.map((stat, index) => (
84-
<tr key={`${stat.name}-${stat.version}`}>
85-
<td>{stat.name}</td>
84+
<tr key={`${stat.workflow_name}-${stat.version}`}>
85+
<td>{stat.workflow_name}</td>
8686
<td>v{stat.version}</td>
8787
<td>{stat.total_instances}</td>
88-
<td>{stat.completed}</td>
89-
<td>{stat.failed}</td>
90-
<td>{stat.running}</td>
88+
<td>{stat.completed_instances}</td>
89+
<td>{stat.failed_instances}</td>
90+
<td>{stat.running_instances}</td>
9191
<td>
92-
{getSuccessRate(stat.completed, stat.total_instances)}%
92+
{getSuccessRate(stat.completed_instances, stat.total_instances)}%
9393
</td>
94-
<td>{formatDuration(stat.avg_duration_seconds)}</td>
94+
<td>{formatDuration(stat.average_duration)}</td>
9595
</tr>
9696
))}
9797
</tbody>
@@ -104,37 +104,25 @@ export const Stats: React.FC = () => {
104104
<div className="stats-grid">
105105
<div className="stat-card">
106106
<div className="stat-number">
107-
{stats.reduce((sum, stat) => {
108-
const num = typeof stat.total_instances === 'string' ? parseInt(stat.total_instances) : stat.total_instances;
109-
return sum + (isNaN(num) ? 0 : num);
110-
}, 0)}
107+
{stats.reduce((sum, stat) => sum + stat.total_instances, 0)}
111108
</div>
112109
<div className="stat-label">Total Instances</div>
113110
</div>
114111
<div className="stat-card">
115112
<div className="stat-number">
116-
{stats.reduce((sum, stat) => {
117-
const num = typeof stat.completed === 'string' ? parseInt(stat.completed) : stat.completed;
118-
return sum + (isNaN(num) ? 0 : num);
119-
}, 0)}
113+
{stats.reduce((sum, stat) => sum + stat.completed_instances, 0)}
120114
</div>
121115
<div className="stat-label">Completed</div>
122116
</div>
123117
<div className="stat-card">
124118
<div className="stat-number">
125-
{stats.reduce((sum, stat) => {
126-
const num = typeof stat.failed === 'string' ? parseInt(stat.failed) : stat.failed;
127-
return sum + (isNaN(num) ? 0 : num);
128-
}, 0)}
119+
{stats.reduce((sum, stat) => sum + stat.failed_instances, 0)}
129120
</div>
130121
<div className="stat-label">Failed</div>
131122
</div>
132123
<div className="stat-card">
133124
<div className="stat-number">
134-
{stats.reduce((sum, stat) => {
135-
const num = typeof stat.running === 'string' ? parseInt(stat.running) : stat.running;
136-
return sum + (isNaN(num) ? 0 : num);
137-
}, 0)}
125+
{stats.reduce((sum, stat) => sum + stat.running_instances, 0)}
138126
</div>
139127
<div className="stat-label">Running</div>
140128
</div>

0 commit comments

Comments
 (0)