From df62dfcdccfbc5e42609e7cd832e58f6dcb8aa2c Mon Sep 17 00:00:00 2001 From: Peter Makowski Date: Wed, 3 Apr 2024 16:19:17 +0200 Subject: [PATCH 1/6] feat: mark utc timestamps MAASENG-1315 - add branded UtcTimestamp type --- .../components/DhcpForm/DhcpForm.test.tsx | 8 +-- .../DhcpFormFields/DhcpFormFields.test.tsx | 8 +-- .../Notification/Notification.test.tsx | 2 +- .../components/StatusBar/StatusBar.test.tsx | 26 +++++----- .../base/components/StatusBar/StatusBar.tsx | 9 ++-- .../NodeLogs/EventLogs/EventLogs.test.tsx | 18 +++---- .../node/NodeTestsTable/NodeTestsTable.tsx | 3 +- .../ImagesTable/ImagesTable.test.tsx | 18 +++---- .../views/Dhcp/DhcpForm/DhcpForm.test.tsx | 4 -- .../settings/views/Dhcp/DhcpList/DhcpList.tsx | 7 +-- .../RepositoryForm/RepositoryForm.test.tsx | 4 +- .../Scripts/ScriptsList/ScriptsList.test.tsx | 4 +- .../views/Users/UsersList/UsersList.tsx | 7 +-- src/app/store/bootresource/types/base.ts | 6 +-- src/app/store/controller/types/base.ts | 8 ++- src/app/store/machine/types/base.ts | 12 +++-- src/app/store/scriptresult/types/base.ts | 6 +-- src/app/store/types/model.ts | 6 ++- src/app/store/user/types/base.ts | 4 +- .../SubnetUsedIPs/SubnetUsedIPs.test.tsx | 12 ++--- src/app/utils/time.test.ts | 49 +++++++++++++------ src/app/utils/time.ts | 29 ++++++++--- src/testing/factories/bootresource.ts | 5 +- src/testing/factories/dhcpsnippet.ts | 3 +- src/testing/factories/general.ts | 4 ++ src/testing/factories/index.ts | 1 + src/testing/factories/model.ts | 6 ++- src/testing/factories/nodes.ts | 19 +++---- src/testing/factories/scriptResult.ts | 7 +-- src/testing/factories/subnet.ts | 5 +- src/testing/factories/user.ts | 3 +- 31 files changed, 177 insertions(+), 126 deletions(-) diff --git a/src/app/base/components/DhcpForm/DhcpForm.test.tsx b/src/app/base/components/DhcpForm/DhcpForm.test.tsx index 4463abce2a..eceb81bc75 100644 --- a/src/app/base/components/DhcpForm/DhcpForm.test.tsx +++ b/src/app/base/components/DhcpForm/DhcpForm.test.tsx @@ -22,17 +22,17 @@ describe("DhcpForm", () => { dhcpsnippet: factory.dhcpSnippetState({ items: [ factory.dhcpSnippet({ - created: "Thu, 15 Aug. 2019 06:21:39", + created: factory.timestamp("Thu, 15 Aug. 2019 06:21:39"), id: 1, name: "lease", - updated: "Thu, 15 Aug. 2019 06:21:39", + updated: factory.timestamp("Thu, 15 Aug. 2019 06:21:39"), value: "lease 10", }), factory.dhcpSnippet({ - created: "Thu, 15 Aug. 2019 06:21:39", + created: factory.timestamp("Thu, 15 Aug. 2019 06:21:39"), id: 2, name: "class", - updated: "Thu, 15 Aug. 2019 06:21:39", + updated: factory.timestamp("Thu, 15 Aug. 2019 06:21:39"), }), ], loaded: true, diff --git a/src/app/base/components/DhcpFormFields/DhcpFormFields.test.tsx b/src/app/base/components/DhcpFormFields/DhcpFormFields.test.tsx index 8da85b0c32..c62a11cc13 100644 --- a/src/app/base/components/DhcpFormFields/DhcpFormFields.test.tsx +++ b/src/app/base/components/DhcpFormFields/DhcpFormFields.test.tsx @@ -37,17 +37,17 @@ describe("DhcpFormFields", () => { dhcpsnippet: factory.dhcpSnippetState({ items: [ factory.dhcpSnippet({ - created: "Thu, 15 Aug. 2019 06:21:39", + created: factory.timestamp("Thu, 15 Aug. 2019 06:21:39"), id: 1, name: "lease", - updated: "Thu, 15 Aug. 2019 06:21:39", + updated: factory.timestamp("Thu, 15 Aug. 2019 06:21:39"), value: "lease 10", }), factory.dhcpSnippet({ - created: "Thu, 15 Aug. 2019 06:21:39", + created: factory.timestamp("Thu, 15 Aug. 2019 06:21:39"), id: 2, name: "class", - updated: "Thu, 15 Aug. 2019 06:21:39", + updated: factory.timestamp("Thu, 15 Aug. 2019 06:21:39"), }), ], loaded: true, diff --git a/src/app/base/components/NotificationGroup/Notification/Notification.test.tsx b/src/app/base/components/NotificationGroup/Notification/Notification.test.tsx index 697f2da8fb..dabc40b0e6 100644 --- a/src/app/base/components/NotificationGroup/Notification/Notification.test.tsx +++ b/src/app/base/components/NotificationGroup/Notification/Notification.test.tsx @@ -101,7 +101,7 @@ describe("NotificationGroupNotification", () => { it("shows the date for upgrade notifications", () => { const notification = factory.notification({ - created: "Tue, 27 Apr. 2021 00:34:39", + created: factory.timestamp("Tue, 27 Apr. 2021 00:34:39"), ident: NotificationIdent.UPGRADE_STATUS, }); const state = factory.rootState({ diff --git a/src/app/base/components/StatusBar/StatusBar.test.tsx b/src/app/base/components/StatusBar/StatusBar.test.tsx index 017a66b6bc..4a7b8a7b58 100644 --- a/src/app/base/components/StatusBar/StatusBar.test.tsx +++ b/src/app/base/components/StatusBar/StatusBar.test.tsx @@ -52,7 +52,7 @@ it("can show if a machine is currently commissioning", () => { it("can show if a machine has not been commissioned yet", () => { state.machine.items = [ factory.machineDetails({ - commissioning_start_time: "", + commissioning_start_time: factory.timestamp(""), fqdn: "test.maas", system_id: "abc123", }), @@ -67,7 +67,7 @@ it("can show the last time a machine was commissioned", () => { state.machine.items = [ factory.machineDetails({ enable_hw_sync: false, - commissioning_start_time: "Thu, 31 Dec. 2020 22:59:00", + commissioning_start_time: factory.timestamp("Thu, 31 Dec. 2020 22:59:00"), fqdn: "test.maas", status: NodeStatus.DEPLOYED, system_id: "abc123", @@ -85,7 +85,7 @@ it("can handle an incorrectly formatted commissioning timestamp", () => { state.machine.items = [ factory.machineDetails({ enable_hw_sync: false, - commissioning_start_time: "2020-03-01 09:12:43", + commissioning_start_time: factory.timestamp("2020-03-01 09:12:43"), fqdn: "test.maas", status: NodeStatus.DEPLOYED, system_id: "abc123", @@ -102,13 +102,13 @@ it("can handle an incorrectly formatted commissioning timestamp", () => { it("displays Last and Next sync instead of Last commissioned date for deployed machines with hardware sync enabled ", () => { state.machine.items = [ factory.machineDetails({ - commissioning_start_time: "Thu, 31 Dec. 2020 22:59:00", + commissioning_start_time: factory.timestamp("Thu, 31 Dec. 2020 22:59:00"), fqdn: "test.maas", status: NodeStatus.DEPLOYED, system_id: "abc123", enable_hw_sync: true, - last_sync: "Thu, 31 Dec. 2020 22:00:00", - next_sync: "Thu, 31 Dec. 2020 23:01:00", + last_sync: factory.timestamp("Thu, 31 Dec. 2020 22:00:00"), + next_sync: factory.timestamp("Thu, 31 Dec. 2020 23:01:00"), }), ]; @@ -131,13 +131,13 @@ it("displays Last and Next sync instead of Last commissioned date for deployed m it("doesn't display last or next sync for deploying machines with hardware sync enabled", () => { state.machine.items = [ factory.machineDetails({ - commissioning_start_time: "Thu, 31 Dec. 2020 22:59:00", + commissioning_start_time: factory.timestamp("Thu, 31 Dec. 2020 22:59:00"), fqdn: "test.maas", status: NodeStatus.DEPLOYING, system_id: "abc123", enable_hw_sync: true, - last_sync: "Thu, 31 Dec. 2020 22:00:00", - next_sync: "Thu, 31 Dec. 2020 23:01:00", + last_sync: factory.timestamp("Thu, 31 Dec. 2020 22:00:00"), + next_sync: factory.timestamp("Thu, 31 Dec. 2020 23:01:00"), }), ]; @@ -159,13 +159,13 @@ it("doesn't display last or next sync for deploying machines with hardware sync it("displays correct text for machines with hardware sync enabled and no last_sync or next_sync", () => { state.machine.items = [ factory.machineDetails({ - commissioning_start_time: "Thu, 31 Dec. 2020 22:59:00", + commissioning_start_time: factory.timestamp("Thu, 31 Dec. 2020 22:59:00"), fqdn: "test.maas", status: NodeStatus.DEPLOYED, system_id: "abc123", enable_hw_sync: true, - last_sync: "", - next_sync: "", + last_sync: factory.timestamp("Thu, 31 Dec. 2020 22:00:00"), + next_sync: factory.timestamp("Thu, 31 Dec. 2020 23:01:00"), }), ]; @@ -189,7 +189,7 @@ it("displays correct text for machines with hardware sync enabled and no last_sy it("displays last image sync timestamp for a rack or region+rack controller", () => { const controller = factory.controllerDetails({ - last_image_sync: "Thu, 02 Jun. 2022 00:48:41", + last_image_sync: factory.timestamp("Thu, 02 Jun. 2022 00:48:41"), node_type: NodeType.RACK_CONTROLLER, }); state.controller.active = controller.system_id; diff --git a/src/app/base/components/StatusBar/StatusBar.tsx b/src/app/base/components/StatusBar/StatusBar.tsx index 14572d968f..3155c4af88 100644 --- a/src/app/base/components/StatusBar/StatusBar.tsx +++ b/src/app/base/components/StatusBar/StatusBar.tsx @@ -18,8 +18,9 @@ import { isDeployedWithHardwareSync, isMachineDetails, } from "@/app/store/machine/utils"; +import type { UtcTimestamp } from "@/app/store/types/model"; import { NodeStatus } from "@/app/store/types/node"; -import { getTimeDistanceString } from "@/app/utils/time"; +import { getUtcTimestamp, getTimeDistanceString } from "@/app/utils/time"; const getLastCommissionedString = (machine: MachineDetails) => { if (machine.status === NodeStatus.COMMISSIONING) { @@ -37,7 +38,7 @@ const getLastCommissionedString = (machine: MachineDetails) => { } }; -const getSyncStatusString = (syncStatus: string) => { +const getSyncStatusString = (syncStatus: UtcTimestamp) => { if (syncStatus === "") { return "Never"; } @@ -87,7 +88,9 @@ export const StatusBar = (): JSX.Element | null => { isControllerDetails(activeController) && (isRack(activeController) || isRegionAndRack(activeController)) ) { - status = `Last image sync: ${activeController.last_image_sync}`; + status = `Last image sync: ${getUtcTimestamp( + activeController.last_image_sync + )}`; } return ( diff --git a/src/app/base/components/node/NodeLogs/EventLogs/EventLogs.test.tsx b/src/app/base/components/node/NodeLogs/EventLogs/EventLogs.test.tsx index d5645502d5..2b53485d9b 100644 --- a/src/app/base/components/node/NodeLogs/EventLogs/EventLogs.test.tsx +++ b/src/app/base/components/node/NodeLogs/EventLogs/EventLogs.test.tsx @@ -60,7 +60,7 @@ describe("EventLogs", () => { state.event.items.push( factory.eventRecord({ node_id: 1, - created: "Tue, 16 Mar. 2021 03:04:00", + created: factory.timestamp("Tue, 16 Mar. 2021 03:04:00"), }) ); } @@ -116,7 +116,7 @@ describe("EventLogs", () => { state.event.items.push( factory.eventRecord({ node_id: 1, - created: "Tue, 16 Mar. 2021 03:04:00", + created: factory.timestamp("Tue, 16 Mar. 2021 03:04:00"), }) ); } @@ -156,11 +156,11 @@ describe("EventLogs", () => { it("orders the rows by most recent first", () => { state.event.items = [ factory.eventRecord({ - created: "Tue, 16 Mar. 2021 03:04:00", + created: factory.timestamp("Tue, 16 Mar. 2021 03:04:00"), node_id: 1, }), factory.eventRecord({ - created: "Tue, 17 Mar. 2021 03:04:00", + created: factory.timestamp("Tue, 17 Mar. 2021 03:04:00"), node_id: 1, }), ]; @@ -221,7 +221,7 @@ describe("EventLogs", () => { state.event.items.push( factory.eventRecord({ node_id: 1, - created: "Tue, 16 Mar. 2021 03:04:00", + created: factory.timestamp("Tue, 16 Mar. 2021 03:04:00"), }) ); } @@ -239,7 +239,7 @@ describe("EventLogs", () => { state.event.items.push( factory.eventRecord({ node_id: 1, - created: "Tue, 16 Mar. 2021 03:04:00", + created: factory.timestamp("Tue, 16 Mar. 2021 03:04:00"), }) ); } @@ -273,7 +273,7 @@ describe("EventLogs", () => { state.event.items.push( factory.eventRecord({ node_id: 1, - created: "Tue, 16 Mar. 2021 03:04:00", + created: factory.timestamp("Tue, 16 Mar. 2021 03:04:00"), }) ); } @@ -292,7 +292,7 @@ describe("EventLogs", () => { state.event.items.push( factory.eventRecord({ node_id: 1, - created: "Tue, 16 Mar. 2021 03:04:00", + created: factory.timestamp("Tue, 16 Mar. 2021 03:04:00"), }) ); } @@ -311,7 +311,7 @@ describe("EventLogs", () => { state.event.items.push( factory.eventRecord({ node_id: 1, - created: "Tue, 16 Mar. 2021 03:04:00", + created: factory.timestamp("Tue, 16 Mar. 2021 03:04:00"), }) ); } diff --git a/src/app/base/components/node/NodeTestsTable/NodeTestsTable.tsx b/src/app/base/components/node/NodeTestsTable/NodeTestsTable.tsx index 8a82a260e8..77b283f103 100644 --- a/src/app/base/components/node/NodeTestsTable/NodeTestsTable.tsx +++ b/src/app/base/components/node/NodeTestsTable/NodeTestsTable.tsx @@ -19,6 +19,7 @@ import type { ScriptResult } from "@/app/store/scriptresult/types"; import { ScriptResultType } from "@/app/store/scriptresult/types"; import { canBeSuppressed } from "@/app/store/scriptresult/utils"; import { nodeIsMachine } from "@/app/store/utils"; +import { getUtcTimestamp } from "@/app/utils/time"; export enum ScriptResultAction { VIEW_METRICS = "viewMetrics", @@ -131,7 +132,7 @@ const NodeTestsTable = ({ node, scriptResults }: Props): JSX.Element => { }, { className: "date-col", - content: result.updated, + content: getUtcTimestamp(result.updated), }, { className: "runtime-col", diff --git a/src/app/images/components/ImagesTable/ImagesTable.test.tsx b/src/app/images/components/ImagesTable/ImagesTable.test.tsx index 9c7a2b30e5..7f4bbfe8cc 100644 --- a/src/app/images/components/ImagesTable/ImagesTable.test.tsx +++ b/src/app/images/components/ImagesTable/ImagesTable.test.tsx @@ -98,7 +98,7 @@ it("renders the correct status for a downloaded image that is not selected", () }); it("renders the time of last update", () => { - const lastUpdate = "Mon, 30 Jan. 2023 15:54:44"; + const lastUpdate = factory.timestamp("Mon, 30 Jan. 2023 15:54:44"); const resource = factory.bootResource({ arch: "amd64", complete: true, @@ -304,7 +304,7 @@ it("disables delete action for images being downloaded", async () => { }); it("displays a correct last deployed time and machine count", () => { - const lastDeployed = "Fri, 18 Nov. 2022 09:55:21"; + const lastDeployed = factory.timestamp("Fri, 18 Nov. 2022 09:55:21"); const resources = [ factory.bootResource({ arch: "amd64", @@ -358,7 +358,7 @@ it("can handle empty string for last deployed time", () => { factory.bootResource({ arch: "amd64", name: "ubuntu/focal", - lastDeployed: "", + lastDeployed: factory.timestamp(""), machineCount: 768, }), ]; @@ -403,20 +403,20 @@ it("can sort by last deployed time", async () => { name: "ubuntu/xenial", arch: "amd64", title: "16.04 LTS", - lastDeployed: "Tue, 16 Nov. 2022 09:55:21", + lastDeployed: factory.timestamp("Tue, 16 Nov. 2022 09:55:21"), }), factory.bootResource({ arch: "amd64", name: "ubuntu/focal", title: "20.04 LTS", - lastDeployed: "Thu, 17 Nov. 2022 09:55:21", + lastDeployed: factory.timestamp("Thu, 17 Nov. 2022 09:55:21"), machineCount: 768, }), factory.bootResource({ name: "ubuntu/bionic", arch: "i386", title: "18.04 LTS", - lastDeployed: "Wed, 18 Nov. 2022 08:55:21", + lastDeployed: factory.timestamp("Wed, 18 Nov. 2022 08:55:21"), }), ]; const state = factory.rootState({ @@ -465,20 +465,20 @@ it("sorts by release by default", () => { arch: "amd64", name: "ubuntu/focal", title: "20.04 LTS", - lastDeployed: "Thu, 17 Nov. 2022 09:55:21", + lastDeployed: factory.timestamp("Thu, 17 Nov. 2022 09:55:21"), machineCount: 768, }), factory.bootResource({ name: "ubuntu/bionic", arch: "i386", title: "18.04 LTS", - lastDeployed: "Wed, 18 Nov. 2022 08:55:21", + lastDeployed: factory.timestamp("Wed, 18 Nov. 2022 08:55:21"), }), factory.bootResource({ name: "ubuntu/xenial", arch: "amd64", title: "16.04 LTS", - lastDeployed: "Tue, 16 Nov. 2022 09:55:21", + lastDeployed: factory.timestamp("Tue, 16 Nov. 2022 09:55:21"), }), ]; const state = factory.rootState({ diff --git a/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.test.tsx b/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.test.tsx index cdb9d89967..c33c16aab8 100644 --- a/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.test.tsx +++ b/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.test.tsx @@ -17,17 +17,13 @@ describe("DhcpForm", () => { dhcpsnippet: factory.dhcpSnippetState({ items: [ factory.dhcpSnippet({ - created: "Thu, 15 Aug. 2019 06:21:39", id: 1, name: "lease", - updated: "Thu, 15 Aug. 2019 06:21:39", value: "lease 10", }), factory.dhcpSnippet({ - created: "Thu, 15 Aug. 2019 06:21:39", id: 2, name: "class", - updated: "Thu, 15 Aug. 2019 06:21:39", }), ], loaded: true, diff --git a/src/app/settings/views/Dhcp/DhcpList/DhcpList.tsx b/src/app/settings/views/Dhcp/DhcpList/DhcpList.tsx index 16d2f7d5c1..69c379a7e7 100644 --- a/src/app/settings/views/Dhcp/DhcpList/DhcpList.tsx +++ b/src/app/settings/views/Dhcp/DhcpList/DhcpList.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import { ContentSection } from "@canonical/maas-react-components"; import { Code, Col, Row } from "@canonical/react-components"; -import { format, parse } from "date-fns"; import { useDispatch, useSelector } from "react-redux"; import type { Dispatch } from "redux"; @@ -34,6 +33,7 @@ import type { RootState } from "@/app/store/root/types"; import { subnetActions } from "@/app/store/subnet"; import subnetSelectors from "@/app/store/subnet/selectors"; import type { Subnet } from "@/app/store/subnet/types"; +import { formatUtcTimestamp } from "@/app/utils/time"; const getTargetName = ( controllers: Controller[], @@ -80,10 +80,7 @@ const generateRows = ( const expanded = expandedId === dhcpsnippet.id; // Dates are in the format: Thu, 15 Aug. 2019 06:21:39. const updated = dhcpsnippet.updated - ? format( - parse(dhcpsnippet.updated, "E, dd LLL. yyyy HH:mm:ss", new Date()), - "yyyy-LL-dd H:mm" - ) + ? formatUtcTimestamp(dhcpsnippet.updated) : "Never"; const enabled = dhcpsnippet.enabled ? "Yes" : "No"; const showDelete = expandedType === "delete"; diff --git a/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.test.tsx b/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.test.tsx index 7b330e44ac..7ed4129d5a 100644 --- a/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.test.tsx +++ b/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.test.tsx @@ -193,8 +193,8 @@ describe("RepositoryForm", () => { const store = mockStore(state); const repository = { id: 9, - created: "Tue, 27 Aug. 2019 12:39:12", - updated: "Tue, 27 Aug. 2019 12:39:12", + created: factory.timestamp("Tue, 27 Aug. 2019 12:39:12"), + updated: factory.timestamp("Tue, 27 Aug. 2019 12:39:12"), name: "name", url: "http://www.website.com", distributions: [], diff --git a/src/app/settings/views/Scripts/ScriptsList/ScriptsList.test.tsx b/src/app/settings/views/Scripts/ScriptsList/ScriptsList.test.tsx index cfab712df3..8bb51eb836 100644 --- a/src/app/settings/views/Scripts/ScriptsList/ScriptsList.test.tsx +++ b/src/app/settings/views/Scripts/ScriptsList/ScriptsList.test.tsx @@ -300,7 +300,7 @@ describe("ScriptsList", () => { loaded: true, items: [ factory.script({ - created: "Thu, 31 Dec. 2020 22:59:00", + created: factory.timestamp("Thu, 31 Dec. 2020 22:59:00"), script_type: ScriptType.TESTING, }), ], @@ -326,7 +326,7 @@ describe("ScriptsList", () => { loaded: true, items: [ factory.script({ - created: "", + created: factory.timestamp(""), script_type: ScriptType.TESTING, }), ], diff --git a/src/app/settings/views/Users/UsersList/UsersList.tsx b/src/app/settings/views/Users/UsersList/UsersList.tsx index 4e4b6c69e3..66d235a145 100644 --- a/src/app/settings/views/Users/UsersList/UsersList.tsx +++ b/src/app/settings/views/Users/UsersList/UsersList.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import { ContentSection } from "@canonical/maas-react-components"; import { Notification } from "@canonical/react-components"; -import { format, parse } from "date-fns"; import { useDispatch, useSelector } from "react-redux"; import TableActions from "@/app/base/components/TableActions"; @@ -23,6 +22,7 @@ import { userActions } from "@/app/store/user"; import userSelectors from "@/app/store/user/selectors"; import type { User } from "@/app/store/user/types"; import { isComparable } from "@/app/utils"; +import { formatUtcTimestamp } from "@/app/utils/time"; type SortKey = keyof User; @@ -35,10 +35,7 @@ const generateUserRows = ( const isAuthUser = user.id === authUser?.id; // Dates are in the format: Thu, 15 Aug. 2019 06:21:39. const last_login = user.last_login - ? format( - parse(user.last_login, "E, dd LLL. yyyy HH:mm:ss", new Date()), - "yyyy-LL-dd H:mm" - ) + ? formatUtcTimestamp(user.last_login) : "Never"; const fullName = user.last_name; return { diff --git a/src/app/store/bootresource/types/base.ts b/src/app/store/bootresource/types/base.ts index 6d8e4bb39c..08402eeb72 100644 --- a/src/app/store/bootresource/types/base.ts +++ b/src/app/store/bootresource/types/base.ts @@ -1,6 +1,6 @@ import type { BootResourceSourceType, BootResourceType } from "./enum"; -import type { Model } from "@/app/store/types/model"; +import type { Model, UtcTimestamp } from "@/app/store/types/model"; export type BaseImageFields = { checked: boolean; @@ -15,8 +15,8 @@ export type BootResource = Model & { complete: boolean; downloading: boolean; icon: "in-progress" | "queued" | "succeeded" | "waiting"; - lastUpdate: string; - lastDeployed: string; + lastUpdate: UtcTimestamp; + lastDeployed: UtcTimestamp; machineCount: number; name: string; numberOfNodes: number; diff --git a/src/app/store/controller/types/base.ts b/src/app/store/controller/types/base.ts index 6468345e28..e976808868 100644 --- a/src/app/store/controller/types/base.ts +++ b/src/app/store/controller/types/base.ts @@ -8,7 +8,11 @@ import type { import type { APIError } from "@/app/base/types"; import type { CertificateMetadata, PowerType } from "@/app/store/general/types"; import type { PowerState, StorageLayout } from "@/app/store/types/enum"; -import type { ModelRef, TimestampFields } from "@/app/store/types/model"; +import type { + ModelRef, + TimestampFields, + UtcTimestamp, +} from "@/app/store/types/model"; import type { NodeActions, BaseNode, @@ -65,7 +69,7 @@ export type ControllerActions = export type BaseController = BaseNode & { actions: ControllerActions[]; - last_image_sync: string; + last_image_sync: UtcTimestamp; link_type: NodeLinkType.CONTROLLER; node_type_display: | NodeTypeDisplay.RACK_CONTROLLER diff --git a/src/app/store/machine/types/base.ts b/src/app/store/machine/types/base.ts index 23f0c91de1..e265e42406 100644 --- a/src/app/store/machine/types/base.ts +++ b/src/app/store/machine/types/base.ts @@ -5,7 +5,11 @@ import type { ActionState, APIError, Seconds } from "@/app/base/types"; import type { CloneError } from "@/app/machines/components/MachineForms/MachineActionFormWrapper/CloneForm/CloneResults/CloneResults"; import type { CertificateMetadata, PowerType } from "@/app/store/general/types"; import type { PowerState, StorageLayout } from "@/app/store/types/enum"; -import type { ModelRef, TimestampFields } from "@/app/store/types/model"; +import type { + ModelRef, + TimestampFields, + UtcTimestamp, +} from "@/app/store/types/model"; import type { BaseNode, Disk, @@ -69,7 +73,7 @@ export type MachineDetails = BaseMachine & bmc: number; boot_disk: Disk | null; certificate?: CertificateMetadata; - commissioning_start_time: string; + commissioning_start_time: UtcTimestamp; commissioning_status: TestStatus; cpu_speed: BaseNode["cpu_speed"]; cpu_test_status: TestStatus; @@ -122,8 +126,8 @@ type HardwareSyncFields = } | { enable_hw_sync: true; - last_sync: string; - next_sync: string; + last_sync: UtcTimestamp; + next_sync: UtcTimestamp; is_sync_healthy: boolean; sync_interval: Seconds; }; diff --git a/src/app/store/scriptresult/types/base.ts b/src/app/store/scriptresult/types/base.ts index bf494a68cf..b81a85567b 100644 --- a/src/app/store/scriptresult/types/base.ts +++ b/src/app/store/scriptresult/types/base.ts @@ -9,7 +9,7 @@ import type { import type { HardwareType } from "@/app/base/enum"; import type { APIError } from "@/app/base/types"; -import type { Model } from "@/app/store/types/model"; +import type { Model, UtcTimestamp } from "@/app/store/types/model"; import type { NetworkInterface } from "@/app/store/types/node"; import type { GenericState } from "@/app/store/types/state"; @@ -29,7 +29,7 @@ export type PartialScriptResult = Model & { status: ScriptResultStatus; status_name: string; suppressed: boolean; - updated?: string; + updated?: UtcTimestamp; }; export type ScriptResult = PartialScriptResult & { @@ -76,7 +76,7 @@ export type ScriptResult = PartialScriptResult & { results: ScriptResultResult[]; script?: number; script_version?: number | null; - started?: string; + started?: UtcTimestamp; tags: string; }; diff --git a/src/app/store/types/model.ts b/src/app/store/types/model.ts index ee3ec4aac9..8d48cdd8fe 100644 --- a/src/app/store/types/model.ts +++ b/src/app/store/types/model.ts @@ -2,9 +2,11 @@ export type Model = { id: number; }; +// Expected format: "Thu, 15 Aug. 2019 06:21:39" or "" +export type UtcTimestamp = string & { readonly __brand: unique symbol }; export type TimestampFields = { - created: string; - updated: string; + created: UtcTimestamp; + updated: UtcTimestamp; }; export type TimestampedModel = Model & TimestampFields; diff --git a/src/app/store/user/types/base.ts b/src/app/store/user/types/base.ts index f7b6c6c099..8891dbfef2 100644 --- a/src/app/store/user/types/base.ts +++ b/src/app/store/user/types/base.ts @@ -1,5 +1,5 @@ import type { APIError } from "@/app/base/types"; -import type { Model } from "@/app/store/types/model"; +import type { Model, UtcTimestamp } from "@/app/store/types/model"; import type { GenericState } from "@/app/store/types/state"; export type User = Model & { @@ -9,7 +9,7 @@ export type User = Model & { is_local: boolean; is_superuser: boolean; last_name: string; - last_login: string; + last_login: UtcTimestamp; machines_count: number; sshkeys_count: number; username: string; diff --git a/src/app/subnets/views/SubnetDetails/SubnetUsedIPs/SubnetUsedIPs.test.tsx b/src/app/subnets/views/SubnetDetails/SubnetUsedIPs/SubnetUsedIPs.test.tsx index f8e1081b24..6eec4d6991 100644 --- a/src/app/subnets/views/SubnetDetails/SubnetUsedIPs/SubnetUsedIPs.test.tsx +++ b/src/app/subnets/views/SubnetDetails/SubnetUsedIPs/SubnetUsedIPs.test.tsx @@ -12,18 +12,14 @@ const mockStore = configureStore(); it("displays correct IP addresses", () => { const subnet = factory.subnetDetails({ ip_addresses: [ - { + factory.subnetIP({ ip: "11.1.1.1", alloc_type: 4, - created: "yesterday", - updated: "today", - }, - { + }), + factory.subnetIP({ ip: "11.1.1.2", alloc_type: 5, - created: "yesterday", - updated: "today", - }, + }), ], }); const state = factory.rootState({ diff --git a/src/app/utils/time.test.ts b/src/app/utils/time.test.ts index 9aa4b5aa24..62944bbd32 100644 --- a/src/app/utils/time.test.ts +++ b/src/app/utils/time.test.ts @@ -1,7 +1,14 @@ import MockDate from "mockdate"; import timezoneMock from "timezone-mock"; -import { formatUtcDatetime, getTimeDistanceString } from "./time"; +import { + formatUtcTimestamp, + getTimeDistanceString, + getUtcTimestamp, +} from "./time"; + +import type { UtcTimestamp } from "@/app/store/types/model"; +import * as factory from "@/testing/factories"; beforeEach(() => { MockDate.set("Fri, 18 Nov. 2022 01:01:00"); @@ -13,27 +20,41 @@ afterEach(() => { describe("getTimeDistanceString", () => { it("returns time distance for UTC TimeString in the past", () => { - expect(getTimeDistanceString("Fri, 18 Nov. 2022 01:00:50")).toEqual( - "less than a minute ago" - ); + expect( + getTimeDistanceString("Fri, 18 Nov. 2022 01:00:50" as UtcTimestamp) + ).toEqual("less than a minute ago"); }); it("returns time distance for UTC TimeString in the future", () => { - expect(getTimeDistanceString("Fri, 18 Nov. 2022 01:01:10")).toEqual( - "in less than a minute" - ); + expect( + getTimeDistanceString("Fri, 18 Nov. 2022 01:01:10" as UtcTimestamp) + ).toEqual("in less than a minute"); }); }); -describe("formatUtcDatetime", () => { +describe("formatUtcTimestamp", () => { it("returns UTC date time in a correct format", () => { - expect(formatUtcDatetime("Fri, 18 Nov. 2022 01:00:50")).toEqual( - "Fri, 18 Nov. 2022 01:00:50" - ); + expect( + formatUtcTimestamp("Fri, 18 Nov. 2022 01:00:50" as UtcTimestamp) + ).toEqual("Fri, 18 Nov. 2022 01:00:50"); }); it("returns UTC date time in local time", () => { timezoneMock.register("Etc/GMT-1"); - expect(formatUtcDatetime("Fri, 18 Nov. 2022 03:00:00")).toEqual( - "Fri, 18 Nov. 2022 04:00:00" - ); + expect( + formatUtcTimestamp("Fri, 18 Nov. 2022 03:00:00" as UtcTimestamp) + ).toEqual("Fri, 18 Nov. 2022 04:00:00"); + }); +}); + +describe("getUtcTimestamp", () => { + it("appends (UTC) to the given time string", () => { + const inputTimeString = "Fri, 18 Nov. 2022 01:00:50" as UtcTimestamp; + const expectedOutput = "Fri, 18 Nov. 2022 01:00:50 (UTC)"; + expect(getUtcTimestamp(inputTimeString)).toEqual(expectedOutput); + }); + + it("works with different date formats", () => { + const inputTimeString = factory.timestamp("2022-11-18T01:00:50Z"); + const expectedOutput = "2022-11-18T01:00:50Z (UTC)"; + expect(getUtcTimestamp(inputTimeString)).toEqual(expectedOutput); }); }); diff --git a/src/app/utils/time.ts b/src/app/utils/time.ts index 71ff73a5a2..e342ecf822 100644 --- a/src/app/utils/time.ts +++ b/src/app/utils/time.ts @@ -1,23 +1,40 @@ import { format, formatDistance, parse } from "date-fns"; +import type { UtcTimestamp } from "@/app/store/types/model"; + const DATETIME_FORMAT = "E, dd LLL. yyyy HH:mm:ss"; const UTC_DATETIME_FORMAT = `${DATETIME_FORMAT} x`; -export const parseUtcDatetime = (utcTimeString: string): Date => +export const parseUtcDatetime = (utcTimeString: UtcTimestamp): Date => parse( `${utcTimeString} +00`, // let parse fn know it's UTC UTC_DATETIME_FORMAT, new Date() ); -export const getTimeDistanceString = (utcTimeString: string): string => +export const getTimeDistanceString = (utcTimeString: UtcTimestamp): string => formatDistance(parseUtcDatetime(utcTimeString), new Date(), { addSuffix: true, }); /** - * @param utcTimeString - time string in UTC_DATETIME_FORMAT - * @returns time string adjusted for local time zone in DATETIME_FORMAT + * Appends "(UTC)" to a given string to indicate the time zone explicitly. + * @param utcTimeString - time string in UTC + * @returns Time string with appended "(UTC)" + */ +export const getUtcTimestamp = (utcTimeString?: UtcTimestamp): string => + utcTimeString ? utcTimeString + " (UTC)" : ""; + +/** + * Formats a given UTC time string into a more readable format and appends "(UTC)" to indicate the time zone explicitly. + * It converts the time string from "E, dd LLL. yyyy HH:mm:ss" format to "yyyy-LL-dd H:mm" format. + * @param utcTimeString - time string in UTC to be formatted + * @returns Formatted time string with appended "(UTC)" */ -export const formatUtcDatetime = (utcTimeString: string): string => - format(parseUtcDatetime(utcTimeString), DATETIME_FORMAT); +export const formatUtcTimestamp = (utcTimeString?: UtcTimestamp): string => + utcTimeString + ? format( + parse(utcTimeString, "E, dd LLL. yyyy HH:mm:ss", new Date()), + "yyyy-LL-dd H:mm" + ) + " (UTC)" + : ""; diff --git a/src/testing/factories/bootresource.ts b/src/testing/factories/bootresource.ts index 68380cc62b..b6b770b0dc 100644 --- a/src/testing/factories/bootresource.ts +++ b/src/testing/factories/bootresource.ts @@ -1,5 +1,6 @@ import { define, extend } from "cooky-cutter"; +import { timestamp } from "./general"; import { model } from "./model"; import type { @@ -34,8 +35,8 @@ export const bootResource = extend(model, { downloading: false, machineCount: 0, numberOfNodes: 0, - lastUpdate: "Tue, 08 Jun. 2021 02:12:47", - lastDeployed: "Tue, 08 Jun. 2021 02:12:47", + lastUpdate: () => timestamp("Tue, 08 Jun. 2021 02:12:47"), + lastDeployed: () => timestamp("Tue, 08 Jun. 2021 02:12:47"), canDeployToMemory: true, }); diff --git a/src/testing/factories/dhcpsnippet.ts b/src/testing/factories/dhcpsnippet.ts index e40c4b2031..82050cb97d 100644 --- a/src/testing/factories/dhcpsnippet.ts +++ b/src/testing/factories/dhcpsnippet.ts @@ -7,9 +7,10 @@ import type { DHCPSnippetHistory, } from "@/app/store/dhcpsnippet/types"; import type { Model, TimestampedModel } from "@/app/store/types/model"; +import { timestamp } from "@/testing/factories"; export const dhcpSnippetHistory = extend(model, { - created: "Wed, 08 Jul. 2020 05:35:4", + created: () => timestamp("Wed, 08 Jul. 2020 05:35:04"), value: "test value", }); diff --git a/src/testing/factories/general.ts b/src/testing/factories/general.ts index 9802943713..5c1acaf7c9 100644 --- a/src/testing/factories/general.ts +++ b/src/testing/factories/general.ts @@ -29,6 +29,7 @@ import { PowerFieldScope, PowerFieldType, } from "@/app/store/general/types"; +import type { UtcTimestamp } from "@/app/store/types/model"; import { NodeActions } from "@/app/store/types/node"; export const architecture = define("amd64"); @@ -126,3 +127,6 @@ export const tlsCertificate = define({ }); export const version = define("test version"); + +export const timestamp = (timestamp: string) => + (timestamp as UtcTimestamp) || ("Wed, 08 Jul. 2020 05:35:4" as UtcTimestamp); diff --git a/src/testing/factories/index.ts b/src/testing/factories/index.ts index f9b331ab19..a087bd6355 100644 --- a/src/testing/factories/index.ts +++ b/src/testing/factories/index.ts @@ -170,6 +170,7 @@ export { powerField, powerFieldChoice, powerType, + timestamp, tlsCertificate, version, } from "./general"; diff --git a/src/testing/factories/model.ts b/src/testing/factories/model.ts index 2c0cdef8c0..cb3e872084 100644 --- a/src/testing/factories/model.ts +++ b/src/testing/factories/model.ts @@ -1,5 +1,7 @@ import { define, extend, random, sequence } from "cooky-cutter"; +import { timestamp } from "./general"; + import type { Model, ModelRef, @@ -11,8 +13,8 @@ export const model = define({ }); export const timestampedModel = extend(model, { - created: "Wed, 19 Feb. 2020 11:59:19", - updated: "Fri, 03 Jul. 2020 02:44:12", + created: () => timestamp("Wed, 19 Feb. 2020 11:59:19"), + updated: () => timestamp("Fri, 03 Jul. 2020 02:44:12"), }); export const modelRef = extend(model, { diff --git a/src/testing/factories/nodes.ts b/src/testing/factories/nodes.ts index a354158e0f..4324bf4a47 100644 --- a/src/testing/factories/nodes.ts +++ b/src/testing/factories/nodes.ts @@ -1,5 +1,6 @@ import { define, extend, random, sequence } from "cooky-cutter"; +import { timestamp } from "./general"; import { model, modelRef, timestampedModel } from "./model"; import type { @@ -211,7 +212,7 @@ export const deviceInterface = extend( ); export const deviceDetails = extend(device, { - created: "Thu, 15 Oct. 2020 07:25:10", + created: () => timestamp("Thu, 15 Oct. 2020 07:25:10"), description: "Device description", interfaces: () => [deviceInterface()], locked: false, @@ -219,7 +220,7 @@ export const deviceDetails = extend(device, { on_network: false, pool: null, swap_size: null, - updated: "Thu, 15 Oct. 2020 07:25:10", + updated: () => timestamp("Thu, 15 Oct. 2020 07:25:10"), }); const node = extend(simpleNode, { @@ -307,7 +308,7 @@ export const machineEventType = extend(model, { }); export const machineEvent = extend(model, { - created: "Mon, 19 Oct. 2020 07:04:37", + created: () => timestamp("Mon, 19 Oct. 2020 07:04:37"), description: "smartctl-validate on name-VZJoCN timed out", type: machineEventType, }); @@ -335,9 +336,9 @@ export const machineDetails = extend(machine, { bios_boot_method: "uefi", bmc: 190, boot_disk: null, - commissioning_start_time: "Thu, 15 Oct. 2020 07:25:10", + commissioning_start_time: () => timestamp("Thu, 15 Oct. 2020 07:25:10"), commissioning_status: testStatus, - created: "Thu, 15 Oct. 2020 07:25:10", + created: () => timestamp("Thu, 15 Oct. 2020 07:25:10"), current_commissioning_script_set: 6188, current_installation_script_set: 6174, current_testing_script_set: 6192, @@ -388,13 +389,13 @@ export const machineDetails = extend(machine, { supported_filesystems: () => [], cpu_speed: 1000, swap_size: null, - updated: "Fri, 23 Oct. 2020 05:24:41", + updated: () => timestamp("Fri, 23 Oct. 2020 05:24:41"), }); export const controller = extend(node, { actions, description: "a test controller", - last_image_sync: "Thu, 02 Jul. 2020 22:55:00", + last_image_sync: () => timestamp("Thu, 02 Jul. 2020 22:55:00"), link_type: NodeLinkType.CONTROLLER, node_type_display: NodeTypeDisplay.REGION_AND_RACK_CONTROLLER, node_type: 4, @@ -412,7 +413,7 @@ export const controllerDetails = extend( boot_disk: null, commissioning_start_time: "Thu, 15 Oct. 2020 07:25:10", commissioning_status: testStatus, - created: "Thu, 15 Oct. 2020 07:25:10", + created: () => timestamp("Thu, 15 Oct. 2020 07:25:10"), current_commissioning_script_set: 6188, current_installation_script_set: 6174, current_testing_script_set: 6192, @@ -480,7 +481,7 @@ export const controllerDetails = extend( swap_size: null, testing_start_time: "Thu, 15 Oct. 2020 07:25:10", testing_status: testStatus, - updated: "Fri, 23 Oct. 2020 05:24:41", + updated: () => timestamp("Fri, 23 Oct. 2020 05:24:41"), vault_configured: false, vlan: null, vlan_ids: () => [], diff --git a/src/testing/factories/scriptResult.ts b/src/testing/factories/scriptResult.ts index 6fe12cb3fa..86f62a5e6c 100644 --- a/src/testing/factories/scriptResult.ts +++ b/src/testing/factories/scriptResult.ts @@ -1,5 +1,6 @@ import { array, define, extend } from "cooky-cutter"; +import { timestamp } from "./general"; import { model } from "./model"; import type { @@ -26,7 +27,7 @@ export const partialScriptResult = extend(model, { status: 2, status_name: "test status", suppressed: false, - updated: "Fri, 13 Nov. 2020 04:50:27", + updated: () => timestamp("Fri, 13 Nov. 2020 04:50:27"), }); export const scriptResult = extend(model, { @@ -44,13 +45,13 @@ export const scriptResult = extend(model, { runtime: "0:00:00", script: 1, script_version: 2, - started: "Fri, 13 Nov. 2020 04:50:26", + started: () => timestamp("Fri, 13 Nov. 2020 04:50:26"), starttime: 605243026.966467, status: 2, status_name: "test status", suppressed: false, tags: "test, tags", - updated: "Fri, 13 Nov. 2020 04:50:27", + updated: () => timestamp("Fri, 13 Nov. 2020 04:50:27"), }); export const scriptResultData = define({ diff --git a/src/testing/factories/subnet.ts b/src/testing/factories/subnet.ts index 26f79307a1..7487ba593e 100644 --- a/src/testing/factories/subnet.ts +++ b/src/testing/factories/subnet.ts @@ -1,5 +1,6 @@ import { array, define, extend, random } from "cooky-cutter"; +import { timestamp } from "./general"; import { model, timestampedModel } from "./model"; import { PodType } from "@/app/store/pod/constants"; @@ -68,9 +69,9 @@ export const subnetIPNodeSummary = define({ export const subnetIP = define({ alloc_type: IPAddressType.AUTO, - created: "Wed, 08 Jul. 2020 05:35:4", + created: () => timestamp("Wed, 08 Jul. 2020 05:35:04"), ip: "192.168.1.1", - updated: "Wed, 08 Jul. 2020 05:35:4", + updated: () => timestamp("Wed, 08 Jul. 2020 05:35:04"), }); export const subnetScanFailure = define({ diff --git a/src/testing/factories/user.ts b/src/testing/factories/user.ts index 61e0c997ca..a1be8f7d0c 100644 --- a/src/testing/factories/user.ts +++ b/src/testing/factories/user.ts @@ -1,5 +1,6 @@ import { define, extend } from "cooky-cutter"; +import { timestamp } from "./general"; import { model } from "./model"; import type { Model } from "@/app/store/types/model"; @@ -15,7 +16,7 @@ export const user = extend(model, { is_local: true, is_superuser: true, last_name: "Full Name jr.", - last_login: "Fri, 23 Oct. 2020 00:00:00", + last_login: () => timestamp("Fri, 23 Oct. 2020 00:00:00"), sshkeys_count: 3, username: (i: number) => `user${i}`, machines_count: 1, From 0f8f110c163a3efd95d76ba8ab124d345d329cf9 Mon Sep 17 00:00:00 2001 From: Peter Makowski Date: Thu, 4 Apr 2024 12:08:00 +0200 Subject: [PATCH 2/6] add UTC timestamp to tags list and notifications --- .../NotificationGroup/Notification/Notification.tsx | 4 +++- src/app/tags/views/TagList/TagTable/TagTable.tsx | 3 ++- .../TagUpdate/TagUpdateFormFields/TagUpdateFormFields.tsx | 6 +++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/app/base/components/NotificationGroup/Notification/Notification.tsx b/src/app/base/components/NotificationGroup/Notification/Notification.tsx index 82a66f40d7..46bcb5fcb9 100644 --- a/src/app/base/components/NotificationGroup/Notification/Notification.tsx +++ b/src/app/base/components/NotificationGroup/Notification/Notification.tsx @@ -13,6 +13,7 @@ import { isUpgradeNotification, } from "@/app/store/notification/utils"; import type { RootState } from "@/app/store/root/types"; +import { getUtcTimestamp } from "@/app/utils/time"; type Props = { className?: string | null; @@ -31,6 +32,7 @@ const NotificationGroupNotification = ({ const notification = useSelector((state: RootState) => notificationSelectors.getById(state, id) ); + const createdTimestamp = getUtcTimestamp(notification?.created); if (!notification) { return null; } @@ -59,7 +61,7 @@ const NotificationGroupNotification = ({ : undefined } severity={severity} - timestamp={showDate ? notification.created : null} + timestamp={showDate ? createdTimestamp : null} > { /> - + From 169789275d54b3850f63bab95375c596acf874c9 Mon Sep 17 00:00:00 2001 From: Peter Makowski Date: Thu, 4 Apr 2024 12:17:32 +0200 Subject: [PATCH 3/6] add UTC timestamps to discovery list --- package.json | 1 + .../Notification/Notification.tsx | 4 +- .../components/StatusBar/StatusBar.test.tsx | 6 +- .../base/components/StatusBar/StatusBar.tsx | 8 +-- .../node/NodeTestsTable/NodeTestsTable.tsx | 4 +- .../views/DiscoveriesList/DiscoveriesList.tsx | 7 ++- .../settings/views/Dhcp/DhcpList/DhcpList.tsx | 4 +- .../Scripts/ScriptsList/ScriptsList.test.tsx | 4 +- .../views/Scripts/ScriptsList/ScriptsList.tsx | 5 +- .../views/Users/UsersList/UsersList.tsx | 4 +- src/app/store/bootresource/types/base.ts | 6 +- src/app/store/controller/types/base.ts | 4 +- src/app/store/discovery/selectors.test.ts | 8 +-- src/app/store/discovery/types/base.ts | 4 +- src/app/store/machine/types/base.ts | 8 +-- src/app/store/scriptresult/types/base.ts | 6 +- src/app/store/types/model.ts | 7 ++- src/app/store/user/types/base.ts | 4 +- .../tags/views/TagList/TagTable/TagTable.tsx | 4 +- .../TagUpdateFormFields.tsx | 4 +- src/app/utils/time.test.ts | 55 ++++++++++++------- src/app/utils/time.ts | 50 ++++++++--------- src/testing/factories/discovery.ts | 3 +- src/testing/factories/general.ts | 6 +- yarn.lock | 8 +++ 25 files changed, 128 insertions(+), 96 deletions(-) diff --git a/package.json b/package.json index 3cf0ffb6ff..78288ba104 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "classnames": "2.5.1", "clone-deep": "4.0.1", "date-fns": "2.30.0", + "date-fns-tz": "2.0.1", "fast-deep-equal": "3.1.3", "formik": "2.4.5", "history": "5.3.0", diff --git a/src/app/base/components/NotificationGroup/Notification/Notification.tsx b/src/app/base/components/NotificationGroup/Notification/Notification.tsx index 46bcb5fcb9..5105aeee26 100644 --- a/src/app/base/components/NotificationGroup/Notification/Notification.tsx +++ b/src/app/base/components/NotificationGroup/Notification/Notification.tsx @@ -13,7 +13,7 @@ import { isUpgradeNotification, } from "@/app/store/notification/utils"; import type { RootState } from "@/app/store/root/types"; -import { getUtcTimestamp } from "@/app/utils/time"; +import { formatUtcDatetime } from "@/app/utils/time"; type Props = { className?: string | null; @@ -32,7 +32,7 @@ const NotificationGroupNotification = ({ const notification = useSelector((state: RootState) => notificationSelectors.getById(state, id) ); - const createdTimestamp = getUtcTimestamp(notification?.created); + const createdTimestamp = formatUtcDatetime(notification?.created); if (!notification) { return null; } diff --git a/src/app/base/components/StatusBar/StatusBar.test.tsx b/src/app/base/components/StatusBar/StatusBar.test.tsx index 4a7b8a7b58..0a12194c1e 100644 --- a/src/app/base/components/StatusBar/StatusBar.test.tsx +++ b/src/app/base/components/StatusBar/StatusBar.test.tsx @@ -164,8 +164,8 @@ it("displays correct text for machines with hardware sync enabled and no last_sy status: NodeStatus.DEPLOYED, system_id: "abc123", enable_hw_sync: true, - last_sync: factory.timestamp("Thu, 31 Dec. 2020 22:00:00"), - next_sync: factory.timestamp("Thu, 31 Dec. 2020 23:01:00"), + last_sync: factory.timestamp(""), + next_sync: factory.timestamp(""), }), ]; @@ -198,7 +198,7 @@ it("displays last image sync timestamp for a rack or region+rack controller", () renderWithMockStore(, { state }); expect(screen.getByTestId("status-bar-status")).toHaveTextContent( - `Last image sync: ${controller.last_image_sync}` + `Last image sync: Thu, 02 Jun. 2022 00:48:41 (UTC)` ); }); diff --git a/src/app/base/components/StatusBar/StatusBar.tsx b/src/app/base/components/StatusBar/StatusBar.tsx index 3155c4af88..33e5184b9b 100644 --- a/src/app/base/components/StatusBar/StatusBar.tsx +++ b/src/app/base/components/StatusBar/StatusBar.tsx @@ -18,9 +18,9 @@ import { isDeployedWithHardwareSync, isMachineDetails, } from "@/app/store/machine/utils"; -import type { UtcTimestamp } from "@/app/store/types/model"; +import type { UtcDatetime } from "@/app/store/types/model"; import { NodeStatus } from "@/app/store/types/node"; -import { getUtcTimestamp, getTimeDistanceString } from "@/app/utils/time"; +import { formatUtcDatetime, getTimeDistanceString } from "@/app/utils/time"; const getLastCommissionedString = (machine: MachineDetails) => { if (machine.status === NodeStatus.COMMISSIONING) { @@ -38,7 +38,7 @@ const getLastCommissionedString = (machine: MachineDetails) => { } }; -const getSyncStatusString = (syncStatus: UtcTimestamp) => { +const getSyncStatusString = (syncStatus: UtcDatetime) => { if (syncStatus === "") { return "Never"; } @@ -88,7 +88,7 @@ export const StatusBar = (): JSX.Element | null => { isControllerDetails(activeController) && (isRack(activeController) || isRegionAndRack(activeController)) ) { - status = `Last image sync: ${getUtcTimestamp( + status = `Last image sync: ${formatUtcDatetime( activeController.last_image_sync )}`; } diff --git a/src/app/base/components/node/NodeTestsTable/NodeTestsTable.tsx b/src/app/base/components/node/NodeTestsTable/NodeTestsTable.tsx index 77b283f103..446afffb47 100644 --- a/src/app/base/components/node/NodeTestsTable/NodeTestsTable.tsx +++ b/src/app/base/components/node/NodeTestsTable/NodeTestsTable.tsx @@ -19,7 +19,7 @@ import type { ScriptResult } from "@/app/store/scriptresult/types"; import { ScriptResultType } from "@/app/store/scriptresult/types"; import { canBeSuppressed } from "@/app/store/scriptresult/utils"; import { nodeIsMachine } from "@/app/store/utils"; -import { getUtcTimestamp } from "@/app/utils/time"; +import { formatUtcDatetime } from "@/app/utils/time"; export enum ScriptResultAction { VIEW_METRICS = "viewMetrics", @@ -132,7 +132,7 @@ const NodeTestsTable = ({ node, scriptResults }: Props): JSX.Element => { }, { className: "date-col", - content: getUtcTimestamp(result.updated), + content: formatUtcDatetime(result.updated), }, { className: "runtime-col", diff --git a/src/app/networkDiscovery/views/DiscoveriesList/DiscoveriesList.tsx b/src/app/networkDiscovery/views/DiscoveriesList/DiscoveriesList.tsx index 3da80adfd5..8f667773ef 100644 --- a/src/app/networkDiscovery/views/DiscoveriesList/DiscoveriesList.tsx +++ b/src/app/networkDiscovery/views/DiscoveriesList/DiscoveriesList.tsx @@ -25,6 +25,7 @@ import type { Discovery } from "@/app/store/discovery/types"; import { DiscoveryMeta } from "@/app/store/discovery/types"; import type { RootState } from "@/app/store/root/types"; import { generateEmptyStateMsg, getTableStatus } from "@/app/utils"; +import { formatUtcDatetime } from "@/app/utils/time"; export enum Labels { DiscoveriesList = "Discoveries list", @@ -78,7 +79,11 @@ const generateRows = ( content: discovery.observer_hostname, }, { - content:
{discovery.last_seen}
, + content: ( +
+ {formatUtcDatetime(discovery.last_seen)} +
+ ), }, { content: ( diff --git a/src/app/settings/views/Dhcp/DhcpList/DhcpList.tsx b/src/app/settings/views/Dhcp/DhcpList/DhcpList.tsx index 69c379a7e7..5de926aa5e 100644 --- a/src/app/settings/views/Dhcp/DhcpList/DhcpList.tsx +++ b/src/app/settings/views/Dhcp/DhcpList/DhcpList.tsx @@ -33,7 +33,7 @@ import type { RootState } from "@/app/store/root/types"; import { subnetActions } from "@/app/store/subnet"; import subnetSelectors from "@/app/store/subnet/selectors"; import type { Subnet } from "@/app/store/subnet/types"; -import { formatUtcTimestamp } from "@/app/utils/time"; +import { formatUtcDatetime } from "@/app/utils/time"; const getTargetName = ( controllers: Controller[], @@ -80,7 +80,7 @@ const generateRows = ( const expanded = expandedId === dhcpsnippet.id; // Dates are in the format: Thu, 15 Aug. 2019 06:21:39. const updated = dhcpsnippet.updated - ? formatUtcTimestamp(dhcpsnippet.updated) + ? formatUtcDatetime(dhcpsnippet.updated) : "Never"; const enabled = dhcpsnippet.enabled ? "Yes" : "No"; const showDelete = expandedType === "delete"; diff --git a/src/app/settings/views/Scripts/ScriptsList/ScriptsList.test.tsx b/src/app/settings/views/Scripts/ScriptsList/ScriptsList.test.tsx index 8bb51eb836..9de2e14d0b 100644 --- a/src/app/settings/views/Scripts/ScriptsList/ScriptsList.test.tsx +++ b/src/app/settings/views/Scripts/ScriptsList/ScriptsList.test.tsx @@ -315,7 +315,7 @@ describe("ScriptsList", () => { ); expect( within(screen.getByRole("row", { name: "test name 33" })).getByText( - "2020-12-31 22:59" + "Thu, 31 Dec. 2020 22:59:00 (UTC)" ) ).toBeInTheDocument(); }); @@ -326,7 +326,7 @@ describe("ScriptsList", () => { loaded: true, items: [ factory.script({ - created: factory.timestamp(""), + created: () => factory.timestamp(""), script_type: ScriptType.TESTING, }), ], diff --git a/src/app/settings/views/Scripts/ScriptsList/ScriptsList.tsx b/src/app/settings/views/Scripts/ScriptsList/ScriptsList.tsx index 3ac9498df3..05c3fee0ba 100644 --- a/src/app/settings/views/Scripts/ScriptsList/ScriptsList.tsx +++ b/src/app/settings/views/Scripts/ScriptsList/ScriptsList.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from "react"; import { ContentSection } from "@canonical/maas-react-components"; -import { format } from "date-fns"; import { useDispatch, useSelector } from "react-redux"; import type { Dispatch } from "redux"; @@ -17,7 +16,7 @@ import { scriptActions } from "@/app/store/script"; import scriptSelectors from "@/app/store/script/selectors"; import type { Script } from "@/app/store/script/types"; import { generateEmptyStateMsg, getTableStatus } from "@/app/utils"; -import { parseUtcDatetime } from "@/app/utils/time"; +import { formatUtcDatetime } from "@/app/utils/time"; export enum Labels { Actions = "Table actions", @@ -49,7 +48,7 @@ const generateRows = ( // history timestamps are in the format: Mon, 02 Sep 2019 02:02:39 -0000 let uploadedOn: string; try { - uploadedOn = format(parseUtcDatetime(script.created), "yyyy-LL-dd H:mm"); + uploadedOn = formatUtcDatetime(script.created); } catch (error) { uploadedOn = "Never"; } diff --git a/src/app/settings/views/Users/UsersList/UsersList.tsx b/src/app/settings/views/Users/UsersList/UsersList.tsx index 66d235a145..3247dfdfbd 100644 --- a/src/app/settings/views/Users/UsersList/UsersList.tsx +++ b/src/app/settings/views/Users/UsersList/UsersList.tsx @@ -22,7 +22,7 @@ import { userActions } from "@/app/store/user"; import userSelectors from "@/app/store/user/selectors"; import type { User } from "@/app/store/user/types"; import { isComparable } from "@/app/utils"; -import { formatUtcTimestamp } from "@/app/utils/time"; +import { formatUtcDatetime } from "@/app/utils/time"; type SortKey = keyof User; @@ -35,7 +35,7 @@ const generateUserRows = ( const isAuthUser = user.id === authUser?.id; // Dates are in the format: Thu, 15 Aug. 2019 06:21:39. const last_login = user.last_login - ? formatUtcTimestamp(user.last_login) + ? formatUtcDatetime(user.last_login) : "Never"; const fullName = user.last_name; return { diff --git a/src/app/store/bootresource/types/base.ts b/src/app/store/bootresource/types/base.ts index 08402eeb72..6a73e9b0e7 100644 --- a/src/app/store/bootresource/types/base.ts +++ b/src/app/store/bootresource/types/base.ts @@ -1,6 +1,6 @@ import type { BootResourceSourceType, BootResourceType } from "./enum"; -import type { Model, UtcTimestamp } from "@/app/store/types/model"; +import type { Model, UtcDatetime } from "@/app/store/types/model"; export type BaseImageFields = { checked: boolean; @@ -15,8 +15,8 @@ export type BootResource = Model & { complete: boolean; downloading: boolean; icon: "in-progress" | "queued" | "succeeded" | "waiting"; - lastUpdate: UtcTimestamp; - lastDeployed: UtcTimestamp; + lastUpdate: UtcDatetime; + lastDeployed: UtcDatetime; machineCount: number; name: string; numberOfNodes: number; diff --git a/src/app/store/controller/types/base.ts b/src/app/store/controller/types/base.ts index e976808868..0a65a5a690 100644 --- a/src/app/store/controller/types/base.ts +++ b/src/app/store/controller/types/base.ts @@ -11,7 +11,7 @@ import type { PowerState, StorageLayout } from "@/app/store/types/enum"; import type { ModelRef, TimestampFields, - UtcTimestamp, + UtcDatetime, } from "@/app/store/types/model"; import type { NodeActions, @@ -69,7 +69,7 @@ export type ControllerActions = export type BaseController = BaseNode & { actions: ControllerActions[]; - last_image_sync: UtcTimestamp; + last_image_sync: UtcDatetime; link_type: NodeLinkType.CONTROLLER; node_type_display: | NodeTypeDisplay.RACK_CONTROLLER diff --git a/src/app/store/discovery/selectors.test.ts b/src/app/store/discovery/selectors.test.ts index b4ba5d1600..ceabed0bbc 100644 --- a/src/app/store/discovery/selectors.test.ts +++ b/src/app/store/discovery/selectors.test.ts @@ -62,7 +62,7 @@ describe("discovery selectors", () => { mac_organization: "Acme Inc.", ip: "0.0.0.0", observer_hostname: "alpha", - last_seen: "Mon, 19 Oct. 2020 01:15:57", + last_seen: factory.timestamp("Mon, 19 Oct. 2020 01:15:57"), }), factory.discovery({ hostname: "bar", @@ -70,7 +70,7 @@ describe("discovery selectors", () => { mac_organization: "Foodies Inc.", ip: "1.1.1.1", observer_hostname: "bravo", - last_seen: "Sat, 17 Oct. 2020 01:15:57", + last_seen: factory.timestamp("Sat, 17 Oct. 2020 01:15:57"), }), factory.discovery({ hostname: "foobar", @@ -78,7 +78,7 @@ describe("discovery selectors", () => { mac_organization: "Roxxon", ip: "2.2.2.2", observer_hostname: "foot", - last_seen: "Mon, 19 Oct. 2020 01:15:57", + last_seen: factory.timestamp("Mon, 19 Oct. 2020 01:15:57"), }), factory.discovery({ hostname: "fizz", @@ -86,7 +86,7 @@ describe("discovery selectors", () => { mac_organization: "Pacific Couriers", ip: "3.3.3.3", observer_hostname: "alpha", - last_seen: "Mon, 19 Oct. 2020 01:15:57", + last_seen: factory.timestamp("Mon, 19 Oct. 2020 01:15:57"), }), ], }), diff --git a/src/app/store/discovery/types/base.ts b/src/app/store/discovery/types/base.ts index b4ed8ba645..a600fb43f2 100644 --- a/src/app/store/discovery/types/base.ts +++ b/src/app/store/discovery/types/base.ts @@ -1,5 +1,5 @@ import type { APIError } from "@/app/base/types"; -import type { Model } from "@/app/store/types/model"; +import type { Model, UtcDatetime } from "@/app/store/types/model"; import type { GenericState } from "@/app/store/types/state"; export type Discovery = Model & { @@ -10,7 +10,7 @@ export type Discovery = Model & { hostname: string | null; ip: string | null; is_external_dhcp: boolean | null; - last_seen: string; + last_seen: UtcDatetime; mac_address: string | null; mac_organization: string; mdns: number | null; diff --git a/src/app/store/machine/types/base.ts b/src/app/store/machine/types/base.ts index e265e42406..b53e6b4401 100644 --- a/src/app/store/machine/types/base.ts +++ b/src/app/store/machine/types/base.ts @@ -8,7 +8,7 @@ import type { PowerState, StorageLayout } from "@/app/store/types/enum"; import type { ModelRef, TimestampFields, - UtcTimestamp, + UtcDatetime, } from "@/app/store/types/model"; import type { BaseNode, @@ -73,7 +73,7 @@ export type MachineDetails = BaseMachine & bmc: number; boot_disk: Disk | null; certificate?: CertificateMetadata; - commissioning_start_time: UtcTimestamp; + commissioning_start_time: UtcDatetime; commissioning_status: TestStatus; cpu_speed: BaseNode["cpu_speed"]; cpu_test_status: TestStatus; @@ -126,8 +126,8 @@ type HardwareSyncFields = } | { enable_hw_sync: true; - last_sync: UtcTimestamp; - next_sync: UtcTimestamp; + last_sync: UtcDatetime; + next_sync: UtcDatetime; is_sync_healthy: boolean; sync_interval: Seconds; }; diff --git a/src/app/store/scriptresult/types/base.ts b/src/app/store/scriptresult/types/base.ts index b81a85567b..54afbdcb48 100644 --- a/src/app/store/scriptresult/types/base.ts +++ b/src/app/store/scriptresult/types/base.ts @@ -9,7 +9,7 @@ import type { import type { HardwareType } from "@/app/base/enum"; import type { APIError } from "@/app/base/types"; -import type { Model, UtcTimestamp } from "@/app/store/types/model"; +import type { Model, UtcDatetime } from "@/app/store/types/model"; import type { NetworkInterface } from "@/app/store/types/node"; import type { GenericState } from "@/app/store/types/state"; @@ -29,7 +29,7 @@ export type PartialScriptResult = Model & { status: ScriptResultStatus; status_name: string; suppressed: boolean; - updated?: UtcTimestamp; + updated?: UtcDatetime; }; export type ScriptResult = PartialScriptResult & { @@ -76,7 +76,7 @@ export type ScriptResult = PartialScriptResult & { results: ScriptResultResult[]; script?: number; script_version?: number | null; - started?: UtcTimestamp; + started?: UtcDatetime; tags: string; }; diff --git a/src/app/store/types/model.ts b/src/app/store/types/model.ts index 8d48cdd8fe..e3630552a3 100644 --- a/src/app/store/types/model.ts +++ b/src/app/store/types/model.ts @@ -3,10 +3,11 @@ export type Model = { }; // Expected format: "Thu, 15 Aug. 2019 06:21:39" or "" -export type UtcTimestamp = string & { readonly __brand: unique symbol }; +export type UtcDatetime = string & { readonly __brand: unique symbol }; +export type UtcDatetimeDisplay = `${string} (UTC)` | "Never"; export type TimestampFields = { - created: UtcTimestamp; - updated: UtcTimestamp; + created: UtcDatetime; + updated: UtcDatetime; }; export type TimestampedModel = Model & TimestampFields; diff --git a/src/app/store/user/types/base.ts b/src/app/store/user/types/base.ts index 8891dbfef2..5b9f9f9e31 100644 --- a/src/app/store/user/types/base.ts +++ b/src/app/store/user/types/base.ts @@ -1,5 +1,5 @@ import type { APIError } from "@/app/base/types"; -import type { Model, UtcTimestamp } from "@/app/store/types/model"; +import type { Model, UtcDatetime } from "@/app/store/types/model"; import type { GenericState } from "@/app/store/types/state"; export type User = Model & { @@ -9,7 +9,7 @@ export type User = Model & { is_local: boolean; is_superuser: boolean; last_name: string; - last_login: UtcTimestamp; + last_login: UtcDatetime; machines_count: number; sshkeys_count: number; username: string; diff --git a/src/app/tags/views/TagList/TagTable/TagTable.tsx b/src/app/tags/views/TagList/TagTable/TagTable.tsx index 4ff736f2fa..539eab3775 100644 --- a/src/app/tags/views/TagList/TagTable/TagTable.tsx +++ b/src/app/tags/views/TagList/TagTable/TagTable.tsx @@ -24,7 +24,7 @@ import type { Tag } from "@/app/store/tag/types"; import { TagMeta } from "@/app/store/tag/types"; import AppliedTo from "@/app/tags/components/AppliedTo"; import { isComparable } from "@/app/utils"; -import { getUtcTimestamp } from "@/app/utils/time"; +import { formatUtcDatetime } from "@/app/utils/time"; type Props = PropsWithSpread< { @@ -76,7 +76,7 @@ const generateRows = ( }, { "aria-label": Label.Updated, - content: getUtcTimestamp(tag.updated), + content: formatUtcDatetime(tag.updated), }, { "aria-label": Label.Auto, diff --git a/src/app/tags/views/TagUpdate/TagUpdateFormFields/TagUpdateFormFields.tsx b/src/app/tags/views/TagUpdate/TagUpdateFormFields/TagUpdateFormFields.tsx index 17e06ab835..2144082bbb 100644 --- a/src/app/tags/views/TagUpdate/TagUpdateFormFields/TagUpdateFormFields.tsx +++ b/src/app/tags/views/TagUpdate/TagUpdateFormFields/TagUpdateFormFields.tsx @@ -12,7 +12,7 @@ import AppliedTo from "@/app/tags/components/AppliedTo"; import DefinitionField from "@/app/tags/components/DefinitionField"; import KernelOptionsField from "@/app/tags/components/KernelOptionsField"; import { Label } from "@/app/tags/views/TagDetails"; -import { getUtcTimestamp } from "@/app/utils/time"; +import { formatUtcDatetime } from "@/app/utils/time"; type Props = { id: Tag[TagMeta.PK]; @@ -41,7 +41,7 @@ export const TagUpdateFormFields = ({ id }: Props): JSX.Element | null => { diff --git a/src/app/utils/time.test.ts b/src/app/utils/time.test.ts index 62944bbd32..510c5d4bae 100644 --- a/src/app/utils/time.test.ts +++ b/src/app/utils/time.test.ts @@ -2,59 +2,74 @@ import MockDate from "mockdate"; import timezoneMock from "timezone-mock"; import { - formatUtcTimestamp, + formatUtcDatetime, getTimeDistanceString, - getUtcTimestamp, + parseUtcDatetime, } from "./time"; -import type { UtcTimestamp } from "@/app/store/types/model"; -import * as factory from "@/testing/factories"; +import type { UtcDatetime } from "@/app/store/types/model"; beforeEach(() => { MockDate.set("Fri, 18 Nov. 2022 01:01:00"); + timezoneMock.register("Etc/GMT+5"); }); afterEach(() => { MockDate.reset(); + timezoneMock.unregister(); }); describe("getTimeDistanceString", () => { it("returns time distance for UTC TimeString in the past", () => { expect( - getTimeDistanceString("Fri, 18 Nov. 2022 01:00:50" as UtcTimestamp) + getTimeDistanceString("Fri, 18 Nov. 2022 01:00:50" as UtcDatetime) ).toEqual("less than a minute ago"); }); it("returns time distance for UTC TimeString in the future", () => { expect( - getTimeDistanceString("Fri, 18 Nov. 2022 01:01:10" as UtcTimestamp) + getTimeDistanceString("Fri, 18 Nov. 2022 01:01:10" as UtcDatetime) ).toEqual("in less than a minute"); }); }); -describe("formatUtcTimestamp", () => { +describe("formatUtcDatetime", () => { it("returns UTC date time in a correct format", () => { + timezoneMock.register("Etc/GMT+0"); expect( - formatUtcTimestamp("Fri, 18 Nov. 2022 01:00:50" as UtcTimestamp) - ).toEqual("Fri, 18 Nov. 2022 01:00:50"); + formatUtcDatetime("Fri, 18 Nov. 2022 01:00:50" as UtcDatetime) + ).toEqual("Fri, 18 Nov. 2022 01:00:50 (UTC)"); }); - it("returns UTC date time in local time", () => { + it("returns UTC date time in UTC regardless of timezone", () => { timezoneMock.register("Etc/GMT-1"); expect( - formatUtcTimestamp("Fri, 18 Nov. 2022 03:00:00" as UtcTimestamp) - ).toEqual("Fri, 18 Nov. 2022 04:00:00"); + formatUtcDatetime("Fri, 18 Nov. 2022 03:00:00" as UtcDatetime) + ).toEqual("Fri, 18 Nov. 2022 03:00:00 (UTC)"); + }); + it("returns Never if no time is provided", () => { + const inputTimeString = "" as UtcDatetime; + const expectedOutput = "Never"; + expect(formatUtcDatetime(inputTimeString)).toEqual(expectedOutput); }); -}); -describe("getUtcTimestamp", () => { it("appends (UTC) to the given time string", () => { - const inputTimeString = "Fri, 18 Nov. 2022 01:00:50" as UtcTimestamp; + const inputTimeString = "Fri, 18 Nov. 2022 01:00:50" as UtcDatetime; const expectedOutput = "Fri, 18 Nov. 2022 01:00:50 (UTC)"; - expect(getUtcTimestamp(inputTimeString)).toEqual(expectedOutput); + expect(formatUtcDatetime(inputTimeString)).toEqual(expectedOutput); + }); +}); + +describe("parseUtcDatetime", () => { + it("parses UTC time string into Date object correctly", () => { + const utcTimeString = "Fri, 18 Nov. 2022 01:00:50" as UtcDatetime; + const expectedDate = new Date(Date.UTC(2022, 10, 18, 1, 0, 50)); // Fri, 18 Nov. 2022 01:00:50 + const result = parseUtcDatetime(utcTimeString); + expect(result).toEqual(expectedDate); }); - it("works with different date formats", () => { - const inputTimeString = factory.timestamp("2022-11-18T01:00:50Z"); - const expectedOutput = "2022-11-18T01:00:50Z (UTC)"; - expect(getUtcTimestamp(inputTimeString)).toEqual(expectedOutput); + it("handles leap years correctly", () => { + const utcTimeString = "Mon, 29 Feb. 2016 12:00:00" as UtcDatetime; + const expectedDate = new Date(Date.UTC(2016, 1, 29, 12, 0, 0)); // Mon, 29 Feb. 2016 12:00:00 + const result = parseUtcDatetime(utcTimeString); + expect(result).toEqual(expectedDate); }); }); diff --git a/src/app/utils/time.ts b/src/app/utils/time.ts index e342ecf822..4706341a9d 100644 --- a/src/app/utils/time.ts +++ b/src/app/utils/time.ts @@ -1,40 +1,40 @@ -import { format, formatDistance, parse } from "date-fns"; +import { formatDistance, parse } from "date-fns"; +import { formatInTimeZone } from "date-fns-tz"; -import type { UtcTimestamp } from "@/app/store/types/model"; +import type { UtcDatetime, UtcDatetimeDisplay } from "@/app/store/types/model"; -const DATETIME_FORMAT = "E, dd LLL. yyyy HH:mm:ss"; -const UTC_DATETIME_FORMAT = `${DATETIME_FORMAT} x`; +const DATETIME_DISPLAY_FORMAT = "E, dd LLL. yyyy HH:mm:ss"; -export const parseUtcDatetime = (utcTimeString: UtcTimestamp): Date => +export const parseUtcDatetime = (utcTimeString: UtcDatetime): Date => parse( `${utcTimeString} +00`, // let parse fn know it's UTC - UTC_DATETIME_FORMAT, + `${DATETIME_DISPLAY_FORMAT} x`, new Date() ); -export const getTimeDistanceString = (utcTimeString: UtcTimestamp): string => +export const getTimeDistanceString = (utcTimeString: UtcDatetime) => formatDistance(parseUtcDatetime(utcTimeString), new Date(), { addSuffix: true, }); /** - * Appends "(UTC)" to a given string to indicate the time zone explicitly. - * @param utcTimeString - time string in UTC - * @returns Time string with appended "(UTC)" + * @param utcTimeString - time string in UTC_DATETIME_FORMAT + * @returns time string adjusted for local time zone in DATETIME_FORMAT */ -export const getUtcTimestamp = (utcTimeString?: UtcTimestamp): string => - utcTimeString ? utcTimeString + " (UTC)" : ""; +export const formatUtcDatetime = ( + utcTimeString?: UtcDatetime +): UtcDatetimeDisplay => { + if (!utcTimeString) return "Never"; -/** - * Formats a given UTC time string into a more readable format and appends "(UTC)" to indicate the time zone explicitly. - * It converts the time string from "E, dd LLL. yyyy HH:mm:ss" format to "yyyy-LL-dd H:mm" format. - * @param utcTimeString - time string in UTC to be formatted - * @returns Formatted time string with appended "(UTC)" - */ -export const formatUtcTimestamp = (utcTimeString?: UtcTimestamp): string => - utcTimeString - ? format( - parse(utcTimeString, "E, dd LLL. yyyy HH:mm:ss", new Date()), - "yyyy-LL-dd H:mm" - ) + " (UTC)" - : ""; + try { + const utcTime = `${formatInTimeZone( + parseUtcDatetime(utcTimeString), + "UTC", + DATETIME_DISPLAY_FORMAT, + { addSuffix: true } + )} (UTC)` as const; + return utcTime; + } catch (error) { + return "Never"; + } +}; diff --git a/src/testing/factories/discovery.ts b/src/testing/factories/discovery.ts index 82dba7bd46..73a94f7750 100644 --- a/src/testing/factories/discovery.ts +++ b/src/testing/factories/discovery.ts @@ -1,5 +1,6 @@ import { extend, random } from "cooky-cutter"; +import { timestamp } from "./general"; import { model } from "./model"; import type { Discovery } from "@/app/store/discovery/types"; @@ -13,7 +14,7 @@ export const discovery = extend(model, { hostname: "discovery-hostname", ip: "192.168.1.1", is_external_dhcp: false, - last_seen: "Wed, 08 Jul. 2020 05:35:4", + last_seen: () => timestamp("Wed, 08 Jul. 2020 05:35:4"), mac_address: "00:00:00:00:00:00", mac_organization: "Business Corp, Inc.", mdns: 2, diff --git a/src/testing/factories/general.ts b/src/testing/factories/general.ts index 5c1acaf7c9..0f6af4551a 100644 --- a/src/testing/factories/general.ts +++ b/src/testing/factories/general.ts @@ -29,7 +29,7 @@ import { PowerFieldScope, PowerFieldType, } from "@/app/store/general/types"; -import type { UtcTimestamp } from "@/app/store/types/model"; +import type { UtcDatetime } from "@/app/store/types/model"; import { NodeActions } from "@/app/store/types/node"; export const architecture = define("amd64"); @@ -129,4 +129,6 @@ export const tlsCertificate = define({ export const version = define("test version"); export const timestamp = (timestamp: string) => - (timestamp as UtcTimestamp) || ("Wed, 08 Jul. 2020 05:35:4" as UtcTimestamp); + timestamp !== undefined + ? (timestamp as UtcDatetime) + : ("Wed, 08 Jul. 2020 05:35:4" as UtcDatetime); diff --git a/yarn.lock b/yarn.lock index 62b91a7db1..2027f6fc43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6848,6 +6848,11 @@ data-urls@^5.0.0: whatwg-mimetype "^4.0.0" whatwg-url "^14.0.0" +date-fns-tz@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/date-fns-tz/-/date-fns-tz-2.0.1.tgz#0a9b2099031c0d74120b45de9fd23192e48ea495" + integrity sha512-fJCG3Pwx8HUoLhkepdsP7Z5RsucUi+ZBOxyM5d0ZZ6c4SdYustq0VMmOu6Wf7bli+yS/Jwp91TOCqn9jMcVrUA== + date-fns@2.30.0, date-fns@^2.30.0: version "2.30.0" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0" @@ -12461,6 +12466,7 @@ string-natural-compare@^3.0.1: integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== "string-width-cjs@npm:string-width@^4.2.0", string-width@^4.2.3: + name string-width-cjs version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -12544,6 +12550,7 @@ string_decoder@~1.1.1: safe-buffer "~5.1.0" "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.1: + name strip-ansi-cjs version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -13735,6 +13742,7 @@ wordwrap@^1.0.0: integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + name wrap-ansi-cjs version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From 65c8f5a6f96514b4f8d93d331a9bed9bbce9baa1 Mon Sep 17 00:00:00 2001 From: Peter Makowski Date: Fri, 5 Apr 2024 14:32:13 +0200 Subject: [PATCH 4/6] test: fix add subnet test --- cypress/e2e/with-users/subnets/add.spec.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cypress/e2e/with-users/subnets/add.spec.ts b/cypress/e2e/with-users/subnets/add.spec.ts index 6eab330e33..37ad3a8eed 100644 --- a/cypress/e2e/with-users/subnets/add.spec.ts +++ b/cypress/e2e/with-users/subnets/add.spec.ts @@ -90,27 +90,27 @@ context("Subnets - Add", () => { }); it("can add and delete a new subnet", () => { - const fabricName = `cy-fabric-${generateId()}`; + const fabric = `cy-fabric-${generateId()}`; const spaceName = `cy-space-${generateId()}`; const vid = generateVid(); - const vlanName = `cy-vlan-${vid}`; + const vlan = `cy-vlan-${vid}`; const cidr = "192.168.122.18"; const subnetName = `cy-subnet-${generateId()}`; - completeForm("Fabric", fabricName); + completeForm("Fabric", fabric); completeForm("Space", spaceName); - completeAddVlanForm(vid, vlanName, fabricName, spaceName); - cy.addSubnet(subnetName, cidr, fabricName, vid, vlanName); + completeAddVlanForm(vid, vlan, fabric, spaceName); + cy.addSubnet({ subnetName, cidr, fabric, vid, vlan }); - cy.findAllByRole("link", { name: fabricName }).should("have.length", 2); + cy.findAllByRole("link", { name: fabric }).should("have.length", 2); // Check it groups items added to the same fabric correctly - cy.findAllByRole("row", { name: fabricName }) + cy.findAllByRole("row", { name: fabric }) .eq(1) .within(() => { cy.findAllByRole("gridcell") .eq(1) - .should("have.text", `${vid} (${vlanName})`); + .should("have.text", `${vid} (${vlan})`); cy.findAllByRole("gridcell").eq(3).should("contain.text", subnetName); cy.findAllByRole("gridcell").eq(5).should("have.text", spaceName); }); From 21d4156327394e304bd0ca88c0b09203045a7803 Mon Sep 17 00:00:00 2001 From: Peter Makowski Date: Fri, 5 Apr 2024 14:36:46 +0200 Subject: [PATCH 5/6] test: run tsc in cypress directory on lint --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 78288ba104..3a1e985b96 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "cypress-open": "yarn cypress open", "cypress-run-a11y": "yarn cypress run --config specPattern=cypress/e2e/accessibility/**/*.ts", "cypress-run": "yarn cypress run", - "lint": "npmPkgJsonLint . && eslint src cypress && tsc --project tsconfig.json", + "lint": "npmPkgJsonLint . && eslint src cypress && tsc --project tsconfig.json --noEmit && tsc --project cypress/tsconfig.json --noEmit", "link-components": "yarn link \"@canonical/react-components\" && yarn link \"react\" && yarn install", "percy": "./cypress/percy.sh", "release": "yarn clean && yarn install && CI=true yarn test && yarn build && yarn version --new-version", From af853f95527a986aed4ffd89de6bfc6d2fd28fe7 Mon Sep 17 00:00:00 2001 From: Peter Makowski Date: Fri, 5 Apr 2024 15:16:49 +0200 Subject: [PATCH 6/6] update last deployed images table --- .../images/components/ImagesTable/ImagesTable.test.tsx | 3 ++- src/app/images/components/ImagesTable/ImagesTable.tsx | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/images/components/ImagesTable/ImagesTable.test.tsx b/src/app/images/components/ImagesTable/ImagesTable.test.tsx index 7f4bbfe8cc..ac93597b12 100644 --- a/src/app/images/components/ImagesTable/ImagesTable.test.tsx +++ b/src/app/images/components/ImagesTable/ImagesTable.test.tsx @@ -305,6 +305,7 @@ it("disables delete action for images being downloaded", async () => { it("displays a correct last deployed time and machine count", () => { const lastDeployed = factory.timestamp("Fri, 18 Nov. 2022 09:55:21"); + const lastDeployedDisplay = `Fri, 18 Nov. 2022 09:55:21 (UTC)`; const resources = [ factory.bootResource({ arch: "amd64", @@ -344,7 +345,7 @@ it("displays a correct last deployed time and machine count", () => { screen.getByRole("columnheader", { name: /Machines/i }) ).toBeInTheDocument(); const row = screen.getByRole("row", { name: "18.04 LTS" }); - expect(within(row).getByText(lastDeployed)).toBeInTheDocument(); + expect(within(row).getByText(lastDeployedDisplay)).toBeInTheDocument(); expect( within(row).getByRole("gridcell", { name: /about 1 hour ago/ }) ).toBeInTheDocument(); diff --git a/src/app/images/components/ImagesTable/ImagesTable.tsx b/src/app/images/components/ImagesTable/ImagesTable.tsx index f07c9122ec..feb0300bbf 100644 --- a/src/app/images/components/ImagesTable/ImagesTable.tsx +++ b/src/app/images/components/ImagesTable/ImagesTable.tsx @@ -11,7 +11,11 @@ import type { BootResource } from "@/app/store/bootresource/types"; import { splitResourceName } from "@/app/store/bootresource/utils"; import configSelectors from "@/app/store/config/selectors"; import { sizeStringToNumber } from "@/app/utils/formatBytes"; -import { getTimeDistanceString, parseUtcDatetime } from "@/app/utils/time"; +import { + formatUtcDatetime, + getTimeDistanceString, + parseUtcDatetime, +} from "@/app/utils/time"; type Props = { handleClear?: (image: ImageValue) => void; @@ -190,7 +194,7 @@ const generateResourceRow = ({ content: resource.lastDeployed ? ( ) : ( "—"