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 @@
1import { Result, useAtomValue } from "@effect-atom/atom-react";
2
3import { SecretId } from "@executor/sdk";
4import { useScope } from "@executor/react/api/scope-context";
5import { secretStatusAtom } from "@executor/react/api/atoms";
6import { Badge } from "@executor/react/components/badge";
7import { Skeleton } from "@executor/react/components/skeleton";
8
9import { openApiSourceAtom } from "./atoms";
10
11function 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.
1export default function OpenApiSourceSummary(props: { sourceId: string }) {44export 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} />;
3}55}
packages/react/src/pages/sources.tsx+39−19
@@ -1,4 +1,4 @@
1import { useState, useCallback, useMemo } from "react";1import { Suspense, useState, useCallback, useMemo } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";2import { Link, useNavigate } from "@tanstack/react-router";
3import { Result, useAtomSet } from "@effect-atom/atom-react";3import { Result, useAtomSet } from "@effect-atom/atom-react";
4import { detectSource } from "../api/atoms";4import { detectSource } from "../api/atoms";
@@ -172,7 +172,10 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) { · 167 unmodified lines
172 <div className="mb-8 space-y-8">172 <div className="mb-8 space-y-8">
173 {connectedSources.length > 0 && (173 {connectedSources.length > 0 && (
174 <section className="space-y-3">174 <section className="space-y-3">
175 <SourceGrid sources={connectedSources} />175 <SourceGrid
176 sources={connectedSources}
177 sourcePlugins={sourcePlugins}
178 />
176 </section>179 </section>
177 )}180 )}
178 </div>181 </div>
@@ -273,28 +276,45 @@ function SourceGrid(props: { · 94 unmodified lines
273 url?: string;276 url?: string;
274 runtime?: boolean;277 runtime?: boolean;
275 }[];278 }[];
279 sourcePlugins: readonly SourcePlugin[];
276}) {280}) {
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
277 return (287 return (
278 <CardStack searchable>288 <CardStack searchable>
279 <CardStackHeader>Connected</CardStackHeader>289 <CardStackHeader>Connected</CardStackHeader>
280 <CardStackContent>290 <CardStackContent>
281 {props.sources.map((s) => (291 {props.sources.map((s) => {
282 <CardStackEntry key={s.id} asChild searchText={`${s.name} ${s.id} ${s.kind}`}>292 const pluginKey = KIND_TO_PLUGIN_KEY[s.kind] ?? s.kind;
283 <Link to="/sources/$namespace" params={{ namespace: s.id }}>293 const plugin = pluginByKind.get(pluginKey);
284 <CardStackEntryMedia>294 const SummaryComponent = plugin?.summary;
285 <SourceFavicon url={s.url} size={32} />295 return (
286 </CardStackEntryMedia>296 <CardStackEntry key={s.id} asChild searchText={`${s.name} ${s.id} ${s.kind}`}>
287 <CardStackEntryContent>297 <Link to="/sources/$namespace" params={{ namespace: s.id }}>
288 <CardStackEntryTitle>{s.name}</CardStackEntryTitle>298 <CardStackEntryMedia>
289 <CardStackEntryDescription>{s.id}</CardStackEntryDescription>299 <SourceFavicon url={s.url} size={32} />
290 </CardStackEntryContent>300 </CardStackEntryMedia>
291 <CardStackEntryActions>301 <CardStackEntryContent>
292 {s.runtime && <Badge className="bg-muted text-muted-foreground">built-in</Badge>}302 <CardStackEntryTitle>{s.name}</CardStackEntryTitle>
293 <Badge variant="secondary">{s.kind}</Badge>303 <CardStackEntryDescription>{s.id}</CardStackEntryDescription>
294 </CardStackEntryActions>304 </CardStackEntryContent>
295 </Link>305 <CardStackEntryActions>
296 </CardStackEntry>306 {SummaryComponent && (
297 ))}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 })}
298 </CardStackContent>318 </CardStackContent>
299 </CardStack>319 </CardStack>
300 );320 );