Playlist: Add an api wrapper for playlist requests (#76308)

This commit is contained in:
Ryan McKinley 2023-10-11 09:19:13 -07:00 committed by GitHub
parent bf7fae4bd3
commit 6983af3a70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 183 additions and 87 deletions

View File

@ -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' }],

View File

@ -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<RouteParams> {}
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');
};

View File

@ -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: [

View File

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

View File

@ -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: [],
});

View File

@ -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<Playlist>(getDefaultPlaylist());
const onSubmit = async (playlist: Playlist) => {
await createPlaylist(playlist);
await getPlaylistAPI().createPlaylist(playlist);
locationService.push('/playlists');
};

View File

@ -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<Playlist | undefined>();
@ -31,7 +30,7 @@ export const PlaylistPage = () => {
if (!playlistToDelete) {
return;
}
deletePlaylist(playlistToDelete.uid).finally(() => {
api.deletePlaylist(playlistToDelete.uid).finally(() => {
setForcePlaylistsFetch(forcePlaylistsFetch + 1);
setPlaylistToDelete(undefined);
});

View File

@ -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) => ({

View File

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

View File

@ -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<Playlist[]> {
return getBackendSrv().get<Playlist[]>('/api/playlists/');
}
async getPlaylist(uid: string): Promise<Playlist> {
const p = await getBackendSrv().get<Playlist>(`/api/playlists/${uid}`);
await migrateInternalIDs(p);
return p;
}
async createPlaylist(playlist: Playlist): Promise<void> {
await withErrorHandling(() => getBackendSrv().post('/api/playlists', playlist));
}
async updatePlaylist(playlist: Playlist): Promise<void> {
await withErrorHandling(() => getBackendSrv().put(`/api/playlists/${playlist.uid}`, playlist));
}
async deletePlaylist(uid: string): Promise<void> {
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<Playlist> {
const k8splaylist = await getBackendSrv().get<KubernetesPlaylist>(
`/apis/playlist.x.grafana.com/v0alpha1/namespaces/org-${contextSrv.user.orgId}/playlists/${uid}`
);
const playlist = k8splaylist.spec;
if (playlist.items) {
async getAllPlaylist(): Promise<Playlist[]> {
const result = await getBackendSrv().get<K8sPlaylistList>(this.url);
console.log('getAllPlaylist', result);
const v = result.playlists.map(k8sResourceAsPlaylist);
console.log('after', v);
return v;
}
async getPlaylist(uid: string): Promise<Playlist> {
const r = await getBackendSrv().get<K8sPlaylist>(this.url + '/' + uid);
const p = k8sResourceAsPlaylist(r);
await migrateInternalIDs(p);
return p;
}
async createPlaylist(playlist: Playlist): Promise<void> {
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<void> {
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<void> {
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<string[]>(`/api/dashboards/ids/${item.value}`);
if (uids.length) {
if (uids?.length) {
item.value = uids[0];
}
}
}
}
return playlist;
}
export async function k8sGetAllPlaylist(): Promise<Playlist[]> {
const k8splaylists = await getBackendSrv().get<KubernetesPlaylistList>(
`/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<Playlist> {
const playlist = await getBackendSrv().get<Playlist>(`/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<string[]>(`/api/dashboards/ids/${item.value}`);
if (uids.length) {
item.value = uids[0];
}
}
}
}
return playlist;
}
export async function legacyGetAllPlaylist(): Promise<Playlist[]> {
return getBackendSrv().get<Playlist[]>('/api/playlists/');
}
async function withErrorHandling(apiCall: () => Promise<void>, 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();
}

View File

@ -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<Playlist>;
getAllPlaylist(): Promise<Playlist[]>;
}
export interface KubernetesPlaylistList {
playlists: KubernetesPlaylist[];
}
export interface KubernetesPlaylist {
spec: Playlist;
getPlaylist(uid: string): Promise<Playlist>;
createPlaylist(playlist: Playlist): Promise<void>;
updatePlaylist(playlist: Playlist): Promise<void>;
deletePlaylist(uid: string): Promise<void>;
}
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[];
}