Compare commits

...

14 Commits

Author SHA1 Message Date
nmarrs
8a0d5ce00f Merge remote-tracking branch 'origin' into attempt_at_short_url_updates 2025-12-16 19:31:12 -08:00
nmarrs
ab220ac475 fix e2e test 2025-12-09 14:44:41 -08:00
nmarrs
411b7e4e4c try and fix e2e test 2025-12-09 13:28:17 -08:00
nmarrs
10b5351a03 fix: remove redundant type declaration in shorturl.go
Remove explicit time.Time type from referenceTime variable declaration
as it can be inferred from the right-hand side, fixing staticcheck ST1023.
2025-12-09 12:49:35 -08:00
nmarrs
43b4a6cfc1 revert go.mod change 2025-12-09 12:39:11 -08:00
nmarrs
1f913341b2 Merge remote-tracking branch 'origin' into attempt_at_short_url_updates 2025-12-09 12:36:55 -08:00
nmarrs
e22eba7ae9 Revert expiration default changes to main branch state
These changes are being handled in a separate PR branch (short-url-default-never-expire),
so removing them from this branch to avoid duplication.
2025-12-09 10:34:18 -08:00
nmarrs
b0198a66b8 chore: update workspace dependencies
Run 'make update-workspace' to sync Go workspace dependencies.
This updates go.mod and go.sum files to match the current workspace state.
2025-12-09 10:34:16 -08:00
nmarrs
12a8ffd784 Merge remote-tracking branch 'origin' into attempt_at_short_url_updates 2025-12-09 09:36:43 -08:00
nmarrs
86875733ff Merge remote-tracking branch 'origin' into attempt_at_short_url_updates 2025-12-09 07:27:15 -08:00
nmarrs
af8df0ed90 fix: resolve linter errors in ShareLinkTab
- Add noMargin prop to Field components to remove built-in margins
- Replace deprecated gf-form with TextLink component from @grafana/ui
2025-11-19 21:02:42 -08:00
nmarrs
bc4747c7f0 feat: add lockTimeRange parameter to distinguish locked vs unlocked time ranges in share URLs
- Add lockTimeRange query parameter (true/false) to share URLs to ensure
  different short URLs are generated for the same time range with different
  lock settings
- Update getShareUrlParams to include lockTimeRange parameter
- Add convertToRelativeTime helper to convert absolute timestamps to
  relative format when lock time range is disabled
- Update createDashboardShareUrl to always use current time range from
  scene graph and remove stale time params from location.search
- Update ShareLinkTab to subscribe to time range changes and rebuild URL
  dynamically
- Add comprehensive unit tests for lockTimeRange functionality
- Fix ShareLinkTab to use overrideUseLockedTime parameter for immediate
  URL updates when toggling lock time range setting
2025-11-19 20:53:21 -08:00
nmarrs
178e44423b Merge remote-tracking branch 'origin' into attempt_at_short_url_updates 2025-11-19 18:51:26 -08:00
nmarrs
2171b33477 initial commit 2025-11-19 17:49:48 -08:00
13 changed files with 847 additions and 44 deletions

View File

@@ -119,6 +119,158 @@ test.describe(
expect(responseBody.url).toContain('goto');
});
test('Short URL generation with locked time range produces different URLs', async ({
page,
gotoDashboardPage,
selectors,
}) => {
// Navigate to dashboard with specific time range
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ from: 'now-6h', to: 'now' }),
});
// Open share internally drawer
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.DashNav.newShareButton.arrowMenu).click();
// Set up response listener BEFORE opening drawer (API call happens when drawer opens with shorten URL enabled)
const createResponse1 = page.waitForResponse(
(response) => response.url().includes('/api/short-urls') && response.request().method() === 'POST'
);
await dashboardPage
.getByGrafanaSelector(selectors.pages.Dashboard.DashNav.newShareButton.menu.shareInternally)
.click();
await expect(page).toHaveURL(/.*shareView=link.*/);
// Wait for the first API response
const response1 = await createResponse1;
expect(response1.status()).toBe(200);
const responseBody1 = await response1.json();
const shortUrl1 = responseBody1.url;
expect(shortUrl1).toContain('goto');
// Ensure lock time range is enabled (default) and shorten URL is enabled
const lockTimeRangeSwitch = dashboardPage.getByGrafanaSelector(
selectors.pages.ShareDashboardDrawer.ShareInternally.lockTimeRangeSwitch
);
const shortenUrlSwitch = dashboardPage.getByGrafanaSelector(
selectors.pages.ShareDashboardDrawer.ShareInternally.shortenUrlSwitch
);
// Ensure both are checked
await expect(async () => {
const isLocked = await lockTimeRangeSwitch.isChecked();
if (!isLocked) {
await lockTimeRangeSwitch.check({ force: true });
}
const isShortened = await shortenUrlSwitch.isChecked();
if (!isShortened) {
await shortenUrlSwitch.check({ force: true });
}
}).toPass();
// Wait a moment, then trigger a rebuild to create second short URL
// Toggle a setting off and back on to force URL rebuild
await page.waitForTimeout(1000);
// Set up response listener before toggling (this will trigger URL rebuild and API call)
const createResponse2 = page.waitForResponse(
(response) => response.url().includes('/api/short-urls') && response.request().method() === 'POST'
);
// Toggle lock time range off and back on to force URL rebuild
await lockTimeRangeSwitch.uncheck({ force: true });
await page.waitForTimeout(500);
await lockTimeRangeSwitch.check({ force: true });
const response2 = await createResponse2;
expect(response2.status()).toBe(200);
const responseBody2 = await response2.json();
const shortUrl2 = responseBody2.url;
expect(shortUrl2).toContain('goto');
// When time range is locked, URLs use absolute timestamps (e.g., "2025-12-09T13:26:33Z").
// Since there's a 1-2 second delay between creating the two URLs, the absolute timestamps
// will be different, resulting in different short URLs. This is expected behavior - locked
// time ranges preserve exact timestamps, so each share link represents a specific point in time.
expect(shortUrl1).not.toBe(shortUrl2);
});
test('Short URL de-duplication with unlocked time range', async ({ page, gotoDashboardPage, selectors }) => {
// Navigate to dashboard with specific time range
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ from: 'now-6h', to: 'now' }),
});
// Open share internally drawer
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.DashNav.newShareButton.arrowMenu).click();
// Disable lock time range first, then set up response listener
const lockTimeRangeSwitch = dashboardPage.getByGrafanaSelector(
selectors.pages.ShareDashboardDrawer.ShareInternally.lockTimeRangeSwitch
);
// Set up response listener BEFORE opening drawer (API call happens when drawer opens with shorten URL enabled)
const createResponse1 = page.waitForResponse(
(response) => response.url().includes('/api/short-urls') && response.request().method() === 'POST'
);
await dashboardPage
.getByGrafanaSelector(selectors.pages.Dashboard.DashNav.newShareButton.menu.shareInternally)
.click();
await expect(page).toHaveURL(/.*shareView=link.*/);
// Wait for the first API response
const response1 = await createResponse1;
expect(response1.status()).toBe(200);
const responseBody1 = await response1.json();
const shortUrl1 = responseBody1.url;
expect(shortUrl1).toContain('goto');
// Disable lock time range
await expect(lockTimeRangeSwitch).toBeInViewport();
await expect(async () => {
await lockTimeRangeSwitch.uncheck({ force: true });
}).toPass();
// Ensure shorten URL is enabled
const shortenUrlSwitch = dashboardPage.getByGrafanaSelector(
selectors.pages.ShareDashboardDrawer.ShareInternally.shortenUrlSwitch
);
await expect(async () => {
const isShortened = await shortenUrlSwitch.isChecked();
if (!isShortened) {
await shortenUrlSwitch.check({ force: true });
}
}).toPass();
// Wait a moment, then trigger a rebuild to create second short URL
await page.waitForTimeout(1000);
// Set up response listener before toggling (this will trigger URL rebuild and API call)
const createResponse2 = page.waitForResponse(
(response) => response.url().includes('/api/short-urls') && response.request().method() === 'POST'
);
// Toggle lock time range on and back off to force URL rebuild
await lockTimeRangeSwitch.check({ force: true });
await page.waitForTimeout(500);
await lockTimeRangeSwitch.uncheck({ force: true });
const response2 = await createResponse2;
expect(response2.status()).toBe(200);
const responseBody2 = await response2.json();
const shortUrl2 = responseBody2.url;
expect(shortUrl2).toContain('goto');
// Both short URLs should be the same (de-duplication)
expect(shortUrl1).toBe(shortUrl2);
});
test('Share button gets configured link', async ({ page, gotoDashboardPage, selectors }) => {
// Navigate to dashboard with specific time range
const dashboardPage = await gotoDashboardPage({

View File

@@ -1979,11 +1979,6 @@
"count": 1
}
},
"public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx": {
"no-restricted-syntax": {
"count": 4
}
},
"public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx": {
"no-restricted-syntax": {
"count": 3

View File

@@ -50,14 +50,14 @@ func (hs *HTTPServer) createShortURL(c *contextmodel.ReqContext) response.Respon
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Err(shorturls.ErrShortURLBadRequest.Errorf("bad request data: %w", err))
}
hs.log.Debug("Received request to create short URL", "path", cmd.Path)
hs.log.Debug("Received request to create short URL", "path", cmd.Path, "uid", cmd.UID)
shortURL, err := hs.ShortURLService.CreateShortURL(c.Req.Context(), c.SignedInUser, cmd)
if err != nil {
return response.Err(err)
}
shortURLDTO := hs.ShortURLService.ConvertShortURLToDTO(shortURL, hs.Cfg.AppURL)
c.Logger.Debug("Created short URL", "url", shortURLDTO.URL)
c.Logger.Debug("Created short URL", "url", shortURLDTO.URL, "uid", shortURL.Uid, "signature", shortURL.Signature)
return response.JSON(http.StatusOK, shortURLDTO)
}

View File

@@ -20,6 +20,7 @@ type ShortUrl struct {
OrgId int64 `json:"-"`
Uid string `json:"uid"`
Path string `json:"path"`
Signature string `json:"-" xorm:"signature"`
CreatedBy int64 `json:"-"`
CreatedAt int64 `json:"-"`
LastSeenAt int64 `json:"lastSeenAt"`

View File

@@ -2,8 +2,11 @@ package shorturlimpl
import (
"context"
"crypto/sha256"
"fmt"
"net/url"
"path"
"sort"
"strings"
"time"
@@ -16,6 +19,143 @@ import (
var getTime = time.Now
// normalizeTimeParam normalizes absolute timestamps to relative ranges when possible.
// If both from and to are recent absolute timestamps (within last 24h), it normalizes them
// to a relative representation based on their offset from now.
func normalizeTimeParam(paramName, paramValue string, now time.Time) string {
// Try to parse as ISO timestamp
t, err := time.Parse(time.RFC3339, paramValue)
if err != nil {
// Try parsing with nanoseconds
t, err = time.Parse(time.RFC3339Nano, paramValue)
if err != nil {
// Not an ISO timestamp, return as-is (might be relative like "now-6h")
return paramValue
}
}
// Only normalize timestamps from the last 24 hours
// This prevents normalizing old absolute timestamps that should stay absolute
if t.Before(now.Add(-24 * time.Hour)) {
return paramValue
}
// Calculate offset from now
offset := now.Sub(t)
// For "to" parameter, it might be at or slightly after now
// For "from" parameter, it's typically before now
// Handle both cases
if offset >= 0 {
// t is in the past or at now
offsetMinutes := int(offset.Minutes())
if offsetMinutes == 0 {
return "now"
}
// Convert to relative format
if offsetMinutes < 60 {
return fmt.Sprintf("now-%dm", offsetMinutes)
} else if offsetMinutes < 1440 { // 24 hours
hours := offsetMinutes / 60
return fmt.Sprintf("now-%dh", hours)
}
// For longer offsets, keep as absolute timestamp but round to minute precision
return t.Truncate(time.Minute).Format(time.RFC3339)
} else {
// t is in the future (shouldn't happen for "from", but might for "to")
// If very close to now (within 1 minute), normalize to "now"
futureOffset := -offset
if futureOffset < time.Minute {
return "now"
}
// Otherwise keep as absolute timestamp
return t.Truncate(time.Minute).Format(time.RFC3339)
}
}
// normalizePath normalizes a URL path by sorting query parameters alphabetically
// and normalizing recent absolute timestamps to relative ranges.
func normalizePath(pathStr string) string {
// Try to parse as URL to separate path and query
parsedURL, err := url.Parse(pathStr)
if err != nil {
// If parsing fails, return as-is (no query params to normalize)
return pathStr
}
// If no query parameters, return path as-is
if parsedURL.RawQuery == "" {
return pathStr
}
// Parse query parameters
values := parsedURL.Query()
// Normalize time parameters if present
now := getTime()
// If "to" is present and very close to now (within 5 minutes), use it as the reference point
// This handles the case where "to" represents "now" at the time the URL was created
var referenceTime = now
if toVal, hasTo := values["to"]; hasTo && len(toVal) > 0 {
if toTime, err := time.Parse(time.RFC3339, toVal[0]); err == nil {
// Try with nanoseconds too
if err != nil {
toTime, err = time.Parse(time.RFC3339Nano, toVal[0])
}
if err == nil {
// If "to" is within 5 minutes of now (past or future), use it as reference
// This means the URL was created recently and "to" represents "now"
timeDiff := now.Sub(toTime)
if timeDiff < 5*time.Minute && timeDiff > -5*time.Minute {
referenceTime = toTime
}
}
}
}
if fromVal, hasFrom := values["from"]; hasFrom && len(fromVal) > 0 {
normalized := normalizeTimeParam("from", fromVal[0], referenceTime)
values["from"] = []string{normalized}
}
if toVal, hasTo := values["to"]; hasTo && len(toVal) > 0 {
normalized := normalizeTimeParam("to", toVal[0], referenceTime)
values["to"] = []string{normalized}
}
// Sort keys for consistent ordering
keys := make([]string, 0, len(values))
for k := range values {
keys = append(keys, k)
}
sort.Strings(keys)
// Rebuild query string with sorted keys
var queryParts []string
for _, k := range keys {
// Sort values for each key as well (in case of multiple values)
vals := values[k]
sort.Strings(vals)
for _, v := range vals {
queryParts = append(queryParts, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(v)))
}
}
// Reconstruct normalized path
normalizedQuery := strings.Join(queryParts, "&")
parsedURL.RawQuery = normalizedQuery
return parsedURL.String()
}
// generateSignature creates a SHA256 hash of orgID and normalized path
// to enable de-duplication of short URLs.
func generateSignature(orgID int64, pathStr string) string {
normalizedPath := normalizePath(pathStr)
input := fmt.Sprintf("%d:%s", orgID, normalizedPath)
hash := sha256.Sum256([]byte(input))
return fmt.Sprintf("%x", hash)
}
type ShortURLService struct {
SQLStore store
}
@@ -50,6 +190,34 @@ func (s ShortURLService) CreateShortURL(ctx context.Context, user identity.Reque
return nil, shorturls.ErrShortURLInvalidPath.Errorf("path cannot contain '../': %s", relPath)
}
orgID := user.GetOrgID()
// Generate signature for de-duplication
// If UID is provided, include it in signature to make it unique per UID (bypasses de-duplication)
// If UID is not provided, signature is based on path only (enables de-duplication)
var signature string
if cmd.UID == "" {
signature = generateSignature(orgID, relPath)
// Check if a short URL with the same signature already exists
existingShortURL, err := s.SQLStore.GetBySignature(ctx, orgID, signature)
if err != nil {
if !shorturls.ErrShortURLNotFound.Is(err) {
return nil, shorturls.ErrShortURLInternal.Errorf("failed to check existing short URL by signature: %w", err)
}
// Not found, continue to create new one
} else if existingShortURL != nil {
// Found existing short URL with same signature, update LastSeenAt and return it
if err := s.SQLStore.Update(ctx, existingShortURL); err != nil {
return nil, shorturls.ErrShortURLInternal.Errorf("failed to update existing short URL: %w", err)
}
return existingShortURL, nil
}
} else {
// Include UID in signature to make it unique per custom UID
signature = generateSignature(orgID, relPath+":"+cmd.UID)
}
uid := cmd.UID
if uid == "" {
uid = util.GenerateShortUID()
@@ -74,14 +242,28 @@ func (s ShortURLService) CreateShortURL(ctx context.Context, user identity.Reque
now := time.Now().Unix()
shortURL := shorturls.ShortUrl{
OrgId: user.GetOrgID(),
OrgId: orgID,
Uid: uid,
Path: relPath,
Signature: signature,
CreatedAt: now,
}
shortURL.CreatedBy, _ = user.GetInternalID()
if err := s.SQLStore.Insert(ctx, &shortURL); err != nil {
// Handle potential race condition: if unique constraint violation on (org_id, signature)
// another request may have created the same short URL concurrently
if cmd.UID == "" && signature != "" {
// Check again if it was created by another request
existingShortURL, retryErr := s.SQLStore.GetBySignature(ctx, orgID, signature)
if retryErr == nil && existingShortURL != nil {
// Found it, update LastSeenAt and return it
if updateErr := s.SQLStore.Update(ctx, existingShortURL); updateErr != nil {
return nil, shorturls.ErrShortURLInternal.Errorf("failed to update existing short URL after race condition: %w", updateErr)
}
return existingShortURL, nil
}
}
return nil, shorturls.ErrShortURLInternal.Errorf("failed to insert shorturl: %w", err)
}

View File

@@ -2,6 +2,7 @@ package shorturlimpl
import (
"context"
"fmt"
"testing"
"time"
@@ -22,7 +23,7 @@ func TestMain(m *testing.M) {
func TestIntegrationShortURLService(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
user := &user.SignedInUser{UserID: 1}
testUser := &user.SignedInUser{UserID: 1}
store := db.InitTestDB(t)
t.Run("User can create and read short URLs", func(t *testing.T) {
@@ -32,12 +33,12 @@ func TestIntegrationShortURLService(t *testing.T) {
service := ShortURLService{SQLStore: &sqlStore{db: store}}
newShortURL, err := service.CreateShortURL(context.Background(), user, cmd)
newShortURL, err := service.CreateShortURL(context.Background(), testUser, cmd)
require.NoError(t, err)
require.NotNil(t, newShortURL)
require.NotEmpty(t, newShortURL.Uid)
existingShortURL, err := service.GetShortURLByUID(context.Background(), user, newShortURL.Uid)
existingShortURL, err := service.GetShortURLByUID(context.Background(), testUser, newShortURL.Uid)
require.NoError(t, err)
require.NotNil(t, existingShortURL)
require.Equal(t, cmd.Path, existingShortURL.Path)
@@ -56,13 +57,18 @@ func TestIntegrationShortURLService(t *testing.T) {
err := service.UpdateLastSeenAt(context.Background(), existingShortURL)
require.NoError(t, err)
updatedShortURL, err := service.GetShortURLByUID(context.Background(), user, existingShortURL.Uid)
updatedShortURL, err := service.GetShortURLByUID(context.Background(), testUser, existingShortURL.Uid)
require.NoError(t, err)
require.Equal(t, expectedTime.Unix(), updatedShortURL.LastSeenAt)
})
t.Run("and stale short urls can be deleted", func(t *testing.T) {
staleShortURL, err := service.CreateShortURL(context.Background(), user, cmd)
// Use a custom UID to bypass de-duplication and create a new stale URL
staleCmd := &dtos.CreateShortURLCmd{
Path: cmd.Path,
UID: "stale-url-uid",
}
staleShortURL, err := service.CreateShortURL(context.Background(), testUser, staleCmd)
require.NoError(t, err)
require.NotNil(t, staleShortURL)
require.NotEmpty(t, staleShortURL.Uid)
@@ -74,7 +80,7 @@ func TestIntegrationShortURLService(t *testing.T) {
require.Equal(t, int64(1), cmd.NumDeleted)
t.Run("and previously accessed short urls will still exist", func(t *testing.T) {
updatedShortURL, err := service.GetShortURLByUID(context.Background(), user, existingShortURL.Uid)
updatedShortURL, err := service.GetShortURLByUID(context.Background(), testUser, existingShortURL.Uid)
require.NoError(t, err)
require.NotNil(t, updatedShortURL)
})
@@ -92,7 +98,7 @@ func TestIntegrationShortURLService(t *testing.T) {
t.Run("User cannot look up nonexistent short URLs", func(t *testing.T) {
service := ShortURLService{SQLStore: &sqlStore{db: store}}
shortURL, err := service.GetShortURLByUID(context.Background(), user, "testnotfounduid")
shortURL, err := service.GetShortURLByUID(context.Background(), testUser, "testnotfounduid")
require.Error(t, err)
require.True(t, shorturls.ErrShortURLNotFound.Is(err))
require.Nil(t, shortURL)
@@ -107,26 +113,26 @@ func TestIntegrationShortURLService(t *testing.T) {
Path: "/path?test=true",
}
newShortURL, err := service.CreateShortURL(ctx, user, cmd)
newShortURL, err := service.CreateShortURL(ctx, testUser, cmd)
require.ErrorIs(t, err, shorturls.ErrShortURLAbsolutePath)
require.Nil(t, newShortURL)
cmd2 := &dtos.CreateShortURLCmd{
Path: "path/../test?test=true",
}
newShortURL, err = service.CreateShortURL(ctx, user, cmd2)
newShortURL, err = service.CreateShortURL(ctx, testUser, cmd2)
require.ErrorIs(t, err, shorturls.ErrShortURLInvalidPath)
require.Nil(t, newShortURL)
cmd3 := &dtos.CreateShortURLCmd{
Path: "../path/test?test=true",
}
newShortURL, err = service.CreateShortURL(ctx, user, cmd3)
newShortURL, err = service.CreateShortURL(ctx, testUser, cmd3)
require.ErrorIs(t, err, shorturls.ErrShortURLInvalidPath)
require.Nil(t, newShortURL)
})
t.Run("The same URL will generate different entries", func(t *testing.T) {
t.Run("The same URL will return the same entry (de-duplication)", func(t *testing.T) {
service := ShortURLService{SQLStore: &sqlStore{db: store}}
ctx := context.Background()
@@ -134,20 +140,202 @@ func TestIntegrationShortURLService(t *testing.T) {
cmd := &dtos.CreateShortURLCmd{
Path: "mock/path?test=true",
}
newShortURL1, err := service.CreateShortURL(ctx, user, cmd)
newShortURL1, err := service.CreateShortURL(ctx, testUser, cmd)
require.NoError(t, err)
require.NotNil(t, newShortURL1)
require.NotEmpty(t, newShortURL1.Uid)
require.NotEmpty(t, newShortURL1.Signature)
newShortURL2, err := service.CreateShortURL(ctx, testUser, cmd)
require.NoError(t, err)
require.NotNil(t, newShortURL2)
// Should return the same short URL (de-duplication)
require.Equal(t, newShortURL1.Uid, newShortURL2.Uid)
require.Equal(t, newShortURL1.Path, newShortURL2.Path)
require.Equal(t, newShortURL1.Signature, newShortURL2.Signature)
})
t.Run("Path normalization: different query param orders produce same signature", func(t *testing.T) {
service := ShortURLService{SQLStore: &sqlStore{db: store}}
ctx := context.Background()
cmd1 := &dtos.CreateShortURLCmd{
Path: "mock/path?a=1&b=2",
}
newShortURL1, err := service.CreateShortURL(ctx, testUser, cmd1)
require.NoError(t, err)
require.NotNil(t, newShortURL1)
require.NotEmpty(t, newShortURL1.Signature)
cmd2 := &dtos.CreateShortURLCmd{
Path: "mock/path?b=2&a=1",
}
newShortURL2, err := service.CreateShortURL(ctx, testUser, cmd2)
require.NoError(t, err)
require.NotNil(t, newShortURL2)
// Should return the same short URL due to path normalization
require.Equal(t, newShortURL1.Uid, newShortURL2.Uid)
require.Equal(t, newShortURL1.Signature, newShortURL2.Signature)
})
t.Run("Time normalization: absolute timestamps with same relative range produce same signature", func(t *testing.T) {
service := ShortURLService{SQLStore: &sqlStore{db: store}}
ctx := context.Background()
// Simulate two absolute timestamps that represent the same relative range (now-6h to now)
// These would be created a few seconds apart
now := time.Now()
from1 := now.Add(-6 * time.Hour).Add(-10 * time.Second)
to1 := now.Add(-10 * time.Second)
from2 := now.Add(-6 * time.Hour).Add(-5 * time.Second)
to2 := now.Add(-5 * time.Second)
cmd1 := &dtos.CreateShortURLCmd{
Path: fmt.Sprintf("d/test-dashboard?orgId=1&from=%s&to=%s&timezone=browser",
from1.Format(time.RFC3339), to1.Format(time.RFC3339)),
}
newShortURL1, err := service.CreateShortURL(ctx, testUser, cmd1)
require.NoError(t, err)
require.NotNil(t, newShortURL1)
require.NotEmpty(t, newShortURL1.Signature)
cmd2 := &dtos.CreateShortURLCmd{
Path: fmt.Sprintf("d/test-dashboard?orgId=1&from=%s&to=%s&timezone=browser",
from2.Format(time.RFC3339), to2.Format(time.RFC3339)),
}
newShortURL2, err := service.CreateShortURL(ctx, testUser, cmd2)
require.NoError(t, err)
require.NotNil(t, newShortURL2)
// Should return the same short URL due to time normalization
// Both represent "now-6h to now" so they should have the same signature
require.Equal(t, newShortURL1.Uid, newShortURL2.Uid)
require.Equal(t, newShortURL1.Signature, newShortURL2.Signature)
})
t.Run("Time normalization: old absolute timestamps are not normalized", func(t *testing.T) {
service := ShortURLService{SQLStore: &sqlStore{db: store}}
ctx := context.Background()
// Old timestamps (more than 24 hours ago) should not be normalized
oldTime := time.Now().Add(-48 * time.Hour)
oldFrom := oldTime.Add(-6 * time.Hour)
oldTo := oldTime
cmd1 := &dtos.CreateShortURLCmd{
Path: fmt.Sprintf("d/test-dashboard?orgId=1&from=%s&to=%s",
oldFrom.Format(time.RFC3339), oldTo.Format(time.RFC3339)),
}
newShortURL1, err := service.CreateShortURL(ctx, testUser, cmd1)
require.NoError(t, err)
require.NotNil(t, newShortURL1)
require.NotEmpty(t, newShortURL1.Signature)
// Create another with slightly different old timestamp
oldFrom2 := oldTime.Add(-6 * time.Hour).Add(1 * time.Minute)
oldTo2 := oldTime.Add(1 * time.Minute)
cmd2 := &dtos.CreateShortURLCmd{
Path: fmt.Sprintf("d/test-dashboard?orgId=1&from=%s&to=%s",
oldFrom2.Format(time.RFC3339), oldTo2.Format(time.RFC3339)),
}
newShortURL2, err := service.CreateShortURL(ctx, testUser, cmd2)
require.NoError(t, err)
require.NotNil(t, newShortURL2)
// Old timestamps should NOT be normalized, so they should have different signatures
require.NotEqual(t, newShortURL1.Uid, newShortURL2.Uid)
require.NotEqual(t, newShortURL1.Signature, newShortURL2.Signature)
})
t.Run("Time normalization: relative time ranges are preserved", func(t *testing.T) {
service := ShortURLService{SQLStore: &sqlStore{db: store}}
ctx := context.Background()
cmd1 := &dtos.CreateShortURLCmd{
Path: "d/test-dashboard?orgId=1&from=now-6h&to=now&timezone=browser",
}
newShortURL1, err := service.CreateShortURL(ctx, testUser, cmd1)
require.NoError(t, err)
require.NotNil(t, newShortURL1)
require.NotEmpty(t, newShortURL1.Signature)
cmd2 := &dtos.CreateShortURLCmd{
Path: "d/test-dashboard?orgId=1&from=now-6h&to=now&timezone=browser",
}
newShortURL2, err := service.CreateShortURL(ctx, testUser, cmd2)
require.NoError(t, err)
require.NotNil(t, newShortURL2)
// Relative time ranges should produce the same signature
require.Equal(t, newShortURL1.Uid, newShortURL2.Uid)
require.Equal(t, newShortURL1.Signature, newShortURL2.Signature)
})
t.Run("Different orgs: same path creates different short URLs", func(t *testing.T) {
service := ShortURLService{SQLStore: &sqlStore{db: store}}
ctx := context.Background()
user1 := &user.SignedInUser{UserID: 1, OrgID: 1}
user2 := &user.SignedInUser{UserID: 2, OrgID: 2}
cmd := &dtos.CreateShortURLCmd{
Path: "mock/path?test=true",
}
newShortURL1, err := service.CreateShortURL(ctx, user1, cmd)
require.NoError(t, err)
require.NotNil(t, newShortURL1)
require.NotEmpty(t, newShortURL1.Uid)
newShortURL2, err := service.CreateShortURL(ctx, user, cmd)
newShortURL2, err := service.CreateShortURL(ctx, user2, cmd)
require.NoError(t, err)
require.NotNil(t, newShortURL2)
require.NotEmpty(t, newShortURL2.Uid)
// Should create different short URLs for different orgs
require.NotEqual(t, newShortURL1.Uid, newShortURL2.Uid)
require.NotEqual(t, newShortURL1.Signature, newShortURL2.Signature)
require.Equal(t, newShortURL1.Path, newShortURL2.Path)
})
t.Run("Custom UID bypasses de-duplication", func(t *testing.T) {
service := ShortURLService{SQLStore: &sqlStore{db: store}}
ctx := context.Background()
cmd1 := &dtos.CreateShortURLCmd{
Path: "mock/path?test=true",
UID: "custom-uid-3",
}
newShortURL1, err := service.CreateShortURL(ctx, testUser, cmd1)
require.NoError(t, err)
require.NotNil(t, newShortURL1)
require.Equal(t, "custom-uid-3", newShortURL1.Uid)
require.NotEmpty(t, newShortURL1.Signature) // Custom UID has signature including UID
cmd2 := &dtos.CreateShortURLCmd{
Path: "mock/path?test=true",
UID: "custom-uid-4",
}
newShortURL2, err := service.CreateShortURL(ctx, testUser, cmd2)
require.NoError(t, err)
require.NotNil(t, newShortURL2)
require.Equal(t, "custom-uid-4", newShortURL2.Uid)
require.NotEmpty(t, newShortURL2.Signature) // Custom UID has signature including UID
// Different UIDs, same path - both created because UID was provided
// Signatures should be different because they include the UID
require.NotEqual(t, newShortURL1.Uid, newShortURL2.Uid)
require.NotEqual(t, newShortURL1.Signature, newShortURL2.Signature)
})
t.Run("Create URL providing the UID", func(t *testing.T) {
service := ShortURLService{SQLStore: &sqlStore{db: store}}
@@ -157,7 +345,7 @@ func TestIntegrationShortURLService(t *testing.T) {
Path: "mock/path?test=true",
UID: "custom-uid",
}
newShortURL1, err := service.CreateShortURL(ctx, user, cmd)
newShortURL1, err := service.CreateShortURL(ctx, testUser, cmd)
require.NoError(t, err)
require.NotNil(t, newShortURL1)
require.Equal(t, cmd.UID, newShortURL1.Uid)
@@ -172,12 +360,12 @@ func TestIntegrationShortURLService(t *testing.T) {
Path: "mock/path?test=true",
UID: "custom-uid-2",
}
newShortURL1, err := service.CreateShortURL(ctx, user, cmd)
newShortURL1, err := service.CreateShortURL(ctx, testUser, cmd)
require.NoError(t, err)
require.NotNil(t, newShortURL1)
require.Equal(t, cmd.UID, newShortURL1.Uid)
newShortURL2, err := service.CreateShortURL(ctx, user, cmd)
newShortURL2, err := service.CreateShortURL(ctx, testUser, cmd)
require.ErrorIs(t, err, shorturls.ErrShortURLConflict)
require.Nil(t, newShortURL2)
})

View File

@@ -10,6 +10,7 @@ import (
type store interface {
Get(ctx context.Context, user identity.Requester, uid string) (*shorturls.ShortUrl, error)
GetBySignature(ctx context.Context, orgID int64, signature string) (*shorturls.ShortUrl, error)
Update(ctx context.Context, shortURL *shorturls.ShortUrl) error
Insert(ctx context.Context, shortURL *shorturls.ShortUrl) error
Delete(ctx context.Context, cmd *shorturls.DeleteShortUrlCommand) error
@@ -40,6 +41,26 @@ func (s sqlStore) Get(ctx context.Context, user identity.Requester, uid string)
return &shortURL, nil
}
func (s sqlStore) GetBySignature(ctx context.Context, orgID int64, signature string) (*shorturls.ShortUrl, error) {
var shortURL shorturls.ShortUrl
err := s.db.WithDbSession(ctx, func(dbSession *db.Session) error {
exists, err := dbSession.Where("org_id=? AND signature=? AND signature IS NOT NULL", orgID, signature).Get(&shortURL)
if err != nil {
return err
}
if !exists {
return shorturls.ErrShortURLNotFound.Errorf("short URL not found by signature")
}
return nil
})
if err != nil {
return nil, err
}
return &shortURL, nil
}
func (s sqlStore) Update(ctx context.Context, shortURL *shorturls.ShortUrl) error {
shortURL.LastSeenAt = getTime().Unix()
return s.db.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error {

View File

@@ -28,4 +28,21 @@ func addShortURLMigrations(mg *Migrator) {
mg.AddMigration("alter table short_url alter column created_by type to bigint", NewRawSQLMigration("").
Mysql("ALTER TABLE short_url MODIFY created_by BIGINT;").
Postgres("ALTER TABLE short_url ALTER COLUMN created_by TYPE BIGINT;"))
// Add signature column for de-duplication
shortURLTable := Table{Name: "short_url"}
mg.AddMigration("add signature column to short_url table", NewAddColumnMigration(shortURLTable, &Column{
Name: "signature",
Type: DB_Varchar,
Length: 64,
Nullable: true,
}))
// Add unique index on (org_id, signature) with NULL handling
// PostgreSQL needs a partial unique index to handle NULLs correctly
// MySQL/SQLite allow multiple NULLs in unique indexes, which is fine
mg.AddMigration("add unique index short_url.org_id-signature", NewRawSQLMigration("").
Postgres("CREATE UNIQUE INDEX IF NOT EXISTS UQE_short_url_org_id_signature ON short_url(org_id, signature) WHERE signature IS NOT NULL;").
Mysql("CREATE UNIQUE INDEX UQE_short_url_org_id_signature ON short_url(org_id, signature);").
SQLite("CREATE UNIQUE INDEX IF NOT EXISTS UQE_short_url_org_id_signature ON short_url(org_id, signature);"))
}

View File

@@ -1,12 +1,19 @@
import { LogRowModel } from '@grafana/data';
import { config } from '@grafana/runtime';
import { SceneTimeRangeLike, VizPanel } from '@grafana/scenes';
import { createLogRow } from 'app/features/logs/components/mocks/logRow';
import { ShortURL } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/shorturl_object_gen';
import { defaultSpec } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/types.spec.gen';
import { defaultStatus } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/types.status.gen';
import { createShortLink, createAndCopyShortLink, getLogsPermalinkRange, buildShortUrl } from './shortLinks';
import {
createShortLink,
createAndCopyShortLink,
getLogsPermalinkRange,
buildShortUrl,
getShareUrlParams,
} from './shortLinks';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
@@ -197,3 +204,123 @@ describe('getLogsPermalinkRange', () => {
});
});
});
describe('getShareUrlParams', () => {
const mockTimeRange = {
state: {
value: {
from: new Date('2024-01-01T00:00:00Z'),
to: new Date('2024-01-01T06:00:00Z'),
},
},
} as unknown as SceneTimeRangeLike;
it('should include from and to when useAbsoluteTimeRange is true', () => {
const params = getShareUrlParams({ useAbsoluteTimeRange: true, theme: 'current' }, mockTimeRange);
expect(params.from).toBe('2024-01-01T00:00:00.000Z');
expect(params.to).toBe('2024-01-01T06:00:00.000Z');
expect(params.lockTimeRange).toBe('true');
});
it('should use relative time format when useAbsoluteTimeRange is false', () => {
const mockTimeRangeWithRelative = {
state: {
value: {
from: new Date('2024-01-01T00:00:00Z'),
to: new Date('2024-01-01T06:00:00Z'),
raw: {
from: 'now-6h',
to: 'now',
},
},
},
} as unknown as SceneTimeRangeLike;
const params = getShareUrlParams({ useAbsoluteTimeRange: false, theme: 'current' }, mockTimeRangeWithRelative);
expect(params.from).toBe('now-6h');
expect(params.to).toBe('now');
expect(params.lockTimeRange).toBe('false');
});
it('should include theme when theme is not current', () => {
const mockTimeRangeWithRelative = {
state: {
value: {
from: new Date('2024-01-01T00:00:00Z'),
to: new Date('2024-01-01T06:00:00Z'),
raw: {
from: 'now-6h',
to: 'now',
},
},
},
} as unknown as SceneTimeRangeLike;
const params = getShareUrlParams({ useAbsoluteTimeRange: false, theme: 'dark' }, mockTimeRangeWithRelative);
expect(params.theme).toBe('dark');
expect(params.from).toBe('now-6h');
expect(params.to).toBe('now');
expect(params.lockTimeRange).toBe('false');
});
it('should include viewPanel when panel is provided', () => {
const mockPanel = {
getPathId: () => 'panel-123',
} as unknown as VizPanel;
const mockTimeRangeWithRelative = {
state: {
value: {
from: new Date('2024-01-01T00:00:00Z'),
to: new Date('2024-01-01T06:00:00Z'),
raw: {
from: 'now-6h',
to: 'now',
},
},
},
} as unknown as SceneTimeRangeLike;
const params = getShareUrlParams(
{ useAbsoluteTimeRange: false, theme: 'current' },
mockTimeRangeWithRelative,
mockPanel
);
expect(params.viewPanel).toBe('panel-123');
expect(params.lockTimeRange).toBe('false');
});
it('should include lockTimeRange parameter to distinguish locked vs unlocked time ranges', () => {
const mockTimeRangeWithRelative = {
state: {
value: {
from: new Date('2024-01-01T00:00:00Z'),
to: new Date('2024-01-01T06:00:00Z'),
raw: {
from: 'now-6h',
to: 'now',
},
},
},
} as unknown as SceneTimeRangeLike;
// Locked time range should have lockTimeRange=true and absolute timestamps
const lockedParams = getShareUrlParams({ useAbsoluteTimeRange: true, theme: 'current' }, mockTimeRangeWithRelative);
expect(lockedParams.lockTimeRange).toBe('true');
expect(lockedParams.from).toBe('2024-01-01T00:00:00.000Z');
expect(lockedParams.to).toBe('2024-01-01T06:00:00.000Z');
// Unlocked time range should have lockTimeRange=false and relative timestamps
const unlockedParams = getShareUrlParams(
{ useAbsoluteTimeRange: false, theme: 'current' },
mockTimeRangeWithRelative
);
expect(unlockedParams.lockTimeRange).toBe('false');
expect(unlockedParams.from).toBe('now-6h');
expect(unlockedParams.to).toBe('now');
});
});

View File

@@ -1,6 +1,6 @@
import memoizeOne from 'memoize-one';
import { AbsoluteTimeRange, LogRowModel, UrlQueryMap } from '@grafana/data';
import { AbsoluteTimeRange, dateTime, DateTime, isDateTime, LogRowModel, UrlQueryMap } from '@grafana/data';
import { t } from '@grafana/i18n';
import { getBackendSrv, config, locationService } from '@grafana/runtime';
import { sceneGraph, SceneTimeRangeLike, VizPanel } from '@grafana/scenes';
@@ -123,19 +123,75 @@ export const createAndCopyShareDashboardLink = async (
export const createDashboardShareUrl = (dashboard: DashboardScene, opts: ShareLinkConfiguration, panel?: VizPanel) => {
const location = locationService.getLocation();
const timeRange = sceneGraph.getTimeRange(panel ?? dashboard);
// Get the current time range from the scene graph - this is always up-to-date
// and reflects any changes the user has made to the dashboard time range
// We access the state directly each time to ensure we get the latest values
const timeRangeObj = sceneGraph.getTimeRange(panel ?? dashboard);
const urlParamsUpdate = getShareUrlParams(opts, timeRange, panel);
const urlParamsUpdate = getShareUrlParams(opts, timeRangeObj, panel);
// Remove time params from currentQueryParams to avoid conflicts with the time range from scene graph
// We always use the time range from the scene graph (or remove it if useAbsoluteTimeRange is false)
// Never use stale time params from location.search
let currentQueryParams = location.search;
// Always remove time params from currentQueryParams - we'll add them back if needed via updateQuery
const params = new URLSearchParams(currentQueryParams);
params.delete('from');
params.delete('to');
currentQueryParams = params.toString() ? `?${params.toString()}` : '';
return getDashboardUrl({
uid: dashboard.state.uid,
slug: dashboard.state.meta.slug,
currentQueryParams: location.search,
currentQueryParams: currentQueryParams,
updateQuery: urlParamsUpdate,
absolute: !opts.useShortUrl,
});
};
/**
* Converts a time value to relative format (e.g., now-24h, now) if it's an absolute timestamp.
* If it's already a string (relative format), returns it as-is.
* Only converts recent timestamps (within last 24 hours) to relative format.
*/
function convertToRelativeTime(timeValue: string | DateTime | number): string {
// If it's already a string, assume it's relative format
if (typeof timeValue === 'string') {
return timeValue;
}
// Convert to DateTime if it's a number (epoch milliseconds)
const dateTimeValue = typeof timeValue === 'number' ? dateTime(timeValue) : timeValue;
if (!isDateTime(dateTimeValue)) {
return String(timeValue);
}
const now = dateTime();
const diff = now.diff(dateTimeValue);
// Only convert recent timestamps (within last 24 hours) to relative format
// Older timestamps should stay as absolute
if (Math.abs(diff) > 24 * 60 * 60 * 1000) {
return dateTimeValue.toISOString();
}
// Calculate offset in minutes
const offsetMinutes = Math.round(diff / (60 * 1000));
if (offsetMinutes === 0) {
return 'now';
}
// Convert to relative format
if (Math.abs(offsetMinutes) < 60) {
return `now${offsetMinutes > 0 ? '-' : '+'}${Math.abs(offsetMinutes)}m`;
} else {
const hours = Math.round(offsetMinutes / 60);
return `now${hours > 0 ? '-' : '+'}${Math.abs(hours)}h`;
}
}
export const getShareUrlParams = (
opts: { useAbsoluteTimeRange: boolean; theme: string },
timeRange: SceneTimeRangeLike,
@@ -147,15 +203,34 @@ export const getShareUrlParams = (
urlParamsUpdate.viewPanel = panel.getPathId();
}
// Access state.value directly to ensure we get the latest time range values
// Access the state synchronously at this exact moment to get the current time range
// Note: timeRange.state is reactive, so accessing .value here gets the current state
const currentTimeRange = timeRange.state.value;
if (opts.useAbsoluteTimeRange) {
urlParamsUpdate.from = timeRange.state.value.from.toISOString();
urlParamsUpdate.to = timeRange.state.value.to.toISOString();
// Lock time range: use absolute ISO timestamps
// This converts relative time ranges (e.g., now-24h) to absolute timestamps
urlParamsUpdate.from = currentTimeRange.from.toISOString();
urlParamsUpdate.to = currentTimeRange.to.toISOString();
} else {
// Don't lock time range: use relative time format (e.g., now-24h, now)
// This preserves the current time range but as relative, so it updates when the dashboard is opened
const raw = currentTimeRange.raw;
// Convert to relative format if needed
urlParamsUpdate.from = convertToRelativeTime(raw.from);
urlParamsUpdate.to = convertToRelativeTime(raw.to);
}
if (opts.theme !== 'current') {
urlParamsUpdate.theme = opts.theme;
}
// Include lock time range state in URL to ensure different short URLs for locked vs unlocked
// This allows de-duplication within the same lock state, but different URLs for different states
urlParamsUpdate.lockTimeRange = opts.useAbsoluteTimeRange ? 'true' : 'false';
return urlParamsUpdate;
};

View File

@@ -47,7 +47,7 @@ describe('ShareLinkTab', () => {
buildAndRenderScenario({});
expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue(
'http://dashboards.grafana.com/grafana/d/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=A$panel-12'
'http://dashboards.grafana.com/grafana/d/dash-1?viewPanel=A$panel-12&from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&lockTimeRange=true'
);
});
});
@@ -58,7 +58,7 @@ describe('ShareLinkTab', () => {
await act(() => tab.onToggleLockedTime());
expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue(
'http://dashboards.grafana.com/grafana/d/dash-1?from=now-6h&to=now&viewPanel=A$panel-12'
'http://dashboards.grafana.com/grafana/d/dash-1?viewPanel=A$panel-12&from=now-6h&to=now&lockTimeRange=false'
);
});
});
@@ -68,7 +68,7 @@ describe('ShareLinkTab', () => {
await act(() => tab.onThemeChange('light'));
expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue(
'http://dashboards.grafana.com/grafana/d/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=A$panel-12&theme=light'
'http://dashboards.grafana.com/grafana/d/dash-1?viewPanel=A$panel-12&from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&theme=light&lockTimeRange=true'
);
});
@@ -89,7 +89,7 @@ describe('ShareLinkTab', () => {
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
).toHaveAttribute(
'href',
'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&panelId=A$panel-12&__feature.dashboardSceneSolo=true&width=1000&height=500&tz=Pacific%2FEaster'
'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&lockTimeRange=true&panelId=A$panel-12&__feature.dashboardSceneSolo=true&width=1000&height=500&tz=Pacific%2FEaster'
);
});
});

View File

@@ -49,6 +49,24 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> implements
this.addActivationHandler(() => {
this.buildUrl();
// Subscribe to time range changes to rebuild URL when dashboard time range changes
// Only rebuild if lock time range is disabled - when enabled, the URL should stay locked
const dashboard = getDashboardSceneFor(this);
const panel = state.panelRef?.resolve();
const timeRange = sceneGraph.getTimeRange(panel ?? dashboard);
const subscription = timeRange.subscribeToState(() => {
// Rebuild URL when time range changes
// If lock time range is enabled, this updates the absolute timestamps in the URL
// If disabled, this updates the relative time range in the URL
this.buildUrl();
});
// Return cleanup function to unsubscribe when component is deactivated
return () => {
subscription.unsubscribe();
};
});
this.onToggleLockedTime = this.onToggleLockedTime.bind(this);
@@ -56,9 +74,11 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> implements
this.onThemeChange = this.onThemeChange.bind(this);
}
buildUrl = async (queryOptions?: UrlQueryMap) => {
buildUrl = async (queryOptions?: UrlQueryMap, overrideUseLockedTime?: boolean) => {
this.setState({ isBuildUrlLoading: true });
const { panelRef, useLockedTime: useAbsoluteTimeRange, useShortUrl, selectedTheme } = this.state;
const { panelRef, useLockedTime, useShortUrl, selectedTheme } = this.state;
// Use override value if provided (for immediate updates), otherwise use state
const useAbsoluteTimeRange = overrideUseLockedTime !== undefined ? overrideUseLockedTime : useLockedTime;
const dashboard = getDashboardSceneFor(this);
const panel = panelRef?.resolve();
@@ -101,7 +121,8 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> implements
async onToggleLockedTime() {
const useLockedTime = !this.state.useLockedTime;
this.setState({ useLockedTime });
await this.buildUrl();
// Pass the new value directly to buildUrl to ensure it uses the updated setting immediately
await this.buildUrl(undefined, useLockedTime);
}
async onUrlShorten() {
@@ -163,15 +184,15 @@ function ShareLinkTabRenderer({ model }: SceneComponentProps<ShareLinkTab>) {
</Trans>
</p>
<FieldSet>
<Field label={lockTimeRangeLabel} description={isRelativeTime ? lockTimeRangeDescription : ''}>
<Field noMargin label={lockTimeRangeLabel} description={isRelativeTime ? lockTimeRangeDescription : ''}>
<Switch id="share-current-time-range" value={useLockedTime} onChange={model.onToggleLockedTime} />
</Field>
<ThemePicker selectedTheme={selectedTheme} onChange={model.onThemeChange} />
<Field label={shortenURLTranslation}>
<Field noMargin label={shortenURLTranslation}>
<Switch id="share-shorten-url" value={useShortUrl} onChange={model.onUrlShorten} />
</Field>
<Field label={linkURLTranslation}>
<Field noMargin label={linkURLTranslation}>
<Input
id="link-url-input"
value={shareUrl}
@@ -188,12 +209,12 @@ function ShareLinkTabRenderer({ model }: SceneComponentProps<ShareLinkTab>) {
{panel && config.rendererAvailable && (
<>
{isDashboardSaved && (
<div className="gf-form">
<a href={imageUrl} target="_blank" rel="noreferrer" aria-label={selectors.linkToRenderedImage}>
<div>
<TextLink href={imageUrl} external aria-label={selectors.linkToRenderedImage}>
<Icon name="camera" />
&nbsp;
<Trans i18nKey="share-modal.link.rendered-image">Direct link rendered image</Trans>
</a>
</TextLink>
</div>
)}

View File

@@ -65,4 +65,28 @@ describe('dashboard utils', () => {
expect(url).toBe('/dashboard/new?orgId=1&filter=A');
});
it('should remove time params (from/to) when set to null in updateQuery', () => {
const url = getDashboardUrl({
uid: 'dash-1',
currentQueryParams: '?orgId=1&from=2024-01-01T00:00:00Z&to=2024-01-01T06:00:00Z&theme=dark',
updateQuery: { from: null, to: null },
});
expect(url).toBe('/d/dash-1?orgId=1&theme=dark');
expect(url).not.toContain('from=');
expect(url).not.toContain('to=');
});
it('should remove time params even when other params are present', () => {
const url = getDashboardUrl({
uid: 'dash-1',
currentQueryParams: '?orgId=1&from=now-6h&to=now&var-datasource=prometheus',
updateQuery: { from: null, to: null },
});
expect(url).toBe('/d/dash-1?orgId=1&var-datasource=prometheus');
expect(url).not.toContain('from=');
expect(url).not.toContain('to=');
});
});