@@ -159,6 +159,108 @@ function clearFilters(link: RelatedResourceLink) {
159159 if (parentId ) fetchRelation (link , String (parentId ))
160160}
161161
162+ /**
163+ * @link fallback: When the server doesn't implement a nested navigation endpoint
164+ * (e.g. /systems/{id}/procedures returns 400), try to resolve related resources
165+ * from @link fields embedded in the parent or by searching the collection.
166+ *
167+ * Supported fallbacks:
168+ * - systems → procedures: follow systemKind@link href to fetch the procedure
169+ * - systems → deployments: search /deployments and filter by platform@link.href
170+ * - deployments → systems: follow deployedSystems@link hrefs to fetch each system
171+ */
172+ async function tryLinkFallback(link : RelatedResourceLink , parentId : string ): Promise <any []> {
173+ const parentProps = detail .value ?.properties || detail .value || {}
174+
175+ // ----- System → Procedure via systemKind@link -----
176+ if (props .resourceType === ' systems' && link .relation === ' procedures' ) {
177+ const skLink = parentProps [' systemKind@link' ]
178+ if (skLink ?.href ) {
179+ try {
180+ const acceptType = getContentType (' procedures' )
181+ // href may be absolute URL — strip base URL to get relative path
182+ let path = skLink .href
183+ if (path .startsWith (' http' )) {
184+ const idx = path .indexOf (' /api/' )
185+ if (idx !== - 1 ) path = path .substring (idx + 4 ) // strip everything before /api/
186+ else path = new URL (path ).pathname .replace (/ ^ . * \/ sensorhub/ , ' ' ) // fallback
187+ }
188+ const res = await apiFetch (path , { headers: { ' Accept' : acceptType } })
189+ if (res .ok && res .data ) {
190+ return [res .data ]
191+ }
192+ } catch { /* fallback failed silently */ }
193+ }
194+ }
195+
196+ // ----- System → Deployments via searching deployments for platform@link -----
197+ if (props .resourceType === ' systems' && link .relation === ' deployments' ) {
198+ try {
199+ const acceptType = getContentType (' deployments' )
200+ const res = await apiFetch (' /deployments?limit=100' , { headers: { ' Accept' : acceptType } })
201+ if (res .ok && res .data ) {
202+ const parsed = parseCollectionResponse (res .data )
203+ const systemUrl = ` systems/${parentId } `
204+ const matched = (parsed .items as any []).filter ((dep : any ) => {
205+ const props = dep ?.properties || dep || {}
206+ // Check platform@link.href
207+ const platformLink = props [' platform@link' ]
208+ if (platformLink ?.href && platformLink .href .includes (systemUrl )) return true
209+ // Check deployedSystems@link array
210+ const dsLinks = props [' deployedSystems@link' ]
211+ if (Array .isArray (dsLinks )) {
212+ return dsLinks .some ((l : any ) => l ?.href && l .href .includes (systemUrl ))
213+ }
214+ return false
215+ })
216+ if (matched .length > 0 ) return matched
217+ }
218+ } catch { /* fallback failed silently */ }
219+ }
220+
221+ // ----- Deployment → Systems via deployedSystems@link -----
222+ if (props .resourceType === ' deployments' && link .relation === ' systems' ) {
223+ const dsLinks = parentProps [' deployedSystems@link' ]
224+ if (Array .isArray (dsLinks ) && dsLinks .length > 0 ) {
225+ const items: any [] = []
226+ for (const l of dsLinks ) {
227+ if (! l ?.href ) continue
228+ try {
229+ let path = l .href
230+ if (path .startsWith (' http' )) {
231+ const idx = path .indexOf (' /api/' )
232+ if (idx !== - 1 ) path = path .substring (idx + 4 )
233+ }
234+ const acceptType = getContentType (' systems' )
235+ const res = await apiFetch (path , { headers: { ' Accept' : acceptType } })
236+ if (res .ok && res .data ) items .push (res .data )
237+ } catch { /* skip failed links */ }
238+ }
239+ if (items .length > 0 ) return items
240+ }
241+ }
242+
243+ // ----- Procedure → Systems via systemKind@link reverse search -----
244+ if (props .resourceType === ' procedures' && link .relation === ' systems' ) {
245+ try {
246+ const acceptType = getContentType (' systems' )
247+ const res = await apiFetch (' /systems?limit=100' , { headers: { ' Accept' : acceptType } })
248+ if (res .ok && res .data ) {
249+ const parsed = parseCollectionResponse (res .data )
250+ const procUrl = ` procedures/${parentId } `
251+ const matched = (parsed .items as any []).filter ((sys : any ) => {
252+ const props = sys ?.properties || sys || {}
253+ const skLink = props [' systemKind@link' ]
254+ return skLink ?.href && skLink .href .includes (procUrl )
255+ })
256+ if (matched .length > 0 ) return matched
257+ }
258+ } catch { /* fallback failed silently */ }
259+ }
260+
261+ return []
262+ }
263+
162264/** Fetch a single related resource collection (with filters and client-side fallback) */
163265async function fetchRelation(link : RelatedResourceLink , parentId : string ) {
164266 const state = getRelState (link .relation )
@@ -185,6 +287,19 @@ async function fetchRelation(link: RelatedResourceLink, parentId: string) {
185287 if (res .status !== 404 && res .status !== 400 ) {
186288 state .error = res .error || ' Failed to fetch'
187289 }
290+ // --- @link fallback: when server returns 400 (endpoint not implemented),
291+ // attempt to populate the panel from @link fields in the parent resource.
292+ // This handles OSH SensorHub which doesn't implement /systems/{id}/procedures
293+ // or /systems/{id}/deployments navigation endpoints. ---
294+ if (res .status === 400 && detail .value ) {
295+ const fallbackItems = await tryLinkFallback (link , parentId )
296+ if (fallbackItems .length > 0 ) {
297+ state .items = fallbackItems
298+ state .clientSideFallbackDetails .push (
299+ ` Server returned 400 for /${link .relation } endpoint — resolved ${fallbackItems .length } item(s) via @link fields `
300+ )
301+ }
302+ }
188303 return
189304 }
190305
0 commit comments