diff --git a/dashboard/convert/dashboardtag.go b/dashboard/convert/dashboardtag.go new file mode 100644 index 0000000..884be1b --- /dev/null +++ b/dashboard/convert/dashboardtag.go @@ -0,0 +1,39 @@ +package convert + +import ( + "github.com/traggo/server/generated/gqlmodel" + "github.com/traggo/server/model" +) + +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 { + result = append(result, &gqlmodel.TimeSpanTag{ + Key: tag.Key, + Value: tag.StringValue, + }) + } + } + return result +} + +func TagFiltersToInternal(gqls []*gqlmodel.InputTimeSpanTag, include bool) []model.DashboardTagFilter { + result := make([]model.DashboardTagFilter, 0) + for _, tag := range gqls { + result = append(result, tagFilterToInternal(*tag, include)) + } + return result +} + +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 c606341..a61f6f8 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: 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 f9fe8ee..c9f3fcf 100644 --- a/dashboard/entry/add.go +++ b/dashboard/entry/add.go @@ -24,6 +24,13 @@ func (r *ResolverForEntry) AddDashboardEntry(ctx context.Context, dashboardID in return nil, err } + tagFilters := convert.TagFiltersToInternal(stats.ExcludeTags, false) + tagFilters = append(tagFilters, convert.TagFiltersToInternal(stats.IncludeTags, true)...) + + if err := tagsDuplicates(tagFilters); err != nil { + return nil, err + } + entry := model.DashboardEntry{ Keys: strings.Join(stats.Tags, ","), Type: convert.InternalEntryType(entryType), @@ -34,6 +41,7 @@ func (r *ResolverForEntry) AddDashboardEntry(ctx context.Context, dashboardID in MobilePosition: convert.EmptyPos(), DesktopPosition: convert.EmptyPos(), RangeID: -1, + TagFilters: tagFilters, } if len(stats.Tags) == 0 { diff --git a/dashboard/entry/tagcheck.go b/dashboard/entry/tagcheck.go new file mode 100644 index 0000000..1cde8dd --- /dev/null +++ b/dashboard/entry/tagcheck.go @@ -0,0 +1,33 @@ +package entry + +import ( + "fmt" + + "github.com/traggo/server/model" +) + +func tagsDuplicates(tags []model.DashboardTagFilter) error { + existingTags := make(map[model.DashboardTagFilter]struct{}) + + for _, tag := range tags { + 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) + } + } + + existingTags[tag] = struct{}{} + } + + return nil +} diff --git a/dashboard/entry/update.go b/dashboard/entry/update.go index d5d406b..b4b110a 100644 --- a/dashboard/entry/update.go +++ b/dashboard/entry/update.go @@ -36,9 +36,22 @@ 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 { + if _, err := util.FindDashboardRange(tx, *stats.RangeID); err != nil { return nil, err } entry.RangeID = *stats.RangeID @@ -53,6 +66,19 @@ func (r *ResolverForEntry) UpdateDashboardEntry(ctx context.Context, id int, ent entry.RangeFrom = stats.Range.From entry.RangeTo = stats.Range.To } + + tagFilters := convert.TagFiltersToInternal(stats.ExcludeTags, false) + tagFilters = append(tagFilters, convert.TagFiltersToInternal(stats.IncludeTags, true)...) + + if err := tagsDuplicates(tagFilters); err != nil { + return nil, err + } + + 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) + } + + entry.TagFilters = tagFilters entry.Keys = strings.Join(stats.Tags, ",") entry.Interval = convert.InternalInterval(stats.Interval) } @@ -65,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) } diff --git a/dashboard/get.go b/dashboard/get.go index a4a5715..2c66eee 100644 --- a/dashboard/get.go +++ b/dashboard/get.go @@ -15,7 +15,13 @@ 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.TagFilters") + 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 27d2378..7f71a3e 100644 --- a/model/all.go +++ b/model/all.go @@ -11,6 +11,7 @@ func All() []interface{} { new(UserSetting), new(Dashboard), new(DashboardEntry), + new(DashboardTagFilter), new(DashboardRange), } } diff --git a/model/dashboard.go b/model/dashboard.go index 759de85..4e9190a 100644 --- a/model/dashboard.go +++ b/model/dashboard.go @@ -36,11 +36,20 @@ type DashboardEntry struct { RangeID int RangeFrom string RangeTo string + TagFilters []DashboardTagFilter MobilePosition string DesktopPosition string } +// 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 type DashboardType string diff --git a/ui/src/dashboard/Entry/AddPopup.tsx b/ui/src/dashboard/Entry/AddPopup.tsx index 22aa090..43bf878 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/DashboardEntry.tsx b/ui/src/dashboard/Entry/DashboardEntry.tsx index 4822d27..7653c53 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, }, }, }); diff --git a/ui/src/dashboard/Entry/DashboardEntryForm.tsx b/ui/src/dashboard/Entry/DashboardEntryForm.tsx index ae5d02b..476d4ce 100644 --- a/ui/src/dashboard/Entry/DashboardEntryForm.tsx +++ b/ui/src/dashboard/Entry/DashboardEntryForm.tsx @@ -5,6 +5,7 @@ import InputLabel from '@material-ui/core/InputLabel'; import Select from '@material-ui/core/NativeSelect/NativeSelect'; import {EntryType, StatsInterval} from '../../gql/__generated__/globalTypes'; import {TagKeySelector} from '../../tag/TagKeySelector'; +import {TagFilterSelector} from '../../tag/TagFilterSelector'; import {Dashboards_dashboards_items, Dashboards_dashboards_items_statsSelection_range} from '../../gql/__generated__/Dashboards'; import {RelativeDateTimeSelector} from '../../common/RelativeDateTimeSelector'; import {parseRelativeTime} from '../../utils/time'; @@ -31,6 +32,24 @@ export const isValidDashboardEntry = (item: Dashboards_dashboards_items): boolea export const DashboardEntryForm: React.FC = ({entry, onChange: setEntry, disabled = false, ranges}) => { const [staticRange, setStaticRange] = React.useState(!entry.statsSelection.rangeId); + const excludeTags = (entry.statsSelection.excludeTags || []).map((tag) => ({ + tag: { + key: tag.key, + color: '', + __typename: 'TagDefinition' as 'TagDefinition', + }, + value: tag.value, + })); + + const includeTags = (entry.statsSelection.includeTags || []).map((tag) => ({ + tag: { + key: tag.key, + color: '', + __typename: 'TagDefinition' as 'TagDefinition', + }, + value: tag.value, + })); + const range: Dashboards_dashboards_items_statsSelection_range = entry.statsSelection.range ? entry.statsSelection.range : { @@ -189,6 +208,30 @@ export const DashboardEntryForm: React.FC = ({entry, onChange: s setEntry(entry); }} /> + { + entry.statsSelection.excludeTags = tags.map((tag) => ({ + key: tag.tag.key, + value: tag.value, + __typename: 'TimeSpanTag', + })); + setEntry(entry); + }} + /> + { + entry.statsSelection.includeTags = tags.map((tag) => ({ + key: tag.tag.key, + value: tag.value, + __typename: 'TimeSpanTag', + })); + setEntry(entry); + }} + /> ); }; diff --git a/ui/src/dashboard/Entry/EditPopup.tsx b/ui/src/dashboard/Entry/EditPopup.tsx index 5a5f66a..6519a4a 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 diff --git a/ui/src/tag/TagFilterSelector.tsx b/ui/src/tag/TagFilterSelector.tsx new file mode 100644 index 0000000..57291b4 --- /dev/null +++ b/ui/src/tag/TagFilterSelector.tsx @@ -0,0 +1,156 @@ +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} +
+ ); + }} +
+ ); +}; diff --git a/ui/src/tag/suggest.ts b/ui/src/tag/suggest.ts index 31a6f82..1c0eab4 100644 --- a/ui/src/tag/suggest.ts +++ b/ui/src/tag/suggest.ts @@ -9,7 +9,8 @@ export const useSuggest = ( tagResult: QueryResult, inputValue: string, usedTags: string[], - skipValue = false + skipValue = false, + includeInputValueOnNoMatch = true ): TagSelectorEntry[] => { const [tagKeySomeCase, tagValue] = inputValue.split(':'); const tagKey = tagKeySomeCase.toLowerCase(); @@ -22,7 +23,7 @@ export const useSuggest = ( }); if (exactMatch && tagValue !== undefined && usedTags.indexOf(exactMatch.key) === -1 && !skipValue) { - return suggestTagValue(exactMatch, tagValue, valueResult); + return suggestTagValue(exactMatch, tagValue, valueResult, includeInputValueOnNoMatch); } else { return suggestTag(exactMatch, tagResult, tagKey, usedTags); } @@ -59,11 +60,12 @@ const suggestTag = ( const suggestTagValue = ( exactMatch: TagSelectorEntry['tag'], tagValue: string, - valueResult: QueryResult + valueResult: QueryResult, + includeInputValueOnNoMatch: boolean ): TagSelectorEntry[] => { let someValues = (valueResult.data && valueResult.data.values) || []; - if (someValues.indexOf(tagValue) === -1) { + if (includeInputValueOnNoMatch && someValues.indexOf(tagValue) === -1) { someValues = [tagValue, ...someValues]; }