Compare commits
12 Commits
dependabot
...
eledobleef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01894b62ca | ||
|
|
44a4020139 | ||
|
|
b0785e506f | ||
|
|
5f8668b3aa | ||
|
|
fc49c8d47a | ||
|
|
5c17e4e05e | ||
|
|
bdad692470 | ||
|
|
61097047ff | ||
|
|
5b234a251e | ||
|
|
9acafa2b50 | ||
|
|
80c7d17543 | ||
|
|
9a7a05be50 |
@@ -5,7 +5,7 @@ go 1.25.5
|
||||
require (
|
||||
github.com/grafana/grafana-app-sdk v0.48.7
|
||||
github.com/grafana/grafana-app-sdk/logging v0.48.7
|
||||
github.com/prometheus/common v0.67.5
|
||||
github.com/prometheus/common v0.67.4
|
||||
k8s.io/apimachinery v0.34.3
|
||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e
|
||||
)
|
||||
|
||||
@@ -128,8 +128,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU=
|
||||
|
||||
@@ -186,7 +186,7 @@ For the JSON and field usage notes, refer to the [links schema documentation](ht
|
||||
|
||||
### `tags`
|
||||
|
||||
The tags associated with the dashboard:
|
||||
Tags associated with the dashboard. Each tag can be up to 50 characters long.
|
||||
|
||||
` [...string]`
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Keys:
|
||||
- **theme** - One of: `light`, `dark`, or an empty string for the default theme
|
||||
- **homeDashboardId** - Deprecated. Use `homeDashboardUID` instead.
|
||||
- **homeDashboardUID**: The `:uid` of a dashboard
|
||||
- **timezone** - One of: `utc`, `browser`, or an empty string for the default
|
||||
- **timezone** - Any valid IANA timezone string (e.g., `America/New_York`, `Europe/London`), `utc`, `browser`, or an empty string for the default.
|
||||
|
||||
Omitting a key will cause the current value to be replaced with the
|
||||
system default value.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"version": "12.4.0-pre",
|
||||
"repository": "github:grafana/grafana",
|
||||
"scripts": {
|
||||
"analytics-report": "node --experimental-strip-types ./scripts/cli/analytics/main.mts",
|
||||
"check-frontend-dev": "./scripts/check-frontend-dev.sh",
|
||||
"build": "NODE_ENV=production nx exec --verbose -- webpack --config scripts/webpack/webpack.prod.js",
|
||||
"build:nominify": "yarn run build -- --env noMinify=1",
|
||||
@@ -430,6 +431,7 @@
|
||||
"swagger-ui-react": "5.30.3",
|
||||
"symbol-observable": "4.0.0",
|
||||
"systemjs": "6.15.1",
|
||||
"ts-morph": "^27.0.2",
|
||||
"tslib": "2.8.1",
|
||||
"tween-functions": "^1.2.0",
|
||||
"type-fest": "^4.18.2",
|
||||
|
||||
@@ -5312,7 +5312,8 @@ export type PatchPrefsCmd = {
|
||||
queryHistory?: QueryHistoryPreference;
|
||||
regionalFormat?: string;
|
||||
theme?: 'light' | 'dark';
|
||||
timezone?: 'utc' | 'browser';
|
||||
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
|
||||
timezone?: string;
|
||||
weekStart?: string;
|
||||
};
|
||||
export type UpdatePrefsCmd = {
|
||||
@@ -5325,7 +5326,8 @@ export type UpdatePrefsCmd = {
|
||||
queryHistory?: QueryHistoryPreference;
|
||||
regionalFormat?: string;
|
||||
theme?: 'light' | 'dark' | 'system';
|
||||
timezone?: 'utc' | 'browser';
|
||||
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
|
||||
timezone?: string;
|
||||
weekStart?: string;
|
||||
};
|
||||
export type OrgUserDto = {
|
||||
|
||||
@@ -86,7 +86,8 @@ export type PatchPrefsCmd = {
|
||||
queryHistory?: QueryHistoryPreference;
|
||||
regionalFormat?: string;
|
||||
theme?: 'light' | 'dark';
|
||||
timezone?: 'utc' | 'browser';
|
||||
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
|
||||
timezone?: string;
|
||||
weekStart?: string;
|
||||
};
|
||||
export type UpdatePrefsCmd = {
|
||||
@@ -99,7 +100,8 @@ export type UpdatePrefsCmd = {
|
||||
queryHistory?: QueryHistoryPreference;
|
||||
regionalFormat?: string;
|
||||
theme?: 'light' | 'dark' | 'system';
|
||||
timezone?: 'utc' | 'browser';
|
||||
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
|
||||
timezone?: string;
|
||||
weekStart?: string;
|
||||
};
|
||||
export const {
|
||||
|
||||
@@ -181,3 +181,24 @@ When a violation is detected, the rule reports:
|
||||
```
|
||||
Import '../status-history/utils' reaches outside the 'histogram' plugin directory. Plugins should only import from external dependencies or relative paths within their own directory.
|
||||
```
|
||||
|
||||
### `tracking-event-creation`
|
||||
|
||||
Checks that the process to create a tracking event is followed in the right way.
|
||||
|
||||
#### `eventFactoryLiterals`
|
||||
|
||||
Check if the values passed to `createEventFactory` are literals.
|
||||
|
||||
```tsx
|
||||
// Bad ❌
|
||||
const repo = 'grafana';
|
||||
const createUnifiedHistoryEvent = createEventFactory(repo, 'unified_history');
|
||||
|
||||
// Bad ❌
|
||||
const history = 'history';
|
||||
const createUnifiedHistoryEvent = createEventFactory('grafana', `unified_${history}`);
|
||||
|
||||
// Good ✅
|
||||
const createUnifiedHistoryEvent = createEventFactory('grafana', 'unified_history');
|
||||
```
|
||||
|
||||
@@ -5,6 +5,7 @@ const themeTokenUsage = require('./rules/theme-token-usage.cjs');
|
||||
const noRestrictedImgSrcs = require('./rules/no-restricted-img-srcs.cjs');
|
||||
const consistentStoryTitles = require('./rules/consistent-story-titles.cjs');
|
||||
const noPluginExternalImportPaths = require('./rules/no-plugin-external-import-paths.cjs');
|
||||
const trackingEventCreation = require('./rules/tracking-event-creation.cjs');
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
@@ -15,5 +16,6 @@ module.exports = {
|
||||
'no-restricted-img-srcs': noRestrictedImgSrcs,
|
||||
'consistent-story-titles': consistentStoryTitles,
|
||||
'no-plugin-external-import-paths': noPluginExternalImportPaths,
|
||||
'tracking-event-creation': trackingEventCreation,
|
||||
},
|
||||
};
|
||||
|
||||
158
packages/grafana-eslint-rules/rules/tracking-event-creation.cjs
Normal file
158
packages/grafana-eslint-rules/rules/tracking-event-creation.cjs
Normal file
@@ -0,0 +1,158 @@
|
||||
// @ts-check
|
||||
const { ESLintUtils, AST_NODE_TYPES } = require('@typescript-eslint/utils');
|
||||
|
||||
const createRule = ESLintUtils.RuleCreator(
|
||||
(name) => `https://github.com/grafana/grafana/blob/main/packages/grafana-eslint-rules/README.md#${name}`
|
||||
);
|
||||
|
||||
const trackingEventCreation = createRule({
|
||||
create(context) {
|
||||
// Track what name createEventFactory is imported as
|
||||
let createEventFactoryName = 'createEventFactory';
|
||||
// Track if createEventFactory is imported
|
||||
let isCreateEventFactoryImported = false;
|
||||
// Track variables that store createEventFactory calls
|
||||
const eventFactoryVariables = new Set();
|
||||
|
||||
return {
|
||||
ImportSpecifier(node) {
|
||||
if (node.imported.type === AST_NODE_TYPES.Identifier && node.imported.name === 'createEventFactory') {
|
||||
// Remember what name it was imported as (handles aliased imports)
|
||||
createEventFactoryName = node.local.name;
|
||||
isCreateEventFactoryImported = true;
|
||||
}
|
||||
},
|
||||
VariableDeclarator(node) {
|
||||
if (!isCreateEventFactoryImported) {
|
||||
return;
|
||||
}
|
||||
// Track variables initialized with createEventFactory calls
|
||||
if (
|
||||
node.init?.type === AST_NODE_TYPES.CallExpression &&
|
||||
node.init.callee.type === AST_NODE_TYPES.Identifier &&
|
||||
node.init.callee.name === createEventFactoryName
|
||||
) {
|
||||
const variableName = node.id.type === AST_NODE_TYPES.Identifier && node.id.name;
|
||||
if (variableName) {
|
||||
eventFactoryVariables.add(variableName);
|
||||
}
|
||||
|
||||
// Check if arguments are literals
|
||||
const args = node.init.arguments;
|
||||
const argsAreNotLiterals = args.some((arg) => arg.type !== AST_NODE_TYPES.Literal);
|
||||
if (argsAreNotLiterals) {
|
||||
return context.report({
|
||||
node: node.init,
|
||||
messageId: 'eventFactoryLiterals',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
ExportNamedDeclaration(node) {
|
||||
if (!isCreateEventFactoryImported) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
node.declaration?.type === AST_NODE_TYPES.VariableDeclaration &&
|
||||
node.declaration.declarations[0].init?.type === AST_NODE_TYPES.CallExpression
|
||||
) {
|
||||
const callee = node.declaration.declarations[0].init.callee;
|
||||
if (callee.type === AST_NODE_TYPES.Identifier && eventFactoryVariables.has(callee.name)) {
|
||||
// Check for comments
|
||||
// Check for comments
|
||||
const comments = context.sourceCode.getCommentsBefore(node);
|
||||
|
||||
if (!comments || comments.length === 0) {
|
||||
return context.report({
|
||||
node,
|
||||
messageId: 'missingFunctionComment',
|
||||
});
|
||||
}
|
||||
|
||||
const jsDocComment = comments.find((comment) => comment.value.slice(0, 1) === '*');
|
||||
|
||||
if (!jsDocComment) {
|
||||
return context.report({
|
||||
node,
|
||||
messageId: 'missingJsDocComment',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
TSInterfaceDeclaration(node) {
|
||||
if (!isCreateEventFactoryImported) {
|
||||
return;
|
||||
}
|
||||
// Check if interface extends TrackingEvent
|
||||
let extendsTrackingEvent = false;
|
||||
if (node.extends && node.extends.length > 0) {
|
||||
const interfaceExtends = node.extends;
|
||||
extendsTrackingEvent = interfaceExtends.some((extend) => {
|
||||
return (
|
||||
extend.expression.type === AST_NODE_TYPES.Identifier && extend.expression.name === 'TrackingEventProps'
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!node.extends || !extendsTrackingEvent) {
|
||||
return context.report({
|
||||
node,
|
||||
messageId: 'interfaceMustExtend',
|
||||
});
|
||||
}
|
||||
//Check if the interface properties has comments
|
||||
if (node.body.type === AST_NODE_TYPES.TSInterfaceBody) {
|
||||
const properties = node.body.body;
|
||||
properties.forEach((property) => {
|
||||
const comments = context.sourceCode.getCommentsBefore(property);
|
||||
if (!comments || comments.length === 0) {
|
||||
return context.report({
|
||||
node: property,
|
||||
messageId: 'missingPropertyComment',
|
||||
});
|
||||
}
|
||||
const jsDocComment = comments.find((comment) => comment.value.slice(0, 1) === '*');
|
||||
|
||||
if (!jsDocComment) {
|
||||
return context.report({
|
||||
node: property,
|
||||
messageId: 'missingJsDocComment',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
TSTypeAliasDeclaration(node) {
|
||||
if (!isCreateEventFactoryImported) {
|
||||
return;
|
||||
}
|
||||
// Check if types has comments
|
||||
const comments = context.sourceCode.getCommentsBefore(node);
|
||||
if (!comments || comments.length === 0) {
|
||||
return context.report({
|
||||
node,
|
||||
messageId: 'missingPropertyComment',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
name: 'tracking-event-creation',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Check that the tracking event is created in the right way',
|
||||
},
|
||||
messages: {
|
||||
eventFactoryLiterals: 'Params passed to `createEventFactory` must be literals',
|
||||
missingFunctionComment: 'Event function needs to have a description of its purpose',
|
||||
missingPropertyComment: 'Event property needs to have a description of its purpose',
|
||||
interfaceMustExtend: 'Interface must extend `TrackingEvent`',
|
||||
missingJsDocComment: 'Comment needs to be a jsDoc comment (begin comment with `*`)',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
});
|
||||
|
||||
module.exports = trackingEventCreation;
|
||||
@@ -54,6 +54,7 @@ export const TagsInput = forwardRef<HTMLInputElement, Props>(
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
const styles = useStyles2(getStyles);
|
||||
const theme = useTheme2();
|
||||
const isTagTooLong = newTagName.length > 50;
|
||||
|
||||
const onNameChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewTagName(event.target.value);
|
||||
@@ -65,6 +66,9 @@ export const TagsInput = forwardRef<HTMLInputElement, Props>(
|
||||
|
||||
const onAdd = (event?: React.MouseEvent | React.KeyboardEvent) => {
|
||||
event?.preventDefault();
|
||||
if (newTagName.length > 50) {
|
||||
return;
|
||||
}
|
||||
if (!tags.includes(newTagName)) {
|
||||
onChange(tags.concat(newTagName));
|
||||
}
|
||||
@@ -94,14 +98,17 @@ export const TagsInput = forwardRef<HTMLInputElement, Props>(
|
||||
value={newTagName}
|
||||
onKeyDown={onKeyboardAdd}
|
||||
onBlur={onBlur}
|
||||
invalid={invalid}
|
||||
invalid={invalid || isTagTooLong}
|
||||
suffix={
|
||||
<Button
|
||||
fill="text"
|
||||
className={styles.addButtonStyle}
|
||||
onClick={onAdd}
|
||||
size="md"
|
||||
disabled={newTagName.length <= 0}
|
||||
disabled={newTagName.length <= 0 || isTagTooLong}
|
||||
title={
|
||||
isTagTooLong ? t('grafana-ui.tags-input.tag-too-long', 'Tag too long, max 50 characters') : undefined
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="grafana-ui.tags-input.add">Add</Trans>
|
||||
</Button>
|
||||
|
||||
@@ -13,7 +13,7 @@ type UpdatePrefsCmd struct {
|
||||
// Deprecated: Use HomeDashboardUID instead
|
||||
HomeDashboardID int64 `json:"homeDashboardId"`
|
||||
HomeDashboardUID *string `json:"homeDashboardUID,omitempty"`
|
||||
// Enum: utc,browser
|
||||
// Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string
|
||||
Timezone string `json:"timezone"`
|
||||
WeekStart string `json:"weekStart"`
|
||||
QueryHistory *pref.QueryHistoryPreference `json:"queryHistory,omitempty"`
|
||||
@@ -31,7 +31,7 @@ type PatchPrefsCmd struct {
|
||||
// Default:0
|
||||
// Deprecated: Use HomeDashboardUID instead
|
||||
HomeDashboardID *int64 `json:"homeDashboardId,omitempty"`
|
||||
// Enum: utc,browser
|
||||
// Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string
|
||||
Timezone *string `json:"timezone,omitempty"`
|
||||
WeekStart *string `json:"weekStart,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
|
||||
@@ -134,6 +134,10 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te
|
||||
return response.Error(http.StatusBadRequest, "Invalid theme", nil)
|
||||
}
|
||||
|
||||
if dtoCmd.Timezone != nil && !pref.IsValidTimezone(*dtoCmd.Timezone) {
|
||||
return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil)
|
||||
}
|
||||
|
||||
// convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query
|
||||
// nolint:staticcheck
|
||||
dashboardID := dtoCmd.HomeDashboardID
|
||||
|
||||
@@ -389,6 +389,11 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A
|
||||
return apierrors.NewBadRequest(err.Error())
|
||||
}
|
||||
|
||||
// Validate tags
|
||||
if err := validateDashboardTags(dashObj); err != nil {
|
||||
return apierrors.NewBadRequest(err.Error())
|
||||
}
|
||||
|
||||
id, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting requester: %w", err)
|
||||
@@ -459,6 +464,11 @@ func (b *DashboardsAPIBuilder) validateUpdate(ctx context.Context, a admission.A
|
||||
return apierrors.NewBadRequest(err.Error())
|
||||
}
|
||||
|
||||
// Validate tags
|
||||
if err := validateDashboardTags(newDashObj); err != nil {
|
||||
return apierrors.NewBadRequest(err.Error())
|
||||
}
|
||||
|
||||
// Validate folder existence if specified and changed
|
||||
if !a.IsDryRun() && newAccessor.GetFolder() != oldAccessor.GetFolder() && newAccessor.GetFolder() != "" {
|
||||
id, err := identity.GetRequester(ctx)
|
||||
@@ -556,6 +566,32 @@ func getDashboardProperties(obj runtime.Object) (string, string, error) {
|
||||
return title, refresh, nil
|
||||
}
|
||||
|
||||
// validateDashboardTags validates that all dashboard tags are within the maximum length
|
||||
func validateDashboardTags(obj runtime.Object) error {
|
||||
var tags []string
|
||||
|
||||
switch d := obj.(type) {
|
||||
case *dashv0.Dashboard:
|
||||
tags = d.Spec.GetNestedStringSlice("tags")
|
||||
case *dashv1.Dashboard:
|
||||
tags = d.Spec.GetNestedStringSlice("tags")
|
||||
case *dashv2alpha1.Dashboard:
|
||||
tags = d.Spec.Tags
|
||||
case *dashv2beta1.Dashboard:
|
||||
tags = d.Spec.Tags
|
||||
default:
|
||||
return fmt.Errorf("unsupported dashboard version: %T", obj)
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
if len(tag) > 50 {
|
||||
return dashboards.ErrDashboardTagTooLong
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
||||
storageOpts := apistore.StorageOptions{
|
||||
EnableFolderSupport: true,
|
||||
|
||||
@@ -208,6 +208,11 @@ func (s *preferenceStorage) save(ctx context.Context, obj runtime.Object) (runti
|
||||
|
||||
// Create implements rest.Creater.
|
||||
func (s *preferenceStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||
if createValidation != nil {
|
||||
if err := createValidation(ctx, obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s.save(ctx, obj)
|
||||
}
|
||||
|
||||
@@ -223,6 +228,12 @@ func (s *preferenceStorage) Update(ctx context.Context, name string, objInfo res
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if updateValidation != nil {
|
||||
if err := updateValidation(ctx, obj, old); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
obj, err = s.save(ctx, obj)
|
||||
return obj, false, err
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package preferences
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/admission"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
@@ -24,7 +29,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupValidation = (*APIBuilder)(nil)
|
||||
)
|
||||
|
||||
type APIBuilder struct {
|
||||
@@ -108,3 +114,31 @@ func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
|
||||
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
|
||||
return b.merger.GetAPIRoutes(defs)
|
||||
}
|
||||
|
||||
// Validate validates that the preference object has valid theme and timezone (if specified)
|
||||
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
|
||||
if a.GetResource().Resource != "preferences" {
|
||||
return nil
|
||||
}
|
||||
|
||||
op := a.GetOperation()
|
||||
if op != admission.Create && op != admission.Update {
|
||||
return nil
|
||||
}
|
||||
|
||||
obj := a.GetObject()
|
||||
p, ok := obj.(*preferences.Preferences)
|
||||
if !ok {
|
||||
return apierrors.NewBadRequest(fmt.Sprintf("expected Preferences object, got %T", obj))
|
||||
}
|
||||
|
||||
if p.Spec.Timezone != nil && !pref.IsValidTimezone(*p.Spec.Timezone) {
|
||||
return apierrors.NewBadRequest("invalid timezone: must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string")
|
||||
}
|
||||
|
||||
if p.Spec.Theme != nil && *p.Spec.Theme != "" && !pref.IsValidThemeID(*p.Spec.Theme) {
|
||||
return apierrors.NewBadRequest("invalid theme")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -542,6 +542,9 @@ func (d *dashboardStore) saveDashboard(ctx context.Context, sess *db.Session, cm
|
||||
tags := dash.GetTags()
|
||||
if len(tags) > 0 {
|
||||
for _, tag := range tags {
|
||||
if len(tag) > 50 {
|
||||
return nil, dashboards.ErrDashboardTagTooLong
|
||||
}
|
||||
if _, err := sess.Insert(dashboardTag{DashboardId: dash.ID, Term: tag, OrgID: dash.OrgID, DashboardUID: dash.UID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -79,6 +79,11 @@ var (
|
||||
Reason: "message too long, max 500 characters",
|
||||
StatusCode: 400,
|
||||
}
|
||||
ErrDashboardTagTooLong = dashboardaccess.DashboardErr{
|
||||
Reason: "dashboard tag too long, max 50 characters",
|
||||
StatusCode: 400,
|
||||
Status: "tag-too-long",
|
||||
}
|
||||
ErrDashboardCannotSaveProvisionedDashboard = dashboardaccess.DashboardErr{
|
||||
Reason: "Cannot save provisioned dashboard",
|
||||
StatusCode: 400,
|
||||
|
||||
@@ -20,6 +20,10 @@ func UpdatePreferencesFor(ctx context.Context,
|
||||
return response.Error(http.StatusBadRequest, "Invalid theme", nil)
|
||||
}
|
||||
|
||||
if !pref.IsValidTimezone(dtoCmd.Timezone) {
|
||||
return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil)
|
||||
}
|
||||
|
||||
// convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query
|
||||
// nolint:staticcheck
|
||||
dashboardID := dtoCmd.HomeDashboardID
|
||||
|
||||
21
pkg/services/preference/timezone.go
Normal file
21
pkg/services/preference/timezone.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package pref
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// IsValidTimezone checks if the timezone string is valid.
|
||||
// It accepts:
|
||||
// - "" - uses default
|
||||
// - "utc"
|
||||
// - "browser"
|
||||
// - Any valid IANA timezone (e.g., "America/New_York", "Europe/London")
|
||||
func IsValidTimezone(timezone string) bool {
|
||||
if timezone == "" || timezone == "utc" || timezone == "browser" {
|
||||
return true
|
||||
}
|
||||
|
||||
// try to load as IANA timezone
|
||||
_, err := time.LoadLocation(timezone)
|
||||
return err == nil
|
||||
}
|
||||
38
pkg/services/preference/timezone_test.go
Normal file
38
pkg/services/preference/timezone_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package pref
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsValidTimezone(t *testing.T) {
|
||||
tests := []struct {
|
||||
timezone string
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
timezone: "utc",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
timezone: "browser",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
timezone: "Europe/London",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
timezone: "invalid",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
timezone: "",
|
||||
valid: true,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
assert.Equal(t, test.valid, IsValidTimezone(test.timezone))
|
||||
}
|
||||
}
|
||||
@@ -393,6 +393,66 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) {
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Dashboard tag validations", func(t *testing.T) {
|
||||
t.Run("reject dashboard with tag over 50 characters on creation", func(t *testing.T) {
|
||||
dashObj := createDashboardObject(t, "Dashboard with Long Tag", "", 0)
|
||||
meta, _ := utils.MetaAccessor(dashObj)
|
||||
spec, _ := meta.GetSpec()
|
||||
specMap := spec.(map[string]interface{})
|
||||
specMap["tags"] = []string{"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit"}
|
||||
_ = meta.SetSpec(specMap)
|
||||
_, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "tag too long")
|
||||
})
|
||||
|
||||
t.Run("reject dashboard update with tag over 50 characters", func(t *testing.T) {
|
||||
dash, err := createDashboard(t, adminClient, "Valid Dashboard", nil, nil, ctx.Helper)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dash)
|
||||
meta, _ := utils.MetaAccessor(dash)
|
||||
spec, _ := meta.GetSpec()
|
||||
specMap := spec.(map[string]interface{})
|
||||
specMap["tags"] = []string{"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit"}
|
||||
_ = meta.SetSpec(specMap)
|
||||
_, err = adminClient.Resource.Update(context.Background(), dash, v1.UpdateOptions{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "tag too long")
|
||||
err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("accept dashboard with tag at 50 characters", func(t *testing.T) {
|
||||
dashObj := createDashboardObject(t, "Dashboard with Valid Tag", "", 0)
|
||||
meta, _ := utils.MetaAccessor(dashObj)
|
||||
spec, _ := meta.GetSpec()
|
||||
specMap := spec.(map[string]interface{})
|
||||
specMap["tags"] = []string{"this-tag-is-exactly-fifty-characters-long-12345"}
|
||||
_ = meta.SetSpec(specMap)
|
||||
createdDash, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createdDash)
|
||||
err = adminClient.Resource.Delete(context.Background(), createdDash.GetName(), v1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("reject dashboard with multiple tags where one exceeds limit", func(t *testing.T) {
|
||||
dashObj := createDashboardObject(t, "Dashboard with Mixed Tags", "", 0)
|
||||
meta, _ := utils.MetaAccessor(dashObj)
|
||||
spec, _ := meta.GetSpec()
|
||||
specMap := spec.(map[string]interface{})
|
||||
specMap["tags"] = []string{
|
||||
"valid-tag",
|
||||
"another-valid-tag",
|
||||
"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit",
|
||||
}
|
||||
_ = meta.SetSpec(specMap)
|
||||
_, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "tag too long")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Dashboard folder validations", func(t *testing.T) {
|
||||
// Test non-existent folder UID
|
||||
t.Run("reject dashboard with non-existent folder UID", func(t *testing.T) {
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestIntegrationPreferences(t *testing.T) {
|
||||
Path: fmt.Sprintf("/api/teams/%d/preferences", helper.Org1.Staff.ID),
|
||||
Body: []byte(`{
|
||||
"weekStart": "sunday",
|
||||
"timezone": "africa"
|
||||
"timezone": "Africa/Johannesburg"
|
||||
}`),
|
||||
}, &raw)
|
||||
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "create preference for user")
|
||||
@@ -79,7 +79,7 @@ func TestIntegrationPreferences(t *testing.T) {
|
||||
Path: "/api/org/preferences",
|
||||
Body: []byte(`{
|
||||
"weekStart": "sunday",
|
||||
"timezone": "africa",
|
||||
"timezone": "Africa/Accra",
|
||||
"theme": "dark"
|
||||
}`),
|
||||
}, &raw)
|
||||
@@ -144,7 +144,7 @@ func TestIntegrationPreferences(t *testing.T) {
|
||||
|
||||
jj, _ = json.Marshal(bootdata.Result.User)
|
||||
require.JSONEq(t, `{
|
||||
"timezone":"africa",
|
||||
"timezone":"Africa/Johannesburg",
|
||||
"weekStart":"saturday",
|
||||
"theme":"dark",
|
||||
"language":"en-US", `+ // FROM global default!
|
||||
@@ -157,10 +157,10 @@ func TestIntegrationPreferences(t *testing.T) {
|
||||
Path: "/apis/preferences.grafana.app/v1alpha1/namespaces/default/preferences/merged",
|
||||
}, &preferences.Preferences{})
|
||||
require.Equal(t, http.StatusOK, merged.Response.StatusCode, "get merged preferences")
|
||||
require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user
|
||||
require.Equal(t, "africa", *merged.Result.Spec.Timezone) // from team
|
||||
require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org
|
||||
require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini
|
||||
require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update
|
||||
require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user
|
||||
require.Equal(t, "Africa/Johannesburg", *merged.Result.Spec.Timezone) // from team
|
||||
require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org
|
||||
require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini
|
||||
require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update
|
||||
})
|
||||
}
|
||||
|
||||
14
public/api-enterprise-spec.json
generated
14
public/api-enterprise-spec.json
generated
@@ -6152,11 +6152,8 @@
|
||||
]
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"utc",
|
||||
"browser"
|
||||
]
|
||||
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
|
||||
"type": "string"
|
||||
},
|
||||
"weekStart": {
|
||||
"type": "string"
|
||||
@@ -8657,11 +8654,8 @@
|
||||
]
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"utc",
|
||||
"browser"
|
||||
]
|
||||
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
|
||||
"type": "string"
|
||||
},
|
||||
"weekStart": {
|
||||
"type": "string"
|
||||
|
||||
14
public/api-merged.json
generated
14
public/api-merged.json
generated
@@ -18729,11 +18729,8 @@
|
||||
]
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"utc",
|
||||
"browser"
|
||||
]
|
||||
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
|
||||
"type": "string"
|
||||
},
|
||||
"weekStart": {
|
||||
"type": "string"
|
||||
@@ -23120,11 +23117,8 @@
|
||||
]
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"utc",
|
||||
"browser"
|
||||
]
|
||||
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
|
||||
"type": "string"
|
||||
},
|
||||
"weekStart": {
|
||||
"type": "string"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EchoBackend, EchoMeta, EchoEvent, EchoSrv } from '@grafana/runtime';
|
||||
import { EchoBackend, EchoMeta, EchoEvent, EchoSrv, reportInteraction } from '@grafana/runtime';
|
||||
|
||||
import { contextSrv } from '../context_srv';
|
||||
|
||||
@@ -90,3 +90,15 @@ export class Echo implements EchoSrv {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Analytics framework:
|
||||
* Foundational types and functions for the new tracking event process
|
||||
*/
|
||||
export type TrackingEventProps = {
|
||||
[key: string]: boolean | string | number | undefined;
|
||||
};
|
||||
export const createEventFactory = (product: string, featureName: string) => {
|
||||
return <P extends TrackingEventProps | undefined = undefined>(eventName: string) =>
|
||||
(props: P extends undefined ? void : P) =>
|
||||
reportInteraction(`${product}_${featureName}_${eventName}`, props ?? undefined);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,10 @@ export const validateDashboardJson = (json: string) => {
|
||||
if (hasInvalidTag) {
|
||||
return t('dashboard.validation.tags-expected-strings', 'tags expected array of strings');
|
||||
}
|
||||
const hasTooLongTag = dashboard.tags.some((tag: string) => tag.length > 50);
|
||||
if (hasTooLongTag) {
|
||||
return t('dashboard.validation.tag-too-long', 'Dashboard tag too long, max 50 characters');
|
||||
}
|
||||
} else {
|
||||
return t('dashboard.validation.tags-expected-array', 'tags expected array');
|
||||
}
|
||||
|
||||
@@ -5696,6 +5696,7 @@
|
||||
"validation": {
|
||||
"invalid-dashboard-id": "Could not find a valid Grafana.com ID",
|
||||
"invalid-json": "Not valid JSON",
|
||||
"tag-too-long": "Dashboard tag too long, max 50 characters",
|
||||
"tags-expected-array": "tags expected array",
|
||||
"tags-expected-strings": "tags expected array of strings"
|
||||
},
|
||||
@@ -9254,7 +9255,8 @@
|
||||
"tags-input": {
|
||||
"add": "Add",
|
||||
"placeholder-new-tag": "New tag (enter key to add)",
|
||||
"remove": "Remove tag: {{name}}"
|
||||
"remove": "Remove tag: {{name}}",
|
||||
"tag-too-long": "Tag too long, max 50 characters"
|
||||
},
|
||||
"time-sync-button": {
|
||||
"aria-label-sync": "Sync times",
|
||||
|
||||
10
public/openapi3.json
generated
10
public/openapi3.json
generated
@@ -8264,10 +8264,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"enum": [
|
||||
"utc",
|
||||
"browser"
|
||||
],
|
||||
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
|
||||
"type": "string"
|
||||
},
|
||||
"weekStart": {
|
||||
@@ -12654,10 +12651,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"enum": [
|
||||
"utc",
|
||||
"browser"
|
||||
],
|
||||
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
|
||||
"type": "string"
|
||||
},
|
||||
"weekStart": {
|
||||
|
||||
135
scripts/cli/analytics/eventParser.mts
Normal file
135
scripts/cli/analytics/eventParser.mts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Node, type SourceFile, type ts, type Type, type VariableStatement } from 'ts-morph';
|
||||
|
||||
import type { Event, EventNamespace, EventProperty } from './types.mts';
|
||||
import { resolveType, getMetadataFromJSDocs } from './utils/typeResolution.mts';
|
||||
|
||||
/**
|
||||
* Finds all events - calls to the function returned by createEventFactory - declared in a file
|
||||
*
|
||||
* An event feature namespace is defined by:
|
||||
* const createNavEvent = createEventFactory('grafana', 'navigation');
|
||||
*
|
||||
* Which will be used to define multiple events like:
|
||||
* interface ClickProperties {
|
||||
* linkText: string;
|
||||
* }
|
||||
* const trackClick = createNavEvent<ClickProperties>('click');
|
||||
*/
|
||||
export function parseEvents(file: SourceFile, eventNamespaces: Map<string, EventNamespace>): Event[] {
|
||||
const events: Event[] = [];
|
||||
const variableDecls = file.getVariableDeclarations();
|
||||
|
||||
for (const variableDecl of variableDecls) {
|
||||
// Get the initializer (right hand side of `=`) of the variable declaration
|
||||
// and make sure it's a function call
|
||||
const initializer = variableDecl.getInitializer();
|
||||
if (!initializer || !Node.isCallExpression(initializer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only interested in calls to functions returned by createEventFactory
|
||||
const initializerFnName = initializer.getExpression().getText();
|
||||
const eventNamespace = eventNamespaces.get(initializerFnName);
|
||||
if (!eventNamespace) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Events should be defined with a single string literal argument (e.g. createNavEvent('click'))
|
||||
const [arg, ...restArgs] = initializer.getArguments();
|
||||
if (!arg || !Node.isStringLiteral(arg) || restArgs.length > 0) {
|
||||
throw new Error(`Expected ${initializerFnName} to be called with only 1 string literal argument`);
|
||||
}
|
||||
|
||||
// We're currently using the variable declaration (foo = blah), but we need the variable
|
||||
// statement (const foo = blah) to get the JSDoc nodes
|
||||
const parent = getParentVariableStatement(variableDecl);
|
||||
if (!parent) {
|
||||
throw new Error(`Parent not found for ${variableDecl.getText()}`);
|
||||
}
|
||||
|
||||
const docs = parent.getJsDocs();
|
||||
const { description, owner } = getMetadataFromJSDocs(docs); // TODO: default owner to codeowner if not found
|
||||
if (!description) {
|
||||
throw new Error(`Description not found for ${variableDecl.getText()}`);
|
||||
}
|
||||
|
||||
const eventName = arg.getLiteralText();
|
||||
const event: Event = {
|
||||
fullEventName: `${eventNamespace.eventPrefixProject}_${eventNamespace.eventPrefixFeature}_${eventName}`,
|
||||
eventProject: eventNamespace.eventPrefixProject,
|
||||
eventFeature: eventNamespace.eventPrefixFeature,
|
||||
eventName,
|
||||
|
||||
description,
|
||||
owner,
|
||||
};
|
||||
|
||||
// Get the type of the declared variable and assert it's a function
|
||||
const typeAnnotation = variableDecl.getType();
|
||||
const [callSignature, ...restCallSignatures] = typeAnnotation.getCallSignatures();
|
||||
if (callSignature === undefined || restCallSignatures.length > 0) {
|
||||
const typeAsText = typeAnnotation.getText();
|
||||
throw new Error(`Expected type to be a function with one call signature, got ${typeAsText}`);
|
||||
}
|
||||
|
||||
// The function always only have one parameter type.
|
||||
// Events that have no properties will have a void parameter type.
|
||||
const [parameter, ...restParameters] = callSignature.getParameters();
|
||||
if (parameter === undefined || restParameters.length > 0) {
|
||||
throw new Error('Expected function to have one parameter');
|
||||
}
|
||||
|
||||
// Find where the parameter type was declared and get it's type
|
||||
const parameterType = parameter.getTypeAtLocation(parameter.getDeclarations()[0]);
|
||||
|
||||
// Then describe the schema for the parameters the event function is called with
|
||||
if (parameterType.isObject()) {
|
||||
event.properties = describeObjectParameters(parameterType);
|
||||
} else if (!parameterType.isVoid()) {
|
||||
throw new Error(`Expected parameter type to be an object or void, got ${parameterType.getText()}`);
|
||||
}
|
||||
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function getParentVariableStatement(node: Node): VariableStatement | undefined {
|
||||
let parent: Node | undefined = node.getParent();
|
||||
while (parent && !Node.isVariableStatement(parent)) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
|
||||
if (parent && Node.isVariableStatement(parent)) {
|
||||
return parent;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function describeObjectParameters(objectType: Type<ts.ObjectType>): EventProperty[] {
|
||||
const properties = objectType.getProperties().map((property) => {
|
||||
const declarations = property.getDeclarations();
|
||||
if (declarations.length !== 1) {
|
||||
throw new Error(`Expected property to have one declaration, got ${declarations.length}`);
|
||||
}
|
||||
|
||||
const declaration = declarations[0];
|
||||
const propertyType = property.getTypeAtLocation(declaration);
|
||||
const resolvedType = resolveType(propertyType);
|
||||
|
||||
if (!Node.isPropertySignature(declaration)) {
|
||||
throw new Error(`Expected property to be a property signature, got ${declaration.getKindName()}`);
|
||||
}
|
||||
|
||||
const { description } = getMetadataFromJSDocs(declaration.getJsDocs());
|
||||
return {
|
||||
name: property.getName(),
|
||||
type: resolvedType,
|
||||
description,
|
||||
};
|
||||
});
|
||||
|
||||
return properties;
|
||||
}
|
||||
104
scripts/cli/analytics/findAllEvents.mts
Normal file
104
scripts/cli/analytics/findAllEvents.mts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Event, EventNamespace } from './types.mts';
|
||||
import { parseEvents } from './eventParser.mts';
|
||||
import { type SourceFile, Node } from 'ts-morph';
|
||||
|
||||
/**
|
||||
* Finds all events - calls to the function returned by createEventFactory - declared in files
|
||||
*
|
||||
* An event feature namespace is defined by:
|
||||
* const createNavEvent = createEventFactory('grafana', 'navigation');
|
||||
*
|
||||
* Which will be used to define multiple events like:
|
||||
* interface ClickProperties {
|
||||
* linkText: string;
|
||||
* }
|
||||
* const trackClick = createNavEvent<ClickProperties>('click');
|
||||
* const trackExpand = createNavEvent('expand');
|
||||
*/
|
||||
export function findAnalyticsEvents(files: SourceFile[], createEventFactoryPath: string): Event[] {
|
||||
const allEvents: Event[] = files.flatMap((file) => {
|
||||
// Get the local imported name of createEventFactory
|
||||
const createEventFactoryImportedName = getEventFactoryFunctionName(file, createEventFactoryPath);
|
||||
if (!createEventFactoryImportedName) return [];
|
||||
|
||||
// Find all calls to createEventFactory and the namespaces they create
|
||||
const eventNamespaces = findEventNamespaces(file, createEventFactoryImportedName);
|
||||
|
||||
// Find all events defined in the file
|
||||
const events = parseEvents(file, eventNamespaces);
|
||||
return events;
|
||||
});
|
||||
|
||||
return allEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the local name of the createEventFactory function imported from the given path
|
||||
*
|
||||
* @param file - The file to search for the import
|
||||
* @param createEventFactoryPath - The path to the createEventFactory function
|
||||
*/
|
||||
function getEventFactoryFunctionName(file: SourceFile, createEventFactoryPath: string): string | undefined {
|
||||
const imports = file.getImportDeclarations();
|
||||
|
||||
for (const importDeclaration of imports) {
|
||||
const namedImports = importDeclaration.getNamedImports();
|
||||
|
||||
for (const namedImport of namedImports) {
|
||||
const importName = namedImport.getName();
|
||||
|
||||
if (importName === 'createEventFactory') {
|
||||
const moduleSpecifier = importDeclaration.getModuleSpecifierSourceFile();
|
||||
if (!moduleSpecifier) continue;
|
||||
|
||||
if (moduleSpecifier.getFilePath() === createEventFactoryPath) {
|
||||
return namedImport.getAliasNode()?.getText() || importName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findEventNamespaces(file: SourceFile, createEventFactoryImportedName: string): Map<string, EventNamespace> {
|
||||
const variableDecls = file.getVariableDeclarations();
|
||||
const eventNamespaces = new Map<string, EventNamespace>();
|
||||
|
||||
for (const variableDecl of variableDecls) {
|
||||
const eventFactoryName = variableDecl.getName();
|
||||
|
||||
const initializer = variableDecl.getInitializer();
|
||||
if (!initializer) continue;
|
||||
if (!Node.isCallExpression(initializer)) continue;
|
||||
|
||||
const initializerFnName = initializer.getExpression().getText();
|
||||
if (initializerFnName !== createEventFactoryImportedName) continue;
|
||||
|
||||
const args = initializer.getArguments();
|
||||
if (args.length !== 2) {
|
||||
throw new Error(`Expected ${createEventFactoryImportedName} to have 2 arguments`);
|
||||
}
|
||||
|
||||
const [argA, argB] = args;
|
||||
|
||||
if (!Node.isStringLiteral(argA) || !Node.isStringLiteral(argB)) {
|
||||
throw new Error(`Expected ${createEventFactoryImportedName} to have 2 string arguments`);
|
||||
}
|
||||
|
||||
const eventPrefixRepo = argA.getLiteralText();
|
||||
const eventPrefixFeature = argB.getLiteralText();
|
||||
|
||||
console.log(
|
||||
`found where ${createEventFactoryImportedName} is called, ${eventFactoryName} = ${eventPrefixRepo}_${eventPrefixFeature}`
|
||||
);
|
||||
|
||||
eventNamespaces.set(eventFactoryName, {
|
||||
factoryName: eventFactoryName,
|
||||
eventPrefixProject: eventPrefixRepo,
|
||||
eventPrefixFeature: eventPrefixFeature,
|
||||
});
|
||||
}
|
||||
|
||||
return eventNamespaces;
|
||||
}
|
||||
25
scripts/cli/analytics/main.mts
Normal file
25
scripts/cli/analytics/main.mts
Normal file
@@ -0,0 +1,25 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { Project } from 'ts-morph';
|
||||
import { findAnalyticsEvents } from './findAllEvents.mts';
|
||||
import { formatEventsAsMarkdown } from './outputFormats/markdown.mts';
|
||||
|
||||
const CREATE_EVENT_FACTORY_PATH = path.resolve('public/app/core/services/echo/Echo.ts');
|
||||
const SOURCE_FILE_PATTERNS = ['**/*.ts'];
|
||||
const OUTPUT_FORMAT = 'markdown';
|
||||
|
||||
const project = new Project({
|
||||
tsConfigFilePath: path.resolve('tsconfig.json'),
|
||||
});
|
||||
const files = project.getSourceFiles(SOURCE_FILE_PATTERNS);
|
||||
|
||||
const events = findAnalyticsEvents(files, CREATE_EVENT_FACTORY_PATH);
|
||||
|
||||
if (OUTPUT_FORMAT === 'markdown') {
|
||||
const markdown = await formatEventsAsMarkdown(events);
|
||||
console.log(markdown);
|
||||
|
||||
await fs.writeFile('analytics-report.md', markdown);
|
||||
} else {
|
||||
console.log(JSON.stringify(events, null, 2));
|
||||
}
|
||||
76
scripts/cli/analytics/outputFormats/markdown.mts
Normal file
76
scripts/cli/analytics/outputFormats/markdown.mts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Event } from '../types.mts';
|
||||
import prettier from 'prettier';
|
||||
|
||||
function makeMarkdownTable(properties: Array<Record<string, string | undefined>>): string {
|
||||
const keys = Object.keys(properties[0]);
|
||||
|
||||
const header = `| ${keys.join(' | ')} |`;
|
||||
const border = `| ${keys.map((header) => '-'.padEnd(header.length, '-')).join(' | ')} |`;
|
||||
|
||||
const rows = properties.map((property) => {
|
||||
const columns = keys.map((key) => {
|
||||
const value = property[key] ?? '';
|
||||
return String(value).replace(/\|/g, '\\|');
|
||||
});
|
||||
|
||||
return '| ' + columns.join(' | ') + ' |';
|
||||
});
|
||||
|
||||
return [header, border, ...rows].join('\n');
|
||||
}
|
||||
|
||||
export function formatEventAsMarkdown(event: Event): string {
|
||||
const preparedProperties =
|
||||
event.properties?.map((property) => {
|
||||
return {
|
||||
name: property.name,
|
||||
type: '`' + property.type + '`',
|
||||
description: property.description,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
const propertiesTable = event.properties ? makeMarkdownTable(preparedProperties) : '';
|
||||
|
||||
const markdownRows = [
|
||||
`#### ${event.fullEventName}`,
|
||||
event.description,
|
||||
event.owner ? `**Owner:** ${event.owner}` : undefined,
|
||||
...(event.properties ? [`##### Properties`, propertiesTable] : []),
|
||||
].filter(Boolean);
|
||||
|
||||
return markdownRows.join('\n\n');
|
||||
}
|
||||
|
||||
export async function formatEventsAsMarkdown(events: Event[]): Promise<string> {
|
||||
const byFeature: Record<string, Event[]> = {};
|
||||
|
||||
for (const event of events) {
|
||||
const feature = event.eventFeature;
|
||||
byFeature[feature] = byFeature[feature] ?? [];
|
||||
byFeature[feature].push(event);
|
||||
}
|
||||
|
||||
const markdownPerFeature = Object.entries(byFeature)
|
||||
.map(([feature, events]) => {
|
||||
const markdownPerEvent = events.map(formatEventAsMarkdown).join('\n');
|
||||
|
||||
return `
|
||||
### ${feature}
|
||||
|
||||
${markdownPerEvent}
|
||||
`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const markdown = `
|
||||
# Analytics report
|
||||
|
||||
This report contains all the analytics events that are defined in the project.
|
||||
|
||||
## Events
|
||||
|
||||
${markdownPerFeature}
|
||||
`;
|
||||
|
||||
return prettier.format(markdown, { parser: 'markdown' });
|
||||
}
|
||||
22
scripts/cli/analytics/types.mts
Normal file
22
scripts/cli/analytics/types.mts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface EventNamespace {
|
||||
factoryName: string;
|
||||
eventPrefixProject: string;
|
||||
eventPrefixFeature: string;
|
||||
}
|
||||
|
||||
export interface EventProperty {
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
fullEventName: string;
|
||||
eventProject: string;
|
||||
eventFeature: string;
|
||||
eventName: string;
|
||||
|
||||
description: string;
|
||||
owner?: string;
|
||||
properties?: EventProperty[];
|
||||
}
|
||||
98
scripts/cli/analytics/utils/typeResolution.mts
Normal file
98
scripts/cli/analytics/utils/typeResolution.mts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { JSDoc, Type } from 'ts-morph';
|
||||
|
||||
/**
|
||||
* Resolves a TypeScript type to a string representation. For example for:
|
||||
* type Action = "click" | "hover"
|
||||
* `Action` resolves to `"click" | "hover"`
|
||||
*
|
||||
* @param type Type to resolve
|
||||
* @returns String representation of the type
|
||||
*/
|
||||
export function resolveType(type: Type): string {
|
||||
// If the type is an alias (e.g., `Action`), resolve its declaration
|
||||
const aliasSymbol = type.getAliasSymbol();
|
||||
if (aliasSymbol) {
|
||||
const aliasType = type.getSymbol()?.getDeclarations()?.[0]?.getType();
|
||||
if (aliasType) {
|
||||
return resolveType(aliasType);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: If it's a union type, resolve each member recursively
|
||||
if (type.isUnion()) {
|
||||
return type
|
||||
.getUnionTypes()
|
||||
.map((t) => resolveType(t))
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
// Step 3: If it's a string literal type, return its literal value
|
||||
if (type.isStringLiteral()) {
|
||||
return `"${type.getLiteralValue()}"`;
|
||||
}
|
||||
|
||||
// TODO: handle enums. Would want to represent an enum as a union of its values
|
||||
// If the type is an enum, resolve it to a union of its values
|
||||
if (type.isEnum()) {
|
||||
const enumMembers = type.getSymbol()?.getDeclarations()?.[0]?.getChildren() || [];
|
||||
const values = enumMembers
|
||||
.filter((member) => member.getKindName() === 'SyntaxList' && member.getText() !== `export`)
|
||||
.map((member) => {
|
||||
const value = member.getText();
|
||||
const stripQuotesAndBackticks = value.replace(/['"`]/g, '').replace(/`/g, '');
|
||||
const splitOnCommaAndReturn = stripQuotesAndBackticks.split(',\n');
|
||||
return splitOnCommaAndReturn
|
||||
.map((v) => {
|
||||
const trimmed = v.trim().replace(/,/g, '');
|
||||
const splitOnEquals = trimmed.split('=');
|
||||
return `"${splitOnEquals[1].trim()}"`;
|
||||
})
|
||||
.join(` | `);
|
||||
});
|
||||
return values.join(` | `);
|
||||
}
|
||||
|
||||
return type.getText(); // Default to the type's text representation
|
||||
}
|
||||
|
||||
export interface JSDocMetadata {
|
||||
description?: string;
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts description and owner from a JSDoc comment.
|
||||
*
|
||||
* @param docs JSDoc comment nodes to extract metadata from
|
||||
* @returns Metadata extracted from the JSDoc comments
|
||||
*/
|
||||
export function getMetadataFromJSDocs(docs: JSDoc[]): JSDocMetadata {
|
||||
let description: string | undefined;
|
||||
let owner: string | undefined;
|
||||
|
||||
if (docs.length > 1) {
|
||||
// TODO: Do we need to handle multiple JSDoc comments? Why would there be more than one?
|
||||
throw new Error('Expected only one JSDoc comment');
|
||||
}
|
||||
|
||||
for (const doc of docs) {
|
||||
const desc = trimString(doc.getDescription());
|
||||
if (desc) {
|
||||
description = desc;
|
||||
}
|
||||
|
||||
const tags = doc.getTags();
|
||||
for (const tag of tags) {
|
||||
if (tag.getTagName() === 'owner') {
|
||||
const tagText = tag.getCommentText();
|
||||
owner = tagText && trimString(tagText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { description, owner };
|
||||
}
|
||||
|
||||
function trimString(str: string): string {
|
||||
return str.trim().replace(/\n/g, ' ');
|
||||
}
|
||||
60
yarn.lock
60
yarn.lock
@@ -9859,6 +9859,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ts-morph/common@npm:~0.28.1":
|
||||
version: 0.28.1
|
||||
resolution: "@ts-morph/common@npm:0.28.1"
|
||||
dependencies:
|
||||
minimatch: "npm:^10.0.1"
|
||||
path-browserify: "npm:^1.0.1"
|
||||
tinyglobby: "npm:^0.2.14"
|
||||
checksum: 10/d5c6ed11cf046c186c7263c28c7e9b5fbefb61c65b99f66cfe6a3b249f70f3fbf116b5aace2980602a7ceabecdc399065d5a7f14aabe5475eb43fd573a3cc665
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsconfig/node10@npm:^1.0.7":
|
||||
version: 1.0.8
|
||||
resolution: "@tsconfig/node10@npm:1.0.8"
|
||||
@@ -14274,6 +14285,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"code-block-writer@npm:^13.0.3":
|
||||
version: 13.0.3
|
||||
resolution: "code-block-writer@npm:13.0.3"
|
||||
checksum: 10/771546224f38610eecee0598e83c9e0f86dcd600ea316dbf27c2cfebaab4fed51b042325aa460b8e0f131fac5c1de208f6610a1ddbffe4b22e76f9b5256707cb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"code-point-at@npm:^1.0.0":
|
||||
version: 1.1.0
|
||||
resolution: "code-point-at@npm:1.1.0"
|
||||
@@ -18176,6 +18194,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fdir@npm:^6.5.0":
|
||||
version: 6.5.0
|
||||
resolution: "fdir@npm:6.5.0"
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
checksum: 10/14ca1c9f0a0e8f4f2e9bf4e8551065a164a09545dae548c12a18d238b72e51e5a7b39bd8e5494b56463a0877672d0a6c1ef62c6fa0677db1b0c847773be939b1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fflate@npm:^0.8.2":
|
||||
version: 0.8.2
|
||||
resolution: "fflate@npm:0.8.2"
|
||||
@@ -19790,6 +19820,7 @@ __metadata:
|
||||
testing-library-selector: "npm:0.3.1"
|
||||
tracelib: "npm:1.0.1"
|
||||
ts-jest: "npm:29.4.0"
|
||||
ts-morph: "npm:^27.0.2"
|
||||
ts-node: "npm:10.9.2"
|
||||
tslib: "npm:2.8.1"
|
||||
tween-functions: "npm:^1.2.0"
|
||||
@@ -24357,7 +24388,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minimatch@npm:10.1.1, minimatch@npm:^10.1.1":
|
||||
"minimatch@npm:10.1.1, minimatch@npm:^10.0.1, minimatch@npm:^10.1.1":
|
||||
version: 10.1.1
|
||||
resolution: "minimatch@npm:10.1.1"
|
||||
dependencies:
|
||||
@@ -26970,6 +27001,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"picomatch@npm:^4.0.3":
|
||||
version: 4.0.3
|
||||
resolution: "picomatch@npm:4.0.3"
|
||||
checksum: 10/57b99055f40b16798f2802916d9c17e9744e620a0db136554af01d19598b96e45e2f00014c91d1b8b13874b80caa8c295b3d589a3f72373ec4aaf54baa5962d5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pify@npm:5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "pify@npm:5.0.0"
|
||||
@@ -32415,6 +32453,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tinyglobby@npm:^0.2.14":
|
||||
version: 0.2.15
|
||||
resolution: "tinyglobby@npm:0.2.15"
|
||||
dependencies:
|
||||
fdir: "npm:^6.5.0"
|
||||
picomatch: "npm:^4.0.3"
|
||||
checksum: 10/d72bd826a8b0fa5fa3929e7fe5ba48fceb2ae495df3a231b6c5408cd7d8c00b58ab5a9c2a76ba56a62ee9b5e083626f1f33599734bed1ffc4b792406408f0ca2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tinyqueue@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "tinyqueue@npm:3.0.0"
|
||||
@@ -32785,6 +32833,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ts-morph@npm:^27.0.2":
|
||||
version: 27.0.2
|
||||
resolution: "ts-morph@npm:27.0.2"
|
||||
dependencies:
|
||||
"@ts-morph/common": "npm:~0.28.1"
|
||||
code-block-writer: "npm:^13.0.3"
|
||||
checksum: 10/b9bd8ed86d4b76ca23446d3f808787cfe95a3bcb2316769a2f3fa262ea9ab4043bb76bf8aef677f9be0d758576f074df2926e701a535f84fd21946fdbf465148
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ts-node@npm:10.9.2, ts-node@npm:^10.9.1":
|
||||
version: 10.9.2
|
||||
resolution: "ts-node@npm:10.9.2"
|
||||
|
||||
Reference in New Issue
Block a user