History

sources: per-source connection status badge

- sources page SourceGrid now renders each plugin's summary component with Suspense, passing sourceId. Threads sourcePlugins into the grid. - OpenApiSourceSummary turns into a real ConnectedBadge: reads secretStatusAtom for the configured access token and shows Connected / Not connected / skeleton (initial). Renders nothing for non-OAuth sources so bearer and header-auth sources stay clean.

Rhys Sullivan committed 6 months ago

Commit 1a356d31 parent 28d5a722 files · +92 −20Browse files

Unified · Split

2 files
  1. packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx +53 −1
  2. packages/react/src/pages/sources.tsx +39 −19
packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx+53−1
@@ -1,3 +1,55 @@
1+import { Result, useAtomValue } from "@effect-atom/atom-react";
2+
3+import { SecretId } from "@executor/sdk";
4+import { useScope } from "@executor/react/api/scope-context";
5+import { secretStatusAtom } from "@executor/react/api/atoms";
6+import { Badge } from "@executor/react/components/badge";
7+import { Skeleton } from "@executor/react/components/skeleton";
8+
9+import { openApiSourceAtom } from "./atoms";
10+
11+function ConnectedBadge(props: { accessTokenSecretId: string }) {
12+ const scopeId = useScope();
13+ const status = useAtomValue(
14+ secretStatusAtom(scopeId, SecretId.make(props.accessTokenSecretId)),
15+ );
16+
17+ return Result.match(status, {
18+ onInitial: () => <Skeleton className="h-5 w-20 rounded-full" />,
19+ onFailure: () => (
20+ <Badge variant="outline" className="text-[10px] text-muted-foreground">
21+ Not connected
22+ </Badge>
23+ ),
24+ onSuccess: ({ value }) =>
25+ value.status === "resolved" ? (
26+ <Badge
27+ variant="outline"
28+ className="border-green-500/30 bg-green-500/5 text-[10px] text-green-700 dark:text-green-400"
29+ >
30+ Connected
31+ </Badge>
32+ ) : (
33+ <Badge variant="outline" className="text-[10px] text-muted-foreground">
34+ Not connected
35+ </Badge>
36+ ),
37+ });
38+}
39+
40+// The entry row already renders name + id + kind, so this summary
41+// component only contributes extras — specifically, an OAuth status
42+// badge when the source has OAuth2 configured. Non-OAuth sources
43+// render nothing.
144 export default function OpenApiSourceSummary(props: { sourceId: string }) {
2− return <span>OpenAPI · {props.sourceId}</span>;
45+ const scopeId = useScope();
46+ const sourceResult = useAtomValue(openApiSourceAtom(scopeId, props.sourceId));
47+
48+ const oauth2 =
49+ Result.isSuccess(sourceResult) && sourceResult.value
50+ ? sourceResult.value.config.oauth2
51+ : undefined;
52+
53+ if (!oauth2) return null;
54+ return <ConnectedBadge accessTokenSecretId={oauth2.accessTokenSecretId} />;
355 }
packages/react/src/pages/sources.tsx+39−19
@@ -1,4 +1,4 @@
1−import { useState, useCallback, useMemo } from "react";
1+import { Suspense, useState, useCallback, useMemo } from "react";
22 import { Link, useNavigate } from "@tanstack/react-router";
33 import { Result, useAtomSet } from "@effect-atom/atom-react";
44 import { detectSource } from "../api/atoms";
@@ -172,7 +172,10 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) { · 167 unmodified lines
172172 <div className="mb-8 space-y-8">
173173 {connectedSources.length > 0 && (
174174 <section className="space-y-3">
175− <SourceGrid sources={connectedSources} />
175+ <SourceGrid
176+ sources={connectedSources}
177+ sourcePlugins={sourcePlugins}
178+ />
176179 </section>
177180 )}
178181 </div>
@@ -273,28 +276,45 @@ function SourceGrid(props: { · 94 unmodified lines
273276 url?: string;
274277 runtime?: boolean;
275278 }[];
279+ sourcePlugins: readonly SourcePlugin[];
276280 }) {
281+ const pluginByKind = useMemo(() => {
282+ const out = new Map<string, SourcePlugin>();
283+ for (const p of props.sourcePlugins) out.set(p.key, p);
284+ return out;
285+ }, [props.sourcePlugins]);
286+
277287 return (
278288 <CardStack searchable>
279289 <CardStackHeader>Connected</CardStackHeader>
280290 <CardStackContent>
281− {props.sources.map((s) => (
282− <CardStackEntry key={s.id} asChild searchText={`${s.name} ${s.id} ${s.kind}`}>
283− <Link to="/sources/$namespace" params={{ namespace: s.id }}>
284− <CardStackEntryMedia>
285− <SourceFavicon url={s.url} size={32} />
286− </CardStackEntryMedia>
287− <CardStackEntryContent>
288− <CardStackEntryTitle>{s.name}</CardStackEntryTitle>
289− <CardStackEntryDescription>{s.id}</CardStackEntryDescription>
290− </CardStackEntryContent>
291− <CardStackEntryActions>
292− {s.runtime && <Badge className="bg-muted text-muted-foreground">built-in</Badge>}
293− <Badge variant="secondary">{s.kind}</Badge>
294− </CardStackEntryActions>
295− </Link>
296− </CardStackEntry>
297− ))}
291+ {props.sources.map((s) => {
292+ const pluginKey = KIND_TO_PLUGIN_KEY[s.kind] ?? s.kind;
293+ const plugin = pluginByKind.get(pluginKey);
294+ const SummaryComponent = plugin?.summary;
295+ return (
296+ <CardStackEntry key={s.id} asChild searchText={`${s.name} ${s.id} ${s.kind}`}>
297+ <Link to="/sources/$namespace" params={{ namespace: s.id }}>
298+ <CardStackEntryMedia>
299+ <SourceFavicon url={s.url} size={32} />
300+ </CardStackEntryMedia>
301+ <CardStackEntryContent>
302+ <CardStackEntryTitle>{s.name}</CardStackEntryTitle>
303+ <CardStackEntryDescription>{s.id}</CardStackEntryDescription>
304+ </CardStackEntryContent>
305+ <CardStackEntryActions>
306+ {SummaryComponent && (
307+ <Suspense fallback={null}>
308+ <SummaryComponent sourceId={s.id} />
309+ </Suspense>
310+ )}
311+ {s.runtime && <Badge className="bg-muted text-muted-foreground">built-in</Badge>}
312+ <Badge variant="secondary">{s.kind}</Badge>
313+ </CardStackEntryActions>
314+ </Link>
315+ </CardStackEntry>
316+ );
317+ })}
298318 </CardStackContent>
299319 </CardStack>
300320 );