diff --git a/backend/pkg/api/groups_test.go b/backend/pkg/api/groups_test.go index 375a07140..f8622d7a8 100644 --- a/backend/pkg/api/groups_test.go +++ b/backend/pkg/api/groups_test.go @@ -189,6 +189,26 @@ func TestGetGroupsFiltered(t *testing.T) { } } +func TestVersionBreakDownEmpty(t *testing.T) { + a := newForTest(t) + defer a.Close() + + tTeam, _ := a.AddTeam(&Team{Name: "test_team"}) + tApp, _ := a.AddApp(&Application{Name: "test_app", TeamID: tTeam.ID}) + tPkg, _ := a.AddPackage(&Package{Type: PkgTypeOther, URL: "http://sample.url/pkg", Version: "12.1.0", ApplicationID: tApp.ID}) + tChannel, _ := a.AddChannel(&Channel{Name: "test_channel", Color: "blue", ApplicationID: tApp.ID, PackageID: null.StringFrom(tPkg.ID)}) + _, err := a.AddGroup(&Group{Name: "test_group1", ApplicationID: tApp.ID, ChannelID: null.StringFrom(tChannel.ID), PolicyUpdatesEnabled: true, PolicySafeMode: true, PolicyPeriodInterval: "15 minutes", PolicyMaxUpdatesPerPeriod: 2, PolicyUpdateTimeout: "60 minutes"}) + assert.NoError(t, err) + + groups, err := a.GetGroups(tApp.ID, 0, 0) + assert.NoError(t, err) + g := groups[0] + + versionBreakdown, vbErr := a.GetGroupVersionBreakdown(g.ID) + assert.NoError(t, vbErr) + assert.Len(t, versionBreakdown, 0) +} + func TestGetVersionCountTimeline(t *testing.T) { a := newForTest(t) defer a.Close() diff --git a/backend/pkg/handler/groups.go b/backend/pkg/handler/groups.go index 3e8e951ca..1507404e0 100644 --- a/backend/pkg/handler/groups.go +++ b/backend/pkg/handler/groups.go @@ -188,6 +188,7 @@ func (h *Handler) GetGroupInstanceStats(ctx echo.Context, appID string, groupID func (h *Handler) GetGroupVersionBreakdown(ctx echo.Context, appID string, groupID string) error { versionBreakdown, err := h.db.GetGroupVersionBreakdown(groupID) + if err != nil { if err == sql.ErrNoRows { return ctx.NoContent(http.StatusNotFound) @@ -196,6 +197,10 @@ func (h *Handler) GetGroupVersionBreakdown(ctx echo.Context, appID string, group return ctx.NoContent(http.StatusInternalServerError) } + if len(versionBreakdown) == 0 { + // WAT?: because otherwise it serializes to null not [] + return ctx.JSON(http.StatusOK, []string{}) + } return ctx.JSON(http.StatusOK, versionBreakdown) } diff --git a/backend/test/api/flatcar_package_test.go b/backend/test/api/flatcar_package_test.go index 3bd21bd6e..5fdc1aa23 100644 --- a/backend/test/api/flatcar_package_test.go +++ b/backend/test/api/flatcar_package_test.go @@ -10,10 +10,11 @@ import ( "testing" "github.com/google/uuid" - "github.com/kinvolk/nebraska/backend/pkg/config" - "github.com/kinvolk/nebraska/backend/pkg/server" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/kinvolk/nebraska/backend/pkg/config" + "github.com/kinvolk/nebraska/backend/pkg/server" ) func TestHostFlatcarPackage(t *testing.T) { diff --git a/frontend/src/components/Groups/Charts.tsx b/frontend/src/components/Groups/Charts.tsx deleted file mode 100644 index 8a1a4bf9d..000000000 --- a/frontend/src/components/Groups/Charts.tsx +++ /dev/null @@ -1,605 +0,0 @@ -import { IconifyIcon } from '@iconify/react'; -import { Theme } from '@material-ui/core'; -import Box from '@material-ui/core/Box'; -import Chip from '@material-ui/core/Chip'; -import Grid from '@material-ui/core/Grid'; -import Paper from '@material-ui/core/Paper'; -import Typography from '@material-ui/core/Typography'; -import { useTheme } from '@material-ui/styles'; -import React from 'react'; -import { Area, AreaChart, AreaProps, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts'; -import semver from 'semver'; -import _ from 'underscore'; -import { Group } from '../../api/apiDataTypes'; -import { getMinuteDifference, makeLocaleTime } from '../../i18n/dateTime'; -import { groupChartStore } from '../../stores/Stores'; -import { cleanSemverVersion, getInstanceStatus, makeColorsForVersions } from '../../utils/helpers'; -import Loader from '../common/Loader/Loader'; -import SimpleTable from '../common/SimpleTable/SimpleTable'; -import makeStatusDefs from '../Instances/StatusDefs'; - -function TimelineTooltip(props: { label?: string; data: any }) { - const { label, data } = props; - return ( -
- - - {label && data[label] && makeLocaleTime(data[label].timestamp)} - - -
- ); -} - -function TimelineChart(props: { - width?: number; - height?: number; - interpolation?: AreaProps['type']; - data: any; - onSelect: (activeLabel: any) => void; - colors: any; - keys: string[]; -}) { - const { width = 500, height = 400, interpolation = 'monotone' } = props; - let ticks: { - [key: string]: string; - } = {}; - - function getTickValues() { - const DAY = 24 * 60; - let tickCount = 4; - let dateFormat: { - useDate?: boolean; - showTime?: boolean; - dateFormat?: Intl.DateTimeFormatOptions; - } = { useDate: false }; - const startTs = new Date(props.data[0].timestamp); - const endTs = new Date(props.data[props.data.length - 1].timestamp); - const lengthMinutes = getMinuteDifference(endTs.valueOf(), startTs.valueOf()); - // We remove 1 element since that's "0 hours" - const dimension = props.data.length - 1; - - // Reset the ticks for the chart - ticks = {}; - - if (lengthMinutes === 7 * DAY) { - tickCount = 7; - dateFormat = { dateFormat: { month: 'short', day: 'numeric' }, showTime: false }; - } - if (lengthMinutes === 60) { - for (let i = 0; i < 4; i++) { - const minuteValue = (lengthMinutes / 4) * i; - startTs.setMinutes(new Date(props.data[0].timestamp).getMinutes() + minuteValue); - ticks[i] = makeLocaleTime(startTs, { useDate: false }); - } - return ticks; - } - - if (lengthMinutes === 30 * DAY) { - for (let i = 0; i < props.data.length; i += 2) { - const tickDate = new Date(props.data[i].timestamp); - ticks[i] = makeLocaleTime(tickDate, { - showTime: false, - dateFormat: { month: 'short', day: 'numeric' }, - }); - } - return ticks; - } - // Set up a tick marking the 0 hours of the day contained in the range - const nextDay = new Date(startTs); - nextDay.setHours(24, 0, 0, 0); - const midnightDay = new Date(nextDay); - const nextDayMinuteDiff = getMinuteDifference(nextDay.valueOf(), startTs.valueOf()); - const midnightTick = (nextDayMinuteDiff * dimension) / lengthMinutes; - - // Set up the remaining ticks according to the desired amount, separated - // evenly. - const tickOffsetMinutes = lengthMinutes / tickCount; - - // Set the ticks around midnight. - for (const i of [-1, 1]) { - const tickDate = new Date(nextDay); - - while (true) { - tickDate.setMinutes(nextDay.getMinutes() + tickOffsetMinutes * i); - // Stop if this tick falls outside of the times being charted - - if (tickDate < startTs || tickDate > endTs) { - break; - } - - const tick = - (getMinuteDifference(tickDate.valueOf(), startTs.valueOf()) * dimension) / lengthMinutes; - // Show only the time. - ticks[tick] = makeLocaleTime(tickDate, dateFormat); - } - } - // The midnight tick just gets the date, not the hours (since they're zero) - ticks[midnightTick] = makeLocaleTime(midnightDay, { - dateFormat: { month: 'short', day: 'numeric' }, - showTime: false, - }); - return ticks; - } - - return ( - obj && props.onSelect(obj.activeLabel)} - > - - } /> - { - return ticks[index]; - }} - stroke={'#000'} - /> - - {props.keys.map((key: string, i: number) => ( - - ))} - - ); -} - -export function VersionCountTimeline(props: { - group: Group | null; - duration: { - [key: string]: any; - }; -}) { - const [selectedEntry, setSelectedEntry] = React.useState(-1); - const { duration } = props; - const [timelineChartData, setTimelineChartData] = React.useState<{ - data: any[]; - keys: any[]; - colors: any; - }>({ - data: [], - keys: [], - colors: [], - }); - const [timeline, setTimeline] = React.useState({ - timeline: {}, - // A long time ago, to force the first update... - lastUpdate: new Date(2000, 1, 1).toUTCString(), - }); - - const theme = useTheme(); - - function makeChartData(group: Group, groupTimeline: { [key: string]: any }) { - const data = Object.keys(groupTimeline).map((timestamp, i) => { - const versions = groupTimeline[timestamp]; - return { - index: i, - timestamp: timestamp, - ...versions, - }; - }); - - const versions = getVersionsFromTimeline(groupTimeline); - const versionColors: { - [key: string]: string; - } = makeColorsForVersions(theme as Theme, versions, group.channel); - - setTimelineChartData({ - data: data, - keys: versions, - colors: versionColors, - }); - } - - function getVersionsFromTimeline(timeline: { [key: string]: any }) { - if (Object.keys(timeline).length === 0) { - return []; - } - - const versions: string[] = []; - - Object.keys(Object.values(timeline)[0]).forEach(version => { - const cleanedVersion = cleanSemverVersion(version); - // Discard any invalid versions (empty strings, etc.) - if (semver.valid(cleanedVersion)) { - versions.push(cleanedVersion); - } - }); - - // Sort versions (earliest first) - versions.sort((version1, version2) => { - return semver.compare(version1, version2); - }); - - return versions; - } - - function getInstanceCount(selectedEntry: number) { - const version_breakdown = []; - let selectedEntryPoint = selectedEntry; - - // If there is no timeline or no specific time is selected, - // show the timeline for the last time point. - if (selectedEntry === -1) { - selectedEntryPoint = timelineChartData.data.length - 1; - } - - let total = 0; - - // If we're not using the default group version breakdown, - // let's populate it from the selected time one. - if (version_breakdown.length === 0 && selectedEntryPoint > -1) { - // Create the version breakdown from the timeline - const entries = timelineChartData.data[selectedEntryPoint] || []; - - for (const version of timelineChartData.keys) { - const versionCount = entries[version]; - - total += versionCount; - - version_breakdown.push({ - version: version, - instances: versionCount, - percentage: 0, - }); - } - } - - version_breakdown.forEach((entry: { [key: string]: any }) => { - entry.color = timelineChartData.colors[entry.version]; - - // Calculate the percentage if needed. - if (total > 0) { - entry.percentage = (entry.instances * 100.0) / total; - } - - entry.percentage = parseFloat(entry.percentage).toFixed(1); - }); - - // Sort the entries per number of instances (higher first). - version_breakdown.sort((elem1, elem2) => { - return -(elem1.instances - elem2.instances); - }); - - return version_breakdown; - } - - function getSelectedTime() { - const data = timelineChartData.data; - if (selectedEntry < 0 || data.length === 0) { - return ''; - } - const timestamp = data[selectedEntry] ? data[selectedEntry].timestamp : ''; - return makeLocaleTime(timestamp); - } - - // Make the timeline data again when needed. - React.useEffect(() => { - let canceled = false; - async function getVersionTimeline(group: Group | null) { - if (group) { - // Check if we should update the timeline or it's too early. - const lastUpdate = new Date(timeline.lastUpdate); - setTimelineChartData({ data: [], keys: [], colors: [] }); - try { - const versionCountTimeline = await groupChartStore().getGroupVersionCountTimeline( - group.application_id, - group.id, - duration.queryValue - ); - if (!canceled) { - setTimeline({ - timeline: versionCountTimeline, - lastUpdate: lastUpdate.toUTCString(), - }); - } - makeChartData(group, versionCountTimeline || []); - setSelectedEntry(-1); - } catch (error) { - console.error(error); - } - } - } - getVersionTimeline(props.group); - return () => { - canceled = true; - }; - }, [duration]); - - return ( - - - {timelineChartData.data.length > 0 ? ( - - ) : ( - - )} - - - - - {timelineChartData.data.length > 0 ? ( - selectedEntry !== -1 ? ( - - Showing for: -   - { - setSelectedEntry(-1); - }} - /> - - ) : ( - - Showing data for the last time point. -
- Click the chart to choose a different time point. -
- ) - ) : null} -
-
- - {timelineChartData.data.length > 0 && ( - - )} - -
-
- ); -} - -export function StatusCountTimeline(props: { - duration: { - [key: string]: any; - }; - group: Group | null; -}) { - const [selectedEntry, setSelectedEntry] = React.useState(-1); - const { duration } = props; - const [timelineChartData, setTimelineChartData] = React.useState<{ - data: { - index: number; - timestamp: string; - }[]; - keys: string[]; - colors: { - [key: string]: any; - }; - }>({ - data: [], - keys: [], - colors: [], - }); - - const [timeline, setTimeline] = React.useState<{ - timeline: { - [key: string]: any; - }; - lastUpdate: Date | string; - }>({ - timeline: {}, - // A long time ago, to force the first update... - lastUpdate: new Date(2000, 1, 1), - }); - - const theme = useTheme(); - const statusDefs: { - [key: string]: { - label: string; - color: any; - icon: IconifyIcon; - queryValue: string; - }; - } = makeStatusDefs(theme as Theme); - - function makeChartData(groupTimeline: { [key: string]: any }) { - const data = Object.keys(groupTimeline).map((timestamp, i) => { - const status = groupTimeline[timestamp]; - const statusCount: { - [key: string]: any; - } = {}; - Object.keys(status).forEach((st: string) => { - const values = status[st]; - const count = Object.values(values).reduce((a: any, b: any) => a + b, 0); - statusCount[st] = count; - }); - - return { - index: i, - timestamp: timestamp, - ...statusCount, - }; - }); - - const statuses = getStatusFromTimeline(groupTimeline); - const colors = makeStatusesColors(statuses); - - setTimelineChartData({ - data: data, - keys: statuses, - colors: colors, - }); - } - - function makeStatusesColors(statuses: { [key: string]: any }) { - const colors: { - [key: string]: any; - } = {}; - - Object.values(statuses).forEach(status => { - const statusInfo = getInstanceStatus(status, ''); - colors[status] = statusDefs[statusInfo.type].color; - }); - - return colors; - } - - function getStatusFromTimeline(timeline: { [key: number]: number }) { - if (Object.keys(timeline).length === 0) { - return []; - } - - return Object.keys(Object.values(timeline)[0]).filter(status => parseInt(status) !== 0); - } - - function getInstanceCount(selectedEntry: number) { - const status_breakdown: { - status: string; - version: string; - instances: number; - }[] = []; - const statusTimeline: { - [key: string]: any; - } = timeline.timeline; - - // Populate it from the selected time one. - if (!_.isEmpty(statusTimeline) && !_.isEmpty(timelineChartData.data)) { - const timelineIndex = selectedEntry >= 0 ? selectedEntry : timelineChartData.data.length - 1; - if (timelineIndex < 0) return []; - - const ts = timelineChartData.data[timelineIndex].timestamp; - // Create the version breakdown from the timeline - const entries = statusTimeline[ts] || []; - for (const status in entries) { - if (parseInt(status) === 0) { - continue; - } - - const versions = entries[status]; - - Object.keys(versions).forEach(version => { - const versionCount = versions[version]; - status_breakdown.push({ - status: status, - version: version, - instances: versionCount, - }); - }); - } - } - - status_breakdown.forEach((entry: { status: string; version: string; [key: string]: any }) => { - const statusInfo = getInstanceStatus(parseInt(entry.status), entry.version); - const statusTheme = statusDefs[statusInfo.type]; - - entry.color = statusTheme.color; - entry.status = statusTheme.label; - }); - - // Sort the entries per number of instances (higher first). - status_breakdown.sort((elem1, elem2) => { - return -(elem1.instances - elem2.instances); - }); - - return status_breakdown; - } - - function getSelectedTime() { - const data = timelineChartData.data; - if (selectedEntry < 0 || data.length === 0) { - return ''; - } - const timestamp = data[selectedEntry].timestamp; - return makeLocaleTime(timestamp); - } - - // Make the timeline data again when needed. - React.useEffect(() => { - async function getStatusTimeline(group: Group | null) { - if (group) { - setTimelineChartData({ data: [], keys: [], colors: [] }); - try { - const statusCountTimeline = await groupChartStore().getGroupStatusCountTimeline( - group.application_id, - group.id, - duration.queryValue - ); - setTimeline({ - timeline: statusCountTimeline, - lastUpdate: new Date().toUTCString(), - }); - - makeChartData(statusCountTimeline || []); - setSelectedEntry(-1); - } catch (error) { - console.error(error); - } - } - } - setSelectedEntry(-1); - getStatusTimeline(props.group); - }, [props.duration]); - - return ( - - - {timelineChartData.data.length > 0 ? ( - - ) : ( - - )} - - - - - {timelineChartData.data.length > 0 ? ( - selectedEntry !== -1 ? ( - - Showing for: -   - { - setSelectedEntry(-1); - }} - /> - - ) : ( - - Showing data for the last time point. -
- Click the chart to choose a different time point. -
- ) - ) : null} -
-
- - {timelineChartData.data.length > 0 && ( - - )} - -
-
- ); -} diff --git a/frontend/src/components/Groups/GroupCharts/StatusCountTimeline.stories.tsx b/frontend/src/components/Groups/GroupCharts/StatusCountTimeline.stories.tsx new file mode 100644 index 000000000..1c6aff8f0 --- /dev/null +++ b/frontend/src/components/Groups/GroupCharts/StatusCountTimeline.stories.tsx @@ -0,0 +1,122 @@ +import { Meta, Story } from '@storybook/react/types-6-0'; +import { MemoryRouter } from 'react-router-dom'; +import GroupChartsStore from '../../../stores/GroupChartsStore'; +import { groupChartStoreContext } from '../../../stores/Stores'; +import StatusCountTimeline, { StatusCountTimelineProps } from './StatusCountTimeline'; + +export default { + title: 'groups/StatusCountTimeline', +} as Meta; + +const statusTimelineData = { + '2021-11-07T10:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-07T11:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-07T12:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-07T13:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-07T14:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-07T15:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-07T16:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-07T17:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-07T18:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-07T19:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-07T20:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-07T21:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-07T22:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-07T23:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-08T00:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-08T01:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-08T02:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-08T03:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-08T04:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-08T05:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-08T06:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-08T07:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-08T08:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, + '2021-11-08T09:35:28.823161+01:00': { + '1': { '2191.5.0': 177 }, + '2': { '2191.5.0': 221 }, + '3': { '2191.5.0': 12 }, + '6': { '2191.5.0': 193 }, + '7': { '2191.5.0': 215 }, + }, + '2021-11-08T10:35:28.823161+01:00': { '1': {}, '2': {}, '3': {}, '6': {}, '7': {} }, +}; + +const Template: Story = args => { + class GroupChartsStoreMock extends GroupChartsStore { + /* eslint-disable no-unused-vars */ + async getGroupStatusCountTimeline(appID: string, groupID: string, duration: string) { + return statusTimelineData; + } + } + + const ChartStoreContext = groupChartStoreContext(); + + return ( + + + + + + ); +}; + +export const Timeline = Template.bind({}); + +Timeline.args = { + group: { + id: '9a2deb70-37be-4026-853f-bfdd6b347bbe', + name: 'Stable (AMD64)', + description: 'For production clusters (AMD64)', + created_ts: '2015-09-19T07:09:34.269062+02:00', + rollout_in_progress: true, + application_id: 'e96281a6-d1af-4bde-9a0a-97b76e56dc57', + channel_id: 'e06064ad-4414-4904-9a6e-fd465593d1b2', + policy_updates_enabled: true, + policy_safe_mode: false, + policy_office_hours: false, + policy_timezone: 'Europe/Berlin', + policy_period_interval: '1 minutes', + policy_max_updates_per_period: 999999, + policy_update_timeout: '60 minutes', + channel: { + id: 'e06064ad-4414-4904-9a6e-fd465593d1b2', + name: 'stable', + color: '#14b9d6', + created_ts: '2015-09-19T07:09:34.261241+02:00', + application_id: 'e96281a6-d1af-4bde-9a0a-97b76e56dc57', + package_id: '84b4c599-9b6b-44a8-b13c-d4263fff0403', + package: { + id: '84b4c599-9b6b-44a8-b13c-d4263fff0403', + type: 1, + version: '2191.5.0', + url: 'https://update.release.flatcar-linux.net/amd64-usr/2191.5.0/', + filename: 'flatcar_production_update.gz', + description: 'Flatcar Container Linux 2191.5.0', + size: '465881871', + hash: 'r3nufcxgMTZaxYEqL+x2zIoeClk=', + created_ts: '2019-09-05T12:41:09.265687+02:00', + channels_blacklist: null, + application_id: 'e96281a6-d1af-4bde-9a0a-97b76e56dc57', + flatcar_action: { + id: '1f6e1bcf-4ebb-4fe6-8ca3-2cb6ad90d5dd', + event: 'postinstall', + chromeos_version: '', + sha256: 'LIkAKVZY2EJFiwTmltiJZLFLA5xT/FodbjVgqkyF/y8=', + needs_admin: false, + is_delta: false, + disable_payload_backoff: true, + metadata_signature_rsa: '', + metadata_size: '', + deadline: '', + created_ts: '2019-08-20T02:12:37.532281+02:00', + }, + arch: 1, + extra_files: [], + }, + arch: 1, + }, + track: 'stable', + }, + duration: { displayValue: '1 day', queryValue: '1d', disabled: false }, +}; diff --git a/frontend/src/components/Groups/GroupCharts/StatusCountTimeline.tsx b/frontend/src/components/Groups/GroupCharts/StatusCountTimeline.tsx new file mode 100644 index 000000000..94d463094 --- /dev/null +++ b/frontend/src/components/Groups/GroupCharts/StatusCountTimeline.tsx @@ -0,0 +1,251 @@ +import { IconifyIcon } from '@iconify/react'; +import { Theme } from '@material-ui/core'; +import Box from '@material-ui/core/Box'; +import Chip from '@material-ui/core/Chip'; +import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; +import { useTheme } from '@material-ui/styles'; +import React from 'react'; +import _ from 'underscore'; +import { Group } from '../../../api/apiDataTypes'; +import { makeLocaleTime } from '../../../i18n/dateTime'; +import { groupChartStoreContext } from '../../../stores/Stores'; +import { getInstanceStatus } from '../../../utils/helpers'; +import Loader from '../../common/Loader/Loader'; +import SimpleTable from '../../common/SimpleTable/SimpleTable'; +import makeStatusDefs from '../../Instances/StatusDefs'; +import TimelineChart from './TimelineChart'; +import { Duration } from './TimelineChart'; + +export interface StatusCountTimelineProps { + duration: Duration; + group: Group | null; +} + +export default function StatusCountTimeline(props: StatusCountTimelineProps) { + const [selectedEntry, setSelectedEntry] = React.useState(-1); + const { duration } = props; + const [timelineChartData, setTimelineChartData] = React.useState<{ + data: { + index: number; + timestamp: string; + }[]; + keys: string[]; + colors: { + [key: string]: string; + }; + }>({ + data: [], + keys: [], + colors: {}, + }); + + const [timeline, setTimeline] = React.useState<{ + timeline: { + [key: string]: any; + }; + lastUpdate: Date | string; + }>({ + timeline: {}, + // A long time ago, to force the first update... + lastUpdate: new Date(2000, 1, 1), + }); + + const ChartStoreContext = groupChartStoreContext(); + const groupChartStore = React.useContext(ChartStoreContext); + + const theme = useTheme(); + const statusDefs: { + [key: string]: { + label: string; + color: string; + icon: IconifyIcon; + queryValue: string; + }; + } = makeStatusDefs(theme as Theme); + + function makeChartData(groupTimeline: { [key: string]: any }) { + const data = Object.keys(groupTimeline).map((timestamp, i) => { + const status = groupTimeline[timestamp]; + const statusCount: { + [key: string]: any; + } = {}; + Object.keys(status).forEach((st: string) => { + const values = status[st]; + const count = Object.values(values).reduce((a: any, b: any) => a + b, 0); + statusCount[st] = count; + }); + + return { + index: i, + timestamp: timestamp, + ...statusCount, + }; + }); + + const statuses = getStatusFromTimeline(groupTimeline); + const colors = makeStatusesColors(statuses); + + setTimelineChartData({ + data: data, + keys: statuses, + colors: colors, + }); + } + + function makeStatusesColors(statuses: { [key: string]: any }) { + const colors: { + [key: string]: string; + } = {}; + + Object.values(statuses).forEach(status => { + const statusInfo = getInstanceStatus(status, ''); + colors[status] = statusDefs[statusInfo.type].color; + }); + + return colors; + } + + function getStatusFromTimeline(timeline: { [key: number]: number }) { + if (Object.keys(timeline).length === 0) { + return []; + } + + return Object.keys(Object.values(timeline)[0]).filter(status => parseInt(status) !== 0); + } + + function getInstanceCount(selectedEntry: number) { + const status_breakdown: { + status: string; + version: string; + instances: number; + }[] = []; + const statusTimeline: { + [key: string]: any; + } = timeline.timeline; + + // Populate it from the selected time one. + if (!_.isEmpty(statusTimeline) && !_.isEmpty(timelineChartData.data)) { + const timelineIndex = selectedEntry >= 0 ? selectedEntry : timelineChartData.data.length - 1; + if (timelineIndex < 0) return []; + + const ts = timelineChartData.data[timelineIndex].timestamp; + // Create the version breakdown from the timeline + const entries = statusTimeline[ts] || []; + for (const status in entries) { + if (parseInt(status) === 0) { + continue; + } + + const versions = entries[status]; + + Object.keys(versions).forEach(version => { + const versionCount = versions[version]; + status_breakdown.push({ + status: status, + version: version, + instances: versionCount, + }); + }); + } + } + + status_breakdown.forEach((entry: { status: string; version: string; [key: string]: any }) => { + const statusInfo = getInstanceStatus(parseInt(entry.status), entry.version); + const statusTheme = statusDefs[statusInfo.type]; + + entry.color = statusTheme.color; + entry.status = statusTheme.label; + }); + + // Sort the entries per number of instances (higher first). + status_breakdown.sort((elem1, elem2) => { + return -(elem1.instances - elem2.instances); + }); + + return status_breakdown; + } + + function getSelectedTime() { + const data = timelineChartData.data; + if (selectedEntry < 0 || data.length === 0) { + return ''; + } + const timestamp = data[selectedEntry].timestamp; + return makeLocaleTime(timestamp); + } + + // Make the timeline data again when needed. + React.useEffect(() => { + async function getStatusTimeline(group: Group | null) { + if (group) { + setTimelineChartData({ data: [], keys: [], colors: {} }); + try { + const statusCountTimeline = await groupChartStore.getGroupStatusCountTimeline( + group.application_id, + group.id, + duration.queryValue + ); + setTimeline({ + timeline: statusCountTimeline, + lastUpdate: new Date().toUTCString(), + }); + + makeChartData(statusCountTimeline || []); + setSelectedEntry(-1); + } catch (error) { + console.error(error); + } + } + } + setSelectedEntry(-1); + getStatusTimeline(props.group); + }, [props.duration]); + + return ( + + + {timelineChartData.data.length > 0 ? ( + + ) : ( + + )} + + + + + {timelineChartData.data.length > 0 ? ( + selectedEntry !== -1 ? ( + + Showing for: +   + { + setSelectedEntry(-1); + }} + /> + + ) : ( + + Showing data for the last time point. +
+ Click the chart to choose a different time point. +
+ ) + ) : null} +
+
+ + {timelineChartData.data.length > 0 && ( + + )} + +
+
+ ); +} diff --git a/frontend/src/components/Groups/GroupCharts/TimelineChart.tsx b/frontend/src/components/Groups/GroupCharts/TimelineChart.tsx new file mode 100644 index 000000000..5beba4759 --- /dev/null +++ b/frontend/src/components/Groups/GroupCharts/TimelineChart.tsx @@ -0,0 +1,159 @@ +import Box from '@material-ui/core/Box'; +import Paper from '@material-ui/core/Paper'; +import Typography from '@material-ui/core/Typography'; +import { Area, AreaChart, AreaProps, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts'; +import { getMinuteDifference, makeLocaleTime } from '../../../i18n/dateTime'; + +export interface Duration { + displayValue: string; + queryValue: string; + disabled: boolean; +} + +function TimelineTooltip(props: { label?: string; data: any }) { + const { label, data } = props; + return ( +
+ + + {label && data[label] && makeLocaleTime(data[label].timestamp)} + + +
+ ); +} + +export interface TimelineChartProps { + width?: number; + height?: number; + interpolation?: AreaProps['type']; + data: any; + onSelect: (activeLabel: any) => void; + colors: any; + keys: string[]; +} + +export default function TimelineChart(props: TimelineChartProps) { + const { width = 500, height = 400, interpolation = 'monotone' } = props; + let ticks: { + [key: string]: string; + } = {}; + + function getTickValues() { + const DAY = 24 * 60; + let tickCount = 4; + let dateFormat: { + useDate?: boolean; + showTime?: boolean; + dateFormat?: Intl.DateTimeFormatOptions; + } = { useDate: false }; + const startTs = new Date(props.data[0].timestamp); + const endTs = new Date(props.data[props.data.length - 1].timestamp); + const lengthMinutes = getMinuteDifference(endTs.valueOf(), startTs.valueOf()); + // We remove 1 element since that's "0 hours" + const dimension = props.data.length - 1; + + // Reset the ticks for the chart + ticks = {}; + + if (lengthMinutes === 7 * DAY) { + tickCount = 7; + dateFormat = { dateFormat: { month: 'short', day: 'numeric' }, showTime: false }; + } + if (lengthMinutes === 60) { + for (let i = 0; i < 4; i++) { + const minuteValue = (lengthMinutes / 4) * i; + startTs.setMinutes(new Date(props.data[0].timestamp).getMinutes() + minuteValue); + ticks[i] = makeLocaleTime(startTs, { useDate: false }); + } + return ticks; + } + + if (lengthMinutes === 30 * DAY) { + for (let i = 0; i < props.data.length; i += 2) { + const tickDate = new Date(props.data[i].timestamp); + ticks[i] = makeLocaleTime(tickDate, { + showTime: false, + dateFormat: { month: 'short', day: 'numeric' }, + }); + } + return ticks; + } + // Set up a tick marking the 0 hours of the day contained in the range + const nextDay = new Date(startTs); + nextDay.setHours(24, 0, 0, 0); + const midnightDay = new Date(nextDay); + const nextDayMinuteDiff = getMinuteDifference(nextDay.valueOf(), startTs.valueOf()); + const midnightTick = (nextDayMinuteDiff * dimension) / lengthMinutes; + + // Set up the remaining ticks according to the desired amount, separated + // evenly. + const tickOffsetMinutes = lengthMinutes / tickCount; + + // Set the ticks around midnight. + for (const i of [-1, 1]) { + const tickDate = new Date(nextDay); + + while (true) { + tickDate.setMinutes(nextDay.getMinutes() + tickOffsetMinutes * i); + // Stop if this tick falls outside of the times being charted + + if (tickDate < startTs || tickDate > endTs) { + break; + } + + const tick = + (getMinuteDifference(tickDate.valueOf(), startTs.valueOf()) * dimension) / lengthMinutes; + // Show only the time. + ticks[tick] = makeLocaleTime(tickDate, dateFormat); + } + } + // The midnight tick just gets the date, not the hours (since they're zero) + ticks[midnightTick] = makeLocaleTime(midnightDay, { + dateFormat: { month: 'short', day: 'numeric' }, + showTime: false, + }); + return ticks; + } + + return ( + obj && props.onSelect(obj.activeLabel)} + > + + } /> + { + return ticks[index]; + }} + stroke={'#000'} + /> + + {props.keys.map((key: string, i: number) => ( + + ))} + + ); +} diff --git a/frontend/src/components/Groups/GroupCharts/VersionCountTimeline.stories.tsx b/frontend/src/components/Groups/GroupCharts/VersionCountTimeline.stories.tsx new file mode 100644 index 000000000..5266f5bde --- /dev/null +++ b/frontend/src/components/Groups/GroupCharts/VersionCountTimeline.stories.tsx @@ -0,0 +1,116 @@ +import { Meta, Story } from '@storybook/react/types-6-0'; +import { MemoryRouter } from 'react-router-dom'; +import GroupChartsStore from '../../../stores/GroupChartsStore'; +import { groupChartStoreContext } from '../../../stores/Stores'; +import VersionCountTimeline, { VersionCountTimelineProps } from './VersionCountTimeline'; + +export default { + title: 'groups/VersionCountTimeline', +} as Meta; + +const versionCountTimeline = { + '2021-11-07T10:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-07T11:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-07T12:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-07T13:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-07T14:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-07T15:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-07T16:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-07T17:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-07T18:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-07T19:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-07T20:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-07T21:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-07T22:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-07T23:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-08T00:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-08T01:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-08T02:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-08T03:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-08T04:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-08T05:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-08T06:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-08T07:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-08T08:35:28.827204+01:00': { '0.0.0': 0, '2191.5.0': 0 }, + '2021-11-08T09:35:28.827204+01:00': { '0.0.0': 61, '2191.5.0': 152 }, + '2021-11-08T10:35:28.827204+01:00': { '0.0.0': 61, '2191.5.0': 152 }, +}; + +const Template: Story = args => { + class GroupChartsStoreMock extends GroupChartsStore { + /* eslint-disable no-unused-vars */ + async getGroupVersionCountTimeline(appID: string, groupID: string, duration: string) { + return versionCountTimeline; + } + } + + const ChartStoreContext = groupChartStoreContext(); + + return ( + + + + + + ); +}; + +export const Timeline = Template.bind({}); + +Timeline.args = { + group: { + id: '9a2deb70-37be-4026-853f-bfdd6b347bbe', + name: 'Stable (AMD64)', + description: 'For production clusters (AMD64)', + created_ts: '2015-09-19T07:09:34.269062+02:00', + rollout_in_progress: true, + application_id: 'e96281a6-d1af-4bde-9a0a-97b76e56dc57', + channel_id: 'e06064ad-4414-4904-9a6e-fd465593d1b2', + policy_updates_enabled: true, + policy_safe_mode: false, + policy_office_hours: false, + policy_timezone: 'Europe/Berlin', + policy_period_interval: '1 minutes', + policy_max_updates_per_period: 999999, + policy_update_timeout: '60 minutes', + channel: { + id: 'e06064ad-4414-4904-9a6e-fd465593d1b2', + name: 'stable', + color: '#14b9d6', + created_ts: '2015-09-19T07:09:34.261241+02:00', + application_id: 'e96281a6-d1af-4bde-9a0a-97b76e56dc57', + package_id: '84b4c599-9b6b-44a8-b13c-d4263fff0403', + package: { + id: '84b4c599-9b6b-44a8-b13c-d4263fff0403', + type: 1, + version: '2191.5.0', + url: 'https://update.release.flatcar-linux.net/amd64-usr/2191.5.0/', + filename: 'flatcar_production_update.gz', + description: 'Flatcar Container Linux 2191.5.0', + size: '465881871', + hash: 'r3nufcxgMTZaxYEqL+x2zIoeClk=', + created_ts: '2019-09-05T12:41:09.265687+02:00', + channels_blacklist: null, + application_id: 'e96281a6-d1af-4bde-9a0a-97b76e56dc57', + flatcar_action: { + id: '1f6e1bcf-4ebb-4fe6-8ca3-2cb6ad90d5dd', + event: 'postinstall', + chromeos_version: '', + sha256: 'LIkAKVZY2EJFiwTmltiJZLFLA5xT/FodbjVgqkyF/y8=', + needs_admin: false, + is_delta: false, + disable_payload_backoff: true, + metadata_signature_rsa: '', + metadata_size: '', + deadline: '', + created_ts: '2019-08-20T02:12:37.532281+02:00', + }, + arch: 1, + extra_files: [], + }, + arch: 1, + }, + track: 'stable', + }, + duration: { displayValue: '1 day', queryValue: '1d', disabled: false }, +}; diff --git a/frontend/src/components/Groups/GroupCharts/VersionCountTimeline.tsx b/frontend/src/components/Groups/GroupCharts/VersionCountTimeline.tsx new file mode 100644 index 000000000..c18270044 --- /dev/null +++ b/frontend/src/components/Groups/GroupCharts/VersionCountTimeline.tsx @@ -0,0 +1,229 @@ +import { Theme } from '@material-ui/core'; +import Box from '@material-ui/core/Box'; +import Chip from '@material-ui/core/Chip'; +import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; +import { useTheme } from '@material-ui/styles'; +import React from 'react'; +import semver from 'semver'; +import { Group } from '../../../api/apiDataTypes'; +import { makeLocaleTime } from '../../../i18n/dateTime'; +import { groupChartStoreContext } from '../../../stores/Stores'; +import { cleanSemverVersion, makeColorsForVersions } from '../../../utils/helpers'; +import Loader from '../../common/Loader/Loader'; +import SimpleTable from '../../common/SimpleTable/SimpleTable'; +import TimelineChart from './TimelineChart'; +import { Duration } from './TimelineChart'; + +export interface VersionCountTimelineProps { + group: Group | null; + duration: Duration; +} + +export default function VersionCountTimeline(props: VersionCountTimelineProps) { + const [selectedEntry, setSelectedEntry] = React.useState(-1); + const { duration } = props; + const [timelineChartData, setTimelineChartData] = React.useState<{ + data: any[]; + keys: any[]; + colors: any; + }>({ + data: [], + keys: [], + colors: [], + }); + const [timeline, setTimeline] = React.useState({ + timeline: {}, + // A long time ago, to force the first update... + lastUpdate: new Date(2000, 1, 1).toUTCString(), + }); + + const theme = useTheme(); + + const ChartStoreContext = groupChartStoreContext(); + const groupChartStore = React.useContext(ChartStoreContext); + + function makeChartData(group: Group, groupTimeline: { [key: string]: any }) { + const data = Object.keys(groupTimeline).map((timestamp, i) => { + const versions = groupTimeline[timestamp]; + return { + index: i, + timestamp: timestamp, + ...versions, + }; + }); + + const versions = getVersionsFromTimeline(groupTimeline); + const versionColors: { + [key: string]: string; + } = makeColorsForVersions(theme as Theme, versions, group.channel); + + setTimelineChartData({ + data: data, + keys: versions, + colors: versionColors, + }); + } + + function getVersionsFromTimeline(timeline: { [key: string]: any }) { + if (Object.keys(timeline).length === 0) { + return []; + } + + const versions: string[] = []; + + Object.keys(Object.values(timeline)[0]).forEach(version => { + const cleanedVersion = cleanSemverVersion(version); + // Discard any invalid versions (empty strings, etc.) + if (semver.valid(cleanedVersion)) { + versions.push(cleanedVersion); + } + }); + + // Sort versions (earliest first) + versions.sort((version1, version2) => { + return semver.compare(version1, version2); + }); + + return versions; + } + + function getInstanceCount(selectedEntry: number) { + const version_breakdown = []; + let selectedEntryPoint = selectedEntry; + + // If there is no timeline or no specific time is selected, + // show the timeline for the last time point. + if (selectedEntry === -1) { + selectedEntryPoint = timelineChartData.data.length - 1; + } + + let total = 0; + + // If we're not using the default group version breakdown, + // let's populate it from the selected time one. + if (version_breakdown.length === 0 && selectedEntryPoint > -1) { + // Create the version breakdown from the timeline + const entries = timelineChartData.data[selectedEntryPoint] || []; + + for (const version of timelineChartData.keys) { + const versionCount = entries[version]; + + total += versionCount; + + version_breakdown.push({ + version: version, + instances: versionCount, + percentage: 0, + }); + } + } + + version_breakdown.forEach((entry: { [key: string]: any }) => { + entry.color = timelineChartData.colors[entry.version]; + + // Calculate the percentage if needed. + if (total > 0) { + entry.percentage = (entry.instances * 100.0) / total; + } + + entry.percentage = parseFloat(entry.percentage).toFixed(1); + }); + + // Sort the entries per number of instances (higher first). + version_breakdown.sort((elem1, elem2) => { + return -(elem1.instances - elem2.instances); + }); + + return version_breakdown; + } + + function getSelectedTime() { + const data = timelineChartData.data; + if (selectedEntry < 0 || data.length === 0) { + return ''; + } + const timestamp = data[selectedEntry] ? data[selectedEntry].timestamp : ''; + return makeLocaleTime(timestamp); + } + + // Make the timeline data again when needed. + React.useEffect(() => { + let canceled = false; + async function getVersionTimeline(group: Group | null) { + if (group) { + // Check if we should update the timeline or it's too early. + const lastUpdate = new Date(timeline.lastUpdate); + setTimelineChartData({ data: [], keys: [], colors: [] }); + try { + const versionCountTimeline = await groupChartStore.getGroupVersionCountTimeline( + group.application_id, + group.id, + duration.queryValue + ); + if (!canceled) { + setTimeline({ + timeline: versionCountTimeline, + lastUpdate: lastUpdate.toUTCString(), + }); + } + makeChartData(group, versionCountTimeline || []); + setSelectedEntry(-1); + } catch (error) { + console.error(error); + } + } + } + getVersionTimeline(props.group); + return () => { + canceled = true; + }; + }, [duration]); + + return ( + + + {timelineChartData.data.length > 0 ? ( + + ) : ( + + )} + + + + + {timelineChartData.data.length > 0 ? ( + selectedEntry !== -1 ? ( + + Showing for: +   + { + setSelectedEntry(-1); + }} + /> + + ) : ( + + Showing data for the last time point. +
+ Click the chart to choose a different time point. +
+ ) + ) : null} +
+
+ + {timelineChartData.data.length > 0 && ( + + )} + +
+
+ ); +} diff --git a/frontend/src/components/Groups/GroupCharts/__snapshots__/StatusCountTimeline.stories.storyshot b/frontend/src/components/Groups/GroupCharts/__snapshots__/StatusCountTimeline.stories.storyshot new file mode 100644 index 000000000..4ae5cda7e --- /dev/null +++ b/frontend/src/components/Groups/GroupCharts/__snapshots__/StatusCountTimeline.stories.storyshot @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots groups/StatusCountTimeline Timeline 1`] = ` +
+
+
+
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/frontend/src/components/Groups/GroupCharts/__snapshots__/VersionCountTimeline.stories.storyshot b/frontend/src/components/Groups/GroupCharts/__snapshots__/VersionCountTimeline.stories.storyshot new file mode 100644 index 000000000..5ac9b1ae4 --- /dev/null +++ b/frontend/src/components/Groups/GroupCharts/__snapshots__/VersionCountTimeline.stories.storyshot @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots groups/VersionCountTimeline Timeline 1`] = ` +
+
+
+
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/frontend/src/components/Groups/GroupEditDialog/GroupDetailsForm.stories.tsx b/frontend/src/components/Groups/GroupEditDialog/GroupDetailsForm.stories.tsx new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/frontend/src/components/Groups/GroupEditDialog/GroupDetailsForm.stories.tsx @@ -0,0 +1 @@ +export {}; diff --git a/frontend/src/components/Groups/EditDialog/GroupDetailsForm.tsx b/frontend/src/components/Groups/GroupEditDialog/GroupDetailsForm.tsx similarity index 95% rename from frontend/src/components/Groups/EditDialog/GroupDetailsForm.tsx rename to frontend/src/components/Groups/GroupEditDialog/GroupDetailsForm.tsx index c86e62800..8a4ad0a74 100644 --- a/frontend/src/components/Groups/EditDialog/GroupDetailsForm.tsx +++ b/frontend/src/components/Groups/GroupEditDialog/GroupDetailsForm.tsx @@ -5,11 +5,13 @@ import { useTranslation } from 'react-i18next'; import { Channel } from '../../../api/apiDataTypes'; import { ARCHES } from '../../../utils/helpers'; -export default function GroupDetailsForm(props: { +export interface GroupDetailsFormProps { channels: Channel[]; values: { [key: string]: string }; setFieldValue: (formField: string, value: any) => any; -}) { +} + +export default function GroupDetailsForm(props: GroupDetailsFormProps) { const { t } = useTranslation(); const { channels, values, setFieldValue } = props; diff --git a/frontend/src/components/Groups/GroupEditDialog/GroupEditDialog.stories.tsx b/frontend/src/components/Groups/GroupEditDialog/GroupEditDialog.stories.tsx new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/frontend/src/components/Groups/GroupEditDialog/GroupEditDialog.stories.tsx @@ -0,0 +1 @@ +export {}; diff --git a/frontend/src/components/Groups/EditDialog/index.tsx b/frontend/src/components/Groups/GroupEditDialog/GroupEditDialog.tsx similarity index 98% rename from frontend/src/components/Groups/EditDialog/index.tsx rename to frontend/src/components/Groups/GroupEditDialog/GroupEditDialog.tsx index 713bb56b1..2cd9b03cb 100644 --- a/frontend/src/components/Groups/EditDialog/index.tsx +++ b/frontend/src/components/Groups/GroupEditDialog/GroupEditDialog.tsx @@ -27,14 +27,16 @@ const useStyles = makeStyles({ }, }); -function EditDialog(props: { +export interface GroupEditDialogProps { create?: boolean; data: { [key: string]: any; }; onHide: () => void; show: boolean; -}) { +} + +export default function GroupEditDialog(props: GroupEditDialogProps) { const isCreation = Boolean(props.create); const classes = useStyles(); const [groupEditActiveTab, setGroupEditActiveTab] = React.useState(0); @@ -235,5 +237,3 @@ function EditDialog(props: { ); } - -export default EditDialog; diff --git a/frontend/src/components/Groups/GroupEditDialog/GroupPolicyForm.stories.tsx b/frontend/src/components/Groups/GroupEditDialog/GroupPolicyForm.stories.tsx new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/frontend/src/components/Groups/GroupEditDialog/GroupPolicyForm.stories.tsx @@ -0,0 +1 @@ +export {}; diff --git a/frontend/src/components/Groups/EditDialog/GroupPolicyForm.tsx b/frontend/src/components/Groups/GroupEditDialog/GroupPolicyForm.tsx similarity index 98% rename from frontend/src/components/Groups/EditDialog/GroupPolicyForm.tsx rename to frontend/src/components/Groups/GroupEditDialog/GroupPolicyForm.tsx index e65d96e69..2b16ab231 100644 --- a/frontend/src/components/Groups/EditDialog/GroupPolicyForm.tsx +++ b/frontend/src/components/Groups/GroupEditDialog/GroupPolicyForm.tsx @@ -16,10 +16,12 @@ import { TextField } from 'formik-material-ui'; import { useTranslation } from 'react-i18next'; import TimezonePicker from '../../common/TimezonePicker/TimezonePicker'; -export default function GroupPolicyForm(props: { +export interface GroupPolicyFormProps { values: { [key: string]: string }; setFieldValue: (formField: string, value: any) => any; -}) { +} + +export default function GroupPolicyForm(props: GroupPolicyFormProps) { const { t } = useTranslation(); const { values, setFieldValue } = props; diff --git a/frontend/src/components/Groups/GroupItem.stories.tsx b/frontend/src/components/Groups/GroupItem.stories.tsx new file mode 100644 index 000000000..ca36ed7a5 --- /dev/null +++ b/frontend/src/components/Groups/GroupItem.stories.tsx @@ -0,0 +1,87 @@ +import { Meta, Story } from '@storybook/react/types-6-0'; +import { MemoryRouter } from 'react-router-dom'; +import { PureGroupItem, PureGroupItemProps } from './GroupItem'; + +export default { + title: 'groups/GroupItem', + argTypes: { + handleUpdateGroup: { action: 'handleUpdateGroup' }, + deleteGroup: { action: 'deleteGroup' }, + }, +} as Meta; + +const Template: Story = args => { + return ( + + + + ); +}; + +export const Group = Template.bind({}); + +Group.args = { + versionBreakdown: [], + totalInstances: 2, + group: { + id: '11a585f6-9418-4df0-8863-78b2fd3240f8', + name: 'Stable (ARM)', + description: 'For production clusters (ARM)', + created_ts: '2015-09-19T07:09:34.269062+02:00', + rollout_in_progress: false, + application_id: 'e96281a6-d1af-4bde-9a0a-97b76e56dc57', + channel_id: '5dfe7b12-c94a-470d-a2b6-2eae78c5c9f5', + policy_updates_enabled: true, + policy_safe_mode: false, + policy_office_hours: false, + policy_timezone: 'Europe/Berlin', + policy_period_interval: '1 minutes', + policy_max_updates_per_period: 999999, + policy_update_timeout: '60 minutes', + channel: { + id: '5dfe7b12-c94a-470d-a2b6-2eae78c5c9f5', + name: 'stable', + color: '#1458d6', + created_ts: '2015-09-19T07:09:34.261241+02:00', + application_id: 'e96281a6-d1af-4bde-9a0a-97b76e56dc57', + package_id: null, + package: null, + arch: 2, + }, + track: 'stable', + }, +}; + +export const Loading = Template.bind({}); + +Loading.args = { + versionBreakdown: null, + totalInstances: null, + group: { + id: '11a585f6-9418-4df0-8863-78b2fd3240f8', + name: 'Stable (ARM)', + description: 'For production clusters (ARM)', + created_ts: '2015-09-19T07:09:34.269062+02:00', + rollout_in_progress: false, + application_id: 'e96281a6-d1af-4bde-9a0a-97b76e56dc57', + channel_id: '5dfe7b12-c94a-470d-a2b6-2eae78c5c9f5', + policy_updates_enabled: true, + policy_safe_mode: false, + policy_office_hours: false, + policy_timezone: 'Europe/Berlin', + policy_period_interval: '1 minutes', + policy_max_updates_per_period: 999999, + policy_update_timeout: '60 minutes', + channel: { + id: '5dfe7b12-c94a-470d-a2b6-2eae78c5c9f5', + name: 'stable', + color: '#1458d6', + created_ts: '2015-09-19T07:09:34.261241+02:00', + application_id: 'e96281a6-d1af-4bde-9a0a-97b76e56dc57', + package_id: null, + package: null, + arch: 2, + }, + track: 'stable', + }, +}; diff --git a/frontend/src/components/Groups/Item.tsx b/frontend/src/components/Groups/GroupItem.tsx similarity index 68% rename from frontend/src/components/Groups/Item.tsx rename to frontend/src/components/Groups/GroupItem.tsx index 7bce3e120..228d1d5b7 100644 --- a/frontend/src/components/Groups/Item.tsx +++ b/frontend/src/components/Groups/GroupItem.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import _ from 'underscore'; import API from '../../api/API'; -import { Channel, Group } from '../../api/apiDataTypes'; +import { Group, VersionBreakdownEntry } from '../../api/apiDataTypes'; import { applicationsStore } from '../../stores/Stores'; import { useGroupVersionBreakdown } from '../../utils/helpers'; import ChannelItem from '../Channels/ChannelItem'; @@ -49,66 +49,90 @@ export function formatUpdateLimits(t: TFunction, group: Group) { }); } -function Item(props: { +export interface GroupItemProps { group: Group; - appName: string; - channels: Channel[]; handleUpdateGroup: (appID: string, groupID: string) => void; -}) { - const classes = useStyles(); - const { t } = useTranslation(); - const [totalInstances, setTotalInstances] = React.useState(-1); - - const version_breakdown = useGroupVersionBreakdown(props.group); - const description = props.group.description || t('groups|No description provided'); - const channel = props.group.channel || null; +} - const groupChannel = _.isEmpty(props.group.channel) ? ( - {t('groups|No channel provided')} - ) : ( - - ); - const groupPath = `/apps/${props.group.application_id}/groups/${props.group.id}`; +function GroupItem({ group, handleUpdateGroup }: GroupItemProps) { + const { t } = useTranslation(); + const [totalInstances, setTotalInstances] = React.useState(null); + const versionBreakdown = useGroupVersionBreakdown(group); + console.log('versionBreakdown', JSON.stringify(versionBreakdown)); - function deleteGroup() { + function deleteGroup(appID: string, groupID: string) { const confirmationText = t('groups|Are you sure you want to delete this group?'); if (window.confirm(confirmationText)) { - applicationsStore().deleteGroup(props.group.application_id, props.group.id); + applicationsStore().deleteGroup(appID, groupID); } } - function updateGroup() { - props.handleUpdateGroup(props.group.application_id, props.group.id); - } - React.useEffect(() => { - API.getInstancesCount(props.group.application_id, props.group.id, '1d') + API.getInstancesCount(group.application_id, group.id, '1d') .then(result => { setTotalInstances(result); }) .catch(err => console.error('Error getting total instances in Group/Item', err)); }, []); + return ( + + ); +} + +export interface PureGroupItemProps { + group: Group; + versionBreakdown: VersionBreakdownEntry[] | null; + totalInstances: number | null; + handleUpdateGroup: (appID: string, groupID: string) => void; + deleteGroup: (appID: string, groupID: string) => void; +} + +export function PureGroupItem({ + group, + versionBreakdown, + totalInstances, + handleUpdateGroup, + deleteGroup, +}: PureGroupItemProps) { + const classes = useStyles(); + const { t } = useTranslation(); + + const description = group.description || t('groups|No description provided'); + const channel = group.channel || null; + + const groupChannel = _.isEmpty(group.channel) ? ( + {t('groups|No channel provided')} + ) : ( + + ); + return ( handleUpdateGroup(group.application_id, group.id), }, { label: t('frequent|Delete'), - action: deleteGroup, + action: () => deleteGroup(group.application_id, group.id), }, ]} /> @@ -120,7 +144,15 @@ function Item(props: { {t('groups|Instances')} - {totalInstances > 0 ? totalInstances : t('frequent|None')} + {totalInstances !== null ? ( + totalInstances > 0 ? ( + totalInstances + ) : ( + t('frequent|None') + ) + ) : ( + {t('frequent|Loading...')} + )} @@ -145,7 +177,7 @@ function Item(props: { - {props.group.policy_updates_enabled ? ( + {group.policy_updates_enabled ? ( <> {t('frequent|Enabled')} @@ -167,7 +199,7 @@ function Item(props: { {t('groups|Rollout Policy')} - {formatUpdateLimits(t, props.group)} + {formatUpdateLimits(t, group)} @@ -175,8 +207,10 @@ function Item(props: { {t('groups|Version breakdown')} - {version_breakdown?.length > 0 ? ( - + {versionBreakdown === null ? ( + {t('frequent|Loading...')} + ) : versionBreakdown?.length > 0 ? ( + ) : ( {t('groups|No instances available.')} )} @@ -189,4 +223,4 @@ function Item(props: { ); } -export default Item; +export default GroupItem; diff --git a/frontend/src/components/Groups/GroupItemExtended.stories.tsx b/frontend/src/components/Groups/GroupItemExtended.stories.tsx new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/frontend/src/components/Groups/GroupItemExtended.stories.tsx @@ -0,0 +1 @@ +export {}; diff --git a/frontend/src/components/Groups/ItemExtended.tsx b/frontend/src/components/Groups/GroupItemExtended.tsx similarity index 98% rename from frontend/src/components/Groups/ItemExtended.tsx rename to frontend/src/components/Groups/GroupItemExtended.tsx index c9acf232f..298d3535b 100644 --- a/frontend/src/components/Groups/ItemExtended.tsx +++ b/frontend/src/components/Groups/GroupItemExtended.tsx @@ -18,8 +18,9 @@ import { CardFeatureLabel, CardHeader, CardLabel } from '../common/Card/Card'; import MoreMenu from '../common/MoreMenu/MoreMenu'; import TimeIntervalLinks from '../common/TimeIntervalLinks/TimeIntervalLinks'; import InstanceStatusArea from '../Instances/Charts'; -import { StatusCountTimeline, VersionCountTimeline } from './Charts'; -import { formatUpdateLimits } from './Item'; +import StatusCountTimeline from './GroupCharts/StatusCountTimeline'; +import VersionCountTimeline from './GroupCharts/VersionCountTimeline'; +import { formatUpdateLimits } from './GroupItem'; const useStyles = makeStyles(theme => ({ link: { diff --git a/frontend/src/components/Groups/List.tsx b/frontend/src/components/Groups/GroupList.tsx similarity index 82% rename from frontend/src/components/Groups/List.tsx rename to frontend/src/components/Groups/GroupList.tsx index 9d84fc182..55b853de8 100644 --- a/frontend/src/components/Groups/List.tsx +++ b/frontend/src/components/Groups/GroupList.tsx @@ -1,6 +1,6 @@ -import { withStyles } from '@material-ui/core'; import MuiList from '@material-ui/core/List'; import Paper from '@material-ui/core/Paper'; +import { makeStyles } from '@material-ui/core/styles'; import React from 'react'; import { Trans } from 'react-i18next'; import _ from 'underscore'; @@ -10,20 +10,25 @@ import Empty from '../common/EmptyContent/EmptyContent'; import ListHeader from '../common/ListHeader/ListHeader'; import Loader from '../common/Loader/Loader'; import ModalButton from '../common/ModalButton/ModalButton'; -import EditDialog from './EditDialog'; -import Item from './Item'; +import GroupEditDialog from './GroupEditDialog/GroupEditDialog'; +import GroupItem from './GroupItem'; -const styles = () => ({ +const useStyles = makeStyles(() => ({ root: { '& > hr:first-child': { display: 'none', }, }, -}); +})); -function List(props: { appID: string; classes: Record<'root', string> }) { +export interface GroupListProps { + appID: string; +} + +function GroupList({ appID }: GroupListProps) { + const classes = useStyles(); const [application, setApplication] = React.useState( - applicationsStore().getCachedApplication(props.appID) + applicationsStore().getCachedApplication(appID) ); const [updateGroupModalVisible, setUpdateGroupModalVisible] = React.useState(false); const [updateGroupIDModal, setUpdateGroupIDModal] = React.useState(null); @@ -45,16 +50,14 @@ function List(props: { appID: string; classes: Record<'root', string> }) { }, []); function onChange() { - setApplication(applicationsStore().getCachedApplication(props.appID)); + setApplication(applicationsStore().getCachedApplication(appID)); } let channels: Channel[] = []; let groups: Group[] = []; - let name = ''; let entries: React.ReactNode = ''; if (application) { - name = application.name; groups = application.groups ? application.groups : []; channels = application.channels ? application.channels : []; @@ -73,11 +76,9 @@ function List(props: { appID: string; classes: Record<'root', string> }) { } else { entries = _.map(groups, group => { return ( - ); @@ -91,7 +92,6 @@ function List(props: { appID: string; classes: Record<'root', string> }) { !_.isEmpty(groups) && updateGroupIDModal ? _.findWhere(groups, { id: updateGroupIDModal }) : null; - const { classes } = props; return ( <> @@ -103,7 +103,7 @@ function List(props: { appID: string; classes: Record<'root', string> }) { modalToOpen="AddGroupModal" data={{ channels: channels, - appID: props.appID, + appID: appID, }} />, ]} @@ -111,8 +111,8 @@ function List(props: { appID: string; classes: Record<'root', string> }) { {entries} {groupToUpdate && ( - @@ -122,4 +122,4 @@ function List(props: { appID: string; classes: Record<'root', string> }) { ); } -export default withStyles(styles)(List); +export default GroupList; diff --git a/frontend/src/components/Groups/__snapshots__/GroupItem.stories.storyshot b/frontend/src/components/Groups/__snapshots__/GroupItem.stories.storyshot new file mode 100644 index 000000000..8e85cd5ec --- /dev/null +++ b/frontend/src/components/Groups/__snapshots__/GroupItem.stories.storyshot @@ -0,0 +1,614 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots groups/GroupItem Group 1`] = ` +
+
  • +
    +
    +
    +
    + +
    +
    +

    + stable +

    +
    +
    + + For production clusters (ARM) + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + + Instances + +
    + + 2 + +
    + +
    +

    + last 24 hours +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Channel + + +
    +
    +
    +
    + s +
    +
    +
    +
    +
    +
    + stable +
    +
    +
    +
    + No package (ARM64) +
    +
    +
    +
    +
    +
    +
    +
    + + Updates + +
    + +
    +
    + Enabled +
    +
    + +
    +
    +
    +
    +
    +
    + + Rollout Policy + +
    + + Unlimited number of parallel updates + +
    +
    +
    +
    + + Version breakdown + +
    +
    +
    +

    + No instances available. +

    +
    +
    +
    +
    +
    +
    +
  • +
    +`; + +exports[`Storyshots groups/GroupItem Loading 1`] = ` +
    +
  • +
    +
    +
    +
    + +
    +
    +

    + stable +

    +
    +
    + + For production clusters (ARM) + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + + Instances + +
    + +
    +

    + Loading... +

    +
    +
    +
    + +
    +

    + last 24 hours +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Channel + + +
    +
    +
    +
    + s +
    +
    +
    +
    +
    +
    + stable +
    +
    +
    +
    + No package (ARM64) +
    +
    +
    +
    +
    +
    +
    +
    + + Updates + +
    + +
    +
    + Enabled +
    +
    + +
    +
    +
    +
    +
    +
    + + Rollout Policy + +
    + + Unlimited number of parallel updates + +
    +
    +
    +
    + + Version breakdown + +
    +
    +
    +

    + Loading... +

    +
    +
    +
    +
    +
    +
    +
  • +
    +`; diff --git a/frontend/src/components/common/ModalButton/ModalButton.tsx b/frontend/src/components/common/ModalButton/ModalButton.tsx index 0f58a2f7a..f70d88f2d 100644 --- a/frontend/src/components/common/ModalButton/ModalButton.tsx +++ b/frontend/src/components/common/ModalButton/ModalButton.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import ApplicationEdit from '../../Applications/ApplicationEdit'; import ChannelEdit from '../../Channels/ChannelEdit'; -import GroupEditDialog from '../../Groups/EditDialog'; +import GroupEditDialog from '../../Groups/GroupEditDialog/GroupEditDialog'; import PackageEditDialog, { EditDialogProps as PackageEditDialogProps, } from '../../Packages/EditDialog'; diff --git a/frontend/src/components/layouts/ApplicationLayout/ApplicationLayout.tsx b/frontend/src/components/layouts/ApplicationLayout/ApplicationLayout.tsx index e5ba7d3b7..f7d2bb3a9 100644 --- a/frontend/src/components/layouts/ApplicationLayout/ApplicationLayout.tsx +++ b/frontend/src/components/layouts/ApplicationLayout/ApplicationLayout.tsx @@ -6,7 +6,7 @@ import _ from 'underscore'; import { applicationsStore } from '../../../stores/Stores'; import ChannelList from '../../Channels/ChannelList'; import SectionHeader from '../../common/SectionHeader/SectionHeader'; -import GroupsList from '../../Groups/List'; +import GroupList from '../../Groups/GroupList'; import PackagesList from '../../Packages/List'; function ApplicationLayout() { @@ -47,7 +47,7 @@ function ApplicationLayout() { /> - + diff --git a/frontend/src/components/layouts/GroupLayout/GroupLayout.tsx b/frontend/src/components/layouts/GroupLayout/GroupLayout.tsx index e701facd8..f90c5e72c 100644 --- a/frontend/src/components/layouts/GroupLayout/GroupLayout.tsx +++ b/frontend/src/components/layouts/GroupLayout/GroupLayout.tsx @@ -5,8 +5,8 @@ import _ from 'underscore'; import { Channel, Group } from '../../../api/apiDataTypes'; import { applicationsStore } from '../../../stores/Stores'; import SectionHeader from '../../common/SectionHeader/SectionHeader'; -import EditDialog from '../../Groups/EditDialog'; -import GroupExtended from '../../Groups/ItemExtended'; +import GroupEditDialog from '../../Groups/GroupEditDialog/GroupEditDialog'; +import GroupItemExtended from '../../Groups/GroupItemExtended'; function GroupLayout() { const { appID, groupID } = useParams<{ appID: string; groupID: string }>(); @@ -70,8 +70,8 @@ function GroupLayout() { }, ]} /> - - + ; + activityStoreContext: Context; + groupChartStoreContext: Context; } let stores: Stores | undefined; export function getStores(noRefresh?: boolean): Stores { if (stores === undefined) { + const applicationsStore = new ApplicationsStore(noRefresh); + const activityStore = new ActivityStore(noRefresh); + const groupChartStore = new GroupChartsStore(); + + const applicationsStoreContext = createContext(applicationsStore); + const activityStoreContext = createContext(activityStore); + const groupChartStoreContext = createContext(groupChartStore); + stores = { - applicationsStore: new ApplicationsStore(noRefresh), - activityStore: new ActivityStore(noRefresh), - groupChartStore: new GroupChartsStore(), + applicationsStore, + activityStore, + groupChartStore, + applicationsStoreContext, + activityStoreContext, + groupChartStoreContext, }; } return stores; @@ -31,3 +47,7 @@ export function activityStore(noRefresh?: boolean) { export function groupChartStore(noRefresh?: boolean) { return getStores(noRefresh).groupChartStore; } + +export function groupChartStoreContext() { + return getStores().groupChartStoreContext; +} diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index b50b9db13..9bd135ec8 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -243,6 +243,7 @@ export function useGroupVersionBreakdown(group: Group) { API.getGroupVersionBreakdown(group.application_id, group.id) .then(versions => { + console.log('versionBreakdown versions', versions); setVersionBreakdown(versions); }) .catch(err => {