feat(analytics): dashboard manage cards (#2893)

This commit is contained in:
Shekar Siri 2024-12-20 10:27:58 +01:00 committed by GitHub
parent 99ddcd9708
commit 9d82c2935a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 260 additions and 51 deletions

View file

@ -192,12 +192,6 @@ func (e *handlersImpl) pinDashboard(w http.ResponseWriter, r *http.Request) {
startTime := time.Now()
bodySize := 0
//id, err := getDashboardId(r)
//if err != nil {
// e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
// return
//}
e.log.Info(r.Context(), "Dashboard pinned")
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize)
@ -208,13 +202,52 @@ func (e *handlersImpl) addCardToDashboard(w http.ResponseWriter, r *http.Request
startTime := time.Now()
bodySize := 0
//id, err := getDashboardId(r)
//if err != nil {
// e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
// return
//}
projectID, err := getIDFromRequest(r, "projectId")
if err != nil {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
return
}
e.log.Info(r.Context(), "Card added to dashboard")
dashboardID, err := getIDFromRequest(r, "id")
if err != nil {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
return
}
u := r.Context().Value("userData").(*user.User)
bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit)
if err != nil {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
return
}
bodySize = len(bodyBytes)
req := &models.AddCardToDashboardRequest{}
if err := json.Unmarshal(bodyBytes, req); err != nil {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
return
}
validate := validator.New()
err = validate.Struct(req)
if err != nil {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
return
}
err = e.service.AddCardsToDashboard(projectID, dashboardID, u.ID, req)
if err != nil {
if err.Error() == "not_found: dashboard not found" {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusNotFound, err, startTime, r.URL.Path, bodySize)
} else if err.Error() == "access_denied: user does not have access" {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, err, startTime, r.URL.Path, bodySize)
} else {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
}
return
}
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize)
}
@ -224,11 +257,41 @@ func (e *handlersImpl) removeCardFromDashboard(w http.ResponseWriter, r *http.Re
startTime := time.Now()
bodySize := 0
//id, err := getDashboardId(r)
//if err != nil {
// e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
// return
//}
projectID, err := getIDFromRequest(r, "projectId")
if err != nil {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
return
}
dashboardID, err := getIDFromRequest(r, "id")
if err != nil {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
return
}
cardID, err := getIDFromRequest(r, "cardId")
if err != nil {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
return
}
u := r.Context().Value("userData").(*user.User)
_, err = e.service.GetDashboard(projectID, dashboardID, u.ID)
if err != nil {
if err.Error() == "not_found: dashboard not found" {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusNotFound, err, startTime, r.URL.Path, bodySize)
} else if err.Error() == "access_denied: user does not have access" {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, err, startTime, r.URL.Path, bodySize)
} else {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
}
}
err = e.service.DeleteCardFromDashboard(dashboardID, cardID)
if err != nil {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
return
}
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize)
}

View file

@ -29,6 +29,8 @@ func (e *handlersImpl) GetAll() []*api.Description {
{"/v1/analytics/{projectId}/dashboards/{id}", e.getDashboard, "GET"},
{"/v1/analytics/{projectId}/dashboards/{id}", e.updateDashboard, "PUT"},
{"/v1/analytics/{projectId}/dashboards/{id}", e.deleteDashboard, "DELETE"},
{"/v1/analytics/{projectId}/dashboards/{id}/cards", e.addCardToDashboard, "POST"},
{"/v1/analytics/{projectId}/dashboards/{id}/cards/{cardId}", e.removeCardFromDashboard, "DELETE"},
{"/v1/analytics/{projectId}/cards", e.createCard, "POST"},
{"/v1/analytics/{projectId}/cards", e.getCardsPaginated, "GET"},
{"/v1/analytics/{projectId}/cards/{id}", e.getCard, "GET"},

View file

@ -11,6 +11,7 @@ type CardBase struct {
Name string `json:"name" validate:"required"`
IsPublic bool `json:"isPublic" validate:"omitempty"`
DefaultConfig map[string]any `json:"defaultConfig"`
Config map[string]any `json:"config"`
Thumbnail *string `json:"thumbnail" validate:"omitempty,url"`
MetricType string `json:"metricType" validate:"required,oneof=timeseries table funnel"`
MetricOf string `json:"metricOf" validate:"required,oneof=session_count user_count"`

View file

@ -1,15 +1,16 @@
package models
type Dashboard struct {
DashboardID int `json:"dashboardId"`
ProjectID int `json:"projectId"`
UserID int `json:"userId"`
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"isPublic"`
IsPinned bool `json:"isPinned"`
OwnerEmail string `json:"ownerEmail"`
OwnerName string `json:"ownerName"`
DashboardID int `json:"dashboardId"`
ProjectID int `json:"projectId"`
UserID int `json:"userId"`
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"isPublic"`
IsPinned bool `json:"isPinned"`
OwnerEmail string `json:"ownerEmail"`
OwnerName string `json:"ownerName"`
Metrics []CardBase `json:"cards"`
}
type CreateDashboardResponse struct {
@ -61,9 +62,6 @@ type PinDashboardRequest struct {
}
type AddCardToDashboardRequest struct {
CardIDs []int `json:"card_ids"`
}
type DeleteCardFromDashboardRequest struct {
CardIDs []int `json:"card_ids"`
MetricIDs []int `json:"metric_ids" validate:"required,min=1,dive,gt=0"`
Config map[string]interface{} `json:"config"` // Optional
}

View file

@ -17,6 +17,8 @@ type Service interface {
CreateDashboard(projectId int, userId uint64, req *models.CreateDashboardRequest) (*models.GetDashboardResponse, error)
UpdateDashboard(projectId int, dashboardId int, userId uint64, req *models.UpdateDashboardRequest) (*models.GetDashboardResponse, error)
DeleteDashboard(projectId int, dashboardId int, userId uint64) error
AddCardsToDashboard(projectId int, dashboardId int, userId uint64, req *models.AddCardToDashboardRequest) error
DeleteCardFromDashboard(dashboardId int, cardId int) error
GetCard(projectId int, cardId int) (*models.CardGetResponse, error)
GetCardWithSeries(projectId int, cardId int) (*models.CardGetResponse, error)
GetCards(projectId int) (*models.GetCardsResponse, error)

View file

@ -10,7 +10,7 @@ import (
"strings"
)
func (s serviceImpl) CreateCard(projectId int, userID uint64, req *models.CardCreateRequest) (*models.CardGetResponse, error) {
func (s *serviceImpl) CreateCard(projectId int, userID uint64, req *models.CardCreateRequest) (*models.CardGetResponse, error) {
if req.MetricValue == nil {
req.MetricValue = []string{}
}
@ -73,7 +73,7 @@ func (s serviceImpl) CreateCard(projectId int, userID uint64, req *models.CardCr
return card, nil
}
func (s serviceImpl) CreateSeries(ctx context.Context, tx pgx.Tx, metricId int64, series []models.CardSeriesBase) []models.CardSeries {
func (s *serviceImpl) CreateSeries(ctx context.Context, tx pgx.Tx, metricId int64, series []models.CardSeriesBase) []models.CardSeries {
if len(series) == 0 {
return nil // No series to create
}
@ -127,7 +127,7 @@ func (s serviceImpl) CreateSeries(ctx context.Context, tx pgx.Tx, metricId int64
return seriesList
}
func (s serviceImpl) GetCard(projectId int, cardID int) (*models.CardGetResponse, error) {
func (s *serviceImpl) GetCard(projectId int, cardID int) (*models.CardGetResponse, error) {
sql :=
`SELECT metric_id, project_id, user_id, name, metric_type, view_type, metric_of, metric_value, metric_format, is_public, created_at, edited_at
FROM public.metrics
@ -145,7 +145,7 @@ func (s serviceImpl) GetCard(projectId int, cardID int) (*models.CardGetResponse
return card, nil
}
func (s serviceImpl) GetCardWithSeries(projectId int, cardID int) (*models.CardGetResponse, error) {
func (s *serviceImpl) GetCardWithSeries(projectId int, cardID int) (*models.CardGetResponse, error) {
sql := `
SELECT m.metric_id, m.project_id, m.user_id, m.name, m.metric_type, m.view_type, m.metric_of,
m.metric_value, m.metric_format, m.is_public, m.created_at, m.edited_at,
@ -184,7 +184,7 @@ func (s serviceImpl) GetCardWithSeries(projectId int, cardID int) (*models.CardG
return card, nil
}
func (s serviceImpl) GetCards(projectId int) (*models.GetCardsResponse, error) {
func (s *serviceImpl) GetCards(projectId int) (*models.GetCardsResponse, error) {
sql := `
SELECT metric_id, project_id, user_id, name, metric_type, view_type, metric_of, metric_value, metric_format, is_public, created_at, edited_at
FROM public.metrics
@ -211,7 +211,7 @@ func (s serviceImpl) GetCards(projectId int) (*models.GetCardsResponse, error) {
return &models.GetCardsResponse{Cards: cards}, nil
}
func (s serviceImpl) GetCardsPaginated(
func (s *serviceImpl) GetCardsPaginated(
projectId int,
filters models.CardListFilter,
sort models.CardListSort,
@ -321,7 +321,7 @@ func (s serviceImpl) GetCardsPaginated(
}, nil
}
func (s serviceImpl) UpdateCard(projectId int, cardID int64, userID uint64, req *models.CardUpdateRequest) (*models.CardGetResponse, error) {
func (s *serviceImpl) UpdateCard(projectId int, cardID int64, userID uint64, req *models.CardUpdateRequest) (*models.CardGetResponse, error) {
if req.MetricValue == nil {
req.MetricValue = []string{}
}
@ -380,7 +380,7 @@ func (s serviceImpl) UpdateCard(projectId int, cardID int64, userID uint64, req
return card, nil
}
func (s serviceImpl) DeleteCardSeries(cardId int64) error {
func (s *serviceImpl) DeleteCardSeries(cardId int64) error {
sql := `DELETE FROM public.metric_series WHERE metric_id = $1`
err := s.pgconn.Exec(sql, cardId)
if err != nil {
@ -389,7 +389,7 @@ func (s serviceImpl) DeleteCardSeries(cardId int64) error {
return nil
}
func (s serviceImpl) DeleteCard(projectId int, cardID int64, userID uint64) error {
func (s *serviceImpl) DeleteCard(projectId int, cardID int64, userID uint64) error {
sql := `
UPDATE public.metrics
SET deleted_at = now()
@ -402,7 +402,7 @@ func (s serviceImpl) DeleteCard(projectId int, cardID int64, userID uint64) erro
return nil
}
func (s serviceImpl) GetCardChartData(projectId int, userID uint64, req *models.GetCardChartDataRequest) ([]models.DataPoint, error) {
func (s *serviceImpl) GetCardChartData(projectId int, userID uint64, req *models.GetCardChartDataRequest) ([]models.DataPoint, error) {
jsonInput := `
{
"data": [

View file

@ -1,13 +1,15 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"openreplay/backend/pkg/analytics/api/models"
)
// CreateDashboard Create a new dashboard
func (s serviceImpl) CreateDashboard(projectId int, userID uint64, req *models.CreateDashboardRequest) (*models.GetDashboardResponse, error) {
func (s *serviceImpl) CreateDashboard(projectId int, userID uint64, req *models.CreateDashboardRequest) (*models.GetDashboardResponse, error) {
sql := `
INSERT INTO dashboards (project_id, user_id, name, description, is_public, is_pinned)
VALUES ($1, $2, $3, $4, $5, $6)
@ -30,14 +32,53 @@ func (s serviceImpl) CreateDashboard(projectId int, userID uint64, req *models.C
}
// GetDashboard Fetch a specific dashboard by ID
func (s serviceImpl) GetDashboard(projectId int, dashboardID int, userID uint64) (*models.GetDashboardResponse, error) {
func (s *serviceImpl) GetDashboard(projectId int, dashboardID int, userID uint64) (*models.GetDashboardResponse, error) {
sql := `
SELECT dashboard_id, project_id, name, description, is_public, is_pinned, user_id
FROM dashboards
WHERE dashboard_id = $1 AND project_id = $2 AND deleted_at IS NULL`
WITH series_agg AS (
SELECT
ms.metric_id,
json_agg(
json_build_object(
'index', ms.index,
'name', ms.name,
'filter', ms.filter
)
) AS series
FROM metric_series ms
GROUP BY ms.metric_id
)
SELECT
d.dashboard_id,
d.project_id,
d.name,
d.description,
d.is_public,
d.is_pinned,
d.user_id,
COALESCE(json_agg(
json_build_object(
'config', dw.config,
'metric_id', m.metric_id,
'name', m.name,
'metric_type', m.metric_type,
'view_type', m.view_type,
'metric_of', m.metric_of,
'metric_value', m.metric_value,
'metric_format', m.metric_format,
'series', s.series
)
) FILTER (WHERE m.metric_id IS NOT NULL), '[]') AS metrics
FROM dashboards d
LEFT JOIN dashboard_widgets dw ON d.dashboard_id = dw.dashboard_id
LEFT JOIN metrics m ON dw.metric_id = m.metric_id
LEFT JOIN series_agg s ON m.metric_id = s.metric_id
WHERE d.dashboard_id = $1 AND d.project_id = $2 AND d.deleted_at IS NULL
GROUP BY d.dashboard_id, d.project_id, d.name, d.description, d.is_public, d.is_pinned, d.user_id`
dashboard := &models.GetDashboardResponse{}
var ownerID int
var metricsJSON []byte
err := s.pgconn.QueryRow(sql, dashboardID, projectId).Scan(
&dashboard.DashboardID,
&dashboard.ProjectID,
@ -46,6 +87,7 @@ func (s serviceImpl) GetDashboard(projectId int, dashboardID int, userID uint64)
&dashboard.IsPublic,
&dashboard.IsPinned,
&ownerID,
&metricsJSON,
)
if err != nil {
@ -55,7 +97,10 @@ func (s serviceImpl) GetDashboard(projectId int, dashboardID int, userID uint64)
return nil, fmt.Errorf("error fetching dashboard: %w", err)
}
// Access control
if err := json.Unmarshal(metricsJSON, &dashboard.Metrics); err != nil {
return nil, fmt.Errorf("error unmarshalling metrics: %w", err)
}
if !dashboard.IsPublic && uint64(ownerID) != userID {
return nil, fmt.Errorf("access_denied: user does not have access")
}
@ -63,7 +108,7 @@ func (s serviceImpl) GetDashboard(projectId int, dashboardID int, userID uint64)
return dashboard, nil
}
func (s serviceImpl) GetDashboards(projectId int, userID uint64) (*models.GetDashboardsResponse, error) {
func (s *serviceImpl) GetDashboards(projectId int, userID uint64) (*models.GetDashboardsResponse, error) {
sql := `
SELECT d.dashboard_id, d.user_id, d.project_id, d.name, d.description, d.is_public, d.is_pinned, u.email AS owner_email, u.name AS owner_name
FROM dashboards d
@ -98,7 +143,7 @@ func (s serviceImpl) GetDashboards(projectId int, userID uint64) (*models.GetDas
}
// GetDashboardsPaginated Fetch dashboards with pagination
func (s serviceImpl) GetDashboardsPaginated(projectId int, userID uint64, req *models.GetDashboardsRequest) (*models.GetDashboardsResponsePaginated, error) {
func (s *serviceImpl) GetDashboardsPaginated(projectId int, userID uint64, req *models.GetDashboardsRequest) (*models.GetDashboardsResponsePaginated, error) {
baseSQL, args := buildBaseQuery(projectId, userID, req)
// Count total dashboards
@ -147,7 +192,7 @@ func (s serviceImpl) GetDashboardsPaginated(projectId int, userID uint64, req *m
}
// UpdateDashboard Update a dashboard
func (s serviceImpl) UpdateDashboard(projectId int, dashboardID int, userID uint64, req *models.UpdateDashboardRequest) (*models.GetDashboardResponse, error) {
func (s *serviceImpl) UpdateDashboard(projectId int, dashboardID int, userID uint64, req *models.UpdateDashboardRequest) (*models.GetDashboardResponse, error) {
sql := `
UPDATE dashboards
SET name = $1, description = $2, is_public = $3, is_pinned = $4
@ -171,7 +216,7 @@ func (s serviceImpl) UpdateDashboard(projectId int, dashboardID int, userID uint
}
// DeleteDashboard Soft-delete a dashboard
func (s serviceImpl) DeleteDashboard(projectId int, dashboardID int, userID uint64) error {
func (s *serviceImpl) DeleteDashboard(projectId int, dashboardID int, userID uint64) error {
sql := `
UPDATE dashboards
SET deleted_at = now()
@ -236,3 +281,101 @@ func getOrder(order string) string {
}
return "ASC"
}
func (s *serviceImpl) CardsExist(projectId int, cardIDs []int) (bool, error) {
sql := `
SELECT COUNT(*) FROM public.metrics
WHERE project_id = $1 AND metric_id = ANY($2)
`
var count int
err := s.pgconn.QueryRow(sql, projectId, cardIDs).Scan(&count)
if err != nil {
return false, err
}
return count == len(cardIDs), nil
}
func (s *serviceImpl) AddCardsToDashboard(projectId int, dashboardId int, userId uint64, req *models.AddCardToDashboardRequest) error {
_, err := s.GetDashboard(projectId, dashboardId, userId)
if err != nil {
return fmt.Errorf("failed to get dashboard: %w", err)
}
// Check if all cards exist
exists, err := s.CardsExist(projectId, req.MetricIDs)
if err != nil {
return fmt.Errorf("failed to check card existence: %w", err)
}
if !exists {
return errors.New("not_found: one or more cards do not exist")
}
// Begin a transaction
tx, err := s.pgconn.Begin() // Start transaction
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
ctx := context.Background()
defer func() {
if err != nil {
tx.Rollback(ctx)
if err != nil {
return
}
} else {
err := tx.Commit(ctx)
if err != nil {
return
}
}
}()
// Insert metrics into dashboard_widgets
insertedWidgets := 0
for _, metricID := range req.MetricIDs {
// Check if the widget already exists
var exists bool
err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM public.dashboard_widgets
WHERE dashboard_id = $1 AND metric_id = $2
)
`, dashboardId, metricID).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check existing widget: %w", err)
}
if exists {
continue // Skip duplicates
}
// Insert new widget
_, err = tx.Exec(ctx, `
INSERT INTO public.dashboard_widgets (dashboard_id, metric_id, user_id, config)
VALUES ($1, $2, $3, $4)
`, dashboardId, metricID, userId, req.Config)
if err != nil {
return fmt.Errorf("failed to insert widget: %w", err)
}
insertedWidgets++
}
// Commit transaction
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
func (s *serviceImpl) DeleteCardFromDashboard(dashboardId int, cardId int) error {
sql := `DELETE FROM public.dashboard_widgets WHERE dashboard_id = $1 AND metric_id = $2`
err := s.pgconn.Exec(sql, dashboardId, cardId)
if err != nil {
return fmt.Errorf("failed to delete card from dashboard: %w", err)
}
return nil
}