diff --git a/public/app/features/provisioning/Connection/ConnectionList.tsx b/public/app/features/provisioning/Connection/ConnectionList.tsx
new file mode 100644
index 00000000000..48c8972a9ef
--- /dev/null
+++ b/public/app/features/provisioning/Connection/ConnectionList.tsx
@@ -0,0 +1,45 @@
+import { useState } from 'react';
+
+import { t } from '@grafana/i18n';
+import { EmptyState, FilterInput, Stack } from '@grafana/ui';
+import { Connection } from 'app/api/clients/provisioning/v0alpha1';
+
+import { ConnectionListItem } from './ConnectionListItem';
+
+interface Props {
+ items: Connection[];
+}
+
+export function ConnectionList({ items }: Props) {
+ const [query, setQuery] = useState('');
+
+ const filteredItems = items.filter((item) => {
+ if (!query) {
+ return true;
+ }
+ const lowerQuery = query.toLowerCase();
+ const name = item.metadata?.name?.toLowerCase() ?? '';
+ const providerType = item.spec?.type?.toLowerCase() ?? '';
+ return name.includes(lowerQuery) || providerType.includes(lowerQuery);
+ });
+
+ return (
+
+
+
+ {filteredItems.length ? (
+ filteredItems.map((item) => )
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/public/app/features/provisioning/Connection/ConnectionListItem.tsx b/public/app/features/provisioning/Connection/ConnectionListItem.tsx
new file mode 100644
index 00000000000..4f1dca40aa4
--- /dev/null
+++ b/public/app/features/provisioning/Connection/ConnectionListItem.tsx
@@ -0,0 +1,51 @@
+import { Trans } from '@grafana/i18n';
+import { Card, LinkButton, Stack, Text, TextLink } from '@grafana/ui';
+import { Connection } from 'app/api/clients/provisioning/v0alpha1';
+
+import { RepoIcon } from '../Shared/RepoIcon';
+import { CONNECTIONS_URL } from '../constants';
+
+import { ConnectionStatusBadge } from './ConnectionStatusBadge';
+import { DeleteConnectionButton } from './DeleteConnectionButton';
+
+interface Props {
+ connection: Connection;
+}
+
+export function ConnectionListItem({ connection }: Props) {
+ const { metadata, spec, status } = connection;
+ const name = metadata?.name ?? '';
+ const providerType = spec?.type;
+ const url = spec?.url;
+
+ return (
+
+
+
+
+
+
+ {name}
+
+
+
+
+ {url && (
+
+
+ {url}
+
+
+ )}
+
+
+
+
+ View
+
+
+
+
+
+ );
+}
diff --git a/public/app/features/provisioning/Connection/ConnectionStatusBadge.tsx b/public/app/features/provisioning/Connection/ConnectionStatusBadge.tsx
new file mode 100644
index 00000000000..c12994072ad
--- /dev/null
+++ b/public/app/features/provisioning/Connection/ConnectionStatusBadge.tsx
@@ -0,0 +1,50 @@
+import { t } from '@grafana/i18n';
+import { Badge, IconName } from '@grafana/ui';
+import { ConnectionStatus } from 'app/api/clients/provisioning/v0alpha1';
+
+interface Props {
+ status?: ConnectionStatus;
+}
+
+interface BadgeConfig {
+ color: 'green' | 'red' | 'darkgrey';
+ text: string;
+ icon: IconName;
+}
+
+function getBadgeConfig(status?: ConnectionStatus): BadgeConfig {
+ if (!status) {
+ return {
+ color: 'darkgrey',
+ text: t('provisioning.connections.status-unknown', 'Unknown'),
+ icon: 'question-circle',
+ };
+ }
+
+ switch (status.state) {
+ case 'connected':
+ return {
+ color: 'green',
+ text: t('provisioning.connections.status-connected', 'Connected'),
+ icon: 'check',
+ };
+ case 'disconnected':
+ return {
+ color: 'red',
+ text: t('provisioning.connections.status-disconnected', 'Disconnected'),
+ icon: 'times-circle',
+ };
+ default:
+ return {
+ color: 'darkgrey',
+ text: t('provisioning.connections.status-unknown', 'Unknown'),
+ icon: 'question-circle',
+ };
+ }
+}
+
+export function ConnectionStatusBadge({ status }: Props) {
+ const config = getBadgeConfig(status);
+
+ return ;
+}
diff --git a/public/app/features/provisioning/Connection/ConnectionsPage.tsx b/public/app/features/provisioning/Connection/ConnectionsPage.tsx
new file mode 100644
index 00000000000..cec923071ad
--- /dev/null
+++ b/public/app/features/provisioning/Connection/ConnectionsPage.tsx
@@ -0,0 +1,57 @@
+import { t, Trans } from '@grafana/i18n';
+import { Alert, Button, EmptyState, Stack, Text } from '@grafana/ui';
+import { Page } from 'app/core/components/Page/Page';
+
+import { useConnectionList } from '../hooks/useConnectionList';
+import { getErrorMessage } from '../utils/httpUtils';
+
+import { ConnectionList } from './ConnectionList';
+
+export default function ConnectionsPage() {
+ const [items, isLoading] = useConnectionList();
+
+ const hasError = !isLoading && !items;
+ const hasNoConnections = !isLoading && items?.length === 0;
+
+ return (
+
+ Add connection
+
+ }
+ >
+
+
+ {hasError && (
+
+ {getErrorMessage(hasError)}
+
+ )}
+
+ {hasNoConnections && (
+
+
+ {t(
+ 'provisioning.connections.no-connections-message',
+ 'Add a connection to authenticate with external providers'
+ )}
+
+
+ )}
+
+ {items && items.length > 0 && }
+
+
+
+ );
+}
diff --git a/public/app/features/provisioning/Connection/DeleteConnectionButton.tsx b/public/app/features/provisioning/Connection/DeleteConnectionButton.tsx
new file mode 100644
index 00000000000..d15e206f6f6
--- /dev/null
+++ b/public/app/features/provisioning/Connection/DeleteConnectionButton.tsx
@@ -0,0 +1,47 @@
+import { useCallback, useState } from 'react';
+
+import { t, Trans } from '@grafana/i18n';
+import { reportInteraction } from '@grafana/runtime';
+import { Button, ConfirmModal } from '@grafana/ui';
+import { Connection, useDeleteConnectionMutation } from 'app/api/clients/provisioning/v0alpha1';
+
+interface Props {
+ name: string;
+ connection: Connection;
+}
+
+export function DeleteConnectionButton({ name, connection }: Props) {
+ const [deleteConnection, deleteRequest] = useDeleteConnectionMutation();
+ const [showModal, setShowModal] = useState(false);
+
+ const onConfirm = useCallback(async () => {
+ reportInteraction('grafana_provisioning_connection_deleted', {
+ connectionName: name,
+ connectionType: connection?.spec?.type ?? 'unknown',
+ });
+
+ await deleteConnection({ name });
+ setShowModal(false);
+ }, [deleteConnection, name, connection]);
+
+ const isLoading = deleteRequest.isLoading;
+
+ return (
+ <>
+
+ setShowModal(false)}
+ />
+ >
+ );
+}
diff --git a/public/app/features/provisioning/constants.ts b/public/app/features/provisioning/constants.ts
index 111ff489089..9e9fd6a3aae 100644
--- a/public/app/features/provisioning/constants.ts
+++ b/public/app/features/provisioning/constants.ts
@@ -1,4 +1,5 @@
export const PROVISIONING_URL = '/admin/provisioning';
+export const CONNECTIONS_URL = `${PROVISIONING_URL}/connections`;
export const CONNECT_URL = `${PROVISIONING_URL}/connect`;
export const GETTING_STARTED_URL = `${PROVISIONING_URL}/getting-started`;
export const UPGRADE_URL = 'https://grafana.com/profile/org/subscription';
diff --git a/public/app/features/provisioning/hooks/useConnectionList.ts b/public/app/features/provisioning/hooks/useConnectionList.ts
new file mode 100644
index 00000000000..1330a8d448c
--- /dev/null
+++ b/public/app/features/provisioning/hooks/useConnectionList.ts
@@ -0,0 +1,19 @@
+import { skipToken } from '@reduxjs/toolkit/query';
+
+import { ListConnectionApiArg, Connection, useListConnectionQuery } from 'app/api/clients/provisioning/v0alpha1';
+
+// Sort connections alphabetically by name
+export function useConnectionList(
+ options: ListConnectionApiArg | typeof skipToken = {}
+): [Connection[] | undefined, boolean] {
+ const query = useListConnectionQuery(options);
+ const collator = new Intl.Collator(undefined, { numeric: true });
+
+ const sortedItems = query.data?.items?.slice().sort((a, b) => {
+ const nameA = a.metadata?.name ?? '';
+ const nameB = b.metadata?.name ?? '';
+ return collator.compare(nameA, nameB);
+ });
+
+ return [sortedItems, query.isLoading];
+}
diff --git a/public/app/features/provisioning/utils/routes.ts b/public/app/features/provisioning/utils/routes.ts
index 891ecd090d9..7eecd835a1a 100644
--- a/public/app/features/provisioning/utils/routes.ts
+++ b/public/app/features/provisioning/utils/routes.ts
@@ -3,7 +3,7 @@ import { RouteDescriptor } from 'app/core/navigation/types';
import { DashboardRoutes } from 'app/types/dashboard';
import { checkRequiredFeatures } from '../GettingStarted/features';
-import { PROVISIONING_URL, CONNECT_URL, GETTING_STARTED_URL } from '../constants';
+import { PROVISIONING_URL, CONNECTIONS_URL, CONNECT_URL, GETTING_STARTED_URL } from '../constants';
export function getProvisioningRoutes(): RouteDescriptor[] {
if (!checkRequiredFeatures()) {
@@ -36,6 +36,12 @@ export function getProvisioningRoutes(): RouteDescriptor[] {
)
),
},
+ {
+ path: CONNECTIONS_URL,
+ component: SafeDynamicImport(
+ () => import(/* webpackChunkName: "ConnectionsPage"*/ 'app/features/provisioning/Connection/ConnectionsPage')
+ ),
+ },
{
path: `${CONNECT_URL}/:type`,
component: SafeDynamicImport(
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index a51e48d0e7f..95672edeb24 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -11797,6 +11797,23 @@
"free-tier-limit-tooltip": "Free-tier accounts are restricted to one connection",
"instance-fully-managed-tooltip": "Configuration is disabled because this instance is fully managed"
},
+ "connections": {
+ "add-connection": "Add connection",
+ "create-tooltip": "Connection creation coming soon",
+ "delete": "Delete",
+ "delete-confirm": "Are you sure you want to delete this connection? This action cannot be undone.",
+ "delete-title": "Delete connection",
+ "error-loading": "Failed to load connections",
+ "no-connections": "No connections configured",
+ "no-connections-message": "Add a connection to authenticate with external providers",
+ "no-results": "No results matching your query",
+ "page-subtitle": "View and manage your app connections",
+ "search-placeholder": "Search connections",
+ "status-connected": "Connected",
+ "status-disconnected": "Disconnected",
+ "status-unknown": "Unknown",
+ "view": "View"
+ },
"delete-repository-button": {
"button-delete": "Delete",
"confirm-delete-keep-resources": "Are you sure you want to delete the repository configuration but keep its resources?",