From 416936df81d56bc1eda4afa672c1c47097603f54 Mon Sep 17 00:00:00 2001 From: spl3g Date: Mon, 16 Jun 2025 21:45:43 +0300 Subject: [PATCH 01/12] feat: add server support for storing tag filters --- dashboard/convert/dashboardtag.go | 58 +++++++++++++++++++++++++++++++ dashboard/convert/entry.go | 8 +++-- dashboard/entry/add.go | 14 ++++++++ dashboard/entry/tagcheck.go | 43 +++++++++++++++++++++++ dashboard/entry/update.go | 19 ++++++++++ dashboard/get.go | 9 ++++- model/all.go | 2 ++ model/dashboard.go | 36 +++++++++++++------ 8 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 dashboard/convert/dashboardtag.go create mode 100644 dashboard/entry/tagcheck.go diff --git a/dashboard/convert/dashboardtag.go b/dashboard/convert/dashboardtag.go new file mode 100644 index 00000000..2e37bae1 --- /dev/null +++ b/dashboard/convert/dashboardtag.go @@ -0,0 +1,58 @@ +package convert + +import ( + "github.com/traggo/server/generated/gqlmodel" + "github.com/traggo/server/model" +) + +func excludedTagsToExternal(tags []model.DashboardExcludedTag) []*gqlmodel.TimeSpanTag { + result := []*gqlmodel.TimeSpanTag{} + for _, tag := range tags { + result = append(result, &gqlmodel.TimeSpanTag{ + Key: tag.Key, + Value: tag.StringValue, + }) + } + return result +} + +func ExcludedTagsToInternal(gqls []*gqlmodel.InputTimeSpanTag) []model.DashboardExcludedTag { + result := make([]model.DashboardExcludedTag, 0) + for _, tag := range gqls { + result = append(result, excludedTagToInternal(*tag)) + } + return result +} + +func excludedTagToInternal(gqls gqlmodel.InputTimeSpanTag) model.DashboardExcludedTag { + return model.DashboardExcludedTag{ + Key: gqls.Key, + StringValue: gqls.Value, + } +} + +func includedTagsToExternal(tags []model.DashboardIncludedTag) []*gqlmodel.TimeSpanTag { + result := []*gqlmodel.TimeSpanTag{} + for _, tag := range tags { + result = append(result, &gqlmodel.TimeSpanTag{ + Key: tag.Key, + Value: tag.StringValue, + }) + } + return result +} + +func IncludedTagsToInternal(gqls []*gqlmodel.InputTimeSpanTag) []model.DashboardIncludedTag { + result := make([]model.DashboardIncludedTag, 0) + for _, tag := range gqls { + result = append(result, includedTagToInternal(*tag)) + } + return result +} + +func includedTagToInternal(gqls gqlmodel.InputTimeSpanTag) model.DashboardIncludedTag { + return model.DashboardIncludedTag{ + Key: gqls.Key, + StringValue: gqls.Value, + } +} diff --git a/dashboard/convert/entry.go b/dashboard/convert/entry.go index c606341e..ab5888a1 100644 --- a/dashboard/convert/entry.go +++ b/dashboard/convert/entry.go @@ -38,9 +38,11 @@ func ToExternalEntry(entry model.DashboardEntry) (*gqlmodel.DashboardEntry, erro To: entry.RangeTo, } stats := &gqlmodel.StatsSelection{ - Interval: ExternalInterval(entry.Interval), - Tags: strings.Split(entry.Keys, ","), - Range: dateRange, + Interval: ExternalInterval(entry.Interval), + Tags: strings.Split(entry.Keys, ","), + Range: dateRange, + ExcludeTags: excludedTagsToExternal(entry.ExcludedTags), + IncludeTags: includedTagsToExternal(entry.IncludedTags), } if entry.RangeID != model.NoRangeIDDefined { stats.RangeID = &entry.RangeID diff --git a/dashboard/entry/add.go b/dashboard/entry/add.go index f9fe8ee8..71976d35 100644 --- a/dashboard/entry/add.go +++ b/dashboard/entry/add.go @@ -24,6 +24,18 @@ func (r *ResolverForEntry) AddDashboardEntry(ctx context.Context, dashboardID in return nil, err } + if tag := tagsDuplicates(stats.ExcludeTags, stats.IncludeTags); tag != nil { + return nil, fmt.Errorf("tag '%s' is present in both exclude tags and include tags", tag.Key+":"+tag.Value) + } + + if err := tagsExist(r.DB, auth.GetUser(ctx).ID, stats.ExcludeTags); err != nil { + return nil, fmt.Errorf("exclude tags: %s", err.Error()) + } + + if err := tagsExist(r.DB, auth.GetUser(ctx).ID, stats.IncludeTags); err != nil { + return nil, fmt.Errorf("include tags: %s", err.Error()) + } + entry := model.DashboardEntry{ Keys: strings.Join(stats.Tags, ","), Type: convert.InternalEntryType(entryType), @@ -34,6 +46,8 @@ func (r *ResolverForEntry) AddDashboardEntry(ctx context.Context, dashboardID in MobilePosition: convert.EmptyPos(), DesktopPosition: convert.EmptyPos(), RangeID: -1, + ExcludedTags: convert.ExcludedTagsToInternal(stats.ExcludeTags), + IncludedTags: convert.IncludedTagsToInternal(stats.IncludeTags), } if len(stats.Tags) == 0 { diff --git a/dashboard/entry/tagcheck.go b/dashboard/entry/tagcheck.go new file mode 100644 index 00000000..3f42df2c --- /dev/null +++ b/dashboard/entry/tagcheck.go @@ -0,0 +1,43 @@ +package entry + +import ( + "fmt" + + "github.com/jinzhu/gorm" + "github.com/traggo/server/generated/gqlmodel" + "github.com/traggo/server/model" +) + +func tagsDuplicates(src []*gqlmodel.InputTimeSpanTag, dst []*gqlmodel.InputTimeSpanTag) *gqlmodel.InputTimeSpanTag { + existingTags := make(map[string]struct{}) + for _, tag := range src { + existingTags[tag.Key+":"+tag.Value] = struct{}{} + } + + for _, tag := range dst { + if _, ok := existingTags[tag.Key+":"+tag.Value]; ok { + return tag + } + + existingTags[tag.Key] = struct{}{} + } + + return nil +} + +func tagsExist(db *gorm.DB, userID int, tags []*gqlmodel.InputTimeSpanTag) error { + existingTags := make(map[string]struct{}) + + for _, tag := range tags { + if _, ok := existingTags[tag.Key]; ok { + return fmt.Errorf("tag '%s' is present multiple times", tag.Key) + } + + if db.Where("key = ?", tag.Key).Where("user_id = ?", userID).Find(new(model.TagDefinition)).RecordNotFound() { + return fmt.Errorf("tag '%s' does not exist", tag.Key) + } + + existingTags[tag.Key] = struct{}{} + } + return nil +} diff --git a/dashboard/entry/update.go b/dashboard/entry/update.go index d5d406bf..72d9cc88 100644 --- a/dashboard/entry/update.go +++ b/dashboard/entry/update.go @@ -53,6 +53,25 @@ func (r *ResolverForEntry) UpdateDashboardEntry(ctx context.Context, id int, ent entry.RangeFrom = stats.Range.From entry.RangeTo = stats.Range.To } + + if tag := tagsDuplicates(stats.ExcludeTags, stats.IncludeTags); tag != nil { + return nil, fmt.Errorf("tag '%s' is present in both exclude tags and include tags", tag.Key+":"+tag.Value) + } + + r.DB.Where("dashboard_entry_id = ?", id).Delete(new(model.DashboardExcludedTag)) + r.DB.Where("dashboard_entry_id = ?", id).Delete(new(model.DashboardIncludedTag)) + + if err := tagsExist(r.DB, auth.GetUser(ctx).ID, stats.ExcludeTags); err != nil { + return nil, fmt.Errorf("exclude tags: %s", err.Error()) + } + + if err := tagsExist(r.DB, auth.GetUser(ctx).ID, stats.IncludeTags); err != nil { + return nil, fmt.Errorf("include tags: %s", err.Error()) + } + + entry.ExcludedTags = convert.ExcludedTagsToInternal(stats.ExcludeTags) + entry.IncludedTags = convert.IncludedTagsToInternal(stats.IncludeTags) + entry.Keys = strings.Join(stats.Tags, ",") entry.Interval = convert.InternalInterval(stats.Interval) } diff --git a/dashboard/get.go b/dashboard/get.go index a4a57151..d2a6c1da 100644 --- a/dashboard/get.go +++ b/dashboard/get.go @@ -15,7 +15,14 @@ func (r *ResolverForDashboard) Dashboards(ctx context.Context) ([]*gqlmodel.Dash userID := auth.GetUser(ctx).ID dashboards := []model.Dashboard{} - find := r.DB.Preload("Entries").Preload("Ranges").Where(&model.Dashboard{UserID: userID}).Find(&dashboards) + + q := r.DB + q = q.Preload("Entries") + q = q.Preload("Entries.ExcludedTags") + q = q.Preload("Entries.IncludedTags") + q = q.Preload("Ranges") + + find := q.Where(&model.Dashboard{UserID: userID}).Find(&dashboards) if find.Error != nil { return nil, find.Error diff --git a/model/all.go b/model/all.go index 27d23782..3e054d96 100644 --- a/model/all.go +++ b/model/all.go @@ -11,6 +11,8 @@ func All() []interface{} { new(UserSetting), new(Dashboard), new(DashboardEntry), + new(DashboardExcludedTag), + new(DashboardIncludedTag), new(DashboardRange), } } diff --git a/model/dashboard.go b/model/dashboard.go index 759de851..804c568f 100644 --- a/model/dashboard.go +++ b/model/dashboard.go @@ -26,21 +26,37 @@ type Dashboard struct { // DashboardEntry an entry which represents a diagram in a dashboard. type DashboardEntry struct { - ID int `gorm:"primary_key;unique_index;AUTO_INCREMENT"` - DashboardID int `gorm:"type:int REFERENCES dashboards(id) ON DELETE CASCADE"` - Title string - Total bool `gorm:"default:false"` - Type DashboardType - Keys string - Interval Interval - RangeID int - RangeFrom string - RangeTo string + ID int `gorm:"primary_key;unique_index;AUTO_INCREMENT"` + DashboardID int `gorm:"type:int REFERENCES dashboards(id) ON DELETE CASCADE"` + Title string + Total bool `gorm:"default:false"` + Type DashboardType + Keys string + Interval Interval + RangeID int + RangeFrom string + RangeTo string + ExcludedTags []DashboardExcludedTag + IncludedTags []DashboardIncludedTag MobilePosition string DesktopPosition string } +// DashboardExcludedTag a tag for filtering timespans +type DashboardExcludedTag struct { + DashboardEntryID int `gorm:"type:int REFERENCES dashboard_entries(id) ON DELETE CASCADE"` + Key string + StringValue string +} + +// DashboardIncludedTag a tag for filtering timespans +type DashboardIncludedTag struct { + DashboardEntryID int `gorm:"type:int REFERENCES dashboard_entries(id) ON DELETE CASCADE"` + Key string + StringValue string +} + // DashboardType the dashboard type type DashboardType string From bd6b197f5b1f9b5d8cba71744a4c05d09a7639f0 Mon Sep 17 00:00:00 2001 From: spl3g Date: Tue, 17 Jun 2025 01:18:38 +0300 Subject: [PATCH 02/12] feat: add TagFilterSelector componenet --- ui/src/tag/TagFilterSelector.tsx | 152 +++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 ui/src/tag/TagFilterSelector.tsx diff --git a/ui/src/tag/TagFilterSelector.tsx b/ui/src/tag/TagFilterSelector.tsx new file mode 100644 index 00000000..b380cba0 --- /dev/null +++ b/ui/src/tag/TagFilterSelector.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import Downshift from 'downshift'; +import {makeStyles, Theme} from '@material-ui/core/styles'; +import TextField from '@material-ui/core/TextField'; +import Paper from '@material-ui/core/Paper'; +import MenuItem from '@material-ui/core/MenuItem'; +import Chip from '@material-ui/core/Chip'; +import {useQuery} from '@apollo/react-hooks'; +import {Tags} from '../gql/__generated__/Tags'; +import {TagSelectorEntry, label} from './tagSelectorEntry'; +import * as gqlTags from '../gql/tags'; +import {useSuggest} from './suggest'; + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + flexGrow: 1, + height: 250, + }, + container: { + flexGrow: 1, + position: 'relative', + }, + paper: { + position: 'absolute', + zIndex: 1, + marginTop: theme.spacing(1), + left: 0, + right: 0, + }, + chip: { + margin: theme.spacing(0.5, 0.25), + }, + inputRoot: { + flexWrap: 'wrap', + }, + inputInput: { + width: 'auto', + flexGrow: 1, + }, +})); + +interface TagFilterSelectorProps { + value: TagSelectorEntry[]; + type: string; + onChange: (entries: TagSelectorEntry[]) => void; + disabled?: boolean; +} + +export const TagFilterSelector: React.FC = ({value: selectedItem, type, onChange, disabled = false}) => { + const classes = useStyles(); + const [inputValue, setInputValue] = React.useState(''); + + const tagsResult = useQuery(gqlTags.Tags); + const suggestions = useSuggest(tagsResult, inputValue, []) + .filter((t) => !t.tag.create && !t.tag.alreadyUsed) + .reverse(); + + if (tagsResult.error || tagsResult.loading || !tagsResult.data || !tagsResult.data.tags) { + return null; + } + + function handleKeyDown(event: React.KeyboardEvent) { + if (selectedItem.length && !inputValue.length && event.key === 'Backspace') { + onChange(selectedItem.slice(0, selectedItem.length - 1)); + } + } + + function handleInputChange(event: React.ChangeEvent<{value: string}>) { + setInputValue(event.target.value); + } + + function handleChange(item: TagSelectorEntry) { + if (!item.value) { + setInputValue(item.tag.key + ':'); + return; + } + let newSelectedItem = [...selectedItem]; + if (newSelectedItem.indexOf(item) === -1) { + newSelectedItem = [...newSelectedItem, item]; + } + setInputValue(''); + onChange(newSelectedItem); + } + + const handleDelete = (item: TagSelectorEntry) => () => { + const newSelectedItem = [...selectedItem]; + newSelectedItem.splice(newSelectedItem.indexOf(item), 1); + onChange(newSelectedItem); + }; + + return ( + (item ? label(item) : '')} + defaultIsOpen={false}> + {({getInputProps, getItemProps, getLabelProps, isOpen, highlightedIndex}) => { + const {onBlur, onChange: downshiftOnChange, onFocus, ...inputProps} = getInputProps({ + onKeyDown: handleKeyDown, + placeholder: `Select ${type} Tags`, + }); + return ( +
+ ( + + )), + onBlur, + onChange: (event) => { + handleInputChange(event); + downshiftOnChange!(event as React.ChangeEvent); + }, + onFocus, + }} + label={`${type} Tags`} + fullWidth + inputProps={inputProps} + /> + {isOpen ? ( + + {suggestions.map((suggestion, index) => ( + + {label(suggestion)} + + ))} + + ) : null} +
+ ); + }} +
+ ); +}; From 8ec67ca6bf1cabc5f074a73b5626ad5f928f7e52 Mon Sep 17 00:00:00 2001 From: spl3g Date: Tue, 17 Jun 2025 01:21:01 +0300 Subject: [PATCH 03/12] feat: add exclude and include tags fields to entry popups --- ui/src/dashboard/Entry/AddPopup.tsx | 11 ++++++- ui/src/dashboard/Entry/DashboardEntryForm.tsx | 33 +++++++++++++++++++ ui/src/dashboard/Entry/EditPopup.tsx | 11 ++++++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/ui/src/dashboard/Entry/AddPopup.tsx b/ui/src/dashboard/Entry/AddPopup.tsx index 22aa0904..43bf8789 100644 --- a/ui/src/dashboard/Entry/AddPopup.tsx +++ b/ui/src/dashboard/Entry/AddPopup.tsx @@ -10,6 +10,8 @@ import * as gqlDashboard from '../../gql/dashboard'; import {Fade} from '../../common/Fade'; import {DashboardEntryForm, isValidDashboardEntry} from './DashboardEntryForm'; import {AddDashboardEntry, AddDashboardEntryVariables} from '../../gql/__generated__/AddDashboardEntry'; +import {handleError} from '../../utils/errors'; +import {useSnackbar} from 'notistack'; interface EditPopupProps { dashboardId: number; @@ -38,6 +40,9 @@ export const AddPopup: React.FC = ({ refetchQueries: [{query: gqlDashboard.Dashboards}], }); const valid = isValidDashboardEntry(entry); + + const {enqueueSnackbar} = useSnackbar(); + return ( = ({ } : null, rangeId: entry.statsSelection.rangeId, + excludeTags: entry.statsSelection.excludeTags, + includeTags: entry.statsSelection.includeTags, }, pos: { desktop: { @@ -99,7 +106,9 @@ export const AddPopup: React.FC = ({ }, }, }, - }).then(finish); + }) + .then(finish) + .catch(handleError('Add Dashboard Entry', enqueueSnackbar)); }}> Add diff --git a/ui/src/dashboard/Entry/DashboardEntryForm.tsx b/ui/src/dashboard/Entry/DashboardEntryForm.tsx index 576b53e2..8fcde629 100644 --- a/ui/src/dashboard/Entry/DashboardEntryForm.tsx +++ b/ui/src/dashboard/Entry/DashboardEntryForm.tsx @@ -38,9 +38,13 @@ export const DashboardEntryForm: React.FC = ({entry, onChange: s const tagsResult = useQuery(gqlTags.Tags); let tagKeys; + let excludeTags; + let includeTags; if (!tagsResult.error && !tagsResult.loading && tagsResult.data && tagsResult.data.tags) { const keyInputTags = (entry.statsSelection.tags || []).map((key) => ({key, value: ''})); tagKeys = toTagSelectorEntry(tagsResult.data.tags, keyInputTags); + excludeTags = toTagSelectorEntry(tagsResult.data.tags, entry.statsSelection.excludeTags || []); + includeTags = toTagSelectorEntry(tagsResult.data.tags, entry.statsSelection.includeTags || []); } const range: Dashboards_dashboards_items_statsSelection_range = entry.statsSelection.range @@ -204,6 +208,35 @@ export const DashboardEntryForm: React.FC = ({entry, onChange: s onlySelectKeys removeWhenClicked /> + + { + entry.statsSelection.excludeTags = tags.map((tag) => ({ + key: tag.tag.key, + value: tag.value, + __typename: 'TimeSpanTag', + })); + setEntry(entry); + }} + allowDuplicateKeys + createTags={false} + /> + { + entry.statsSelection.includeTags = tags.map((tag) => ({ + key: tag.tag.key, + value: tag.value, + __typename: 'TimeSpanTag', + })); + setEntry(entry); + }} + allowDuplicateKeys + createTags={false} + /> ); }; diff --git a/ui/src/dashboard/Entry/EditPopup.tsx b/ui/src/dashboard/Entry/EditPopup.tsx index 5a5f66a4..6519a4a0 100644 --- a/ui/src/dashboard/Entry/EditPopup.tsx +++ b/ui/src/dashboard/Entry/EditPopup.tsx @@ -10,6 +10,8 @@ import * as gqlDashboard from '../../gql/dashboard'; import {UpdateDashboardEntry, UpdateDashboardEntryVariables} from '../../gql/__generated__/UpdateDashboardEntry'; import {Fade} from '../../common/Fade'; import {DashboardEntryForm, isValidDashboardEntry} from './DashboardEntryForm'; +import {handleError} from '../../utils/errors'; +import {useSnackbar} from 'notistack'; interface EditPopupProps { entry: Dashboards_dashboards_items; @@ -24,6 +26,9 @@ export const EditPopup: React.FC = ({entry, anchorEl, onChange: refetchQueries: [{query: gqlDashboard.Dashboards}], }); const valid = isValidDashboardEntry(entry); + + const {enqueueSnackbar} = useSnackbar(); + return ( = ({entry, anchorEl, onChange: } : null, rangeId: entry.statsSelection.rangeId, + excludeTags: entry.statsSelection.excludeTags, + includeTags: entry.statsSelection.includeTags, }, }, - }).then(() => setEdit(null)); + }) + .then(() => setEdit(null)) + .catch(handleError('Edit Dashboard Entry', enqueueSnackbar)); }}> Save From 6bc3e44d807eb63ed274ae644b3b024ae1c6ff6c Mon Sep 17 00:00:00 2001 From: spl3g Date: Tue, 17 Jun 2025 01:42:03 +0300 Subject: [PATCH 04/12] feat: provide exclude and include tags when querying stats --- ui/src/dashboard/Entry/DashboardEntry.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/src/dashboard/Entry/DashboardEntry.tsx b/ui/src/dashboard/Entry/DashboardEntry.tsx index 4822d270..7653c533 100644 --- a/ui/src/dashboard/Entry/DashboardEntry.tsx +++ b/ui/src/dashboard/Entry/DashboardEntry.tsx @@ -52,6 +52,8 @@ const SpecificDashboardEntry: React.FC<{entry: Dashboards_dashboards_items; rang range, interval, tags: entry.statsSelection.tags, + excludeTags: entry.statsSelection.excludeTags, + includeTags: entry.statsSelection.includeTags, }, }, }); From 197170c8ac48d005cdbde6df6ff109140ac1e8d5 Mon Sep 17 00:00:00 2001 From: spl3g Date: Tue, 17 Jun 2025 20:04:08 +0300 Subject: [PATCH 05/12] fix: combine excluded and included tags, simplify tag checking --- dashboard/convert/dashboardtag.go | 49 ++++++++----------------------- dashboard/convert/entry.go | 4 +-- dashboard/entry/add.go | 16 ++++------ dashboard/entry/tagcheck.go | 46 ++++++++++++----------------- dashboard/entry/update.go | 20 +++++-------- dashboard/get.go | 3 +- model/all.go | 3 +- model/dashboard.go | 35 +++++++++------------- 8 files changed, 61 insertions(+), 115 deletions(-) diff --git a/dashboard/convert/dashboardtag.go b/dashboard/convert/dashboardtag.go index 2e37bae1..8813be33 100644 --- a/dashboard/convert/dashboardtag.go +++ b/dashboard/convert/dashboardtag.go @@ -5,54 +5,31 @@ import ( "github.com/traggo/server/model" ) -func excludedTagsToExternal(tags []model.DashboardExcludedTag) []*gqlmodel.TimeSpanTag { +func tagFiltersToExternal(tags []model.DashboardTagFilter, include bool) []*gqlmodel.TimeSpanTag { result := []*gqlmodel.TimeSpanTag{} for _, tag := range tags { - result = append(result, &gqlmodel.TimeSpanTag{ - Key: tag.Key, - Value: tag.StringValue, - }) + if tag.Include == include { + result = append(result, &gqlmodel.TimeSpanTag{ + Key: tag.Key, + Value: tag.StringValue, + }) + } } return result } -func ExcludedTagsToInternal(gqls []*gqlmodel.InputTimeSpanTag) []model.DashboardExcludedTag { - result := make([]model.DashboardExcludedTag, 0) +func TagFiltersToInternal(gqls []*gqlmodel.InputTimeSpanTag, include bool) []model.DashboardTagFilter { + result := make([]model.DashboardTagFilter, 0) for _, tag := range gqls { - result = append(result, excludedTagToInternal(*tag)) + result = append(result, tagFilterToInternal(*tag, include)) } return result } -func excludedTagToInternal(gqls gqlmodel.InputTimeSpanTag) model.DashboardExcludedTag { - return model.DashboardExcludedTag{ - Key: gqls.Key, - StringValue: gqls.Value, - } -} - -func includedTagsToExternal(tags []model.DashboardIncludedTag) []*gqlmodel.TimeSpanTag { - result := []*gqlmodel.TimeSpanTag{} - for _, tag := range tags { - result = append(result, &gqlmodel.TimeSpanTag{ - Key: tag.Key, - Value: tag.StringValue, - }) - } - return result -} - -func IncludedTagsToInternal(gqls []*gqlmodel.InputTimeSpanTag) []model.DashboardIncludedTag { - result := make([]model.DashboardIncludedTag, 0) - for _, tag := range gqls { - result = append(result, includedTagToInternal(*tag)) - } - return result -} - -func includedTagToInternal(gqls gqlmodel.InputTimeSpanTag) model.DashboardIncludedTag { - return model.DashboardIncludedTag{ +func tagFilterToInternal(gqls gqlmodel.InputTimeSpanTag, include bool) model.DashboardTagFilter { + return model.DashboardTagFilter{ Key: gqls.Key, StringValue: gqls.Value, + Include: include, } } diff --git a/dashboard/convert/entry.go b/dashboard/convert/entry.go index ab5888a1..a61f6f8b 100644 --- a/dashboard/convert/entry.go +++ b/dashboard/convert/entry.go @@ -41,8 +41,8 @@ func ToExternalEntry(entry model.DashboardEntry) (*gqlmodel.DashboardEntry, erro Interval: ExternalInterval(entry.Interval), Tags: strings.Split(entry.Keys, ","), Range: dateRange, - ExcludeTags: excludedTagsToExternal(entry.ExcludedTags), - IncludeTags: includedTagsToExternal(entry.IncludedTags), + ExcludeTags: tagFiltersToExternal(entry.TagFilters, false), + IncludeTags: tagFiltersToExternal(entry.TagFilters, true), } if entry.RangeID != model.NoRangeIDDefined { stats.RangeID = &entry.RangeID diff --git a/dashboard/entry/add.go b/dashboard/entry/add.go index 71976d35..c9f3fcfe 100644 --- a/dashboard/entry/add.go +++ b/dashboard/entry/add.go @@ -24,16 +24,11 @@ func (r *ResolverForEntry) AddDashboardEntry(ctx context.Context, dashboardID in return nil, err } - if tag := tagsDuplicates(stats.ExcludeTags, stats.IncludeTags); tag != nil { - return nil, fmt.Errorf("tag '%s' is present in both exclude tags and include tags", tag.Key+":"+tag.Value) - } - - if err := tagsExist(r.DB, auth.GetUser(ctx).ID, stats.ExcludeTags); err != nil { - return nil, fmt.Errorf("exclude tags: %s", err.Error()) - } + tagFilters := convert.TagFiltersToInternal(stats.ExcludeTags, false) + tagFilters = append(tagFilters, convert.TagFiltersToInternal(stats.IncludeTags, true)...) - if err := tagsExist(r.DB, auth.GetUser(ctx).ID, stats.IncludeTags); err != nil { - return nil, fmt.Errorf("include tags: %s", err.Error()) + if err := tagsDuplicates(tagFilters); err != nil { + return nil, err } entry := model.DashboardEntry{ @@ -46,8 +41,7 @@ func (r *ResolverForEntry) AddDashboardEntry(ctx context.Context, dashboardID in MobilePosition: convert.EmptyPos(), DesktopPosition: convert.EmptyPos(), RangeID: -1, - ExcludedTags: convert.ExcludedTagsToInternal(stats.ExcludeTags), - IncludedTags: convert.IncludedTagsToInternal(stats.IncludeTags), + TagFilters: tagFilters, } if len(stats.Tags) == 0 { diff --git a/dashboard/entry/tagcheck.go b/dashboard/entry/tagcheck.go index 3f42df2c..1cde8dd1 100644 --- a/dashboard/entry/tagcheck.go +++ b/dashboard/entry/tagcheck.go @@ -3,41 +3,31 @@ package entry import ( "fmt" - "github.com/jinzhu/gorm" - "github.com/traggo/server/generated/gqlmodel" "github.com/traggo/server/model" ) -func tagsDuplicates(src []*gqlmodel.InputTimeSpanTag, dst []*gqlmodel.InputTimeSpanTag) *gqlmodel.InputTimeSpanTag { - existingTags := make(map[string]struct{}) - for _, tag := range src { - existingTags[tag.Key+":"+tag.Value] = struct{}{} - } - - for _, tag := range dst { - if _, ok := existingTags[tag.Key+":"+tag.Value]; ok { - return tag - } - - existingTags[tag.Key] = struct{}{} - } - - return nil -} - -func tagsExist(db *gorm.DB, userID int, tags []*gqlmodel.InputTimeSpanTag) error { - existingTags := make(map[string]struct{}) +func tagsDuplicates(tags []model.DashboardTagFilter) error { + existingTags := make(map[model.DashboardTagFilter]struct{}) for _, tag := range tags { - if _, ok := existingTags[tag.Key]; ok { - return fmt.Errorf("tag '%s' is present multiple times", tag.Key) + if _, ok := existingTags[tag]; ok { + tagType := "exclude" + if tag.Include { + tagType = "include" + } + + return fmt.Errorf("%s tags: tag '%s' is present multiple times", tagType, tag.Key+":"+tag.StringValue) + } else { + copyTag := tag + copyTag.Include = !copyTag.Include + + if _, ok := existingTags[copyTag]; ok { + return fmt.Errorf("tag '%s' is present in both exclude tags and include tags", tag.Key+":"+tag.StringValue) + } } - if db.Where("key = ?", tag.Key).Where("user_id = ?", userID).Find(new(model.TagDefinition)).RecordNotFound() { - return fmt.Errorf("tag '%s' does not exist", tag.Key) - } - - existingTags[tag.Key] = struct{}{} + existingTags[tag] = struct{}{} } + return nil } diff --git a/dashboard/entry/update.go b/dashboard/entry/update.go index 72d9cc88..49b686b6 100644 --- a/dashboard/entry/update.go +++ b/dashboard/entry/update.go @@ -54,24 +54,18 @@ func (r *ResolverForEntry) UpdateDashboardEntry(ctx context.Context, id int, ent entry.RangeTo = stats.Range.To } - if tag := tagsDuplicates(stats.ExcludeTags, stats.IncludeTags); tag != nil { - return nil, fmt.Errorf("tag '%s' is present in both exclude tags and include tags", tag.Key+":"+tag.Value) - } - - r.DB.Where("dashboard_entry_id = ?", id).Delete(new(model.DashboardExcludedTag)) - r.DB.Where("dashboard_entry_id = ?", id).Delete(new(model.DashboardIncludedTag)) + tagFilters := convert.TagFiltersToInternal(stats.ExcludeTags, false) + tagFilters = append(tagFilters, convert.TagFiltersToInternal(stats.IncludeTags, true)...) - if err := tagsExist(r.DB, auth.GetUser(ctx).ID, stats.ExcludeTags); err != nil { - return nil, fmt.Errorf("exclude tags: %s", err.Error()) + if err := tagsDuplicates(tagFilters); err != nil { + return nil, err } - if err := tagsExist(r.DB, auth.GetUser(ctx).ID, stats.IncludeTags); err != nil { - return nil, fmt.Errorf("include tags: %s", err.Error()) + if err := r.DB.Where("dashboard_entry_id = ?", id).Delete(new(model.DashboardTagFilter)).Error; err != nil { + return nil, fmt.Errorf("failed to update tag filters: %s", err) } - entry.ExcludedTags = convert.ExcludedTagsToInternal(stats.ExcludeTags) - entry.IncludedTags = convert.IncludedTagsToInternal(stats.IncludeTags) - + entry.TagFilters = tagFilters entry.Keys = strings.Join(stats.Tags, ",") entry.Interval = convert.InternalInterval(stats.Interval) } diff --git a/dashboard/get.go b/dashboard/get.go index d2a6c1da..2c66eee7 100644 --- a/dashboard/get.go +++ b/dashboard/get.go @@ -18,8 +18,7 @@ func (r *ResolverForDashboard) Dashboards(ctx context.Context) ([]*gqlmodel.Dash q := r.DB q = q.Preload("Entries") - q = q.Preload("Entries.ExcludedTags") - q = q.Preload("Entries.IncludedTags") + q = q.Preload("Entries.TagFilters") q = q.Preload("Ranges") find := q.Where(&model.Dashboard{UserID: userID}).Find(&dashboards) diff --git a/model/all.go b/model/all.go index 3e054d96..7f71a3e1 100644 --- a/model/all.go +++ b/model/all.go @@ -11,8 +11,7 @@ func All() []interface{} { new(UserSetting), new(Dashboard), new(DashboardEntry), - new(DashboardExcludedTag), - new(DashboardIncludedTag), + new(DashboardTagFilter), new(DashboardRange), } } diff --git a/model/dashboard.go b/model/dashboard.go index 804c568f..4e9190a1 100644 --- a/model/dashboard.go +++ b/model/dashboard.go @@ -26,35 +26,28 @@ type Dashboard struct { // DashboardEntry an entry which represents a diagram in a dashboard. type DashboardEntry struct { - ID int `gorm:"primary_key;unique_index;AUTO_INCREMENT"` - DashboardID int `gorm:"type:int REFERENCES dashboards(id) ON DELETE CASCADE"` - Title string - Total bool `gorm:"default:false"` - Type DashboardType - Keys string - Interval Interval - RangeID int - RangeFrom string - RangeTo string - ExcludedTags []DashboardExcludedTag - IncludedTags []DashboardIncludedTag + ID int `gorm:"primary_key;unique_index;AUTO_INCREMENT"` + DashboardID int `gorm:"type:int REFERENCES dashboards(id) ON DELETE CASCADE"` + Title string + Total bool `gorm:"default:false"` + Type DashboardType + Keys string + Interval Interval + RangeID int + RangeFrom string + RangeTo string + TagFilters []DashboardTagFilter MobilePosition string DesktopPosition string } -// DashboardExcludedTag a tag for filtering timespans -type DashboardExcludedTag struct { - DashboardEntryID int `gorm:"type:int REFERENCES dashboard_entries(id) ON DELETE CASCADE"` - Key string - StringValue string -} - -// DashboardIncludedTag a tag for filtering timespans -type DashboardIncludedTag struct { +// DashboardTagFilter a tag for filtering timespans +type DashboardTagFilter struct { DashboardEntryID int `gorm:"type:int REFERENCES dashboard_entries(id) ON DELETE CASCADE"` Key string StringValue string + Include bool } // DashboardType the dashboard type From 7165d8398fbe2349a69f6bbdaa69556104d3bc1e Mon Sep 17 00:00:00 2001 From: spl3g Date: Tue, 17 Jun 2025 23:26:04 +0300 Subject: [PATCH 06/12] feat: add includeInputValueOnNoMatch to useSuggest --- ui/src/tag/TagFilterSelector.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ui/src/tag/TagFilterSelector.tsx b/ui/src/tag/TagFilterSelector.tsx index b380cba0..c84f2767 100644 --- a/ui/src/tag/TagFilterSelector.tsx +++ b/ui/src/tag/TagFilterSelector.tsx @@ -51,9 +51,7 @@ export const TagFilterSelector: React.FC = ({value: sele const [inputValue, setInputValue] = React.useState(''); const tagsResult = useQuery(gqlTags.Tags); - const suggestions = useSuggest(tagsResult, inputValue, []) - .filter((t) => !t.tag.create && !t.tag.alreadyUsed) - .reverse(); + const suggestions = useSuggest(tagsResult, inputValue, [], false, false).filter((t) => !t.tag.create && !t.tag.alreadyUsed); if (tagsResult.error || tagsResult.loading || !tagsResult.data || !tagsResult.data.tags) { return null; @@ -104,7 +102,7 @@ export const TagFilterSelector: React.FC = ({value: sele
Date: Wed, 18 Jun 2025 00:25:56 +0300 Subject: [PATCH 07/12] fix: update handleChange so it opens Downshift automatically --- ui/src/tag/TagFilterSelector.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/src/tag/TagFilterSelector.tsx b/ui/src/tag/TagFilterSelector.tsx index c84f2767..42b5fb8b 100644 --- a/ui/src/tag/TagFilterSelector.tsx +++ b/ui/src/tag/TagFilterSelector.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import Downshift from 'downshift'; +import Downshift, {ControllerStateAndHelpers} from 'downshift'; import {makeStyles, Theme} from '@material-ui/core/styles'; import TextField from '@material-ui/core/TextField'; import Paper from '@material-ui/core/Paper'; @@ -67,11 +67,17 @@ export const TagFilterSelector: React.FC = ({value: sele setInputValue(event.target.value); } - function handleChange(item: TagSelectorEntry) { + function handleChange(item: TagSelectorEntry, state: ControllerStateAndHelpers) { + if (!item) { + return; + } + if (!item.value) { setInputValue(item.tag.key + ':'); + state.setState({isOpen: true}); return; } + let newSelectedItem = [...selectedItem]; if (newSelectedItem.indexOf(item) === -1) { newSelectedItem = [...newSelectedItem, item]; @@ -90,7 +96,7 @@ export const TagFilterSelector: React.FC = ({value: sele (item ? label(item) : '')} defaultIsOpen={false}> {({getInputProps, getItemProps, getLabelProps, isOpen, highlightedIndex}) => { From b5719b70c96823f7ed3c13e09109d4162fdcb45e Mon Sep 17 00:00:00 2001 From: spl3g Date: Fri, 20 Jun 2025 21:24:34 +0300 Subject: [PATCH 08/12] fix: provide handleChange instead of calling it --- ui/src/tag/TagFilterSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/tag/TagFilterSelector.tsx b/ui/src/tag/TagFilterSelector.tsx index 42b5fb8b..57291b4c 100644 --- a/ui/src/tag/TagFilterSelector.tsx +++ b/ui/src/tag/TagFilterSelector.tsx @@ -96,7 +96,7 @@ export const TagFilterSelector: React.FC = ({value: sele (item ? label(item) : '')} defaultIsOpen={false}> {({getInputProps, getItemProps, getLabelProps, isOpen, highlightedIndex}) => { From 6cb53521b930d158ee0081e75fcad7d6d67aa72b Mon Sep 17 00:00:00 2001 From: spl3g Date: Sat, 21 Jun 2025 18:14:24 +0300 Subject: [PATCH 09/12] fix: check if provided tags list is empty --- dashboard/convert/dashboardtag.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dashboard/convert/dashboardtag.go b/dashboard/convert/dashboardtag.go index 8813be33..884be1b2 100644 --- a/dashboard/convert/dashboardtag.go +++ b/dashboard/convert/dashboardtag.go @@ -6,6 +6,10 @@ import ( ) func tagFiltersToExternal(tags []model.DashboardTagFilter, include bool) []*gqlmodel.TimeSpanTag { + if len(tags) == 0 { + return nil + } + result := []*gqlmodel.TimeSpanTag{} for _, tag := range tags { if tag.Include == include { From 4692d83a788fee6e541abda58f4398e8861a1876 Mon Sep 17 00:00:00 2001 From: spl3g Date: Sat, 21 Jun 2025 18:17:43 +0300 Subject: [PATCH 10/12] feat: use a transaction when updating dashboard entry --- dashboard/entry/update.go | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/dashboard/entry/update.go b/dashboard/entry/update.go index 49b686b6..14a18371 100644 --- a/dashboard/entry/update.go +++ b/dashboard/entry/update.go @@ -36,6 +36,19 @@ func (r *ResolverForEntry) UpdateDashboardEntry(ctx context.Context, id int, ent entry.Total = *total } + tx := r.DB.Begin() + if err := tx.Error; err != nil { + return nil, err + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + panic(r) + } else if tx != nil { + tx.Rollback() + } + }() + if stats != nil { if stats.RangeID != nil { if _, err := util.FindDashboardRange(r.DB, *stats.RangeID); err != nil { @@ -61,7 +74,7 @@ func (r *ResolverForEntry) UpdateDashboardEntry(ctx context.Context, id int, ent return nil, err } - if err := r.DB.Where("dashboard_entry_id = ?", id).Delete(new(model.DashboardTagFilter)).Error; err != nil { + if err := tx.Where("dashboard_entry_id = ?", id).Delete(new(model.DashboardTagFilter)).Error; err != nil { return nil, fmt.Errorf("failed to update tag filters: %s", err) } @@ -78,7 +91,14 @@ func (r *ResolverForEntry) UpdateDashboardEntry(ctx context.Context, id int, ent return &gqlmodel.DashboardEntry{}, err } - r.DB.Save(entry) + if err := tx.Save(entry).Error; err != nil { + return nil, err + } + + if err := tx.Commit().Error; err != nil { + return nil, err + } + tx = nil return convert.ToExternalEntry(entry) } From 9a7dfd2abfae4e3e389bf20b510ae97ee7ad4957 Mon Sep 17 00:00:00 2001 From: spl3g Date: Sun, 31 Aug 2025 18:34:00 +0500 Subject: [PATCH 11/12] fix: replace ordinary db connection with transaction --- dashboard/entry/update.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/entry/update.go b/dashboard/entry/update.go index 14a18371..b4b110aa 100644 --- a/dashboard/entry/update.go +++ b/dashboard/entry/update.go @@ -51,7 +51,7 @@ func (r *ResolverForEntry) UpdateDashboardEntry(ctx context.Context, id int, ent if stats != nil { if stats.RangeID != nil { - if _, err := util.FindDashboardRange(r.DB, *stats.RangeID); err != nil { + if _, err := util.FindDashboardRange(tx, *stats.RangeID); err != nil { return nil, err } entry.RangeID = *stats.RangeID From 2ddf0a7411686ed3d5111fbc493f1366e2a1b175 Mon Sep 17 00:00:00 2001 From: spl3g Date: Mon, 15 Dec 2025 16:04:54 +0300 Subject: [PATCH 12/12] feat: remove TagFilterSelector --- ui/src/tag/TagFilterSelector.tsx | 156 ------------------------------- 1 file changed, 156 deletions(-) delete mode 100644 ui/src/tag/TagFilterSelector.tsx diff --git a/ui/src/tag/TagFilterSelector.tsx b/ui/src/tag/TagFilterSelector.tsx deleted file mode 100644 index 57291b4c..00000000 --- a/ui/src/tag/TagFilterSelector.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React from 'react'; -import Downshift, {ControllerStateAndHelpers} from 'downshift'; -import {makeStyles, Theme} from '@material-ui/core/styles'; -import TextField from '@material-ui/core/TextField'; -import Paper from '@material-ui/core/Paper'; -import MenuItem from '@material-ui/core/MenuItem'; -import Chip from '@material-ui/core/Chip'; -import {useQuery} from '@apollo/react-hooks'; -import {Tags} from '../gql/__generated__/Tags'; -import {TagSelectorEntry, label} from './tagSelectorEntry'; -import * as gqlTags from '../gql/tags'; -import {useSuggest} from './suggest'; - -const useStyles = makeStyles((theme: Theme) => ({ - root: { - flexGrow: 1, - height: 250, - }, - container: { - flexGrow: 1, - position: 'relative', - }, - paper: { - position: 'absolute', - zIndex: 1, - marginTop: theme.spacing(1), - left: 0, - right: 0, - }, - chip: { - margin: theme.spacing(0.5, 0.25), - }, - inputRoot: { - flexWrap: 'wrap', - }, - inputInput: { - width: 'auto', - flexGrow: 1, - }, -})); - -interface TagFilterSelectorProps { - value: TagSelectorEntry[]; - type: string; - onChange: (entries: TagSelectorEntry[]) => void; - disabled?: boolean; -} - -export const TagFilterSelector: React.FC = ({value: selectedItem, type, onChange, disabled = false}) => { - const classes = useStyles(); - const [inputValue, setInputValue] = React.useState(''); - - const tagsResult = useQuery(gqlTags.Tags); - const suggestions = useSuggest(tagsResult, inputValue, [], false, false).filter((t) => !t.tag.create && !t.tag.alreadyUsed); - - if (tagsResult.error || tagsResult.loading || !tagsResult.data || !tagsResult.data.tags) { - return null; - } - - function handleKeyDown(event: React.KeyboardEvent) { - if (selectedItem.length && !inputValue.length && event.key === 'Backspace') { - onChange(selectedItem.slice(0, selectedItem.length - 1)); - } - } - - function handleInputChange(event: React.ChangeEvent<{value: string}>) { - setInputValue(event.target.value); - } - - function handleChange(item: TagSelectorEntry, state: ControllerStateAndHelpers) { - if (!item) { - return; - } - - if (!item.value) { - setInputValue(item.tag.key + ':'); - state.setState({isOpen: true}); - return; - } - - let newSelectedItem = [...selectedItem]; - if (newSelectedItem.indexOf(item) === -1) { - newSelectedItem = [...newSelectedItem, item]; - } - setInputValue(''); - onChange(newSelectedItem); - } - - const handleDelete = (item: TagSelectorEntry) => () => { - const newSelectedItem = [...selectedItem]; - newSelectedItem.splice(newSelectedItem.indexOf(item), 1); - onChange(newSelectedItem); - }; - - return ( - (item ? label(item) : '')} - defaultIsOpen={false}> - {({getInputProps, getItemProps, getLabelProps, isOpen, highlightedIndex}) => { - const {onBlur, onChange: downshiftOnChange, onFocus, ...inputProps} = getInputProps({ - onKeyDown: handleKeyDown, - placeholder: `Select ${type} Tags`, - }); - return ( -
- ( - - )), - onBlur, - onChange: (event) => { - handleInputChange(event); - downshiftOnChange!(event as React.ChangeEvent); - }, - onFocus, - }} - label={`${type} Tags`} - fullWidth - inputProps={inputProps} - /> - {isOpen ? ( - - {suggestions.map((suggestion, index) => ( - - {label(suggestion)} - - ))} - - ) : null} -
- ); - }} -
- ); -};