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`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Channel
+
+
+
+
+
+
+
+
+
+
+ No package (ARM64)
+
+
+
+
+
+
+
+
+
+
+ Rollout Policy
+
+
+
+ Unlimited number of parallel updates
+
+
+
+
+
+
+ Version breakdown
+
+
+
+
+
+ No instances available.
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Storyshots groups/GroupItem Loading 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Channel
+
+
+
+
+
+
+
+
+
+
+ No package (ARM64)
+
+
+
+
+
+
+
+
+
+
+ Rollout Policy
+
+
+
+ Unlimited number of parallel updates
+
+
+
+
+
+
+ Version breakdown
+
+
+
+
+
+
+
+
+
+`;
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 => {