Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/docker_build.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: docker
# Trigger rebuild 2

on:
push:
Expand Down Expand Up @@ -40,6 +41,14 @@ jobs:
with:
platforms: linux/amd64,linux/arm64

- name: Compute image tag
id: meta
run: |
# For PRs use the source branch name; for pushes use the ref name
REF="${{ github.head_ref || github.ref_name }}"
# Replace / with - for valid Docker tags
echo "tag=${REF//\//-}" >> "$GITHUB_OUTPUT"

- name: Build and push
uses: docker/build-push-action@v5
with:
Expand All @@ -48,6 +57,6 @@ jobs:
build-args: |
BINGO=false
COMMIT_SHA=${{ github.sha }}
BUILD_BRANCH=main
BUILD_BRANCH=${{ github.head_ref || github.ref_name }}
push: true
tags: intergral/grafana:${{ github.ref_name }}
tags: intergral/grafana:${{ steps.meta.outputs.tag }}
5 changes: 5 additions & 0 deletions pkg/services/accesscontrol/acimpl/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,11 @@ func (s *Service) getCachedTeamsPermissions(ctx context.Context, user identity.R

func (s *Service) ClearUserPermissionCache(user identity.Requester) {
s.cache.Delete(accesscontrol.GetUserDirectPermissionCacheKey(user))
// Also clear basic role caches so managed role permissions for newly created
// resources are picked up on the next request.
for _, role := range accesscontrol.GetOrgRoles(user) {
s.cache.Delete(accesscontrol.GetBasicRolePermissionCacheKey(role, user.GetOrgID()))
}
}

func (s *Service) DeleteUserPermissions(ctx context.Context, orgID int64, userID int64) error {
Expand Down
5 changes: 5 additions & 0 deletions pkg/services/accesscontrol/resolvers.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ func (s *Resolvers) GetScopeAttributeMutator(orgID int64) ScopeAttributeMutator
}
}

// ClearCacheFor removes the cached scope resolution for a specific scope in an org.
func (s *Resolvers) ClearCacheFor(orgID int64, scope string) {
s.cache.Delete(getScopeCacheKey(orgID, scope))
}

// getScopeCacheKey creates an identifier to fetch and store resolution of scopes in the cache
func getScopeCacheKey(orgID int64, scope string) string {
return fmt.Sprintf("%s-%v", scope, orgID)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -573,19 +573,50 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
}
return result;
}
default:
// If reloadDashboardsOnParamsChange is on, we need to process query params for dashboard load
// Since the scene is not yet there, we need to process whatever came through URL
if (config.featureToggles.reloadDashboardsOnParamsChange) {
const queryParamsObject = processQueryParamsForDashboardLoad();
rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid, queryParamsObject);
} else {
rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid);
default: {
// In multi-instance deployments, a post-save redirect may land on an instance
// that hasn't propagated the new dashboard yet. Retry on 404/403 when afterSave is set.
const searchParams = new URLSearchParams(locationService.getLocation().search);
const isAfterSave = searchParams.has('afterSave');
const maxAttempts = isAfterSave ? 6 : 1;

for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
// If reloadDashboardsOnParamsChange is on, we need to process query params for dashboard load
// Since the scene is not yet there, we need to process whatever came through URL
if (config.featureToggles.reloadDashboardsOnParamsChange) {
const queryParamsObject = processQueryParamsForDashboardLoad();
rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid, queryParamsObject);
} else {
rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid);
}
break;
} catch (e) {
const isRetryable =
isAfterSave &&
attempt < maxAttempts - 1 &&
isFetchError(e) &&
(e.status === 404 || e.status === 403 || e.status === 500);
if (!isRetryable) {
throw e;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
}

// Strip afterSave param now that the dashboard loaded successfully
if (isAfterSave) {
searchParams.delete('afterSave');
locationService.replace({
...locationService.getLocation(),
search: searchParams.toString(),
});
}

if (route === DashboardRoutes.Embedded) {
rsp.meta.isEmbedded = true;
}
}
}

// Fix outdated URLs (e.g., old slugs from title changes) but skip during playlist navigation
Expand Down Expand Up @@ -770,13 +801,44 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
case DashboardRoutes.Public: {
return await this.dashboardLoader.loadDashboard('public', '', uid);
}
default:
rsp = await this.dashboardLoader.loadDashboard(type || 'db', slug || '', uid);
default: {
// In multi-instance deployments, a post-save redirect may land on an instance
// that hasn't propagated the new dashboard yet. Retry on 404/403/500 when afterSave is set.
const searchParams = new URLSearchParams(locationService.getLocation().search);
const isAfterSave = searchParams.has('afterSave');
const maxAttempts = isAfterSave ? 6 : 1;

for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
rsp = await this.dashboardLoader.loadDashboard(type || 'db', slug || '', uid);
break;
} catch (e) {
const isRetryable =
isAfterSave &&
attempt < maxAttempts - 1 &&
isFetchError(e) &&
(e.status === 404 || e.status === 403 || e.status === 500);
if (!isRetryable) {
throw e;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
}

// Strip afterSave param now that the dashboard loaded successfully
if (isAfterSave) {
searchParams.delete('afterSave');
locationService.replace({
...locationService.getLocation(),
search: searchParams.toString(),
});
}

if (route === DashboardRoutes.Embedded) {
rsp.metadata.annotations = rsp.metadata.annotations || {};
rsp.metadata.annotations[AnnoKeyEmbedded] = 'embedded';
}
}
}
// Fix outdated URLs (e.g., old slugs from title changes) but skip during playlist navigation
// Playlists manage their own URL generation and redirects would break the navigation flow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,13 @@ export function useSaveDashboard(isCopy = false) {
const newUrl = locationUtil.stripBaseFromUrl(resultData.url);

if (newUrl !== currentLocation.pathname) {
// Signal to the dashboard loader that this is a post-save redirect.
// In multi-instance deployments, the serving instance may not have the
// dashboard propagated yet — the loader will retry on 404/403.
const search = new URLSearchParams(currentLocation.search);
search.set('afterSave', '1');
setTimeout(() => {
locationService.push({ pathname: newUrl, search: currentLocation.search });
locationService.push({ pathname: newUrl, search: search.toString() });
});
}

Expand Down
2 changes: 1 addition & 1 deletion public/app/features/dashboard/api/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
result.meta.folderId = folder.id;
} catch (e) {
// If user has access to dashboard but not to folder, continue without folder info
if (getStatusFromError(e) !== 403) {
if (getStatusFromError(e) !== 403 && getStatusFromError(e) !== 500) {
throw new Error('Failed to load folder');
}
// we still want to save the folder uid so that we can properly handle disabling the folder picker in Settings -> General
Expand Down
2 changes: 1 addition & 1 deletion public/app/features/dashboard/api/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class K8sDashboardV2API
dashboard.metadata.annotations[AnnoKeyFolderUrl] = folder.url;
} catch (e) {
// If user has access to dashboard but not to folder, continue without folder info
if (getStatusFromError(e) !== 403) {
if (getStatusFromError(e) !== 403 && getStatusFromError(e) !== 500) {
throw new Error('Failed to load folder');
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Dashboard } from '@grafana/schema';
import appEvents from 'app/core/app_events';
import { useAppNotification } from 'app/core/copy/appNotification';
import { updateDashboardName } from 'app/core/reducers/navBarTree';
import { contextSrv } from 'app/core/services/context_srv';
import { useSaveDashboardMutation } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
Expand Down Expand Up @@ -72,6 +73,9 @@ export const useDashboardSave = (isCopy = false) => {
const newUrl = locationUtil.stripBaseFromUrl(result.url);

if (newUrl !== currentPath && result.url) {
// Refresh permissions before redirect to avoid "not allowed to view" on new dashboards.
// The save creates new permission entries that the cached user permissions don't include yet.
await contextSrv.fetchUserPermissions();
setTimeout(() => locationService.replace(newUrl));
}
if (dashboard.meta.isStarred) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ describe('DashboardPageProxy', () => {
});

it('should not render DashboardScenePage if route is Normal and has uid', async () => {
jest.spyOn(console, 'error').mockImplementation();
getDashboardScenePageStateManager().setDashboardCache('abc-def', dashMockEditable);
act(() => {
setup({
Expand Down
Loading