Skip to content

Commit 0d3787b

Browse files
bojanclaude
authored andcommitted
feat: Add Activities page and wire up workflow actions
- Add Activities API endpoint (GET /api/activities, GET /api/activities/{name}) - Add Activities page with tag filtering and detail panel - Wire up Terminate and Raise Event buttons in WorkflowDetail - Add confirmation modal for terminate action - Add event name/data input modal for raise event 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e23818e commit 0d3787b

File tree

6 files changed

+571
-6
lines changed

6 files changed

+571
-6
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
using System.Net;
2+
using System.Text.Json;
3+
using Microsoft.Azure.Functions.Worker;
4+
using Microsoft.Azure.Functions.Worker.Http;
5+
using Microsoft.Extensions.Logging;
6+
using Orchestration.Functions.Activities.Registry;
7+
8+
namespace Orchestration.Functions.Http;
9+
10+
/// <summary>
11+
/// HTTP endpoints for browsing available activities.
12+
/// </summary>
13+
public sealed class ActivitiesFunction
14+
{
15+
private readonly IActivityRegistry _activityRegistry;
16+
private readonly ILogger<ActivitiesFunction> _logger;
17+
18+
private static readonly JsonSerializerOptions JsonOptions = new()
19+
{
20+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
21+
WriteIndented = true
22+
};
23+
24+
public ActivitiesFunction(IActivityRegistry activityRegistry, ILogger<ActivitiesFunction> logger)
25+
{
26+
_activityRegistry = activityRegistry;
27+
_logger = logger;
28+
}
29+
30+
/// <summary>
31+
/// Lists all registered activities.
32+
/// GET /api/activities
33+
/// </summary>
34+
[Function("ListActivities")]
35+
public async Task<HttpResponseData> ListActivities(
36+
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "activities")]
37+
HttpRequestData req)
38+
{
39+
_logger.LogInformation("Listing all registered activities");
40+
41+
var activities = _activityRegistry.GetAll();
42+
43+
var response = new ActivityListResponse
44+
{
45+
Count = activities.Count,
46+
Activities = activities.Select(a => new ActivitySummary
47+
{
48+
Name = a.Name,
49+
Description = a.Description,
50+
SupportsCompensation = a.SupportsCompensation,
51+
Tags = a.Tags?.ToList() ?? []
52+
}).ToList()
53+
};
54+
55+
var httpResponse = req.CreateResponse(HttpStatusCode.OK);
56+
httpResponse.Headers.Add("Content-Type", "application/json");
57+
await httpResponse.WriteStringAsync(JsonSerializer.Serialize(response, JsonOptions));
58+
return httpResponse;
59+
}
60+
61+
/// <summary>
62+
/// Gets details about a specific activity.
63+
/// GET /api/activities/{activityName}
64+
/// </summary>
65+
[Function("GetActivity")]
66+
public async Task<HttpResponseData> GetActivity(
67+
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "activities/{activityName}")]
68+
HttpRequestData req,
69+
string activityName)
70+
{
71+
_logger.LogInformation("Getting activity details for {ActivityName}", activityName);
72+
73+
var metadata = _activityRegistry.GetMetadata(activityName);
74+
75+
if (metadata == null)
76+
{
77+
var notFoundResponse = req.CreateResponse(HttpStatusCode.NotFound);
78+
notFoundResponse.Headers.Add("Content-Type", "application/json");
79+
await notFoundResponse.WriteStringAsync(JsonSerializer.Serialize(new { error = $"Activity '{activityName}' not found" }, JsonOptions));
80+
return notFoundResponse;
81+
}
82+
83+
var detail = new ActivityDetail
84+
{
85+
Name = metadata.Name,
86+
Description = metadata.Description,
87+
SupportsCompensation = metadata.SupportsCompensation,
88+
CompensatingActivity = metadata.CompensatingActivity,
89+
Tags = metadata.Tags?.ToList() ?? [],
90+
InputType = metadata.InputType?.Name,
91+
OutputType = metadata.OutputType?.Name
92+
};
93+
94+
var response = req.CreateResponse(HttpStatusCode.OK);
95+
response.Headers.Add("Content-Type", "application/json");
96+
await response.WriteStringAsync(JsonSerializer.Serialize(detail, JsonOptions));
97+
return response;
98+
}
99+
}
100+
101+
public sealed class ActivityListResponse
102+
{
103+
public int Count { get; set; }
104+
public List<ActivitySummary> Activities { get; set; } = [];
105+
}
106+
107+
public sealed class ActivitySummary
108+
{
109+
public required string Name { get; set; }
110+
public string? Description { get; set; }
111+
public bool SupportsCompensation { get; set; }
112+
public List<string> Tags { get; set; } = [];
113+
}
114+
115+
public sealed class ActivityDetail
116+
{
117+
public required string Name { get; set; }
118+
public string? Description { get; set; }
119+
public bool SupportsCompensation { get; set; }
120+
public string? CompensatingActivity { get; set; }
121+
public List<string> Tags { get; set; } = [];
122+
public string? InputType { get; set; }
123+
public string? OutputType { get; set; }
124+
}

ui/src/api/activities.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { apiClient } from './client';
2+
3+
export interface ActivitySummary {
4+
name: string;
5+
description?: string;
6+
supportsCompensation: boolean;
7+
tags: string[];
8+
}
9+
10+
export interface ActivityDetail {
11+
name: string;
12+
description?: string;
13+
supportsCompensation: boolean;
14+
compensatingActivity?: string;
15+
tags: string[];
16+
inputType?: string;
17+
outputType?: string;
18+
}
19+
20+
export interface ActivityListResponse {
21+
count: number;
22+
activities: ActivitySummary[];
23+
}
24+
25+
export async function fetchActivities(): Promise<ActivityListResponse> {
26+
const response = await apiClient.get<ActivityListResponse>('/activities');
27+
return response.data;
28+
}
29+
30+
export async function fetchActivityDetail(activityName: string): Promise<ActivityDetail> {
31+
const response = await apiClient.get<ActivityDetail>(`/activities/${activityName}`);
32+
return response.data;
33+
}
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { useState } from 'react';
2+
import { useActivities, useActivityDetail } from '../../hooks/useActivities';
3+
import { LoadingSpinner } from '../common/LoadingSpinner';
4+
5+
export function ActivityList() {
6+
const { data, isLoading, error, refetch } = useActivities();
7+
const [selectedActivity, setSelectedActivity] = useState<string | null>(null);
8+
const [filterTag, setFilterTag] = useState<string>('all');
9+
10+
// Get unique tags from all activities
11+
const allTags = data?.activities
12+
? [...new Set(data.activities.flatMap(a => a.tags))]
13+
: [];
14+
15+
// Filter activities by tag
16+
const filteredActivities = data?.activities.filter(
17+
a => filterTag === 'all' || a.tags.includes(filterTag)
18+
) ?? [];
19+
20+
return (
21+
<div className="space-y-6">
22+
<div className="flex items-center justify-between">
23+
<div>
24+
<h1 className="text-2xl font-bold text-gray-100">Activities</h1>
25+
<p className="text-gray-400">
26+
{data?.count ?? 0} registered activities
27+
</p>
28+
</div>
29+
<button
30+
onClick={() => refetch()}
31+
className="px-4 py-2 border border-dark-border text-gray-300 rounded-lg hover:bg-dark-hover"
32+
>
33+
Refresh
34+
</button>
35+
</div>
36+
37+
{/* Tag Filter */}
38+
{allTags.length > 0 && (
39+
<div className="flex flex-wrap gap-2">
40+
<button
41+
onClick={() => setFilterTag('all')}
42+
className={`px-3 py-1 rounded-full text-sm transition-colors ${
43+
filterTag === 'all'
44+
? 'bg-blue-600 text-white'
45+
: 'bg-dark-card border border-dark-border text-gray-400 hover:text-gray-200'
46+
}`}
47+
>
48+
All
49+
</button>
50+
{allTags.map(tag => (
51+
<button
52+
key={tag}
53+
onClick={() => setFilterTag(tag)}
54+
className={`px-3 py-1 rounded-full text-sm transition-colors ${
55+
filterTag === tag
56+
? 'bg-blue-600 text-white'
57+
: 'bg-dark-card border border-dark-border text-gray-400 hover:text-gray-200'
58+
}`}
59+
>
60+
{tag}
61+
</button>
62+
))}
63+
</div>
64+
)}
65+
66+
{isLoading && (
67+
<div className="flex justify-center py-12">
68+
<LoadingSpinner />
69+
</div>
70+
)}
71+
72+
{error && (
73+
<div className="bg-red-900/30 text-red-400 p-4 rounded-lg border border-red-800">
74+
Error loading activities: {error.message}
75+
</div>
76+
)}
77+
78+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
79+
{/* Activity List */}
80+
<div className="lg:col-span-2 space-y-3">
81+
{filteredActivities.map((activity) => (
82+
<button
83+
key={activity.name}
84+
onClick={() => setSelectedActivity(activity.name)}
85+
className={`w-full text-left bg-dark-card rounded-lg border p-4 transition-colors ${
86+
selectedActivity === activity.name
87+
? 'border-blue-600'
88+
: 'border-dark-border hover:border-dark-hover'
89+
}`}
90+
>
91+
<div className="flex items-start justify-between">
92+
<div className="flex-1 min-w-0">
93+
<h3 className="text-gray-100 font-semibold font-mono">
94+
{activity.name}
95+
</h3>
96+
{activity.description && (
97+
<p className="text-gray-400 text-sm mt-1">
98+
{activity.description}
99+
</p>
100+
)}
101+
</div>
102+
{activity.supportsCompensation && (
103+
<span className="ml-2 px-2 py-1 bg-orange-900/50 text-orange-400 text-xs rounded-full">
104+
Compensatable
105+
</span>
106+
)}
107+
</div>
108+
{activity.tags.length > 0 && (
109+
<div className="flex flex-wrap gap-1 mt-3">
110+
{activity.tags.map(tag => (
111+
<span
112+
key={tag}
113+
className="px-2 py-0.5 bg-dark-hover text-gray-400 text-xs rounded"
114+
>
115+
{tag}
116+
</span>
117+
))}
118+
</div>
119+
)}
120+
</button>
121+
))}
122+
123+
{data && filteredActivities.length === 0 && (
124+
<div className="bg-dark-card rounded-lg border border-dark-border p-12 text-center">
125+
<p className="text-gray-500">No activities found with tag "{filterTag}".</p>
126+
</div>
127+
)}
128+
</div>
129+
130+
{/* Activity Detail Panel */}
131+
<div className="lg:col-span-1">
132+
{selectedActivity ? (
133+
<ActivityDetailPanel activityName={selectedActivity} />
134+
) : (
135+
<div className="bg-dark-card rounded-lg border border-dark-border p-6 text-center">
136+
<p className="text-gray-500">Select an activity to view details</p>
137+
</div>
138+
)}
139+
</div>
140+
</div>
141+
</div>
142+
);
143+
}
144+
145+
function ActivityDetailPanel({ activityName }: { activityName: string }) {
146+
const { data, isLoading, error } = useActivityDetail(activityName);
147+
148+
if (isLoading) {
149+
return (
150+
<div className="bg-dark-card rounded-lg border border-dark-border p-6">
151+
<LoadingSpinner />
152+
</div>
153+
);
154+
}
155+
156+
if (error || !data) {
157+
return (
158+
<div className="bg-dark-card rounded-lg border border-dark-border p-6">
159+
<p className="text-red-400">Failed to load activity details</p>
160+
</div>
161+
);
162+
}
163+
164+
return (
165+
<div className="bg-dark-card rounded-lg border border-dark-border p-6 space-y-4 sticky top-6">
166+
<h2 className="text-lg font-semibold text-gray-100 font-mono">{data.name}</h2>
167+
168+
{data.description && (
169+
<p className="text-gray-400">{data.description}</p>
170+
)}
171+
172+
<dl className="space-y-3">
173+
<div>
174+
<dt className="text-sm text-gray-500">Supports Compensation</dt>
175+
<dd className={data.supportsCompensation ? 'text-green-400' : 'text-gray-400'}>
176+
{data.supportsCompensation ? 'Yes' : 'No'}
177+
</dd>
178+
</div>
179+
180+
{data.compensatingActivity && (
181+
<div>
182+
<dt className="text-sm text-gray-500">Compensating Activity</dt>
183+
<dd className="text-gray-200 font-mono">{data.compensatingActivity}</dd>
184+
</div>
185+
)}
186+
187+
{data.inputType && (
188+
<div>
189+
<dt className="text-sm text-gray-500">Input Type</dt>
190+
<dd className="text-gray-200 font-mono">{data.inputType}</dd>
191+
</div>
192+
)}
193+
194+
{data.outputType && (
195+
<div>
196+
<dt className="text-sm text-gray-500">Output Type</dt>
197+
<dd className="text-gray-200 font-mono">{data.outputType}</dd>
198+
</div>
199+
)}
200+
201+
{data.tags.length > 0 && (
202+
<div>
203+
<dt className="text-sm text-gray-500 mb-1">Tags</dt>
204+
<dd className="flex flex-wrap gap-1">
205+
{data.tags.map(tag => (
206+
<span
207+
key={tag}
208+
className="px-2 py-0.5 bg-dark-hover text-gray-400 text-xs rounded"
209+
>
210+
{tag}
211+
</span>
212+
))}
213+
</dd>
214+
</div>
215+
)}
216+
</dl>
217+
218+
{/* Usage Example */}
219+
<div className="pt-4 border-t border-dark-border">
220+
<h3 className="text-sm text-gray-500 mb-2">Usage in Workflow Definition</h3>
221+
<pre className="bg-dark-bg rounded-lg p-3 text-sm font-mono text-gray-300 overflow-auto">
222+
{`{
223+
"Type": "Task",
224+
"Activity": "${data.name}",
225+
"Next": "NextState"
226+
}`}
227+
</pre>
228+
</div>
229+
</div>
230+
);
231+
}

0 commit comments

Comments
 (0)