From 0e20d5840694e33e03834f955958a68b7addc819 Mon Sep 17 00:00:00 2001
From: Nkeiruka <nkeiruka.whenu@canonical.com>
Date: Wed, 23 Oct 2024 16:35:05 +0100
Subject: [PATCH] feat: [WD-16007] Replace storage list with device list on
 profile detail page.

Work done:
- Separate Devices section into Networks and Other devices (Currently storage)
- Created a generic NetworkListTable file to generate a network list for instance and profile overview pages.
- Note, the file ProfilNetworkList still exists for the ProfileDetailPanelContent component.

Signed-off-by: Nkeiruka <nkeiruka.whenu@canonical.com>
---
 .../DeviceDetails.tsx}                        |  4 +-
 src/components/DeviceListTable.tsx            | 99 +++++++++++++++++++
 .../NetworkListTable.tsx}                     | 40 +++++---
 src/pages/instances/InstanceOverview.tsx      | 15 ++-
 .../instances/InstanceOverviewDevices.tsx     |  2 +-
 src/pages/profiles/ProfileDetailOverview.tsx  | 44 +++++----
 src/sass/_device_detail_list.scss             | 15 +++
 src/sass/_instance_detail_overview.scss       |  7 --
 src/sass/_network_detail_list.scss            | 10 ++
 src/sass/_profile_detail_overview.scss        |  8 ++
 src/sass/styles.scss                          |  2 +
 src/util/helpers.tsx                          |  6 ++
 12 files changed, 201 insertions(+), 51 deletions(-)
 rename src/{pages/instances/InstanceOverviewDeviceDetail.tsx => components/DeviceDetails.tsx} (92%)
 create mode 100644 src/components/DeviceListTable.tsx
 rename src/{pages/instances/InstanceOverviewNetworks.tsx => components/NetworkListTable.tsx} (70%)
 create mode 100644 src/sass/_device_detail_list.scss
 create mode 100644 src/sass/_network_detail_list.scss

diff --git a/src/pages/instances/InstanceOverviewDeviceDetail.tsx b/src/components/DeviceDetails.tsx
similarity index 92%
rename from src/pages/instances/InstanceOverviewDeviceDetail.tsx
rename to src/components/DeviceDetails.tsx
index 6f39ef06e0..226a2dde5c 100644
--- a/src/pages/instances/InstanceOverviewDeviceDetail.tsx
+++ b/src/components/DeviceDetails.tsx
@@ -9,7 +9,7 @@ interface Props {
   project: string;
 }
 
-const InstanceOverviewDeviceDetail: FC<Props> = ({ device, project }) => {
+const DeviceDetails: FC<Props> = ({ device, project }) => {
   if (device.type === "disk") {
     if (isRootDisk(device as FormDevice)) {
       return (
@@ -65,4 +65,4 @@ const InstanceOverviewDeviceDetail: FC<Props> = ({ device, project }) => {
   return "-";
 };
 
-export default InstanceOverviewDeviceDetail;
+export default DeviceDetails;
diff --git a/src/components/DeviceListTable.tsx b/src/components/DeviceListTable.tsx
new file mode 100644
index 0000000000..cb8d763adc
--- /dev/null
+++ b/src/components/DeviceListTable.tsx
@@ -0,0 +1,99 @@
+import { FC } from "react";
+import { MainTable } from "@canonical/react-components";
+import { isRootDisk } from "util/instanceValidation";
+import { FormDevice } from "util/formDevices";
+import ResourceLink from "components/ResourceLink";
+import { isOtherDevice } from "util/devices";
+import DeviceDetails from "./DeviceDetails";
+import { useParams } from "react-router-dom";
+import { LxdDeviceValue, LxdDevices } from "types/device";
+
+interface Props {
+  configBaseURL: string;
+  devices: LxdDevices;
+}
+
+const DeviceListTable: FC<Props> = ({ configBaseURL, devices }) => {
+  const { project } = useParams<{ project: string }>();
+
+  const byTypeAndName = (
+    a: [string, LxdDeviceValue],
+    b: [string, LxdDeviceValue],
+  ) => {
+    const nameA = a[0].toLowerCase();
+    const nameB = b[0].toLowerCase();
+    const typeA = a[1].type;
+    const typeB = b[1].type;
+    if (typeA === typeB) {
+      return nameA.localeCompare(nameB);
+    }
+
+    return typeA.localeCompare(typeB);
+  };
+
+  // Identify non-NIC devices
+  const overviewDevices = Object.entries(devices ?? {})
+    .filter(([_key, device]) => {
+      return device.type !== "nic" && device.type !== "none";
+    })
+    .sort(byTypeAndName);
+
+  const hasDevices = overviewDevices.length > 0;
+
+  const deviceHeaders = [
+    { content: "Name", sortKey: "name", className: "u-text--muted" },
+    { content: "Type", sortKey: "type", className: "u-text--muted" },
+    { content: "Details", className: "u-text--muted" },
+  ];
+
+  const deviceRows = overviewDevices.map(([devicename, device]) => {
+    return {
+      columns: [
+        {
+          content: (
+            <ResourceLink
+              type="device"
+              value={devicename}
+              to={`${configBaseURL}/${isOtherDevice(device) ? "other" : device.type}`}
+            />
+          ),
+          role: "cell",
+          "aria-label": "Name",
+        },
+        {
+          content: `${device.type} ${isRootDisk(device as FormDevice) ? "(root)" : ""}`,
+
+          role: "cell",
+          "aria-label": "Type",
+        },
+        {
+          content: (
+            <DeviceDetails device={device} project={project as string} />
+          ),
+          role: "cell",
+          "aria-label": "Details",
+        },
+      ],
+      sortData: {
+        name: devicename.toLowerCase(),
+        type: device.type,
+      },
+    };
+  });
+
+  return (
+    <>
+      {hasDevices && (
+        <MainTable
+          headers={deviceHeaders}
+          rows={deviceRows}
+          className={"device-table"}
+          sortable
+        />
+      )}
+      {!hasDevices && <>-</>}
+    </>
+  );
+};
+
+export default DeviceListTable;
diff --git a/src/pages/instances/InstanceOverviewNetworks.tsx b/src/components/NetworkListTable.tsx
similarity index 70%
rename from src/pages/instances/InstanceOverviewNetworks.tsx
rename to src/components/NetworkListTable.tsx
index 7c7187460d..ea024d679a 100644
--- a/src/pages/instances/InstanceOverviewNetworks.tsx
+++ b/src/components/NetworkListTable.tsx
@@ -3,35 +3,38 @@ import { useQuery } from "@tanstack/react-query";
 import { queryKeys } from "util/queryKeys";
 import { MainTable } from "@canonical/react-components";
 import Loader from "components/Loader";
-import { LxdInstance } from "types/instance";
 import { fetchNetworks } from "api/networks";
 import { isNicDevice } from "util/devices";
 import ResourceLink from "components/ResourceLink";
+import { useParams } from "react-router-dom";
+import { LxdDevices } from "types/device";
 
 interface Props {
-  instance: LxdInstance;
   onFailure: (title: string, e: unknown) => void;
+  devices: LxdDevices;
 }
 
-const InstanceOverviewNetworks: FC<Props> = ({ instance, onFailure }) => {
+const NetworkListTable: FC<Props> = ({ onFailure, devices }) => {
+  const { project } = useParams<{ project: string }>();
+
   const {
     data: networks = [],
     error,
     isLoading,
   } = useQuery({
-    queryKey: [queryKeys.projects, instance.project, queryKeys.networks],
-    queryFn: () => fetchNetworks(instance.project),
+    queryKey: [queryKeys.projects, project, queryKeys.networks],
+    queryFn: () => fetchNetworks(project as string),
   });
 
   if (error) {
     onFailure("Loading networks failed", error);
   }
 
-  const instanceNetworks = Object.values(instance.expanded_devices ?? {})
+  const networkDevices = Object.values(devices ?? {})
     .filter(isNicDevice)
     .map((network) => network.network);
 
-  const hasNetworks = instanceNetworks.length > 0;
+  const hasNetworks = networkDevices.length > 0;
 
   const networksHeaders = [
     { content: "Name", sortKey: "name", className: "u-text--muted" },
@@ -49,9 +52,9 @@ const InstanceOverviewNetworks: FC<Props> = ({ instance, onFailure }) => {
   ];
 
   const networksRows = networks
-    .filter((network) => instanceNetworks.includes(network.name))
+    .filter((network) => networkDevices.includes(network.name))
     .map((network) => {
-      const interfaceNames = Object.entries(instance.expanded_devices ?? {})
+      const interfaceNames = Object.entries(devices ?? {})
         .filter(
           ([_key, value]) =>
             value.type === "nic" && value.network === network.name,
@@ -65,25 +68,25 @@ const InstanceOverviewNetworks: FC<Props> = ({ instance, onFailure }) => {
               <ResourceLink
                 type="network"
                 value={network.name}
-                to={`/ui/project/${instance.project}/network/${network.name}`}
+                to={`/ui/project/${project}/network/${network.name}`}
               />
             ),
-            role: "rowheader",
+            role: "cell",
             "aria-label": "Name",
           },
           {
             content: interfaceNames.length > 0 ? interfaceNames.join(" ") : "-",
-            role: "rowheader",
+            role: "cell",
             "aria-label": "Interface",
           },
           {
             content: network.type,
-            role: "rowheader",
+            role: "cell",
             "aria-label": "Type",
           },
           {
             content: network.managed ? "Yes" : "No",
-            role: "rowheader",
+            role: "cell",
             "aria-label": "Managed",
           },
         ],
@@ -100,11 +103,16 @@ const InstanceOverviewNetworks: FC<Props> = ({ instance, onFailure }) => {
     <>
       {isLoading && <Loader text="Loading networks..." />}
       {!isLoading && hasNetworks && (
-        <MainTable headers={networksHeaders} rows={networksRows} sortable />
+        <MainTable
+          headers={networksHeaders}
+          rows={networksRows}
+          sortable
+          className={"network-table"}
+        />
       )}
       {!isLoading && !hasNetworks && <>-</>}
     </>
   );
 };
 
-export default InstanceOverviewNetworks;
+export default NetworkListTable;
diff --git a/src/pages/instances/InstanceOverview.tsx b/src/pages/instances/InstanceOverview.tsx
index d1bf79ce3d..385c986838 100644
--- a/src/pages/instances/InstanceOverview.tsx
+++ b/src/pages/instances/InstanceOverview.tsx
@@ -5,14 +5,15 @@ import { LxdInstance } from "types/instance";
 import { instanceCreationTypes } from "util/instanceOptions";
 import useEventListener from "@use-it/event-listener";
 import { updateMaxHeight } from "util/updateMaxHeight";
-import InstanceOverviewNetworks from "./InstanceOverviewNetworks";
 import InstanceOverviewProfiles from "./InstanceOverviewProfiles";
 import InstanceOverviewMetrics from "./InstanceOverviewMetrics";
 import InstanceIps from "pages/instances/InstanceIps";
 import { useSettings } from "context/useSettings";
 import NotificationRow from "components/NotificationRow";
-import InstanceOverviewDevices from "./InstanceOverviewDevices";
+import DeviceListTable from "components/DeviceListTable";
 import ResourceLabel from "components/ResourceLabel";
+import NetworkListTable from "components/NetworkListTable";
+import { LxdDevices } from "types/device";
 
 interface Props {
   instance: LxdInstance;
@@ -131,7 +132,10 @@ const InstanceOverview: FC<Props> = ({ instance }) => {
           <h2 className="p-heading--5">Networks</h2>
         </Col>
         <Col size={7}>
-          <InstanceOverviewNetworks instance={instance} onFailure={onFailure} />
+          <NetworkListTable
+            devices={instance.expanded_devices as LxdDevices}
+            onFailure={onFailure}
+          />
         </Col>
       </Row>
       <Row className="networks">
@@ -139,7 +143,10 @@ const InstanceOverview: FC<Props> = ({ instance }) => {
           <h2 className="p-heading--5">Devices</h2>
         </Col>
         <Col size={7}>
-          <InstanceOverviewDevices instance={instance} />
+          <DeviceListTable
+            configBaseURL={`/ui/project/${instance.project}/instance/${instance?.name}/configuration`}
+            devices={instance.expanded_devices as LxdDevices}
+          />
         </Col>
       </Row>
       <Row className="profiles">
diff --git a/src/pages/instances/InstanceOverviewDevices.tsx b/src/pages/instances/InstanceOverviewDevices.tsx
index ee0a3b7473..1ebda93780 100644
--- a/src/pages/instances/InstanceOverviewDevices.tsx
+++ b/src/pages/instances/InstanceOverviewDevices.tsx
@@ -6,7 +6,7 @@ import { isRootDisk } from "util/instanceValidation";
 import { FormDevice } from "util/formDevices";
 import ResourceLink from "components/ResourceLink";
 import { isOtherDevice } from "util/devices";
-import InstanceOverviewDeviceDetail from "./InstanceOverviewDeviceDetail";
+import InstanceOverviewDeviceDetail from "components/DeviceDetails";
 
 interface Props {
   instance: LxdInstance;
diff --git a/src/pages/profiles/ProfileDetailOverview.tsx b/src/pages/profiles/ProfileDetailOverview.tsx
index a4d9f2ddbd..88961b8656 100644
--- a/src/pages/profiles/ProfileDetailOverview.tsx
+++ b/src/pages/profiles/ProfileDetailOverview.tsx
@@ -1,6 +1,6 @@
 import { FC, useEffect } from "react";
 import { Link, useParams } from "react-router-dom";
-import { Col, Notification, Row } from "@canonical/react-components";
+import { Col, Notification, Row, useNotify } from "@canonical/react-components";
 import { LxdProfile } from "types/profile";
 import useEventListener from "@use-it/event-listener";
 import { updateMaxHeight } from "util/updateMaxHeight";
@@ -10,8 +10,8 @@ import classnames from "classnames";
 import { CLOUD_INIT } from "./forms/ProfileFormMenu";
 import { slugify } from "util/slugify";
 import { getProfileInstances } from "util/usedBy";
-import ProfileNetworkList from "./ProfileNetworkList";
-import ProfileStorageList from "./ProfileStorageList";
+import NetworkListTable from "components/NetworkListTable";
+import DeviceListTable from "components/DeviceListTable";
 
 interface Props {
   profile: LxdProfile;
@@ -19,12 +19,17 @@ interface Props {
 }
 
 const ProfileDetailOverview: FC<Props> = ({ profile, featuresProfiles }) => {
+  const notify = useNotify();
   const { project } = useParams<{ project: string }>();
 
   if (!project) {
     return <>Missing project</>;
   }
 
+  const onFailure = (title: string, e: unknown) => {
+    notify.failure(title, e);
+  };
+
   const updateContentHeight = () => {
     updateMaxHeight("profile-overview-tab");
   };
@@ -47,7 +52,7 @@ const ProfileDetailOverview: FC<Props> = ({ profile, featuresProfiles }) => {
     <div className="profile-overview-tab">
       {!featuresProfiles && (
         <Notification severity="caution" title="Inherited profile">
-          Modifications are only available in the{" "}
+          Modifications are only available in the
           <Link to={`/ui/project/default/profile/${profile.name}`}>
             default project
           </Link>
@@ -75,27 +80,24 @@ const ProfileDetailOverview: FC<Props> = ({ profile, featuresProfiles }) => {
           </table>
         </Col>
       </Row>
-      <Row className="section">
+
+      <Row className="networks">
+        <Col size={3}>
+          <h2 className="p-heading--5">Networks</h2>
+        </Col>
+        <Col size={7}>
+          <NetworkListTable devices={profile.devices} onFailure={onFailure} />
+        </Col>
+      </Row>
+      <Row className="devices">
         <Col size={3}>
           <h2 className="p-heading--5">Devices</h2>
         </Col>
         <Col size={7}>
-          <table>
-            <tbody>
-              <tr className="list-wrapper">
-                <th className="u-text--muted">Networks</th>
-                <td>
-                  <ProfileNetworkList profile={profile} project={project} />
-                </td>
-              </tr>
-              <tr className="list-wrapper">
-                <th className="u-text--muted">Storage</th>
-                <td>
-                  <ProfileStorageList profile={profile} project={project} />
-                </td>
-              </tr>
-            </tbody>
-          </table>
+          <DeviceListTable
+            configBaseURL={`/ui/project/${project}/profile/${profile.name}/configuration`}
+            devices={profile.devices}
+          />
         </Col>
       </Row>
       <Row className="section">
diff --git a/src/sass/_device_detail_list.scss b/src/sass/_device_detail_list.scss
new file mode 100644
index 0000000000..415c612891
--- /dev/null
+++ b/src/sass/_device_detail_list.scss
@@ -0,0 +1,15 @@
+.device-table {
+  th:nth-child(1) {
+    width: 30%;
+  }
+
+  th:first-of-type,
+  td:first-of-type {
+    padding-left: 0;
+  }
+
+  th:nth-child(2),
+  td:nth-child(2) {
+    width: 12rem;
+  }
+}
diff --git a/src/sass/_instance_detail_overview.scss b/src/sass/_instance_detail_overview.scss
index 84b6a34a82..0186164923 100644
--- a/src/sass/_instance_detail_overview.scss
+++ b/src/sass/_instance_detail_overview.scss
@@ -54,13 +54,6 @@
     }
   }
 
-  .devices {
-    th:nth-child(2),
-    td:nth-child(2) {
-      width: 12rem;
-    }
-  }
-
   .profiles {
     th:nth-child(1) {
       width: 30%;
diff --git a/src/sass/_network_detail_list.scss b/src/sass/_network_detail_list.scss
new file mode 100644
index 0000000000..babeb7e59c
--- /dev/null
+++ b/src/sass/_network_detail_list.scss
@@ -0,0 +1,10 @@
+.network-table {
+  th:nth-child(1) {
+    width: 30%;
+  }
+
+  th:first-of-type,
+  td:first-of-type {
+    padding-left: 0;
+  }
+}
diff --git a/src/sass/_profile_detail_overview.scss b/src/sass/_profile_detail_overview.scss
index ea71d08e82..9191766e61 100644
--- a/src/sass/_profile_detail_overview.scss
+++ b/src/sass/_profile_detail_overview.scss
@@ -51,6 +51,14 @@
     }
   }
 
+  .devices {
+    border-bottom: 1px solid $color-mid-light;
+  }
+
+  .networks {
+    border-bottom: 1px solid $color-mid-light;
+  }
+
   .view-config {
     margin-top: $spv--x-small;
   }
diff --git a/src/sass/styles.scss b/src/sass/styles.scss
index f439dac63a..f658d51bef 100644
--- a/src/sass/styles.scss
+++ b/src/sass/styles.scss
@@ -77,6 +77,7 @@ $border-thin: 1px solid $color-mid-light !default;
 @import "detail_page";
 @import "detail_panels";
 @import "disk_device_form";
+@import "device_detail_list";
 @import "empty_state";
 @import "error_page";
 @import "file_row";
@@ -93,6 +94,7 @@ $border-thin: 1px solid $color-mid-light !default;
 @import "meter";
 @import "migrate_instance";
 @import "modified_actions";
+@import "network_detail_list";
 @import "network_detail_overview";
 @import "network_form";
 @import "network_forwards_form";
diff --git a/src/util/helpers.tsx b/src/util/helpers.tsx
index b52f2eb692..f0ea33ec4d 100644
--- a/src/util/helpers.tsx
+++ b/src/util/helpers.tsx
@@ -328,6 +328,12 @@ export const getInstanceDevices = (
   return Object.entries(instance.expanded_devices ?? {});
 };
 
+export const getProfileDevices = (
+  profile: LxdProfile,
+): [string, LxdDeviceValue][] => {
+  return Object.entries(profile.devices ?? {});
+};
+
 export const getRootPool = (instance: LxdInstance): string => {
   const rootStorage = Object.values(instance.expanded_devices ?? {})
     .filter(isDiskDevice)