Compare commits

...

1 Commits

Author SHA1 Message Date
Clarity-89
64a0ebf3e0 Provisioning: Add connections page 2025-12-29 13:23:42 +02:00
9 changed files with 294 additions and 1 deletions

View File

@@ -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 (
<Stack direction={'column'} gap={3}>
<FilterInput
placeholder={t('provisioning.connections.search-placeholder', 'Search connections')}
value={query}
onChange={setQuery}
/>
<Stack direction={'column'} gap={2}>
{filteredItems.length ? (
filteredItems.map((item) => <ConnectionListItem key={item.metadata?.name} connection={item} />)
) : (
<EmptyState
variant="not-found"
message={t('provisioning.connections.no-results', 'No results matching your query')}
/>
)}
</Stack>
</Stack>
);
}

View File

@@ -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 (
<Card noMargin key={name}>
<Card.Figure>
<RepoIcon type={providerType} />
</Card.Figure>
<Card.Heading>
<Stack gap={2} direction="row" alignItems="center">
<Text variant="h3">{name}</Text>
<ConnectionStatusBadge status={status} />
</Stack>
</Card.Heading>
{url && (
<Card.Meta>
<TextLink external href={url}>
{url}
</TextLink>
</Card.Meta>
)}
<Card.Actions>
<Stack gap={1} direction="row">
<LinkButton icon="eye" href={`${CONNECTIONS_URL}/${name}`} variant="primary" size="md">
<Trans i18nKey="provisioning.connections.view">View</Trans>
</LinkButton>
<DeleteConnectionButton name={name} connection={connection} />
</Stack>
</Card.Actions>
</Card>
);
}

View File

@@ -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 <Badge color={config.color} text={config.text} icon={config.icon} />;
}

View File

@@ -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 (
<Page
navId="provisioning"
subTitle={t('provisioning.connections.page-subtitle', 'View and manage your app connections')}
actions={
<Button
variant="primary"
disabled
tooltip={t('provisioning.connections.create-tooltip', 'Connection creation coming soon')}
>
<Trans i18nKey="provisioning.connections.add-connection">Add connection</Trans>
</Button>
}
>
<Page.Contents isLoading={isLoading}>
<Stack direction={'column'} gap={3}>
{hasError && (
<Alert severity="error" title={t('provisioning.connections.error-loading', 'Failed to load connections')}>
{getErrorMessage(hasError)}
</Alert>
)}
{hasNoConnections && (
<EmptyState
variant="call-to-action"
message={t('provisioning.connections.no-connections', 'No connections configured')}
>
<Text element="p">
{t(
'provisioning.connections.no-connections-message',
'Add a connection to authenticate with external providers'
)}
</Text>
</EmptyState>
)}
{items && items.length > 0 && <ConnectionList items={items} />}
</Stack>
</Page.Contents>
</Page>
);
}

View File

@@ -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 (
<>
<Button variant="destructive" size="md" disabled={isLoading} onClick={() => setShowModal(true)}>
<Trans i18nKey="provisioning.connections.delete">Delete</Trans>
</Button>
<ConfirmModal
isOpen={showModal}
title={t('provisioning.connections.delete-title', 'Delete connection')}
body={t(
'provisioning.connections.delete-confirm',
'Are you sure you want to delete this connection? This action cannot be undone.'
)}
confirmText={t('provisioning.connections.delete', 'Delete')}
onConfirm={onConfirm}
onDismiss={() => setShowModal(false)}
/>
</>
);
}

View File

@@ -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';

View File

@@ -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];
}

View File

@@ -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(

View File

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