From 6983af3a70b815f5bb825ee179c4692d928a23b0 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 11 Oct 2023 09:19:13 -0700 Subject: [PATCH] Playlist: Add an api wrapper for playlist requests (#76308) --- .../playlist/PlaylistEditPage.test.tsx | 1 + .../features/playlist/PlaylistEditPage.tsx | 9 +- .../features/playlist/PlaylistForm.test.tsx | 1 + public/app/features/playlist/PlaylistForm.tsx | 2 +- .../playlist/PlaylistNewPage.test.tsx | 1 + .../app/features/playlist/PlaylistNewPage.tsx | 4 +- public/app/features/playlist/PlaylistPage.tsx | 9 +- .../app/features/playlist/PlaylistSrv.test.ts | 5 +- public/app/features/playlist/PlaylistSrv.ts | 9 +- public/app/features/playlist/api.ts | 161 ++++++++++++------ public/app/features/playlist/types.ts | 68 +++++--- 11 files changed, 183 insertions(+), 87 deletions(-) diff --git a/public/app/features/playlist/PlaylistEditPage.test.tsx b/public/app/features/playlist/PlaylistEditPage.test.tsx index 8807cdf88eb..48e85023995 100644 --- a/public/app/features/playlist/PlaylistEditPage.test.tsx +++ b/public/app/features/playlist/PlaylistEditPage.test.tsx @@ -76,6 +76,7 @@ describe('PlaylistEditPage', () => { fireEvent.submit(screen.getByRole('button', { name: /save/i })); await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); expect(putMock).toHaveBeenCalledWith('/api/playlists/foo', { + uid: 'foo', name: 'A Name', interval: '10s', items: [{ title: 'First item', type: 'dashboard_by_uid', order: 1, value: '1' }], diff --git a/public/app/features/playlist/PlaylistEditPage.tsx b/public/app/features/playlist/PlaylistEditPage.tsx index e5d26cb12cf..7ae0f8328ed 100644 --- a/public/app/features/playlist/PlaylistEditPage.tsx +++ b/public/app/features/playlist/PlaylistEditPage.tsx @@ -8,11 +8,9 @@ import { t, Trans } from 'app/core/internationalization'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { PlaylistForm } from './PlaylistForm'; -import { playlistAPI, updatePlaylist } from './api'; +import { getPlaylistAPI } from './api'; import { Playlist } from './types'; -const { getPlaylist } = playlistAPI; - export interface RouteParams { uid: string; } @@ -20,10 +18,11 @@ export interface RouteParams { interface Props extends GrafanaRouteComponentProps {} export const PlaylistEditPage = ({ match }: Props) => { - const playlist = useAsync(() => getPlaylist(match.params.uid), [match.params]); + const api = getPlaylistAPI(); + const playlist = useAsync(() => api.getPlaylist(match.params.uid), [match.params]); const onSubmit = async (playlist: Playlist) => { - await updatePlaylist(match.params.uid, playlist); + await api.updatePlaylist(playlist); locationService.push('/playlists'); }; diff --git a/public/app/features/playlist/PlaylistForm.test.tsx b/public/app/features/playlist/PlaylistForm.test.tsx index 3ab028ee755..366c730ed60 100644 --- a/public/app/features/playlist/PlaylistForm.test.tsx +++ b/public/app/features/playlist/PlaylistForm.test.tsx @@ -108,6 +108,7 @@ describe('PlaylistForm', () => { await userEvent.click(screen.getByRole('button', { name: /save/i })); expect(onSubmitMock).toHaveBeenCalledTimes(1); expect(onSubmitMock).toHaveBeenCalledWith({ + uid: 'foo', name: 'A test playlist', interval: '10m', items: [ diff --git a/public/app/features/playlist/PlaylistForm.tsx b/public/app/features/playlist/PlaylistForm.tsx index 7ea244db5bf..aa6c468cc9b 100644 --- a/public/app/features/playlist/PlaylistForm.tsx +++ b/public/app/features/playlist/PlaylistForm.tsx @@ -29,7 +29,7 @@ export const PlaylistForm = ({ onSubmit, playlist }: Props) => { const doSubmit = (list: Playlist) => { setSaving(true); - onSubmit({ ...list, items }); + onSubmit({ ...list, items, uid: playlist.uid }); }; return ( diff --git a/public/app/features/playlist/PlaylistNewPage.test.tsx b/public/app/features/playlist/PlaylistNewPage.test.tsx index 9034ca2059f..a85004cb8c0 100644 --- a/public/app/features/playlist/PlaylistNewPage.test.tsx +++ b/public/app/features/playlist/PlaylistNewPage.test.tsx @@ -60,6 +60,7 @@ describe('PlaylistNewPage', () => { await waitFor(() => expect(backendSrvMock).toHaveBeenCalledTimes(1)); expect(backendSrvMock).toHaveBeenCalledWith('/api/playlists', { name: 'A new name', + uid: '', interval: '5m', items: [], }); diff --git a/public/app/features/playlist/PlaylistNewPage.tsx b/public/app/features/playlist/PlaylistNewPage.tsx index 2c6a8eb5ef1..eb219d23e02 100644 --- a/public/app/features/playlist/PlaylistNewPage.tsx +++ b/public/app/features/playlist/PlaylistNewPage.tsx @@ -5,14 +5,14 @@ import { locationService } from '@grafana/runtime'; import { Page } from 'app/core/components/Page/Page'; import { PlaylistForm } from './PlaylistForm'; -import { createPlaylist, getDefaultPlaylist } from './api'; +import { getPlaylistAPI, getDefaultPlaylist } from './api'; import { Playlist } from './types'; export const PlaylistNewPage = () => { const [playlist] = useState(getDefaultPlaylist()); const onSubmit = async (playlist: Playlist) => { - await createPlaylist(playlist); + await getPlaylistAPI().createPlaylist(playlist); locationService.push('/playlists'); }; diff --git a/public/app/features/playlist/PlaylistPage.tsx b/public/app/features/playlist/PlaylistPage.tsx index 8a76495fb32..6c01ac2dcc8 100644 --- a/public/app/features/playlist/PlaylistPage.tsx +++ b/public/app/features/playlist/PlaylistPage.tsx @@ -11,15 +11,14 @@ import { contextSrv } from 'app/core/services/context_srv'; import { EmptyQueryListBanner } from './EmptyQueryListBanner'; import { PlaylistPageList } from './PlaylistPageList'; import { StartModal } from './StartModal'; -import { deletePlaylist, searchPlaylists, playlistAPI } from './api'; +import { getPlaylistAPI, searchPlaylists } from './api'; import { Playlist } from './types'; -const { getAllPlaylist } = playlistAPI; - export const PlaylistPage = () => { + const api = getPlaylistAPI(); const [forcePlaylistsFetch, setForcePlaylistsFetch] = useState(0); const [searchQuery, setSearchQuery] = useState(''); - const allPlaylists = useAsync(() => getAllPlaylist(), [forcePlaylistsFetch]); + const allPlaylists = useAsync(() => api.getAllPlaylist(), [forcePlaylistsFetch]); const playlists = useMemo(() => searchPlaylists(allPlaylists.value ?? [], searchQuery), [searchQuery, allPlaylists]); const [startPlaylist, setStartPlaylist] = useState(); @@ -31,7 +30,7 @@ export const PlaylistPage = () => { if (!playlistToDelete) { return; } - deletePlaylist(playlistToDelete.uid).finally(() => { + api.deletePlaylist(playlistToDelete.uid).finally(() => { setForcePlaylistsFetch(forcePlaylistsFetch + 1); setPlaylistToDelete(undefined); }); diff --git a/public/app/features/playlist/PlaylistSrv.test.ts b/public/app/features/playlist/PlaylistSrv.test.ts index 0c4910caae0..b5ccdc6d411 100644 --- a/public/app/features/playlist/PlaylistSrv.test.ts +++ b/public/app/features/playlist/PlaylistSrv.test.ts @@ -11,16 +11,17 @@ import { PlaylistSrv } from './PlaylistSrv'; import { Playlist, PlaylistItem } from './types'; jest.mock('./api', () => ({ - playlistAPI: { + getPlaylistAPI: () => ({ getPlaylist: jest.fn().mockReturnValue({ interval: '1s', uid: 'xyz', + name: 'The display', items: [ { type: 'dashboard_by_uid', value: 'aaa' }, { type: 'dashboard_by_uid', value: 'bbb' }, ], } as Playlist), - }, + }), loadDashboards: (items: PlaylistItem[]) => { return Promise.resolve( items.map((v) => ({ diff --git a/public/app/features/playlist/PlaylistSrv.ts b/public/app/features/playlist/PlaylistSrv.ts index 409469c9e5a..b74b620ab3d 100644 --- a/public/app/features/playlist/PlaylistSrv.ts +++ b/public/app/features/playlist/PlaylistSrv.ts @@ -4,9 +4,8 @@ import { pickBy } from 'lodash'; import { locationUtil, urlUtil, rangeUtil } from '@grafana/data'; import { locationService } from '@grafana/runtime'; -import { playlistAPI, loadDashboards } from './api'; - -const { getPlaylist } = playlistAPI; +import { getPlaylistAPI, loadDashboards } from './api'; +import { PlaylistAPI } from './types'; export const queryParamsToPreserve: { [key: string]: boolean } = { kiosk: true, @@ -23,11 +22,13 @@ export class PlaylistSrv { private numberOfLoops = 0; private declare validPlaylistUrl: string; private locationListenerUnsub?: () => void; + private api: PlaylistAPI; isPlaying = false; constructor() { this.locationUpdated = this.locationUpdated.bind(this); + this.api = getPlaylistAPI(); } next() { @@ -81,7 +82,7 @@ export class PlaylistSrv { this.locationListenerUnsub = locationService.getHistory().listen(this.locationUpdated); const urls: string[] = []; - let playlist = await getPlaylist(playlistUid); + let playlist = await this.api.getPlaylist(playlistUid); if (!playlist.items?.length) { // alert return; diff --git a/public/app/features/playlist/api.ts b/public/app/features/playlist/api.ts index 85b76ab99c4..f5987ad5bad 100644 --- a/public/app/features/playlist/api.ts +++ b/public/app/features/playlist/api.ts @@ -11,71 +11,132 @@ import { dispatch } from 'app/store/store'; import { DashboardQueryResult, getGrafanaSearcher, SearchQuery } from '../search/service'; -import { Playlist, PlaylistItem, KubernetesPlaylist, KubernetesPlaylistList, PlaylistAPI } from './types'; +import { Playlist, PlaylistItem, PlaylistAPI } from './types'; -export async function createPlaylist(playlist: Playlist) { - await withErrorHandling(() => getBackendSrv().post('/api/playlists', playlist)); +class LegacyAPI implements PlaylistAPI { + async getAllPlaylist(): Promise { + return getBackendSrv().get('/api/playlists/'); + } + + async getPlaylist(uid: string): Promise { + const p = await getBackendSrv().get(`/api/playlists/${uid}`); + await migrateInternalIDs(p); + return p; + } + + async createPlaylist(playlist: Playlist): Promise { + await withErrorHandling(() => getBackendSrv().post('/api/playlists', playlist)); + } + + async updatePlaylist(playlist: Playlist): Promise { + await withErrorHandling(() => getBackendSrv().put(`/api/playlists/${playlist.uid}`, playlist)); + } + + async deletePlaylist(uid: string): Promise { + await withErrorHandling(() => getBackendSrv().delete(`/api/playlists/${uid}`), 'Playlist deleted'); + } } -export async function updatePlaylist(uid: string, playlist: Playlist) { - await withErrorHandling(() => getBackendSrv().put(`/api/playlists/${uid}`, playlist)); +interface K8sPlaylistList { + playlists: K8sPlaylist[]; } -export async function deletePlaylist(uid: string) { - await withErrorHandling(() => getBackendSrv().delete(`/api/playlists/${uid}`), 'Playlist deleted'); +interface K8sPlaylist { + metadata: { + name: string; + }; + spec: { + name: string; + interval: string; + items: PlaylistItem[]; + }; } -export const playlistAPI: PlaylistAPI = { - getPlaylist: config.featureToggles.kubernetesPlaylists ? k8sGetPlaylist : legacyGetPlaylist, - getAllPlaylist: config.featureToggles.kubernetesPlaylists ? k8sGetAllPlaylist : legacyGetAllPlaylist, -}; +class K8sAPI implements PlaylistAPI { + readonly url = `/apis/playlist.x.grafana.com/v0alpha1/namespaces/org-${contextSrv.user.orgId}/playlists`; + readonly legacy = new LegacyAPI(); // set to null for full CRUD -/** This returns a playlist where all ids are replaced with UIDs */ -export async function k8sGetPlaylist(uid: string): Promise { - const k8splaylist = await getBackendSrv().get( - `/apis/playlist.x.grafana.com/v0alpha1/namespaces/org-${contextSrv.user.orgId}/playlists/${uid}` - ); - const playlist = k8splaylist.spec; - if (playlist.items) { + async getAllPlaylist(): Promise { + const result = await getBackendSrv().get(this.url); + console.log('getAllPlaylist', result); + const v = result.playlists.map(k8sResourceAsPlaylist); + console.log('after', v); + return v; + } + + async getPlaylist(uid: string): Promise { + const r = await getBackendSrv().get(this.url + '/' + uid); + const p = k8sResourceAsPlaylist(r); + await migrateInternalIDs(p); + return p; + } + + async createPlaylist(playlist: Playlist): Promise { + if (this.legacy) { + return this.legacy.createPlaylist(playlist); + } + await withErrorHandling(() => + getBackendSrv().post(this.url, { + apiVersion: 'playlists.grafana.com/v0alpha1', + kind: 'Playlist', + metadata: { + name: playlist.uid, + }, + spec: playlist, + }) + ); + } + + async updatePlaylist(playlist: Playlist): Promise { + if (this.legacy) { + return this.legacy.updatePlaylist(playlist); + } + await withErrorHandling(() => + getBackendSrv().put(`${this.url}/${playlist.uid}`, { + apiVersion: 'playlists.grafana.com/v0alpha1', + kind: 'Playlist', + metadata: { + name: playlist.uid, + }, + spec: { + ...playlist, + title: playlist.name, + }, + }) + ); + } + + async deletePlaylist(uid: string): Promise { + if (this.legacy) { + return this.legacy.deletePlaylist(uid); + } + await withErrorHandling(() => getBackendSrv().delete(`${this.url}/${uid}`), 'Playlist deleted'); + } +} + +// This converts a saved k8s resource into a playlist object +// the main difference is that k8s uses metdata.name as the uid +// to avoid future confusion, the display name is now called "title" +function k8sResourceAsPlaylist(r: K8sPlaylist): Playlist { + return { + ...r.spec, + uid: r.metadata.name, // replace the uid from the k8s name + }; +} + +/** @deprecated -- this migrates playlists saved with internal ids to uid */ +async function migrateInternalIDs(playlist: Playlist) { + if (playlist?.items) { for (const item of playlist.items) { if (item.type === 'dashboard_by_id') { item.type = 'dashboard_by_uid'; const uids = await getBackendSrv().get(`/api/dashboards/ids/${item.value}`); - if (uids.length) { + if (uids?.length) { item.value = uids[0]; } } } } - return playlist; -} - -export async function k8sGetAllPlaylist(): Promise { - const k8splaylists = await getBackendSrv().get( - `/apis/playlist.x.grafana.com/v0alpha1/namespaces/org-${contextSrv.user.orgId}/playlists` - ); - return k8splaylists.playlists.map((p) => p.spec); -} - -/** This returns a playlist where all ids are replaced with UIDs */ -export async function legacyGetPlaylist(uid: string): Promise { - const playlist = await getBackendSrv().get(`/api/playlists/${uid}`); - if (playlist.items) { - for (const item of playlist.items) { - if (item.type === 'dashboard_by_id') { - item.type = 'dashboard_by_uid'; - const uids = await getBackendSrv().get(`/api/dashboards/ids/${item.value}`); - if (uids.length) { - item.value = uids[0]; - } - } - } - } - return playlist; -} - -export async function legacyGetAllPlaylist(): Promise { - return getBackendSrv().get('/api/playlists/'); } async function withErrorHandling(apiCall: () => Promise, message = 'Playlist saved') { @@ -158,3 +219,7 @@ export function searchPlaylists(playlists: Playlist[], query?: string): Playlist query = query.toLowerCase(); return playlists.filter((v) => v.name.toLowerCase().includes(query!)); } + +export function getPlaylistAPI() { + return config.featureToggles.kubernetesPlaylists ? new K8sAPI() : new LegacyAPI(); +} diff --git a/public/app/features/playlist/types.ts b/public/app/features/playlist/types.ts index 19cf25f5fbf..61263cc0492 100644 --- a/public/app/features/playlist/types.ts +++ b/public/app/features/playlist/types.ts @@ -1,37 +1,65 @@ -import { PlaylistItem as PlaylistItemFromSchema } from '@grafana/schema'; - import { DashboardQueryResult } from '../search/service'; export type PlaylistMode = boolean | 'tv'; -export interface PlayListItemDTO { - id: number; - title: string; - playlistid: string; - type: 'dashboard' | 'tag'; -} - export interface PlaylistAPI { - getPlaylist(uid: string): Promise; getAllPlaylist(): Promise; -} - -export interface KubernetesPlaylistList { - playlists: KubernetesPlaylist[]; -} - -export interface KubernetesPlaylist { - spec: Playlist; + getPlaylist(uid: string): Promise; + createPlaylist(playlist: Playlist): Promise; + updatePlaylist(playlist: Playlist): Promise; + deletePlaylist(uid: string): Promise; } export interface Playlist { + /** + * Unique playlist identifier. Generated on creation, either by the + * creator of the playlist of by the application. + */ uid: string; + + /** + * Name of the playlist. + */ name: string; + + /** + * Interval sets the time between switching views in a playlist. + */ interval: string; + + /** + * The ordered list of items that the playlist will iterate over. + */ items?: PlaylistItem[]; } -export interface PlaylistItem extends PlaylistItemFromSchema { - // Loaded in the frontend +export interface PlaylistItem { + /** + * Type of the item. + */ + type: // Use an explicit dashboard + | 'dashboard_by_uid' + // find all dashboards with a given tag + | 'dashboard_by_tag' + // @deprecated use a dashboard with a given internal id + | 'dashboard_by_id'; + + /** + * Value depends on type and describes the playlist item. + * + * - dashboard_by_id: The value is an internal numerical identifier set by Grafana. This + * is not portable as the numerical identifier is non-deterministic between different instances. + * Will be replaced by dashboard_by_uid in the future. (deprecated) + * - dashboard_by_tag: The value is a tag which is set on any number of dashboards. All + * dashboards behind the tag will be added to the playlist. + * - dashboard_by_uid: The value is the dashboard UID + */ + value: string; + + /** + * Loaded at runtime by the frontend. + * + * The values are not stored in the backend database. + */ dashboards?: DashboardQueryResult[]; }