mirror of
https://github.com/grafana/grafana.git
synced 2026-01-15 13:48:14 +00:00
Compare commits
14 Commits
ash/react-
...
attempt_at
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a0d5ce00f | ||
|
|
ab220ac475 | ||
|
|
411b7e4e4c | ||
|
|
10b5351a03 | ||
|
|
43b4a6cfc1 | ||
|
|
1f913341b2 | ||
|
|
e22eba7ae9 | ||
|
|
b0198a66b8 | ||
|
|
12a8ffd784 | ||
|
|
86875733ff | ||
|
|
af8df0ed90 | ||
|
|
bc4747c7f0 | ||
|
|
178e44423b | ||
|
|
2171b33477 |
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);"))
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" />
|
||||
|
||||
<Trans i18nKey="share-modal.link.rendered-image">Direct link rendered image</Trans>
|
||||
</a>
|
||||
</TextLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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=');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user