Repository: gravitational/teleport
Pull request #54475: Web: add support for rendering status panel
Author: login: kimlisa
Labels: name: ui
name: size/md
name: health-check
description: Resource health check related
Created: 2025-05-02T06:03:48Z | Merged: 2025-05-08T04:53:55Z

part of https://github.com/gravitational/teleport/issues/20544

recommend reviewing by commit

This PR just adds support for the target health field, so that unhealthy status shows up on unified resources view, and users can click on the status badges to see more info (teleterm support will come later)

Since this PR is based on https://github.com/gravitational/teleport/pull/54474, commits to that PR, may mess up this PR, so the commits for my changes start at commit message `Return target health field with db's extracted from db_servers`

changelog: Adds support for viewing status information for databases where connection is unhealthy in the Unified Resources view in the web UI



https://github.com/user-attachments/assets/6ea94f64-56ee-413c-af2c-ffadfa66619e





Diff:
diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go
index 3f9ffcb11d55e..31731c5bb42cd 100644
--- a/lib/web/apiserver.go
+++ b/lib/web/apiserver.go
@@ -3168,7 +3168,7 @@ func (h *Handler) clusterUnifiedResourcesGet(w http.ResponseWriter, request *htt
 				unifiedResources = append(unifiedResources, ui.MakeGitServer(site.GetName(), r, enriched.RequiresRequest))
 			}
 		case types.DatabaseServer:
-			db := ui.MakeDatabase(r.GetDatabase(), accessChecker, h.cfg.DatabaseREPLRegistry, enriched.RequiresRequest)
+			db := ui.MakeDatabaseFromDatabaseServer(r, accessChecker, h.cfg.DatabaseREPLRegistry, enriched.RequiresRequest)
 			unifiedResources = append(unifiedResources, db)
 		case types.AppServer:
 			allowedAWSRoles, err := calculateAppLogins(accessChecker, r, enriched.Logins)
diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go
index f611dc9c84c84..f35b73634fe24 100644
--- a/lib/web/apiserver_test.go
+++ b/lib/web/apiserver_test.go
@@ -1348,6 +1348,7 @@ func TestUnifiedResourcesGet(t *testing.T) {
 		_, err = env.server.Auth().UpsertNode(context.Background(), node)
 		require.NoError(t, err)
 	}
+
 	// add db
 	db, err := types.NewDatabaseV3(types.Metadata{
 		Name: "dbdb",
@@ -1367,6 +1368,7 @@ func TestUnifiedResourcesGet(t *testing.T) {
 		Database: db,
 	})
 	require.NoError(t, err)
+	dbServer.SetTargetHealth(types.TargetHealth{Status: "testing-status"})
 	_, err = env.server.Auth().UpsertDatabaseServer(context.Background(), dbServer)
 	require.NoError(t, err)
 
@@ -1423,6 +1425,28 @@ func TestUnifiedResourcesGet(t *testing.T) {
 	require.NoError(t, json.Unmarshal(re.Bytes(), &res))
 	require.Empty(t, res.Items)
 
+	// query only databases
+	type dbResponse struct {
+		Items      []webui.Database `json:"items"`
+		TotalCount int              `json:"totalCount"`
+	}
+	query = url.Values{"sort": []string{"name"}, "limit": []string{"1"}, "kinds": []string{types.KindDatabase}}
+	re, err = pack.clt.Get(context.Background(), endpoint, query)
+	require.NoError(t, err)
+	dbRes := dbResponse{}
+	require.NoError(t, json.Unmarshal(re.Bytes(), &dbRes))
+	require.Len(t, dbRes.Items, 1)
+	require.ElementsMatch(t, dbRes.Items, []webui.Database{{
+		Kind:         types.KindDatabase,
+		Name:         "dbdb",
+		Type:         types.DatabaseTypeSelfHosted,
+		Labels:       []ui.Label{{Name: "env", Value: "prod"}},
+		Protocol:     "test-protocol",
+		Hostname:     "test-uri",
+		URI:          "test-uri",
+		TargetHealth: types.TargetHealth{Status: "testing-status"},
+	}})
+
 	// should return first page and have a second page
 	query = url.Values{"sort": []string{"name"}, "limit": []string{"15"}}
 	re, err = pack.clt.Get(context.Background(), endpoint, query)
@@ -4115,6 +4139,7 @@ func TestClusterDatabasesGet_NoRole(t *testing.T) {
 		Database: db,
 	})
 	require.NoError(t, err)
+	dbServer.SetTargetHealth(types.TargetHealth{Status: "testing-status"})
 
 	_, err = env.server.Auth().UpsertDatabaseServer(context.Background(), dbServer)
 	require.NoError(t, err)
@@ -4127,13 +4152,14 @@ func TestClusterDatabasesGet_NoRole(t *testing.T) {
 	require.NoError(t, json.Unmarshal(re.Bytes(), &resp))
 	require.Len(t, resp.Items, 1)
 	require.ElementsMatch(t, resp.Items, []webui.Database{{
-		Kind:     types.KindDatabase,
-		Name:     "dbdb",
-		Type:     types.DatabaseTypeSelfHosted,
-		Labels:   []ui.Label{{Name: "env", Value: "prod"}},
-		Protocol: "test-protocol",
-		Hostname: "test-uri",
-		URI:      "test-uri:1234",
+		Kind:         types.KindDatabase,
+		Name:         "dbdb",
+		Type:         types.DatabaseTypeSelfHosted,
+		Labels:       []ui.Label{{Name: "env", Value: "prod"}},
+		Protocol:     "test-protocol",
+		Hostname:     "test-uri",
+		URI:          "test-uri:1234",
+		TargetHealth: types.TargetHealth{Status: "testing-status"},
 	}})
 }
 
diff --git a/lib/web/databases.go b/lib/web/databases.go
index b701789d43f40..461fff60fc43c 100644
--- a/lib/web/databases.go
+++ b/lib/web/databases.go
@@ -303,10 +303,11 @@ func (h *Handler) handleDatabaseGetIAMPolicy(w http.ResponseWriter, r *http.Requ
 		return nil, trace.Wrap(err)
 	}
 
-	database, err := fetchDatabaseWithName(r.Context(), clt, r, databaseName)
+	dbServer, err := fetchDatabaseServerByDatabaseName(r.Context(), clt, r, databaseName)
 	if err != nil {
 		return nil, trace.Wrap(err)
 	}
+	database := dbServer.GetDatabase()
 
 	switch {
 	case database.IsAWSHosted():
@@ -818,8 +819,8 @@ func (s *databaseInteractiveSession) sendSessionMetadata() error {
 	return nil
 }
 
-// fetchDatabaseWithName fetch a database with provided database name.
-func fetchDatabaseWithName(ctx context.Context, clt resourcesAPIGetter, r *http.Request, databaseName string) (types.Database, error) {
+// fetchDatabaseServerByDatabaseName fetch a database with provided database name.
+func fetchDatabaseServerByDatabaseName(ctx context.Context, clt resourcesAPIGetter, r *http.Request, databaseName string) (types.DatabaseServer, error) {
 	resp, err := clt.ListResources(ctx, proto.ListResourcesRequest{
 		Limit:               defaults.MaxIterationLimit,
 		ResourceType:        types.KindDatabaseServer,
@@ -839,7 +840,7 @@ func fetchDatabaseWithName(ctx context.Context, clt resourcesAPIGetter, r *http.
 	case 0:
 		return nil, trace.NotFound("database %q not found", databaseName)
 	default:
-		return servers[0].GetDatabase(), nil
+		return servers[0], nil
 	}
 }
 
diff --git a/lib/web/servers.go b/lib/web/servers.go
index 09dfefaaf74ad..3dfa9fe6674db 100644
--- a/lib/web/servers.go
+++ b/lib/web/servers.go
@@ -113,25 +113,25 @@ func (h *Handler) clusterDatabasesGet(w http.ResponseWriter, r *http.Request, p
 		return nil, trace.Wrap(err)
 	}
 
-	// Make a list of all proxied databases.
-	databases := make([]*types.DatabaseV3, 0, len(page.Resources))
-	for _, server := range page.Resources {
-		databases = append(databases, server.GetDatabase().Copy())
-	}
-
 	accessChecker, err := sctx.GetUserAccessChecker()
 	if err != nil {
 		return nil, trace.Wrap(err)
 	}
 
+	uiItems := make([]webui.Database, 0, len(page.Resources))
+	for _, dbServer := range page.Resources {
+		db := webui.MakeDatabaseFromDatabaseServer(dbServer, accessChecker, h.cfg.DatabaseREPLRegistry, false /* requires reset*/)
+		uiItems = append(uiItems, db)
+	}
+
 	return listResourcesGetResponse{
-		Items:      webui.MakeDatabases(databases, accessChecker, h.cfg.DatabaseREPLRegistry),
+		Items:      uiItems,
 		StartKey:   page.NextKey,
 		TotalCount: page.Total,
 	}, nil
 }
 
-// clusterDatabaseGet returns a list of db servers in a form the UI can present.
+// clusterDatabaseGet returns a database in a form the UI can present.
 func (h *Handler) clusterDatabaseGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
 	databaseName := p.ByName("database")
 	if databaseName == "" {
@@ -143,7 +143,7 @@ func (h *Handler) clusterDatabaseGet(w http.ResponseWriter, r *http.Request, p h
 		return nil, trace.Wrap(err)
 	}
 
-	database, err := fetchDatabaseWithName(r.Context(), clt, r, databaseName)
+	dbServer, err := fetchDatabaseServerByDatabaseName(r.Context(), clt, r, databaseName)
 	if err != nil {
 		return nil, trace.Wrap(err)
 	}
@@ -153,7 +153,7 @@ func (h *Handler) clusterDatabaseGet(w http.ResponseWriter, r *http.Request, p h
 		return nil, trace.Wrap(err)
 	}
 
-	return webui.MakeDatabase(database, accessChecker, h.cfg.DatabaseREPLRegistry, false /* requiresRequest */), nil
+	return webui.MakeDatabaseFromDatabaseServer(dbServer, accessChecker, h.cfg.DatabaseREPLRegistry, false /* requiresRequest */), nil
 }
 
 // clusterDatabaseServicesList returns a list of DatabaseServices (database agents) in a form the UI can present.
diff --git a/lib/web/ui/server.go b/lib/web/ui/server.go
index 419874ee8dcc8..b53014318fcdf 100644
--- a/lib/web/ui/server.go
+++ b/lib/web/ui/server.go
@@ -314,6 +314,16 @@ type Database struct {
 	// AutoUsersEnabled is a flag to indicate the database has user auto
 	// provisioning enabled
 	AutoUsersEnabled bool `json:"auto_users_enabled,omitempty"`
+	// TargetHealth describes the health status of network connectivity
+	// reported from an agent (db_service) that is proxying this database.
+	//
+	// This field will be empty if the database was not extracted from
+	// a db_server resource. The following endpoints will set this field
+	// since these endpoints query for db_server under the hood and then
+	// extract db from it:
+	// - webapi/sites/:site/databases/:database (singular)
+	// - webapi/sites/:site/resources (unified resources)
+	TargetHealth types.TargetHealth `json:"targetHealth,omitempty"`
 }
 
 // AWS contains AWS specific fields.
@@ -393,6 +403,13 @@ func MakeDatabase(database types.Database, accessChecker services.AccessChecker,
 	return db
 }
 
+// MakeDatabaseFromDatabaseServer creates a database object with db_server target health info.
+func MakeDatabaseFromDatabaseServer(dbServer types.DatabaseServer, accessChecker services.AccessChecker, interactiveChecker DatabaseInteractiveChecker, requiresRequest bool) Database {
+	db := MakeDatabase(dbServer.GetDatabase(), accessChecker, interactiveChecker, requiresRequest)
+	db.TargetHealth = dbServer.GetTargetHealth()
+	return db
+}
+
 // MakeDatabases creates database objects.
 func MakeDatabases(databases []*types.DatabaseV3, accessChecker services.AccessChecker, interactiveChecker DatabaseInteractiveChecker) []Database {
 	uiServers := make([]Database, 0, len(databases))
diff --git a/web/packages/shared/components/SlidingSidePanel/InfoGuide/InfoGuideContext.tsx b/web/packages/shared/components/SlidingSidePanel/InfoGuide/InfoGuideContext.tsx
index a0b0dd04db166..792825a3321f8 100644
--- a/web/packages/shared/components/SlidingSidePanel/InfoGuide/InfoGuideContext.tsx
+++ b/web/packages/shared/components/SlidingSidePanel/InfoGuide/InfoGuideContext.tsx
@@ -37,6 +37,27 @@ export type InfoGuideConfig = {
    * The component that contains the guide to render.
    */
   guide: JSX.Element;
+  /**
+   * If true, means the view where this guide will get
+   * rendered already has it's own side panel. Normally
+   * the parent container that renders this guide will need
+   * to set a margin-right equal to the guide's panelWidth
+   * to make space to render the guide (so it doesn't render
+   * over existing contents), but with this flag set to true,
+   * the parent container will not set any margin-right since
+   * it's assumed the space will already be accounted for.
+   *
+   * Eg: In unified resources view (UnifiedResourcesE.tsx) in
+   * enterprise version, there exists a side panel for access request
+   * checkout. If a resource is checked out, the view will render a side
+   * panel that already pushes contents out of the way. If the guide
+   * renders, we will use the same side panel to push contents
+   * out of the way. This avoids extra widths added and width flickering
+   * if we try to conditionally push contents out of the way with the
+   * guides parent container when both the guide and the checkout
+   * is activated.
+   */
+  viewHasOwnSidePanel?: boolean;
   /**
    * Optional custom title for the guide panel.
    */
diff --git a/web/packages/shared/components/SlidingSidePanel/InfoGuide/const.ts b/web/packages/shared/components/SlidingSidePanel/InfoGuide/const.ts
index 8f194ca33ff33..3bf34728c4462 100644
--- a/web/packages/shared/components/SlidingSidePanel/InfoGuide/const.ts
+++ b/web/packages/shared/components/SlidingSidePanel/InfoGuide/const.ts
@@ -19,7 +19,7 @@
 /**
  * Used to display unified resource status info
  */
-export const resourceStatusPanelWidth = 430;
+export const resourceStatusPanelWidth = 450;
 /**
  * Used to display documentation/help/hint info
  */
diff --git a/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx b/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx
index cb8347480156d..9df012fa27a9a 100644
--- a/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx
+++ b/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx
@@ -131,6 +131,7 @@ const story = ({
               disabled: false,
             },
           ]}
+          onShowStatusInfo={() => null}
           params={mergedParams}
           setParams={() => undefined}
           pinning={pinning}
diff --git a/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx b/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx
index 84be96bde0e65..ae69d6915861e 100644
--- a/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx
+++ b/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx
@@ -57,15 +57,18 @@ import { makeAdvancedSearchQueryForLabel } from 'shared/utils/advancedSearchLabe
 
 import { ResourcesResponse } from 'teleport/services/agents';
 
+import { useInfoGuide } from '../SlidingSidePanel/InfoGuide';
 import { CardsView } from './CardsView/CardsView';
 import { FilterPanel } from './FilterPanel';
 import { ListView } from './ListView/ListView';
 import { ResourceTab } from './ResourceTab';
+import { getResourceId } from './shared/StatusInfo';
 import { mapResourceToViewItem } from './shared/viewItemsFactory';
 import {
   IncludedResourceMode,
   PinningSupport,
   SharedUnifiedResource,
+  UnifiedResourceDefinition,
   UnifiedResourcesPinning,
   UnifiedResourcesQueryParams,
 } from './types';
@@ -172,6 +175,12 @@ export interface UnifiedResourcesProps {
   updateUnifiedResourcesPreferences(
     preferences: UnifiedResourcePreferences
   ): void;
+
+  /**
+   * When called, slides opens a InfoGuideSidePanel component
+   * with selected resources status info.
+   */
+  onShowStatusInfo(resource: UnifiedResourceDefinition): void;
 }
 
 export function UnifiedResources(props: UnifiedResourcesProps) {
@@ -189,6 +198,7 @@ export function UnifiedResources(props: UnifiedResourcesProps) {
     unifiedResourcePreferences,
     ClusterDropdown,
     bulkActions = [],
+    onShowStatusInfo,
   } = props;
 
   const containerRef = useRef<HTMLDivElement>();
@@ -197,6 +207,8 @@ export function UnifiedResources(props: UnifiedResourcesProps) {
     fetch: fetchResources,
   });
 
+  const { infoGuideConfig } = useInfoGuide();
+
   const [selectedResources, setSelectedResources] = useState<string[]>([]);
   const [forceCardView, setForceCardView] = useState(false);
 
@@ -605,8 +617,13 @@ export function UnifiedResources(props: UnifiedResourcesProps) {
                   },
                 }),
                 key: generateUnifiedResourceKey(resource),
-                onShowStatusInfo: () => null,
-                showingStatusInfo: false,
+                // TODO(kimlisa): teleterm will pass this field as "null"
+                // to add support later.
+                onShowStatusInfo: () =>
+                  onShowStatusInfo ? onShowStatusInfo(resource) : null,
+                showingStatusInfo:
+                  infoGuideConfig?.id &&
+                  infoGuideConfig.id === getResourceId(resource),
               }))
             : []
         }
diff --git a/web/packages/shared/components/UnifiedResources/shared/StatusInfo.story.tsx b/web/packages/shared/components/UnifiedResources/shared/StatusInfo.story.tsx
index 1d8a138cc5542..e3759cccdf1a0 100644
--- a/web/packages/shared/components/UnifiedResources/shared/StatusInfo.story.tsx
+++ b/web/packages/shared/components/UnifiedResources/shared/StatusInfo.story.tsx
@@ -137,13 +137,13 @@ const fewDbServers: DatabaseServer[] = [
     kind: 'db_server',
     hostId: 'host-id-1',
     hostname: 'hostname-1',
-    targetHealth: { status: 'unhealthy', reason: 'reason 1' },
+    targetHealth: { status: 'unhealthy', error: 'error reason 1' },
   },
   {
     kind: 'db_server',
     hostId: 'host-id-2',
     hostname: 'hostname-2',
-    targetHealth: { status: 'unhealthy', reason: 'reason 2' },
+    targetHealth: { status: 'unhealthy', error: 'error reason 2' },
   },
   {
     kind: 'db_server',
@@ -151,7 +151,7 @@ const fewDbServers: DatabaseServer[] = [
       'host-id-long-george-washington-cherry-blossom-apple-banana-orange-chocolate-meow',
     hostname:
       'hostname-long-really-long-like-really-long-longer-pumpkin-pie-halloween',
-    targetHealth: { status: 'unknown', reason: loremTxt },
+    targetHealth: { status: 'unknown', error: loremTxt },
   },
 ];
 
diff --git a/web/packages/shared/components/UnifiedResources/shared/StatusInfo.tsx b/web/packages/shared/components/UnifiedResources/shared/StatusInfo.tsx
index b02e0cbafbcaa..efe63af93d6c4 100644
--- a/web/packages/shared/components/UnifiedResources/shared/StatusInfo.tsx
+++ b/web/packages/shared/components/UnifiedResources/shared/StatusInfo.tsx
@@ -29,9 +29,11 @@ import {
 import { ResourceIcon } from 'design/ResourceIcon';
 import { H2, H3 } from 'design/Text';
 import {
+  InfoGuideConfig,
   InfoParagraph,
   InfoTitle,
 } from 'shared/components/SlidingSidePanel/InfoGuide';
+import { resourceStatusPanelWidth } from 'shared/components/SlidingSidePanel/InfoGuide/const';
 import { useInfiniteScroll } from 'shared/hooks';
 import { Attempt } from 'shared/hooks/useAttemptNext';
 import { pluralize } from 'shared/utils/text';
@@ -257,9 +259,11 @@ function UnhealthyServerList({ servers }: { servers: SharedResourceServer[] }) {
       <Text>
         <InfoField>UUID:</InfoField> {server.hostId}
       </Text>
-      <Text>
-        <InfoField>Reason:</InfoField> {server.targetHealth?.reason}
-      </Text>
+      {server.targetHealth?.error && (
+        <Text>
+          <InfoField>Reason:</InfoField> {server.targetHealth.error}
+        </Text>
+      )}
     </Flex>
   ));
 }
@@ -267,3 +271,42 @@ function UnhealthyServerList({ servers }: { servers: SharedResourceServer[] }) {
 const InfoField = styled.span`
   font-weight: bold;
 `;
+
+/**
+ * Returns a unique id by appending the resource kind with
+ * their name/id (for most resources their id is the "name" field,
+ * other resources does not have name field, but an "id" field).
+ */
+export function getResourceId(resource: UnifiedResourceDefinition) {
+  const kind = resource.kind;
+  let id;
+  if (kind === 'node' || kind === 'git_server') {
+    id = resource.id;
+  } else {
+    id = resource.name;
+  }
+
+  return `${kind}/${id}`;
+}
+
+export function openStatusInfoPanel({
+  resource,
+  setInfoGuideConfig,
+  guide,
+  isEnterprise = false,
+}: {
+  resource: UnifiedResourceDefinition;
+  setInfoGuideConfig: (cfg: InfoGuideConfig) => void;
+  guide: JSX.Element;
+  isEnterprise?: boolean;
+}) {
+  if (resource.kind === 'db') {
+    setInfoGuideConfig({
+      guide,
+      id: getResourceId(resource),
+      title: <StatusInfoHeader resource={resource} />,
+      panelWidth: resourceStatusPanelWidth,
+      viewHasOwnSidePanel: isEnterprise,
+    });
+  }
+}
diff --git a/web/packages/shared/components/UnifiedResources/types.ts b/web/packages/shared/components/UnifiedResources/types.ts
index 322ec78bc3030..428baafb193a9 100644
--- a/web/packages/shared/components/UnifiedResources/types.ts
+++ b/web/packages/shared/components/UnifiedResources/types.ts
@@ -34,7 +34,7 @@ export type ResourceStatus = 'healthy' | 'unhealthy' | 'unknown' | '';
 
 export type ResourceTargetHealth = {
   status: ResourceStatus;
-  reason?: string;
+  error?: string;
 };
 
 export type UnifiedResourceApp = {
diff --git a/web/packages/teleport/src/Databases/fixtures/index.ts b/web/packages/teleport/src/Databases/fixtures/index.ts
index 674af73c10c1e..b42c40377eee2 100644
--- a/web/packages/teleport/src/Databases/fixtures/index.ts
+++ b/web/packages/teleport/src/Databases/fixtures/index.ts
@@ -31,9 +31,7 @@ export const databases: Database[] = [
     ],
     hostname: 'aurora-hostname',
     targetHealth: { status: 'unhealthy' },
-    // TODO(kimlisa): remove as any once we add
-    // "targetHealth" field to db response
-  } as any,
+  },
   {
     kind: 'db',
     name: 'mongodbizzle',
@@ -46,9 +44,7 @@ export const databases: Database[] = [
     ],
     hostname: 'mongo-bongo',
     targetHealth: { status: 'unknown' },
-    // TODO(kimlisa): remove as any once we add
-    // "targetHealth" field to db response
-  } as any,
+  },
   {
     kind: 'db',
     name: 'Dynamooooo',
@@ -109,9 +105,7 @@ export const databases: Database[] = [
     ],
     hostname: 'postgres-hostname',
     targetHealth: { status: 'unhealthy' },
-    // TODO(kimlisa): remove as any once we add
-    // "targetHealth" field to db response
-  } as any,
+  },
   {
     kind: 'db',
     name: 'mysql-aurora-56',
diff --git a/web/packages/teleport/src/Main/Main.tsx b/web/packages/teleport/src/Main/Main.tsx
index eac7aaf8c810d..e603c59469785 100644
--- a/web/packages/teleport/src/Main/Main.tsx
+++ b/web/packages/teleport/src/Main/Main.tsx
@@ -319,7 +319,7 @@ export const ContentMinWidth = ({ children }: { children: ReactNode }) => {
           overflow-y: auto;
           ${marginTransitionCss({
             sidePanelOpened: infoGuideSidePanelOpened,
-            panelWidth,
+            panelWidth: infoGuideConfig?.viewHasOwnSidePanel ? 0 : panelWidth,
           })}
         `}
       >
diff --git a/web/packages/teleport/src/UnifiedResources/StatusInfo.tsx b/web/packages/teleport/src/UnifiedResources/StatusInfo.tsx
index 7b939b828d23c..55bfc4c6381f5 100644
--- a/web/packages/teleport/src/UnifiedResources/StatusInfo.tsx
+++ b/web/packages/teleport/src/UnifiedResources/StatusInfo.tsx
@@ -51,6 +51,7 @@ export function StatusInfo({
           params: {
             ...params,
             query: `name == "${resource.name}"`,
+            searchAsRoles: resource.requiresRequest ? 'yes' : '',
           },
           signal,
         });
diff --git a/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx b/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx
index a4cd6eb212355..43af6c6b366fe 100644
--- a/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx
+++ b/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx
@@ -22,15 +22,21 @@ import styled from 'styled-components';
 import { Box, Flex } from 'design';
 import { Danger } from 'design/Alert';
 import { DefaultTab } from 'gen-proto-ts/teleport/userpreferences/v1/unified_resource_preferences_pb';
+import { useInfoGuide } from 'shared/components/SlidingSidePanel/InfoGuide';
 import {
   BulkAction,
   FilterKind,
   IncludedResourceMode,
   ResourceAvailabilityFilter,
   UnifiedResources as SharedUnifiedResources,
+  UnifiedResourceDefinition,
   UnifiedResourcesPinning,
   useUnifiedResourcesFetch,
 } from 'shared/components/UnifiedResources';
+import {
+  getResourceId,
+  openStatusInfoPanel,
+} from 'shared/components/UnifiedResources/shared/StatusInfo';
 
 import { useTeleport } from 'teleport';
 import AgentButtonAdd from 'teleport/components/AgentButtonAdd';
@@ -43,6 +49,7 @@ import {
   FeatureHeaderTitle,
 } from 'teleport/components/Layout';
 import { ServersideSearchPanel } from 'teleport/components/ServersideSearchPanel';
+import cfg from 'teleport/config';
 import { SearchResource } from 'teleport/Discover/SelectResource';
 import { useNoMinWidth } from 'teleport/Main';
 import {
@@ -55,6 +62,7 @@ import { useUser } from 'teleport/User/UserContext';
 import useStickyClusterId from 'teleport/useStickyClusterId';
 
 import { ResourceActionButton } from './ResourceActionButton';
+import { StatusInfo } from './StatusInfo';
 
 export function UnifiedResources() {
   const { clusterId, isLeafCluster } = useStickyClusterId();
@@ -245,10 +253,27 @@ export function ClusterResources({
     clear();
   }
 
+  const { setInfoGuideConfig } = useInfoGuide();
+  function onShowStatusInfo(resource: UnifiedResourceDefinition) {
+    openStatusInfoPanel({
+      isEnterprise: cfg.edition === 'ent',
+      resource,
+      setInfoGuideConfig,
+      guide: (
+        <StatusInfo
+          resource={resource}
+          clusterId={clusterId}
+          key={getResourceId(resource)}
+        />
+      ),
+    });
+  }
+
   return (
     <>
       {loadClusterError && <Danger>{loadClusterError}</Danger>}
       <SharedUnifiedResources
+        onShowStatusInfo={onShowStatusInfo}
         bulkActions={bulkActions}
         params={params}
         fetchResources={fetch}
diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts
index c7436a12a8c13..9784ec426076a 100644
--- a/web/packages/teleport/src/config.ts
+++ b/web/packages/teleport/src/config.ts
@@ -52,7 +52,7 @@ export type Cfg = typeof cfg;
 const cfg = {
   /** @deprecated Use cfg.edition instead. */
   isEnterprise: false,
-  edition: 'oss',
+  edition: 'oss', // oss, community, ent
   isCloud: false,
   automaticUpgrades: false,
   // TODO (avatus) this is a temporary escape hatch. Delete in v18
@@ -264,7 +264,7 @@ const cfg = {
     passwordTokenPath: '/v1/webapi/users/password/token/:tokenId?',
     changeUserPasswordPath: '/v1/webapi/users/password',
     unifiedResourcesPath:
-      '/v1/webapi/sites/:clusterId/resources?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&kinds=:kinds?&query=:query?&search=:search?&sort=:sort?&pinnedOnly=:pinnedOnly?&includedResourceMode=:includedResourceMode?',
+      '/v1/webapi/sites/:clusterId/resources?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&kinds=:kinds?&query=:query?&search=:search?&sort=:sort?&pinnedOnly=:pinnedOnly?&includedResourceMode=:includedResourceMode?&status=:status?',
     nodesPath:
       '/v1/webapi/sites/:clusterId/nodes?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?&search=:search?&sort=:sort?',
     nodesPathNoParams: '/v1/webapi/sites/:clusterId/nodes',
@@ -280,7 +280,7 @@ const cfg = {
     databasesPath: `/v1/webapi/sites/:clusterId/databases?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?&search=:search?&sort=:sort?`,
 
     databaseServer: {
-      list: `/v1/webapi/sites/:clusterId/databaseservers?&limit=:limit?&startKey=:startKey?&query=:query?`,
+      list: `/v1/webapi/sites/:clusterId/databaseservers?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?`,
     },
 
     desktopsPath: `/v1/webapi/sites/:clusterId/desktops?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?&search=:search?&sort=:sort?`,
diff --git a/web/packages/teleport/src/services/databases/makeDatabase.ts b/web/packages/teleport/src/services/databases/makeDatabase.ts
index ac82cfb7cf74e..848405579a658 100644
--- a/web/packages/teleport/src/services/databases/makeDatabase.ts
+++ b/web/packages/teleport/src/services/databases/makeDatabase.ts
@@ -21,7 +21,8 @@ import { formatDatabaseInfo } from 'shared/services/databases';
 import { Aws, Database, DatabaseServer, DatabaseService } from './types';
 
 export function makeDatabase(json: any): Database {
-  const { name, desc, protocol, type, aws, requiresRequest } = json;
+  const { name, desc, protocol, type, aws, requiresRequest, targetHealth } =
+    json;
 
   const labels = json.labels || [];
 
@@ -57,6 +58,10 @@ export function makeDatabase(json: any): Database {
     requiresRequest,
     supportsInteractive: json.supports_interactive || false,
     autoUsersEnabled: json.auto_users_enabled || false,
+    targetHealth: targetHealth && {
+      status: targetHealth.status,
+      error: targetHealth.transition_error,
+    },
   };
 }
 
@@ -104,7 +109,7 @@ export function makeDatabaseServer(json: any): DatabaseServer {
     targetHealth: status &&
       status.target_health && {
         status: status.target_health.status,
-        reason: status.target_health.transition_error,
+        error: status.target_health.transition_error,
       },
   };
 }
diff --git a/web/packages/teleport/src/services/databases/types.ts b/web/packages/teleport/src/services/databases/types.ts
index d45ceb2f7ed05..b724f495a63ae 100644
--- a/web/packages/teleport/src/services/databases/types.ts
+++ b/web/packages/teleport/src/services/databases/types.ts
@@ -55,6 +55,18 @@ export interface Database {
   requiresRequest?: boolean;
   supportsInteractive?: boolean;
   autoUsersEnabled?: boolean;
+  /**
+   * targetHealth describes the health status of network connectivity
+   * reported from an agent (db_service) that is proxying this database.
+   *
+   * This field will be empty if the database was not extracted from
+   * a db_server resource. The following endpoints will set this field
+   * since these endpoints query for db_server under the hood and then
+   * extract db from it:
+   * - webapi/sites/:site/databases/:database (singular)
+   * - webapi/sites/:site/resources (unified resources)
+   */
+  targetHealth?: ResourceTargetHealth;
 }
 
 export type DatabasesResponse = {
diff --git a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx
index 48c6c2f7efab4..eb726d2ddee8c 100644
--- a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx
+++ b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx
@@ -402,6 +402,8 @@ const Resources = memo(
         })}
       >
         <SharedUnifiedResources
+          // TODO(kimlisa): add support later
+          onShowStatusInfo={() => null}
           params={props.queryParams}
           setParams={props.onParamsChange}
           unifiedResourcePreferencesAttempt={props.userPreferencesAttempt}


Reviews:
user: login: avatus
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T15:46:29Z
user: login: kimlisa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T20:43:56Z
user: login: kimlisa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T20:44:05Z
user: login: kimlisa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T20:49:45Z
user: login: rudream
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T22:41:13Z

Review comments:
diff_hunk: @@ -257,13 +259,54 @@ function UnhealthyServerList({ servers }: { servers: SharedResourceServer[] }) {
       <Text>
         <InfoField>UUID:</InfoField> {server.hostId}
       </Text>
-      <Text>
-        <InfoField>Reason:</InfoField> {server.targetHealth?.reason}
-      </Text>
+      {server.targetHealth?.error && (
+        <Text>
+          <InfoField>Reason:</InfoField> {server.targetHealth.error}
+        </Text>
+      )}
     </Flex>
   ));
 }
 
 const InfoField = styled.span`
   font-weight: bold;
 `;
+
+/**
+ * Returns a unique id by appending the resource kind with
+ * their name/id (for most resources their id is the "name" field,
+ * other resources does not have name field, but an "id" field).
+ */
+export function getResourceId(resource: UnifiedResourceDefinition) {
+  const kind = resource.kind;
+  let id;
+  if (kind === 'node' || kind === 'git_server') {
+    id = resource.id;
+  } else {
+    id = resource.name;
+  }
+
+  return `${kind}/${id}`;
+}
+
+export function openStatusInfoPanel({
+  resource,
+  setInfoGuideConfig,
+  guide,
+  isEnterprise = false,
+}: {
+  resource: UnifiedResourceDefinition;
+  setInfoGuideConfig: (cfg: InfoGuideConfig) => void;
+  guide: JSX.Element;
+  isEnterprise?: boolean;
+}) {
+  if (resource.kind === 'db') {
+    setInfoGuideConfig({
+      guide,
+      id: getResourceId(resource),
+      title: <StatusInfoHeader resource={resource} />,
+      panelWidth: resourceStatusPanelWidth,
+      viewHasOwnSidePanel: isEnterprise,
+    });
+  }
path: web/packages/shared/components/UnifiedResources/shared/StatusInfo.tsx
user: login: avatus
body: is this safety check just so we can add more types later?
created_at: 2025-05-02T15:44:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -245,10 +253,27 @@ export function ClusterResources({
     clear();
   }
 
+  const { setInfoGuideConfig } = useInfoGuide();
+  function onShowStatusInfo(resource: UnifiedResourceDefinition) {
+    openStatusInfoPanel({
+      isEnterprise: cfg.edition === 'ent',
path: web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx
user: login: avatus
body: what will `isEnterprise` affect? 
created_at: 2025-05-02T15:45:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -319,7 +319,7 @@ export const ContentMinWidth = ({ children }: { children: ReactNode }) => {
           overflow-y: auto;
           ${marginTransitionCss({
             sidePanelOpened: infoGuideSidePanelOpened,
-            panelWidth,
+            panelWidth: infoGuideConfig?.viewHasOwnSidePanel ? 0 : panelWidth,
path: web/packages/teleport/src/Main/Main.tsx
user: login: avatus
body: does this need to have `px` after? im guessing not but just curious
created_at: 2025-05-02T15:46:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -319,7 +319,7 @@ export const ContentMinWidth = ({ children }: { children: ReactNode }) => {
           overflow-y: auto;
           ${marginTransitionCss({
             sidePanelOpened: infoGuideSidePanelOpened,
-            panelWidth,
+            panelWidth: infoGuideConfig?.viewHasOwnSidePanel ? 0 : panelWidth,
path: web/packages/teleport/src/Main/Main.tsx
user: login: kimlisa
body: no, we just pass a number, and append px at the callsite
created_at: 2025-05-06T20:43:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -257,13 +259,54 @@ function UnhealthyServerList({ servers }: { servers: SharedResourceServer[] }) {
       <Text>
         <InfoField>UUID:</InfoField> {server.hostId}
       </Text>
-      <Text>
-        <InfoField>Reason:</InfoField> {server.targetHealth?.reason}
-      </Text>
+      {server.targetHealth?.error && (
+        <Text>
+          <InfoField>Reason:</InfoField> {server.targetHealth.error}
+        </Text>
+      )}
     </Flex>
   ));
 }
 
 const InfoField = styled.span`
   font-weight: bold;
 `;
+
+/**
+ * Returns a unique id by appending the resource kind with
+ * their name/id (for most resources their id is the "name" field,
+ * other resources does not have name field, but an "id" field).
+ */
+export function getResourceId(resource: UnifiedResourceDefinition) {
+  const kind = resource.kind;
+  let id;
+  if (kind === 'node' || kind === 'git_server') {
+    id = resource.id;
+  } else {
+    id = resource.name;
+  }
+
+  return `${kind}/${id}`;
+}
+
+export function openStatusInfoPanel({
+  resource,
+  setInfoGuideConfig,
+  guide,
+  isEnterprise = false,
+}: {
+  resource: UnifiedResourceDefinition;
+  setInfoGuideConfig: (cfg: InfoGuideConfig) => void;
+  guide: JSX.Element;
+  isEnterprise?: boolean;
+}) {
+  if (resource.kind === 'db') {
+    setInfoGuideConfig({
+      guide,
+      id: getResourceId(resource),
+      title: <StatusInfoHeader resource={resource} />,
+      panelWidth: resourceStatusPanelWidth,
+      viewHasOwnSidePanel: isEnterprise,
+    });
+  }
path: web/packages/shared/components/UnifiedResources/shared/StatusInfo.tsx
user: login: kimlisa
body: yup
created_at: 2025-05-06T20:44:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -245,10 +253,27 @@ export function ClusterResources({
     clear();
   }
 
+  const { setInfoGuideConfig } = useInfoGuide();
+  function onShowStatusInfo(resource: UnifiedResourceDefinition) {
+    openStatusInfoPanel({
+      isEnterprise: cfg.edition === 'ent',
path: web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx
user: login: kimlisa
body: it sets this [field](https://github.com/gravitational/teleport/pull/54475/files#diff-5e92f33068cb57a641154d6f26a85acc0b058f62af74977f6cf4b497c311812eR40) to true. the comment explains the reason for that flag i linked, but the side panel acts like a  global modal inside a root container (for web UI it's the Main.tsx), where when rendered, the root container adds space to push contents aside for the panel, but for unified resources enterprise version, a side panel already exists for the request checkout, so we don't need the root container to push aside some space
created_at: 2025-05-06T20:49:45Z
author_association: CONTRIBUTOR

PR comments:
user: login: kimlisa
created_at: 2025-05-02T06:05:19Z
body: tagging @avatus since rafal is on vacation 🙏 
author_association: CONTRIBUTOR
user: login: zmb3
created_at: 2025-05-02T21:44:00Z
body: Love the UI, looks great.

<img width="631" alt="image" src="https://github.com/user-attachments/assets/96408c16-cac3-46b0-b0a2-a4149f91edd5" />

What's this about though? @flyinghermit maybe?
author_association: COLLABORATOR
user: login: flyinghermit
created_at: 2025-05-02T21:53:30Z
body: That looks like access request to `saml_idp_service_provider` is broken. 
author_association: CONTRIBUTOR