33 * (STATS + Notes). Phase 030 of workspace design parity.
44 */
55import { useCallback , useEffect , useRef , useState , type ReactNode } from "react" ;
6+ import { readJsonOrThrow } from "../../fetch-json" ;
67import { useT , useI18n } from "../../i18n/shared" ;
78import { IconAlert , IconCheck } from "../../icons" ;
89import { binProviderStatus , type WorkspaceItem } from "../../provider-workspace/catalog" ;
@@ -12,8 +13,24 @@ import type { ProviderUsageTotals } from "./types";
1213import { authModeLabel } from "./ProviderRail" ;
1314import type { ProviderUpdatePatch } from "./types" ;
1415
16+ type ConnectionTestResult = {
17+ applicable ?: boolean ;
18+ ok ?: boolean ;
19+ latencyMs ?: number ;
20+ reason ?: string ;
21+ message ?: string ;
22+ error ?: string ;
23+ } ;
24+
25+ type ConnectionTestState = {
26+ key : string ;
27+ testing : boolean ;
28+ result : ConnectionTestResult | null ;
29+ } ;
30+
1531export default function ProviderOverview ( {
1632 item, usageTotals, quotaReport, oauthEmail,
33+ apiBase, connectionIdentity,
1734 onEditSettings, onViewUsage, onUpdateProvider,
1835 onReauthenticate, onCancelLogin, reauthBusy = false ,
1936 accountPanel,
@@ -22,6 +39,9 @@ export default function ProviderOverview({
2239 usageTotals ?: ProviderUsageTotals ;
2340 quotaReport ?: ProviderQuotaReportView ;
2441 oauthEmail ?: string ;
42+ apiBase ?: string ;
43+ /** Opaque active credential identity used only to invalidate stale probe results. */
44+ connectionIdentity ?: string ;
2545 onEditSettings ?: ( ) => void ;
2646 onViewUsage ?: ( ) => void ;
2747 onUpdateProvider ?: ( name : string , patch : ProviderUpdatePatch ) => Promise < { ok : boolean ; error ?: string } > ;
@@ -48,6 +68,81 @@ export default function ProviderOverview({
4868 const requests = usageTotals ?. requests ;
4969 const tokens = usageTotals ?. totalTokens ;
5070 const quota = accountQuotaFromReport ( quotaReport ) ;
71+ const connectionProbeKey = JSON . stringify ( [
72+ apiBase ?? null ,
73+ item . name ,
74+ item . adapter ,
75+ item . baseUrl ,
76+ item . authMode ?? null ,
77+ item . apiKeyTransport ?? null ,
78+ item . liveModels ?? null ,
79+ item . disabled === true ,
80+ item . hasApiKey === true ,
81+ item . hasHeaders === true ,
82+ item . allowPrivateNetwork === true ,
83+ item . keyOptional === true ,
84+ item . activeNeedsReauth === true ,
85+ connectionIdentity ?? null ,
86+ ] ) ;
87+ const [ connectionTest , setConnectionTest ] = useState < ConnectionTestState | null > ( null ) ;
88+ const connectionAbortRef = useRef < { key : string ; controller : AbortController } | null > ( null ) ;
89+ const testingConnection = connectionTest ?. key === connectionProbeKey && connectionTest . testing ;
90+ const connectionResult = connectionTest ?. key === connectionProbeKey ? connectionTest . result : null ;
91+
92+ useEffect ( ( ) => {
93+ return ( ) => {
94+ if ( connectionAbortRef . current ?. key === connectionProbeKey ) {
95+ connectionAbortRef . current . controller . abort ( ) ;
96+ connectionAbortRef . current = null ;
97+ }
98+ } ;
99+ } , [ connectionProbeKey ] ) ;
100+
101+ const testConnection = useCallback ( async ( ) => {
102+ if ( ! apiBase ) return ;
103+ connectionAbortRef . current ?. controller . abort ( ) ;
104+ const controller = new AbortController ( ) ;
105+ connectionAbortRef . current = { key : connectionProbeKey , controller } ;
106+ setConnectionTest ( { key : connectionProbeKey , testing : true , result : null } ) ;
107+ try {
108+ const response = await fetch ( `${ apiBase } /api/providers/test?name=${ encodeURIComponent ( item . name ) } ` , {
109+ method : "POST" ,
110+ signal : controller . signal ,
111+ } ) ;
112+ const result = await readJsonOrThrow < ConnectionTestResult > ( response , t ( "pws.connectionFailed" ) ) ;
113+ if ( ! result ) throw new Error ( t ( "pws.connectionFailed" ) ) ;
114+ if ( ! controller . signal . aborted ) {
115+ setConnectionTest ( { key : connectionProbeKey , testing : false , result } ) ;
116+ }
117+ } catch ( error ) {
118+ if ( ! controller . signal . aborted ) {
119+ setConnectionTest ( {
120+ key : connectionProbeKey ,
121+ testing : false ,
122+ result : {
123+ applicable : true ,
124+ ok : false ,
125+ error : error instanceof Error ? error . message : t ( "pws.connectionFailed" ) ,
126+ } ,
127+ } ) ;
128+ }
129+ } finally {
130+ if ( connectionAbortRef . current ?. controller === controller ) {
131+ connectionAbortRef . current = null ;
132+ }
133+ }
134+ } , [ apiBase , connectionProbeKey , item . name , t ] ) ;
135+
136+ const connectionState = connectionResult ?. applicable === false
137+ ? "not-applicable"
138+ : connectionResult ?. ok === true
139+ ? "ok"
140+ : "failed" ;
141+ const connectionText = connectionResult ?. applicable === false
142+ ? t ( "pws.connectionNotApplicable" )
143+ : connectionResult ?. ok === true
144+ ? ( connectionResult . message || t ( "pws.connectionOk" ) )
145+ : ( connectionResult ?. error || t ( "pws.connectionFailed" ) ) ;
51146 return (
52147 < div className = "pws-overview-layout" >
53148 < div className = "pws-overview-main" >
@@ -82,6 +177,27 @@ export default function ProviderOverview({
82177 </ div >
83178 ) }
84179 </ dl >
180+ { apiBase && (
181+ < div className = "row" style = { { marginTop : 12 , alignItems : "center" } } >
182+ < button
183+ type = "button"
184+ className = "btn btn-ghost btn-sm"
185+ disabled = { testingConnection }
186+ onClick = { ( ) => void testConnection ( ) }
187+ >
188+ { testingConnection ? t ( "pws.testing" ) : t ( "pws.testConnection" ) }
189+ </ button >
190+ { connectionResult && (
191+ < span
192+ role = "status"
193+ className = { connectionState === "ok" ? "pws-status-ok" : connectionState === "failed" ? "pws-status-warn" : "muted" }
194+ data-connection-test-state = { connectionState }
195+ >
196+ { connectionText }
197+ </ span >
198+ ) }
199+ </ div >
200+ ) }
85201 { onEditSettings && (
86202 < button type = "button" className = "link-btn pws-edit-settings-link" onClick = { onEditSettings } >
87203 { t ( "pws.editSettings" ) }
0 commit comments