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?",