diff --git a/public/app/core/utils/shortLinks.ts b/public/app/core/utils/shortLinks.ts index b056e318c35..9dbf1d414fb 100644 --- a/public/app/core/utils/shortLinks.ts +++ b/public/app/core/utils/shortLinks.ts @@ -6,7 +6,7 @@ import { sceneGraph, SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; -import { getDashboardUrl } from 'app/features/dashboard-scene/utils/urlBuilders'; +import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; import { dispatch } from 'app/store/store'; import { ShareLinkConfiguration } from '../../features/dashboard-scene/sharing/ShareButton/utils'; diff --git a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx index 285ac26b6a0..2b75bec161f 100644 --- a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx +++ b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx @@ -15,7 +15,7 @@ import { getDataSourceWithInspector } from 'app/features/dashboard/components/In import { supportsDataQuery } from 'app/features/dashboard/components/PanelEditor/utils'; import { InspectTab } from 'app/features/inspector/types'; -import { getDashboardUrl } from '../utils/urlBuilders'; +import { getDashboardUrl } from '../utils/getDashboardUrl'; import { getDashboardSceneFor } from '../utils/utils'; import { HelpWizard } from './HelpWizard/HelpWizard'; diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 39fed18d289..d033f2da590 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -55,7 +55,8 @@ import { historySrv } from '../settings/version-history'; import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; -import { getDashboardUrl, getViewPanelUrl } from '../utils/urlBuilders'; +import { getDashboardUrl } from '../utils/getDashboardUrl'; +import { getViewPanelUrl } from '../utils/urlBuilders'; import { getClosestVizPanel, getDashboardSceneFor, diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx index 6151a44fa5f..4ff3461734e 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx @@ -9,8 +9,8 @@ import { createDashboardShareUrl, createShortLink, getShareUrlParams } from 'app import { ThemePicker } from 'app/features/dashboard/components/ShareModal/ThemePicker'; import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; +import { getDashboardUrl } from '../utils/getDashboardUrl'; import { DashboardInteractions } from '../utils/interactions'; -import { getDashboardUrl } from '../utils/urlBuilders'; import { getDashboardSceneFor } from '../utils/utils'; import { SceneShareTabState, ShareView } from './types'; diff --git a/public/app/features/dashboard-scene/sharing/SharePanelEmbedTab.tsx b/public/app/features/dashboard-scene/sharing/SharePanelEmbedTab.tsx index b679f4be329..ccd0dabfa8a 100644 --- a/public/app/features/dashboard-scene/sharing/SharePanelEmbedTab.tsx +++ b/public/app/features/dashboard-scene/sharing/SharePanelEmbedTab.tsx @@ -7,7 +7,7 @@ import { buildParams, shareDashboardType } from 'app/features/dashboard/componen import { DashboardScene } from '../scene/DashboardScene'; import { PanelTimeRange } from '../scene/PanelTimeRange'; -import { getDashboardUrl } from '../utils/urlBuilders'; +import { getDashboardUrl } from '../utils/getDashboardUrl'; import { getDashboardSceneFor, getPanelIdForVizPanel } from '../utils/utils'; import { SceneShareTabState } from './types'; diff --git a/public/app/features/dashboard-scene/utils/urlBuilders.test.ts b/public/app/features/dashboard-scene/utils/getDashboardUrl.test.ts similarity index 96% rename from public/app/features/dashboard-scene/utils/urlBuilders.test.ts rename to public/app/features/dashboard-scene/utils/getDashboardUrl.test.ts index 4e7f02369f0..f6c1456d282 100644 --- a/public/app/features/dashboard-scene/utils/urlBuilders.test.ts +++ b/public/app/features/dashboard-scene/utils/getDashboardUrl.test.ts @@ -1,4 +1,4 @@ -import { getDashboardUrl } from './urlBuilders'; +import { getDashboardUrl } from './getDashboardUrl'; describe('dashboard utils', () => { it('Can getUrl', () => { diff --git a/public/app/features/dashboard-scene/utils/getDashboardUrl.ts b/public/app/features/dashboard-scene/utils/getDashboardUrl.ts new file mode 100644 index 00000000000..71900e57115 --- /dev/null +++ b/public/app/features/dashboard-scene/utils/getDashboardUrl.ts @@ -0,0 +1,79 @@ +import { UrlQueryMap, urlUtil } from '@grafana/data'; +import { config, locationSearchToObject } from '@grafana/runtime'; + +export interface DashboardUrlOptions { + uid?: string; + slug?: string; + subPath?: string; + updateQuery?: UrlQueryMap; + /** Set to location.search to preserve current params */ + currentQueryParams: string; + /** * Returns solo panel route instead */ + soloRoute?: boolean; + /** return render url */ + render?: boolean; + /** Return an absolute URL */ + absolute?: boolean; + // Add tz to query params + timeZone?: string; + // Check if we are on the home dashboard + isHomeDashboard?: boolean; +} + +export function getDashboardUrl(options: DashboardUrlOptions) { + let path = `/d/${options.uid}`; + + if (!options.uid) { + path = '/dashboard/new'; + } + + if (options.soloRoute) { + path = `/d-solo/${options.uid}`; + } + + if (options.slug) { + path += `/${options.slug}`; + } + + if (options.subPath) { + path += options.subPath; + } + + if (options.render) { + path = '/render' + path; + + options.updateQuery = { + ...options.updateQuery, + width: options.updateQuery?.width || 1000, + height: options.updateQuery?.height || 500, + tz: options.timeZone, + }; + } + + if (options.isHomeDashboard) { + path = '/'; + } + + const params = options.currentQueryParams ? locationSearchToObject(options.currentQueryParams) : {}; + + delete params['shareView']; + + if (options.updateQuery) { + for (const key in options.updateQuery) { + // removing params with null | undefined + if (options.updateQuery[key] === null || options.updateQuery[key] === undefined) { + delete params[key]; + } else { + params[key] = options.updateQuery[key]; + } + } + } + + const relativeUrl = urlUtil.renderUrl(path, params); + + if (options.absolute) { + return config.appUrl + relativeUrl.slice(1); + } + + return relativeUrl; +} diff --git a/public/app/features/dashboard-scene/utils/urlBuilders.ts b/public/app/features/dashboard-scene/utils/urlBuilders.ts index 30d4808e302..1ee1b37ca94 100644 --- a/public/app/features/dashboard-scene/utils/urlBuilders.ts +++ b/public/app/features/dashboard-scene/utils/urlBuilders.ts @@ -1,5 +1,5 @@ -import { locationUtil, UrlQueryMap, urlUtil } from '@grafana/data'; -import { config, locationSearchToObject, locationService } from '@grafana/runtime'; +import { locationUtil } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; import { sceneGraph, VizPanel } from '@grafana/scenes'; import { contextSrv } from 'app/core/core'; import { getExploreUrl } from 'app/core/utils/explore'; @@ -7,83 +7,6 @@ import { InspectTab } from 'app/features/inspector/types'; import { getQueryRunnerFor } from './utils'; -export interface DashboardUrlOptions { - uid?: string; - slug?: string; - subPath?: string; - updateQuery?: UrlQueryMap; - /** Set to location.search to preserve current params */ - currentQueryParams: string; - /** * Returns solo panel route instead */ - soloRoute?: boolean; - /** return render url */ - render?: boolean; - /** Return an absolute URL */ - absolute?: boolean; - // Add tz to query params - timeZone?: string; - // Check if we are on the home dashboard - isHomeDashboard?: boolean; -} - -export function getDashboardUrl(options: DashboardUrlOptions) { - let path = `/d/${options.uid}`; - - if (!options.uid) { - path = '/dashboard/new'; - } - - if (options.soloRoute) { - path = `/d-solo/${options.uid}`; - } - - if (options.slug) { - path += `/${options.slug}`; - } - - if (options.subPath) { - path += options.subPath; - } - - if (options.render) { - path = '/render' + path; - - options.updateQuery = { - ...options.updateQuery, - width: options.updateQuery?.width || 1000, - height: options.updateQuery?.height || 500, - tz: options.timeZone, - }; - } - - if (options.isHomeDashboard) { - path = '/'; - } - - const params = options.currentQueryParams ? locationSearchToObject(options.currentQueryParams) : {}; - - delete params['shareView']; - - if (options.updateQuery) { - for (const key in options.updateQuery) { - // removing params with null | undefined - if (options.updateQuery[key] === null || options.updateQuery[key] === undefined) { - delete params[key]; - } else { - params[key] = options.updateQuery[key]; - } - } - } - - const relativeUrl = urlUtil.renderUrl(path, params); - - if (options.absolute) { - return config.appUrl + relativeUrl.slice(1); - } - - return relativeUrl; -} - export function getViewPanelUrl(vizPanel: VizPanel) { return locationUtil.getUrlForPartial(locationService.getLocation(), { viewPanel: vizPanel.state.key, diff --git a/public/app/features/dashboard/api/v0.test.ts b/public/app/features/dashboard/api/v0.test.ts index f70deaeb347..c21e103bb10 100644 --- a/public/app/features/dashboard/api/v0.test.ts +++ b/public/app/features/dashboard/api/v0.test.ts @@ -1,3 +1,4 @@ +import { GrafanaConfig, locationUtil } from '@grafana/data'; import { backendSrv } from 'app/core/services/backend_srv'; import { AnnoKeyFolder } from 'app/features/apiserver/types'; import { DashboardDataDTO } from 'app/types'; @@ -25,11 +26,78 @@ const mockDashboardDto: DashboardWithAccessInfo = { access: {}, }; +const saveDashboardResponse = { + kind: 'Dashboard', + apiVersion: 'dashboard.grafana.app/v0alpha1', + metadata: { + name: 'adh59cn', + namespace: 'default', + uid: '7970c819-9fa9-469e-8f8b-ba540110d81e', + resourceVersion: '26830000001', + generation: 1, + creationTimestamp: '2025-01-08T15:45:54Z', + labels: { + 'grafana.app/deprecatedInternalID': '2683', + }, + annotations: { + 'grafana.app/createdBy': 'user:u000000001', + 'grafana.app/saved-from-ui': 'Grafana v11.5.0-pre (79cd8ac894)', + }, + }, + spec: { + annotations: { + list: [ + { + builtIn: 1, + datasource: { + type: 'grafana', + uid: '-- Grafana --', + }, + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations \u0026 Alerts', + type: 'dashboard', + }, + ], + }, + description: '', + editable: true, + fiscalYearStartMonth: 0, + graphTooltip: 0, + id: null, + links: [], + panels: [], + preload: false, + refresh: '', + schemaVersion: 40, + tags: [], + templating: { + list: [], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: {}, + timezone: 'browser', + title: 'New dashboard saved', + uid: '', + version: 0, + weekStart: '', + }, +}; jest.mock('@grafana/runtime', () => ({ getBackendSrv: () => ({ get: () => mockDashboardDto, + put: jest.fn().mockResolvedValue(saveDashboardResponse), + post: jest.fn().mockResolvedValue(saveDashboardResponse), }), - config: {}, + config: { + buildInfo: { + version: '11.5.0-test-version-string', + }, + }, })); jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({ @@ -62,4 +130,106 @@ describe('v0 dashboard API', () => { expect(result.meta.folderUrl).toBe('/folder/url'); expect(result.meta.folderUid).toBe('new-folder'); }); + + describe('saveDashboard', () => { + beforeEach(() => { + locationUtil.initialize({ + config: { + appSubUrl: '', + } as GrafanaConfig, + getTimeRangeForUrl: jest.fn(), + getVariablesUrlParams: jest.fn(), + }); + }); + + describe('saving a existing dashboard', () => { + it('should provide dashboard URL', async () => { + const api = new K8sDashboardAPI(); + const result = await api.saveDashboard({ + dashboard: { + title: 'Existing dashboard', + uid: 'adh59cn', + schemaVersion: 0, + }, + message: 'test', + overwrite: false, + folderUid: 'test', + }); + + expect(result.uid).toBe('adh59cn'); + expect(result.version).toBe(0); + expect(result.url).toBe('/d/adh59cn/new-dashboard-saved'); + }); + it('should provide dashboard URL with app sub url configured', async () => { + const api = new K8sDashboardAPI(); + + locationUtil.initialize({ + config: { + appSubUrl: '/grafana', + } as GrafanaConfig, + getTimeRangeForUrl: jest.fn(), + getVariablesUrlParams: jest.fn(), + }); + + const result = await api.saveDashboard({ + dashboard: { + title: 'Existing dashboard', + uid: 'adh59cn', + schemaVersion: 0, + }, + message: 'test', + overwrite: false, + folderUid: 'test', + }); + + expect(result.uid).toBe('adh59cn'); + expect(result.version).toBe(0); + expect(result.url).toBe('/grafana/d/adh59cn/new-dashboard-saved'); + }); + }); + describe('saving a new dashboard', () => { + it('should provide dashboard URL', async () => { + const api = new K8sDashboardAPI(); + const result = await api.saveDashboard({ + dashboard: { + title: 'Existing dashboard', + schemaVersion: 0, + }, + message: 'test', + overwrite: false, + folderUid: 'test', + }); + + expect(result.uid).toBe('adh59cn'); + expect(result.version).toBe(0); + expect(result.url).toBe('/d/adh59cn/new-dashboard-saved'); + }); + + it('should provide dashboard URL with app sub url configured', async () => { + const api = new K8sDashboardAPI(); + + locationUtil.initialize({ + config: { + appSubUrl: '/grafana', + } as GrafanaConfig, + getTimeRangeForUrl: jest.fn(), + getVariablesUrlParams: jest.fn(), + }); + + const result = await api.saveDashboard({ + dashboard: { + title: 'Existing dashboard', + schemaVersion: 0, + }, + message: 'test', + overwrite: false, + folderUid: 'test', + }); + + expect(result.uid).toBe('adh59cn'); + expect(result.version).toBe(0); + expect(result.url).toBe('/grafana/d/adh59cn/new-dashboard-saved'); + }); + }); + }); }); diff --git a/public/app/features/dashboard/api/v0.ts b/public/app/features/dashboard/api/v0.ts index 3ebd6b084ae..8ec3eb77d2c 100644 --- a/public/app/features/dashboard/api/v0.ts +++ b/public/app/features/dashboard/api/v0.ts @@ -1,4 +1,6 @@ +import { locationUtil } from '@grafana/data'; import { backendSrv } from 'app/core/services/backend_srv'; +import kbn from 'app/core/utils/kbn'; import { ScopedResourceClient } from 'app/features/apiserver/client'; import { ResourceClient, @@ -7,6 +9,7 @@ import { AnnoKeyFolder, Resource, } from 'app/features/apiserver/types'; +import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/types'; @@ -60,13 +63,21 @@ export class K8sDashboardAPI implements DashboardAPI { } asSaveDashboardResponseDTO(v: Resource): SaveDashboardResponseDTO { + const url = locationUtil.assureBaseUrl( + getDashboardUrl({ + uid: v.metadata.name, + currentQueryParams: '', + slug: kbn.slugifyForUrl(v.spec.title), + }) + ); + return { uid: v.metadata.name, version: v.spec.version ?? 0, id: v.spec.id ?? 0, status: 'success', + url, slug: '', - url: '', }; }