Peter Makowski has proposed merging 
~petermakowski/maas-site-manager:fix-validation-onSubmit-MAASENG-1571 into 
maas-site-manager:main.

Commit message:
fix validation onSubmit MAASENG-1571

Requested reviews:
  MAAS Lander (maas-lander): unittests
  MAAS Committers (maas-committers)

For more details, see:
https://code.launchpad.net/~petermakowski/maas-site-manager/+git/site-manager/+merge/441949

## QA Steps

Go to /sites
Select a site
Click "Remove"
Click on the text input field
Click cancel
The side panel should close
Click "Remove" again
Press "Esc" button
The side panel should close

Go to /settings/tokens
Click "Generate tokens"
Click cancel
The side panel should close
-- 
Your team MAAS Committers is requested to review the proposed merge of 
~petermakowski/maas-site-manager:fix-validation-onSubmit-MAASENG-1571 into 
maas-site-manager:main.
diff --git a/frontend/src/_utils.scss b/frontend/src/_utils.scss
index b666356..514f0bf 100644
--- a/frontend/src/_utils.scss
+++ b/frontend/src/_utils.scss
@@ -38,9 +38,6 @@
 .u-no-border {
   border: 0 !important;
 }
-.u-no-line-height {
-  line-height: 0 !important;
-}
 .u-padding-top--medium {
   padding-top: $spv--medium !important;
 }
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index c124177..486a255 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -16,14 +16,12 @@ export type Site = {
   id: string;
   name: string;
   url: string; // <full URL including protocol>,
-  connection: Stats["connection"];
-  last_seen: string; // <ISO 8601 date>,
   country: string; // <alpha2 country code>,
   city: string;
   zip: string;
   street: string;
   timezone: string; // IANA time zone name,
-  stats: Stats;
+  stats: Stats | null;
 };
 
 export type PaginatedQueryResult<D extends unknown> = {
diff --git a/frontend/src/components/RemoveRegions/RemoveRegions.test.tsx b/frontend/src/components/RemoveRegions/RemoveRegions.test.tsx
index e5414c3..5b1c6ef 100644
--- a/frontend/src/components/RemoveRegions/RemoveRegions.test.tsx
+++ b/frontend/src/components/RemoveRegions/RemoveRegions.test.tsx
@@ -19,10 +19,18 @@ it("if the correct phrase has been entered the 'Remove' button becomes enabled."
   expect(screen.getByRole("button", { name: /Remove/i })).toBeEnabled();
 });
 
-it("if the confirmation string is not correct and the user unfoxuses the input field a error state is shown.", async () => {
+it("if the confirmation string is not correct and the user unfocuses the input field a error state is shown.", async () => {
   render(<RemoveRegions />);
   expect(screen.getByRole("button", { name: /Remove/i })).toBeDisabled();
   await userEvent.type(screen.getByRole("textbox"), "incorrect string{tab}");
   expect(screen.getByText(/Confirmation string is not correct/i)).toBeInTheDocument();
   expect(screen.getByRole("button", { name: /Remove/i })).toBeDisabled();
 });
+
+it("does not display error message on blur if the value has not chagned", async () => {
+  render(<RemoveRegions />);
+  expect(screen.getByRole("button", { name: /Remove/i })).toBeDisabled();
+  await userEvent.type(screen.getByRole("textbox"), "{tab}");
+  expect(screen.queryByText(/Confirmation string is not correct/i)).not.toBeInTheDocument();
+  expect(screen.getByRole("button", { name: /Remove/i })).toBeDisabled();
+});
diff --git a/frontend/src/components/RemoveRegions/RemoveRegions.tsx b/frontend/src/components/RemoveRegions/RemoveRegions.tsx
index 4136519..1f6f32a 100644
--- a/frontend/src/components/RemoveRegions/RemoveRegions.tsx
+++ b/frontend/src/components/RemoveRegions/RemoveRegions.tsx
@@ -60,6 +60,7 @@ const RemoveRegions = () => {
       initialValues={initialValues}
       onSubmit={handleSubmit}
       validate={createHandleValidate({ expectedConfirmTextValue })}
+      validateOnBlur={false}
     >
       {({ isSubmitting, errors, touched, isValid, dirty }) => (
         <Form aria-labelledby={headingId} className="tokens-create" noValidate>
diff --git a/frontend/src/components/RequestsTable/RequestsTable.tsx b/frontend/src/components/RequestsTable/RequestsTable.tsx
index b4ccfc1..5a3bd3d 100644
--- a/frontend/src/components/RequestsTable/RequestsTable.tsx
+++ b/frontend/src/components/RequestsTable/RequestsTable.tsx
@@ -32,11 +32,10 @@ const RequestsTable = ({
 
   const columns = useMemo<EnrollmentRequestsColumnDef[]>(
     () => [
-      {
+      columnHelper.accessor("name", {
         id: "select",
-        accessorKey: "name",
         header: ({ table }) => <SelectAllCheckbox table={table} />,
-        cell: ({ row, getValue }: { row: Row<EnrollmentRequest>; getValue: Getter<EnrollmentRequest["name"]> }) => {
+        cell: ({ row, getValue }) => {
           return (
             <label className="p-checkbox--inline">
               <input
@@ -53,7 +52,7 @@ const RequestsTable = ({
             </label>
           );
         },
-      },
+      }),
       columnHelper.accessor("name", {
         id: "name",
         header: () => <div>Name</div>,
diff --git a/frontend/src/components/SitesList/SitesTable/ConnectionInfo/ConnectionInfo.test.tsx b/frontend/src/components/SitesList/SitesTable/ConnectionInfo/ConnectionInfo.test.tsx
index 88e355c..3ebdf1b 100644
--- a/frontend/src/components/SitesList/SitesTable/ConnectionInfo/ConnectionInfo.test.tsx
+++ b/frontend/src/components/SitesList/SitesTable/ConnectionInfo/ConnectionInfo.test.tsx
@@ -1,8 +1,20 @@
+import * as timezoneMock from "timezone-mock";
+
 import ConnectionInfo, { connectionIcons, connectionLabels } from "./ConnectionInfo";
 
 import { connections } from "@/mocks/factories";
 import { render, screen } from "@/test-utils";
 
+beforeEach(() => {
+  vi.useFakeTimers();
+  timezoneMock.register("Etc/GMT");
+});
+
+afterEach(() => {
+  timezoneMock.unregister();
+  vi.useRealTimers();
+});
+
 connections.forEach((connection) => {
   it(`displays correct connection status icon and label for ${connection} connection`, () => {
     const { container } = render(<ConnectionInfo connection={connection} />);
@@ -11,3 +23,15 @@ connections.forEach((connection) => {
     expect(container.querySelector(".status-icon")).toHaveClass(connectionIcons[connection]);
   });
 });
+
+it("displays last seen text relative to local time correctly", () => {
+  const date = new Date("2000-01-01T12:00:00Z");
+  vi.setSystemTime(date);
+  render(<ConnectionInfo connection={connections[0]} lastSeen="2000-01-01T11:58:00Z" />);
+  expect(screen.getByText("2 minutes ago")).toBeInTheDocument();
+});
+
+it("displays 'waiting for first' text for the unknown status", () => {
+  render(<ConnectionInfo connection="unknown" />);
+  expect(screen.getByText(/waiting for first/i)).toBeInTheDocument();
+});
diff --git a/frontend/src/components/SitesList/SitesTable/ConnectionInfo/ConnectionInfo.tsx b/frontend/src/components/SitesList/SitesTable/ConnectionInfo/ConnectionInfo.tsx
index 3eeef54..764d9da 100644
--- a/frontend/src/components/SitesList/SitesTable/ConnectionInfo/ConnectionInfo.tsx
+++ b/frontend/src/components/SitesList/SitesTable/ConnectionInfo/ConnectionInfo.tsx
@@ -1,50 +1,63 @@
 import classNames from "classnames";
 import get from "lodash/get";
 
-import type { Site } from "@/api/types";
+import type { Stats } from "@/api/types";
 import docsUrls from "@/base/docsUrls";
 import ExternalLink from "@/components/ExternalLink";
 import TooltipButton from "@/components/base/TooltipButton";
+import { formatDistanceToNow } from "@/utils";
 
-export const connectionIcons: Record<Site["connection"], string> = {
+export const connectionIcons: Record<Stats["connection"], string> = {
   stable: "is-stable",
   lost: "is-lost",
   unknown: "is-unknown",
 } as const;
-export const connectionLabels: Record<Site["connection"], string> = {
+export const connectionLabels: Record<Stats["connection"], string> = {
   stable: "Stable",
   lost: "Lost",
   unknown: "Waiting for first",
 } as const;
 
-type ConnectionInfoProps = { connection: Site["connection"]; lastSeen?: Site["last_seen"] };
+type ConnectionInfoProps = { connection: Stats["connection"]; lastSeen?: Stats["last_seen"] };
 
-const ConnectionInfo = ({ connection, lastSeen }: ConnectionInfoProps) => (
-  <>
-    <TooltipButton
-      iconName=""
-      message={
-        connection === "unknown" ? (
-          "Haven't received a heartbeat from this region yet"
-        ) : connection === "stable" ? (
-          "Received a heartbeat in the expected interval of 5 minutes"
-        ) : (
-          <>
-            Haven't received a heartbeat in the expected interval of 5 minutes.
-            <br />
-            <ExternalLink to={docsUrls.troubleshooting}>
-              Check the documentation for troubleshooting steps.
-            </ExternalLink>
-          </>
-        )
-      }
-      position="btm-center"
-    >
-      <div className={classNames("connection__text", "status-icon", get(connectionIcons, connection))}>
-        {get(connectionLabels, connection)}
+const getLastSeenText = ({ connection, lastSeen }: ConnectionInfoProps) => {
+  if (!lastSeen) {
+    return null;
+  }
+  return connection === "unknown" ? `heartbeat since ${formatDistanceToNow(lastSeen)}` : formatDistanceToNow(lastSeen);
+};
+
+const ConnectionInfo = ({ connection, lastSeen }: ConnectionInfoProps) => {
+  return (
+    <>
+      <TooltipButton
+        iconName=""
+        message={
+          connection === "unknown" ? (
+            "Haven't received a heartbeat from this region yet"
+          ) : connection === "stable" ? (
+            "Received a heartbeat in the expected interval of 5 minutes"
+          ) : (
+            <>
+              Haven't received a heartbeat in the expected interval of 5 minutes.
+              <br />
+              <ExternalLink to={docsUrls.troubleshooting}>
+                Check the documentation for troubleshooting steps.
+              </ExternalLink>
+            </>
+          )
+        }
+        position="btm-center"
+      >
+        <div className={classNames("connection__text", "status-icon", get(connectionIcons, connection))}>
+          {get(connectionLabels, connection)}
+        </div>
+      </TooltipButton>
+      <div className="connection__text u-text--muted">
+        <time dateTime={lastSeen}>{getLastSeenText({ connection, lastSeen })}</time>
       </div>
-    </TooltipButton>
-    <div className="connection__text u-text--muted">{lastSeen}</div>
-  </>
-);
+    </>
+  );
+};
+
 export default ConnectionInfo;
diff --git a/frontend/src/components/SitesList/SitesTable/SitesTable.tsx b/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
index 4d214ed..f27ea4a 100644
--- a/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
+++ b/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
@@ -2,11 +2,12 @@ import { useEffect, useMemo } from "react";
 
 import { useReactTable, flexRender, getCoreRowModel } from "@tanstack/react-table";
 import type { ColumnDef, Column, Getter, Row } from "@tanstack/react-table";
+import classNames from "classnames";
 import pick from "lodash/fp/pick";
 import useLocalStorageState from "use-local-storage-state";
 
 import AggregatedStats from "./AggregatedStatus";
-import ConnectionInfo from "./ConnectionInfo/ConnectionInfo";
+import ConnectionInfo from "./ConnectionInfo";
 import SitesTableControls from "./SitesTableControls/SitesTableControls";
 
 import type { SitesQueryResult } from "@/api/types";
@@ -123,7 +124,7 @@ const SitesTable = ({
       },
       {
         id: "time",
-        accessorFn: createAccessor("timezone"),
+        accessorFn: createAccessor(["timezone"]),
         header: () => (
           <>
             <div>local time (24hr)</div>
@@ -148,7 +149,7 @@ const SitesTable = ({
         ),
         cell: ({ getValue }) => {
           const { stats } = getValue();
-          return getAllMachines(stats);
+          return stats ? getAllMachines(stats) : null;
         },
       },
       {
@@ -191,8 +192,6 @@ const SitesTable = ({
     enableRowSelection: true,
     enableMultiRowSelection: true,
     onRowSelectionChange: setRowSelection,
-    enableColumnResizing: false,
-    columnResizeMode: "onChange",
     getCoreRowModel: getCoreRowModel(),
     debugTable: isDev,
     debugHeaders: isDev,
@@ -215,13 +214,6 @@ const SitesTable = ({
                 return (
                   <th className={`${header.column.id}`} colSpan={header.colSpan} key={header.id}>
                     {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
-                    {header.column.getCanResize() && (
-                      <div
-                        className={`resizer ${header.column.getIsResizing() ? "isResizing" : ""}`}
-                        onMouseDown={header.getResizeHandler()}
-                        onTouchStart={header.getResizeHandler()}
-                      ></div>
-                    )}
                   </th>
                 );
               })}
@@ -236,7 +228,10 @@ const SitesTable = ({
           <tbody>
             {table.getRowModel().rows.map((row) => {
               return (
-                <tr key={row.id}>
+                <tr
+                  className={classNames({ "sites-table-row--muted": row.original.stats?.connection === "unknown" })}
+                  key={row.id}
+                >
                   {row.getVisibleCells().map((cell) => {
                     return (
                       <td className={`${cell.column.id}`} key={cell.id}>
diff --git a/frontend/src/components/SitesList/SitesTable/_SitesTable.scss b/frontend/src/components/SitesList/SitesTable/_SitesTable.scss
index 5543712..35397ae 100644
--- a/frontend/src/components/SitesList/SitesTable/_SitesTable.scss
+++ b/frontend/src/components/SitesList/SitesTable/_SitesTable.scss
@@ -1,20 +1,27 @@
+$connection-status-icon-width: 1.5rem;
+
 .sites-table {
   thead th:first-child {
     width: 3rem;
   }
+  td.connection,
   th.connection {
-    padding-left: 1.5rem;
-  }
-  td.connection {
-    padding-left: 0;
     .connection__text {
-      padding-left: 1.5rem;
+      padding-left: $connection-status-icon-width;
+    }
+  }
+  .sites-table-row--muted {
+    td:not(.name) {
+      &,
+      .tooltip-button {
+        @extend %muted-text;
+      }
     }
   }
   .status-icon {
     display: inline-block;
     position: relative;
-    padding-left: 1.5rem;
+    padding-left: $connection-status-icon-width;
   }
   .status-icon::before {
     content: "\00B7";
diff --git a/frontend/src/components/TokensCreate/TokensCreate.test.tsx b/frontend/src/components/TokensCreate/TokensCreate.test.tsx
index 2519f7f..4665b36 100644
--- a/frontend/src/components/TokensCreate/TokensCreate.test.tsx
+++ b/frontend/src/components/TokensCreate/TokensCreate.test.tsx
@@ -29,46 +29,54 @@ afterAll(() => {
   mockServer.close();
 });
 
-describe("TokensCreate", () => {
-  it("renders the form", async () => {
-    renderWithMemoryRouter(<TokensCreate />);
-    expect(screen.getByRole("form", { name: /Generate new enrolment tokens/i })).toBeInTheDocument();
-  });
+it("renders the form", async () => {
+  renderWithMemoryRouter(<TokensCreate />);
+  expect(screen.getByRole("form", { name: /Generate new enrolment tokens/i })).toBeInTheDocument();
+});
 
-  it("if not all required fields have been entered the submit button is disabled", async () => {
-    renderWithMemoryRouter(<TokensCreate />);
-    const amount = screen.getByLabelText(/Amount of tokens to generate/i);
-    const expires = screen.getByLabelText(/Expiration time/i);
-    expect(screen.getByRole("button", { name: /Generate tokens/i })).toBeDisabled();
-    await userEvent.type(amount, "1");
-    await userEvent.type(expires, "1 month");
-    expect(screen.getByRole("button", { name: /Generate tokens/i })).toBeEnabled();
-  });
+it("if not all required fields have been entered the submit button is disabled", async () => {
+  renderWithMemoryRouter(<TokensCreate />);
+  const amount = screen.getByLabelText(/Amount of tokens to generate/i);
+  const expires = screen.getByLabelText(/Expiration time/i);
+  expect(screen.getByRole("button", { name: /Generate tokens/i })).toBeDisabled();
+  await userEvent.type(amount, "1");
+  await userEvent.type(expires, "1 month");
+  expect(screen.getByRole("button", { name: /Generate tokens/i })).toBeEnabled();
+});
 
-  it("displays an error for invalid expiration value", async () => {
-    renderWithMemoryRouter(<TokensCreate />);
-    const expires = screen.getByLabelText(/Expiration time/i);
-    await userEvent.type(expires, "2");
-    await userEvent.tab();
-    expect(expires).toHaveErrorMessage(
-      /Time unit must be a `string` type with a value of weeks, days, hours, and\/or minutes./i,
-    );
-  });
+it("displays an error for invalid expiration value", async () => {
+  renderWithMemoryRouter(<TokensCreate />);
+  const expires = screen.getByLabelText(/Expiration time/i);
+  await userEvent.type(expires, "2");
+  await userEvent.tab();
+  expect(expires).toHaveErrorMessage(
+    /Time unit must be a `string` type with a value of weeks, days, hours, and\/or minutes./i,
+  );
+});
 
-  it("can generate enrolment tokens", async () => {
-    renderWithMemoryRouter(<TokensCreate />);
-    const amount = screen.getByLabelText(/Amount of tokens to generate/i);
-    const expires = screen.getByLabelText(/Expiration time/i);
-    expect(screen.getByRole("button", { name: /Generate tokens/i })).toBeDisabled();
-    // can specify the number of tokens to generate
-    await userEvent.type(amount, "1");
-    // can specify the token expiration time (e.g. 1 week)
-    await userEvent.type(expires, "1 week");
-    await userEvent.click(screen.getByRole("button", { name: /Generate tokens/i }));
-    expect(tokensMutationMock).toHaveBeenCalledTimes(1);
-    expect(tokensMutationMock).toHaveBeenCalledWith({
-      amount: 1,
-      expires: "P0Y0M7DT0H0M0S",
-    });
+it("can generate enrolment tokens", async () => {
+  renderWithMemoryRouter(<TokensCreate />);
+  const amount = screen.getByLabelText(/Amount of tokens to generate/i);
+  const expires = screen.getByLabelText(/Expiration time/i);
+  expect(screen.getByRole("button", { name: /Generate tokens/i })).toBeDisabled();
+  // can specify the number of tokens to generate
+  await userEvent.type(amount, "1");
+  // can specify the token expiration time (e.g. 1 week)
+  await userEvent.type(expires, "1 week");
+  await userEvent.click(screen.getByRole("button", { name: /Generate tokens/i }));
+  expect(tokensMutationMock).toHaveBeenCalledTimes(1);
+  expect(tokensMutationMock).toHaveBeenCalledWith({
+    amount: 1,
+    expires: "P0Y0M7DT0H0M0S",
   });
 });
+
+it("does not display error message on blur if the value has not chagned", async () => {
+  renderWithMemoryRouter(<TokensCreate />);
+  const amount = screen.getByLabelText(/Amount of tokens to generate/i);
+  await userEvent.type(amount, "{tab}");
+  expect(amount).not.toHaveErrorMessage(/Error/i);
+  // enter a value and then delete it
+  await userEvent.type(amount, "1{backspace}");
+  expect(amount).toHaveErrorMessage(/Error/i);
+});
diff --git a/frontend/src/components/TokensCreate/TokensCreate.tsx b/frontend/src/components/TokensCreate/TokensCreate.tsx
index 2443c7b..4f8e32d 100644
--- a/frontend/src/components/TokensCreate/TokensCreate.tsx
+++ b/frontend/src/components/TokensCreate/TokensCreate.tsx
@@ -1,6 +1,7 @@
 import { useId } from "react";
 
 import { Button, Input, Label, Notification } from "@canonical/react-components";
+import type { FormikHelpers } from "formik";
 import { Field, Formik, Form } from "formik";
 import * as Yup from "yup";
 
@@ -44,7 +45,7 @@ const TokensCreate = () => {
   const { setSidebar } = useAppContext();
   const handleSubmit = async (
     { amount, expires }: TokensCreateFormValues,
-    { setSubmitting }: { setSubmitting: (isSubmitting: boolean) => void },
+    { setSubmitting }: FormikHelpers<TokensCreateFormValues>,
   ) => {
     await tokensMutation.mutateAsync({
       amount: Number(amount),
@@ -64,7 +65,12 @@ const TokensCreate = () => {
       {tokensMutation.isError && (
         <Notification severity="negative">There was an error generating the token(s).</Notification>
       )}
-      <Formik initialValues={initialValues} onSubmit={handleSubmit} validationSchema={TokensCreateSchema}>
+      <Formik
+        initialValues={initialValues}
+        onSubmit={handleSubmit}
+        validateOnBlur={false}
+        validationSchema={TokensCreateSchema}
+      >
         {({ isSubmitting, errors, touched, isValid, dirty }) => (
           <Form aria-labelledby={headingId} className="tokens-create" noValidate>
             <Label htmlFor={amountId}>Amount of tokens to generate</Label>
diff --git a/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx b/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
index a951f66..f4c96cd 100644
--- a/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
+++ b/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
@@ -135,13 +135,6 @@ const TokensTable = ({
             {headerGroup.headers.map((header) => (
               <th colSpan={header.colSpan} key={header.id}>
                 {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
-                {header.column.getCanResize() && (
-                  <div
-                    className={`resizer ${header.column.getIsResizing() ? "isResizing" : ""}`}
-                    onMouseDown={header.getResizeHandler()}
-                    onTouchStart={header.getResizeHandler()}
-                  ></div>
-                )}
               </th>
             ))}
           </tr>
diff --git a/frontend/src/components/base/TooltipButton/TooltipButton.tsx b/frontend/src/components/base/TooltipButton/TooltipButton.tsx
index 8d05795..97176d6 100644
--- a/frontend/src/components/base/TooltipButton/TooltipButton.tsx
+++ b/frontend/src/components/base/TooltipButton/TooltipButton.tsx
@@ -25,7 +25,7 @@ const TooltipButton = ({
       <Button
         appearance="link"
         aria-label={ariaLabel}
-        className="tooltip-button u-no-border u-no-line-height u-no-margin"
+        className="tooltip-button u-no-border u-no-padding u-no-margin u-align--left"
         hasIcon
         type="button"
         {...buttonProps}
diff --git a/frontend/src/mocks/factories.ts b/frontend/src/mocks/factories.ts
index 7121d20..c6903ea 100644
--- a/frontend/src/mocks/factories.ts
+++ b/frontend/src/mocks/factories.ts
@@ -1,28 +1,30 @@
 import Chance from "chance";
+import { sub } from "date-fns";
 import { Factory } from "fishery";
 import { uniqueNamesGenerator, adjectives, colors, animals } from "unique-names-generator";
 
-import type { AccessToken, EnrollmentRequest, PaginatedQueryResult, Site, Token } from "@/api/types";
+import type { AccessToken, EnrollmentRequest, PaginatedQueryResult, Site, Stats, Token } from "@/api/types";
 
-export const connections: Site["connection"][] = ["stable", "lost", "unknown"];
+export const connections: Stats["connection"][] = ["stable", "lost", "unknown"];
 
 export const statsFactory = Factory.define<Site["stats"]>(({ sequence }) => {
   const chance = new Chance(`maas-${sequence}`);
+  const now = new Date();
   return {
     deployed_machines: chance.integer({ min: 0, max: 500 }),
     allocated_machines: chance.integer({ min: 0, max: 500 }),
     ready_machines: chance.integer({ min: 0, max: 500 }),
     error_machines: chance.integer({ min: 0, max: 500 }),
-    last_seen: new Date(chance.date({ year: 2023 })).toISOString(),
+    last_seen: new Date(chance.date({ min: sub(now, { minutes: 15 }), max: now })).toISOString(),
     connection: connectionFactory.build(),
   };
 });
 
-export const connectionFactory = Factory.define<Site["connection"]>(({ sequence }) => {
+export const connectionFactory = Factory.define<Stats["connection"]>(({ sequence }) => {
   return uniqueNamesGenerator({
     dictionaries: [connections],
     seed: sequence,
-  }) as Site["connection"];
+  }) as Stats["connection"];
 });
 
 export const siteFactory = Factory.define<Site>(({ sequence }) => {
@@ -38,8 +40,6 @@ export const siteFactory = Factory.define<Site>(({ sequence }) => {
     id: `${sequence}`,
     name,
     url: `http://${name}.${chance.tld()}`,
-    connection: connectionFactory.build(),
-    last_seen: new Date(chance.date({ year: 2023 })).toISOString(),
     country: chance.country(), // <alpha2 country code>,
     city: chance.city(),
     zip: chance.zip(),
diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts
index e5f5a0c..5e3be93 100644
--- a/frontend/src/utils.ts
+++ b/frontend/src/utils.ts
@@ -1,5 +1,5 @@
 import * as Sentry from "@sentry/browser";
-import { parseISO } from "date-fns";
+import { formatDistanceToNowStrict, parseISO } from "date-fns";
 import { getTimezoneOffset, format, utcToZonedTime } from "date-fns-tz";
 import * as countries from "i18n-iso-countries";
 import { getName } from "i18n-iso-countries";
@@ -36,6 +36,11 @@ export const customParamSerializer = (params: Record<string, string | number>, q
   );
 };
 
+export const formatDistanceToNow = (dateString: string) =>
+  formatDistanceToNowStrict(parseISO(dateString), {
+    addSuffix: true,
+  });
+
 export const getTimezoneUTCString = (timezone: string, date?: Date | number) => {
   const offset = getTimezoneOffset(timezone, date);
   const sign = offset < 0 ? "-" : "+";
@@ -70,7 +75,7 @@ export const copyToClipboard = (text: string, callback?: (text: string) => void)
     });
 };
 
-export const getAllMachines = (stats?: Stats) => {
+export const getAllMachines = (stats: Stats) => {
   if (!stats) return null;
   return stats.deployed_machines + stats.allocated_machines + stats.ready_machines + stats.error_machines;
 };
-- 
Mailing list: https://launchpad.net/~sts-sponsors
Post to     : sts-sponsors@lists.launchpad.net
Unsubscribe : https://launchpad.net/~sts-sponsors
More help   : https://help.launchpad.net/ListHelp

Reply via email to