Skip to content

Commit 9fb8f64

Browse files
authored
Merge pull request #142 from debdevops/fix/deep-dive-critical-issues
Enhance AWS support and improve messaging components across services
2 parents f6721c1 + 9181783 commit 9fb8f64

36 files changed

Lines changed: 1671 additions & 194 deletions

apps/web/full-sweep.tmp.mjs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { chromium } from 'playwright';
2+
3+
const AZ = '4172a33a-f9ab-4f6b-86f0-85f0d0f2d93d';
4+
const AWS = 'a7dffab5-4824-4678-8431-8eb0f3c2751e';
5+
const S = process.env.SCRATCH;
6+
7+
const browser = await chromium.launch();
8+
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
9+
await ctx.addInitScript(() => localStorage.setItem('servicehub_tour_completed', 'true'));
10+
const page = await ctx.newPage();
11+
12+
const errors = [];
13+
const results = [];
14+
page.on('response', async r => {
15+
if (r.url().includes('/api/') && r.status() >= 400 && !r.url().includes('simulator/status')) {
16+
let b=''; try{b=(await r.text()).slice(0,100);}catch{}
17+
errors.push(`${r.status()} ${r.request().method()} ${r.url().replace('http://localhost:3000','').split('?')[0]} ${b}`);
18+
}
19+
});
20+
page.on('pageerror', e => errors.push('PAGEERROR: ' + String(e).slice(0, 120)));
21+
const prov = () => page.evaluate(() => document.documentElement.dataset.provider);
22+
const ok = (name, cond) => { results.push(`${cond ? 'PASS' : 'FAIL'} ${name}`); };
23+
const shot = (n) => page.screenshot({ path: `${S}/sweep-${n}.png` });
24+
const toasts = () => page.locator('[role="status"], [class*="toast"]').allTextContents();
25+
26+
// ════════ AZURE ════════
27+
await page.goto(`http://localhost:3000/messages?namespace=${AZ}&queue=testqueue&queueType=active`, { waitUntil: 'load' });
28+
await page.waitForTimeout(6000);
29+
ok('A1 azure theme on queue view', (await prov()) === 'azure');
30+
const azTabs = await page.getByRole('button', { name: /Active \(|Dead-Letter \(/ }).allTextContents();
31+
ok('A1 azure tab labels', azTabs.length === 2 && azTabs[0].startsWith('Active'));
32+
await shot('a1-azure-queue');
33+
34+
// message detail panel
35+
const card = page.locator('div.cursor-pointer').filter({ hasText: 'azureCheck' }).first();
36+
if (await card.count()) {
37+
await card.click(); await page.waitForTimeout(1500);
38+
const detailTabs = await page.getByRole('button', { name: /^(Properties|Body|AI Insights|Headers)$/ }).count();
39+
ok('A2 detail panel tabs', detailTabs === 4);
40+
await shot('a2-azure-detail');
41+
} else { ok('A2 detail panel (no active msg card)', false); }
42+
43+
// DLQ tab + replay
44+
await page.getByRole('button', { name: /Dead-Letter \(/ }).click();
45+
await page.waitForTimeout(5000);
46+
const dlqCard = page.locator('div.cursor-pointer').filter({ hasText: 'azureCheck' }).first();
47+
try {
48+
await dlqCard.click({ timeout: 5000 }); await page.waitForTimeout(1200);
49+
const replayBtn = page.getByRole('button', { name: /^Replay/ }).first();
50+
const enabled = (await replayBtn.count()) > 0 && !(await replayBtn.isDisabled());
51+
ok('A3 replay enabled on DLQ msg', enabled);
52+
if (enabled) {
53+
await replayBtn.click({ timeout: 5000 }); await page.waitForTimeout(600);
54+
await page.getByRole('button', { name: /^Confirm$/ }).last().click({ timeout: 5000 });
55+
await page.waitForTimeout(4000);
56+
ok('A3 replay toast', (await toasts()).some(t => t.includes('replayed successfully')));
57+
}
58+
await shot('a3-azure-replay');
59+
} catch (e) { ok('A3 replay flow: ' + String(e).slice(0, 60), false); }
60+
61+
// FAB: send message
62+
await page.locator('button[title="Open message menu"]').click(); await page.waitForTimeout(500);
63+
await shot('a4-azure-fab');
64+
await page.getByRole('button', { name: /Send Message/ }).click(); await page.waitForTimeout(800);
65+
await page.getByRole('button', { name: /^Send( Message)?$/i }).last().click();
66+
await page.waitForTimeout(3500);
67+
ok('A4 FAB send toast', (await toasts()).some(t => t.includes('sent successfully') || t.includes('Message sent')));
68+
69+
// FAB: test DLQ
70+
await page.locator('button[title="Open message menu"]').click(); await page.waitForTimeout(500);
71+
await page.getByRole('button', { name: /Test DLQ/ }).click();
72+
await page.waitForTimeout(5000);
73+
ok('A5 FAB test-dlq toast', (await toasts()).some(t => t.includes('DLQ') || t.includes('dead-letter')));
74+
75+
// topic with no subscriptions
76+
await page.locator('aside').getByText('testtopic', { exact: true }).click();
77+
await page.waitForTimeout(1500);
78+
const noSubs = await page.locator('aside').getByText(/No subscriptions/i).count();
79+
ok('A6 topic shows "No subscriptions" hint', noSubs > 0);
80+
await shot('a6-azure-topic');
81+
82+
// dashboard blue
83+
await page.goto('http://localhost:3000/dashboard', { waitUntil: 'load' });
84+
await page.waitForTimeout(3000);
85+
ok('A7 dashboard stays azure theme', (await prov()) === 'azure');
86+
await shot('a7-azure-dashboard');
87+
88+
// quick access pages under azure theme
89+
for (const [path, n] of [['/rules','a8-rules'],['/dlq-history','a8-dlq-history'],['/health','a8-health'],['/scheduled','a8-scheduled'],['/cross-cloud-trace','a8-trace'],['/audit','a8-audit'],['/cloud-bridge','a8-bridge']]) {
90+
await page.goto('http://localhost:3000' + path, { waitUntil: 'load' }).catch(()=>{});
91+
await page.waitForTimeout(2200);
92+
await shot(n);
93+
}
94+
ok('A8 quick-access pages visited', true);
95+
96+
// ════════ AWS ════════
97+
await page.goto(`http://localhost:3000/messages?namespace=${AZ}&queue=testqueue&queueType=active`, { waitUntil: 'load' });
98+
await page.waitForTimeout(2500);
99+
await page.locator('aside').getByText('DevAWS').click();
100+
await page.waitForTimeout(800);
101+
ok('B1 click AWS ns header → orange', (await prov()) === 'aws');
102+
103+
await page.goto(`http://localhost:3000/messages?namespace=${AWS}&queue=servicehub-study-orders&queueType=active`, { waitUntil: 'load' });
104+
await page.waitForTimeout(5000);
105+
const awsTabs = await page.getByRole('button', { name: /Queue \(|DLQ \(/ }).allTextContents();
106+
ok('B1 AWS tab labels Queue/DLQ', awsTabs.length === 2);
107+
ok('B1 AWS notice banner', (await page.locator('text=AWS SQS counts every view').count()) > 0);
108+
await shot('b1-aws-queue');
109+
110+
// nested DLQ + replay
111+
await page.locator('aside').getByText('DLQ: servicehub-study-orders-dlq').click();
112+
await page.waitForTimeout(8000);
113+
await shot('b2-aws-dlq');
114+
const awsDlqCard = page.locator('div.cursor-pointer').filter({ hasText: /STUDY-1|plain text|xml|orderId|note/ }).first();
115+
try {
116+
await awsDlqCard.click({ timeout: 8000 }); await page.waitForTimeout(1200);
117+
const rb = page.getByRole('button', { name: /^Replay/ }).first();
118+
const en = (await rb.count()) && !(await rb.isDisabled());
119+
ok('B2 AWS replay enabled', !!en);
120+
if (en) {
121+
await rb.click({ timeout: 5000 }); await page.waitForTimeout(600);
122+
await page.getByRole('button', { name: /^Confirm$/ }).last().click({ timeout: 5000 });
123+
await page.waitForTimeout(6000);
124+
ok('B2 AWS replay toast', (await toasts()).some(t => t.includes('replayed successfully')));
125+
}
126+
} catch (e) { ok('B2 AWS replay flow: ' + String(e).slice(0, 60), false); }
127+
128+
// topic fan-out
129+
await page.locator('aside').getByText('servicehub-study-ev').first().click();
130+
await page.waitForTimeout(3500);
131+
ok('B3 fan-out dashboard', (await page.locator('[data-testid="aws-topic-fanout"]').count()) > 0);
132+
await shot('b3-aws-fanout');
133+
await page.getByRole('button', { name: /Publish message/ }).click({ timeout: 8000 }).catch(() => ok('B3 publish button', false)); await page.waitForTimeout(800);
134+
await page.getByRole('button', { name: /^Send( Message)?$/i }).last().click();
135+
await page.waitForTimeout(3500);
136+
ok('B3 fan-out publish toast', (await toasts()).some(t => t.includes('sent') || t.includes('Sent')));
137+
138+
// FAB on AWS all enabled
139+
await page.getByRole('button', { name: /View messages/ }).first().click(); await page.waitForTimeout(2500);
140+
await page.locator('button[title="Open message menu"]').click(); await page.waitForTimeout(500);
141+
let fabOk = true;
142+
for (const nm of ['Test DLQ','Generate Messages','Send Message']) {
143+
const b = page.getByRole('button', { name: new RegExp(nm) }).first();
144+
if (!(await b.count()) || (await b.isDisabled())) fabOk = false;
145+
}
146+
ok('B4 AWS FAB all actions enabled', fabOk);
147+
await shot('b4-aws-fab');
148+
149+
// dashboard orange
150+
await page.goto('http://localhost:3000/dashboard', { waitUntil: 'load' });
151+
await page.waitForTimeout(3000);
152+
ok('B5 dashboard stays aws theme', (await prov()) === 'aws');
153+
await shot('b5-aws-dashboard');
154+
155+
console.log(results.join('\n'));
156+
console.log('\nAPI/page errors:', errors.length ? '\n' + [...new Set(errors)].join('\n') : 'none');
157+
await browser.close();
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { useState } from 'react';
2+
import { useNavigate } from 'react-router-dom';
3+
import { ArrowRight, CheckCircle2, Megaphone, Radio, Send } from 'lucide-react';
4+
import { useSubscriptions } from '@/hooks/useSubscriptions';
5+
import { useQueues } from '@/hooks/useQueues';
6+
import { SendMessageModal } from '@/components/fab/SendMessageModal';
7+
8+
// ============================================================================
9+
// AWS SNS topic fan-out dashboard.
10+
//
11+
// SNS topics store no messages — they fan each publish out to their
12+
// subscriptions (SQS endpoint queues here). Instead of pretending a topic has
13+
// a message list, this view shows the delivery graph with live queue depths
14+
// and jumps straight to the endpoint queue where messages actually land.
15+
// ============================================================================
16+
17+
interface AwsTopicFanoutProps {
18+
namespaceId: string;
19+
topicName: string;
20+
}
21+
22+
export function AwsTopicFanout({ namespaceId, topicName }: AwsTopicFanoutProps) {
23+
const navigate = useNavigate();
24+
const { data: subscriptions, isLoading: subsLoading } = useSubscriptions(namespaceId, topicName);
25+
const { data: queues } = useQueues(namespaceId);
26+
const [publishOpen, setPublishOpen] = useState(false);
27+
28+
const findQueue = (name: string) => queues?.find(q => q.name === name);
29+
30+
return (
31+
<div className="flex-1 overflow-auto bg-gray-50" data-testid="aws-topic-fanout">
32+
{/* Header */}
33+
<div className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-8 py-6">
34+
<div className="flex items-center justify-between gap-4 max-w-4xl">
35+
<div className="flex items-center gap-3 min-w-0">
36+
<div className="p-3 bg-white/15 rounded-xl shrink-0">
37+
<Megaphone className="w-7 h-7" />
38+
</div>
39+
<div className="min-w-0">
40+
<div className="text-xs font-semibold uppercase tracking-wider text-orange-100">
41+
Amazon SNS Topic
42+
</div>
43+
<h1 className="text-2xl font-bold truncate">{topicName}</h1>
44+
</div>
45+
</div>
46+
<button
47+
onClick={() => setPublishOpen(true)}
48+
className="flex items-center gap-2 px-4 py-2.5 bg-white text-orange-600 rounded-lg font-semibold shadow hover:bg-orange-50 transition-colors shrink-0"
49+
>
50+
<Send className="w-4 h-4" />
51+
Publish message
52+
</button>
53+
</div>
54+
<p className="mt-3 text-sm text-orange-100 max-w-3xl">
55+
SNS topics don't hold messages — every publish is delivered immediately to each
56+
subscription below. To inspect delivered messages, open the endpoint queue.
57+
</p>
58+
</div>
59+
60+
{/* Fan-out graph */}
61+
<div className="px-8 py-6 max-w-4xl">
62+
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
63+
Fan-out · {subscriptions?.length ?? 0} subscription{(subscriptions?.length ?? 0) === 1 ? '' : 's'}
64+
</h2>
65+
66+
{subsLoading ? (
67+
<div className="text-sm text-gray-500 py-8">Loading subscriptions…</div>
68+
) : !subscriptions || subscriptions.length === 0 ? (
69+
<div className="bg-white border border-gray-200 rounded-xl p-8 text-center text-gray-500">
70+
<Radio className="w-10 h-10 text-gray-300 mx-auto mb-3" />
71+
<p className="font-medium text-gray-700">No subscriptions</p>
72+
<p className="text-sm mt-1">
73+
Published messages are dropped until a subscription is added to this topic in AWS.
74+
</p>
75+
</div>
76+
) : (
77+
<div className="space-y-3">
78+
{subscriptions.map(sub => {
79+
const endpointQueue = findQueue(sub.name);
80+
return (
81+
<div
82+
key={sub.name}
83+
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center gap-4 shadow-sm hover:border-orange-300 transition-colors"
84+
>
85+
<div className="flex items-center gap-2 text-orange-500 shrink-0">
86+
<ArrowRight className="w-4 h-4" />
87+
<span className="text-[10px] font-bold uppercase tracking-wide bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded">
88+
sqs
89+
</span>
90+
</div>
91+
92+
<div className="flex-1 min-w-0">
93+
<div className="font-semibold text-gray-900 truncate">📥 {sub.name}</div>
94+
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
95+
<span className="flex items-center gap-1 text-emerald-600">
96+
<CheckCircle2 className="w-3.5 h-3.5" />
97+
{sub.status === 'Active' ? 'Confirmed' : sub.status}
98+
</span>
99+
{endpointQueue && (
100+
<>
101+
<span>
102+
queue depth:{' '}
103+
<strong className="text-gray-700">{endpointQueue.activeMessageCount}</strong>
104+
</span>
105+
<span>
106+
DLQ:{' '}
107+
<strong className={endpointQueue.deadLetterMessageCount > 0 ? 'text-red-600' : 'text-gray-700'}>
108+
{endpointQueue.deadLetterMessageCount}
109+
</strong>
110+
</span>
111+
</>
112+
)}
113+
</div>
114+
</div>
115+
116+
<button
117+
onClick={() => navigate(`/messages?namespace=${namespaceId}&queue=${sub.name}&queueType=active`)}
118+
className="flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-orange-700 bg-orange-50 hover:bg-orange-100 border border-orange-200 rounded-lg transition-colors shrink-0"
119+
>
120+
View messages
121+
<ArrowRight className="w-3.5 h-3.5" />
122+
</button>
123+
</div>
124+
);
125+
})}
126+
</div>
127+
)}
128+
129+
{/* How SNS differs from Azure — the pain point, addressed in-place */}
130+
<div className="mt-6 bg-sky-50 border border-sky-100 rounded-xl p-4 text-xs text-sky-800 space-y-1">
131+
<p className="font-semibold">How this differs from Azure Service Bus</p>
132+
<p>
133+
Azure topics store messages per subscription; SNS delivers and forgets. The messages
134+
you publish here live in the SQS endpoint queues above — including their own separate
135+
dead-letter queues.
136+
</p>
137+
</div>
138+
</div>
139+
140+
<SendMessageModal
141+
isOpen={publishOpen}
142+
onClose={() => setPublishOpen(false)}
143+
onSend={() => setPublishOpen(false)}
144+
defaultNamespaceId={namespaceId}
145+
defaultEntityName={topicName}
146+
defaultEntityType="topic"
147+
/>
148+
</div>
149+
);
150+
}

apps/web/src/components/fab/MessageFAB.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,10 @@ export function MessageFAB({
172172
queryClient.invalidateQueries({ queryKey: ['subscriptions', namespaceId], refetchType: 'active' }),
173173
]);
174174
} else if (result && result.deadLetteredCount === 0) {
175-
toast('No messages available to dead-letter', { icon: 'ℹ️' });
175+
toast(
176+
'No active messages were available to dead-letter. If you just sent messages, an external consumer may be draining this queue.',
177+
{ icon: 'ℹ️', duration: 6000 }
178+
);
176179
}
177180
} catch (error: unknown) {
178181
const err = error as { response?: { data?: { detail?: string; message?: string } }; message?: string };
@@ -260,7 +263,7 @@ export function MessageFAB({
260263
>
261264
<div className={`p-2 rounded-lg transition-colors ${
262265
!hasValidEntity || isProd
263-
? 'bg-gray-200'
266+
? 'bg-gray-200'
264267
: 'bg-red-100 group-hover:bg-red-200'
265268
}`}>
266269
<Skull className={`w-5 h-5 ${!hasValidEntity || isProd ? 'text-gray-400' : 'text-red-600'}`} />

apps/web/src/components/fab/MessageGeneratorModal.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ export function MessageGeneratorModal({
111111
const [isGenerating, setIsGenerating] = useState(false);
112112
const [showCleanup, setShowCleanup] = useState(false);
113113

114-
// Update defaults when props change
114+
// Update defaults when props change — including the entity kind, so a topic
115+
// opened from the FAB generates via the topics route instead of the queue route.
115116
useEffect(() => {
116117
if (defaultNamespaceId) setSelectedNamespace(defaultNamespaceId);
117118
if (defaultEntityName) setSelectedEntity(defaultEntityName);
@@ -178,8 +179,8 @@ export function MessageGeneratorModal({
178179
const results = await Promise.allSettled(
179180
batch.map(async (msg) => {
180181
await messagesApi.send(
181-
selectedNamespace,
182-
selectedEntity,
182+
selectedNamespace,
183+
selectedEntity,
183184
{
184185
body: msg.body,
185186
contentType: msg.contentType,

apps/web/src/components/fab/SendMessageModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ export interface MessagePayload {
3838
}
3939

4040
export function SendMessageModal({
41-
isOpen,
42-
onClose,
41+
isOpen,
42+
onClose,
4343
onSend,
4444
defaultNamespaceId,
4545
defaultEntityName,

0 commit comments

Comments
 (0)