From fa1cd6fb5110c273bb27d556c5f4d9f16da6868f Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Fri, 30 May 2025 15:07:57 -0700 Subject: [PATCH] Add assets Signed-off-by: Sacha Al Himdani --- apps/console/src/components/NavMain.tsx | 9 +- .../organizations/OrganizationBreadcrumb.tsx | 61 + .../src/pages/organizations/Routes.tsx | 6 + ...umbBreadcrumbAssetOverviewQuery.graphql.ts | 127 + .../pages/organizations/assets/AssetPage.tsx | 40 + .../pages/organizations/assets/AssetView.tsx | 445 +++ .../organizations/assets/AssetsListPage.tsx | 53 + .../organizations/assets/AssetsListView.tsx | 367 ++ .../assets/AssetsListViewSkeleton.tsx | 34 + .../organizations/assets/NewAssetPage.tsx | 42 + .../organizations/assets/NewAssetView.tsx | 363 ++ .../__generated__/AssetViewQuery.graphql.ts | 525 +++ .../AssetViewUpdateAssetMutation.graphql.ts | 230 ++ ...setsListViewDeleteAssetMutation.graphql.ts | 132 + .../AssetsListViewPaginationQuery.graphql.ts | 404 ++ .../AssetsListViewQuery.graphql.ts | 404 ++ .../AssetsListView_assets.graphql.ts | 346 ++ ...AssetViewAddAssetVendorMutation.graphql.ts | 156 + ...NewAssetViewCreateAssetMutation.graphql.ts | 272 ++ .../NewAssetViewQuery.graphql.ts | 341 ++ .../NewAssetView_organization.graphql.ts | 40 + .../organizations/people/PeopleListView.tsx | 13 +- .../organizations/risks/ListRiskView.tsx | 12 +- .../organizations/vendors/ListVendorView.tsx | 13 +- pkg/coredata/asset.go | 427 +++ pkg/coredata/asset_order_field.go | 32 + pkg/coredata/asset_type.go | 65 + pkg/coredata/criticity_level.go | 23 + pkg/coredata/entity_type_reg.go | 1 + pkg/coredata/migrations/20250530T215914Z.sql | 27 + pkg/coredata/vendor.go | 93 + pkg/probo/asset_service.go | 228 ++ pkg/probo/service.go | 2 + pkg/server/api/console/v1/schema.graphql | 122 + pkg/server/api/console/v1/schema/schema.go | 3380 +++++++++++++++++ pkg/server/api/console/v1/types/asset.go | 38 + pkg/server/api/console/v1/types/types.go | 89 + pkg/server/api/console/v1/v1_resolver.go | 166 +- 38 files changed, 9121 insertions(+), 7 deletions(-) create mode 100644 apps/console/src/pages/organizations/__generated__/OrganizationBreadcrumbBreadcrumbAssetOverviewQuery.graphql.ts create mode 100644 apps/console/src/pages/organizations/assets/AssetPage.tsx create mode 100644 apps/console/src/pages/organizations/assets/AssetView.tsx create mode 100644 apps/console/src/pages/organizations/assets/AssetsListPage.tsx create mode 100644 apps/console/src/pages/organizations/assets/AssetsListView.tsx create mode 100644 apps/console/src/pages/organizations/assets/AssetsListViewSkeleton.tsx create mode 100644 apps/console/src/pages/organizations/assets/NewAssetPage.tsx create mode 100644 apps/console/src/pages/organizations/assets/NewAssetView.tsx create mode 100644 apps/console/src/pages/organizations/assets/__generated__/AssetViewQuery.graphql.ts create mode 100644 apps/console/src/pages/organizations/assets/__generated__/AssetViewUpdateAssetMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/assets/__generated__/AssetsListViewDeleteAssetMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/assets/__generated__/AssetsListViewPaginationQuery.graphql.ts create mode 100644 apps/console/src/pages/organizations/assets/__generated__/AssetsListViewQuery.graphql.ts create mode 100644 apps/console/src/pages/organizations/assets/__generated__/AssetsListView_assets.graphql.ts create mode 100644 apps/console/src/pages/organizations/assets/__generated__/NewAssetViewAddAssetVendorMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/assets/__generated__/NewAssetViewCreateAssetMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/assets/__generated__/NewAssetViewQuery.graphql.ts create mode 100644 apps/console/src/pages/organizations/assets/__generated__/NewAssetView_organization.graphql.ts create mode 100644 pkg/coredata/asset.go create mode 100644 pkg/coredata/asset_order_field.go create mode 100644 pkg/coredata/asset_type.go create mode 100644 pkg/coredata/criticity_level.go create mode 100644 pkg/coredata/migrations/20250530T215914Z.sql create mode 100644 pkg/probo/asset_service.go create mode 100644 pkg/server/api/console/v1/types/asset.go diff --git a/apps/console/src/components/NavMain.tsx b/apps/console/src/components/NavMain.tsx index 32b28b1c2..65420343e 100644 --- a/apps/console/src/components/NavMain.tsx +++ b/apps/console/src/components/NavMain.tsx @@ -25,7 +25,7 @@ import { SidebarMenuSubButton, SidebarMenuSubItem, } from "@/components/ui/sidebar"; -import { BookOpen, FileText, Settings, ToyBrick, Users } from "lucide-react"; +import { BookOpen, FileText, Settings, Users, Box } from "lucide-react"; interface NavItem { title: string; @@ -180,6 +180,13 @@ function getNavItems(organizationId?: string): NavItem[] { : undefined, icon: FileText, }, + { + title: "Assets", + url: organizationId + ? `/organizations/${organizationId}/assets` + : undefined, + icon: Box, + }, { title: "Settings", url: organizationId diff --git a/apps/console/src/pages/organizations/OrganizationBreadcrumb.tsx b/apps/console/src/pages/organizations/OrganizationBreadcrumb.tsx index 92997c319..2f080b3da 100644 --- a/apps/console/src/pages/organizations/OrganizationBreadcrumb.tsx +++ b/apps/console/src/pages/organizations/OrganizationBreadcrumb.tsx @@ -19,6 +19,7 @@ import { OrganizationBreadcrumbOrganizationQuery } from "./__generated__/Organiz import { OrganizationBreadcrumbBreadcrumbMeasureViewQuery } from "./__generated__/OrganizationBreadcrumbBreadcrumbMeasureViewQuery.graphql"; import { OrganizationBreadcrumbBreadcrumbControlQuery } from "./__generated__/OrganizationBreadcrumbBreadcrumbControlQuery.graphql"; import { OrganizationBreadcrumbBreadcrumbRiskShowQuery } from "./__generated__/OrganizationBreadcrumbBreadcrumbRiskShowQuery.graphql"; +import { OrganizationBreadcrumbBreadcrumbAssetOverviewQuery } from "./__generated__/OrganizationBreadcrumbBreadcrumbAssetOverviewQuery.graphql"; import ErrorBoundary from "@/components/ErrorBoundary"; const New = () => { @@ -451,6 +452,53 @@ function BreadcrumbRiskShow() { ); } +function BreadcrumbAssetList() { + const { organizationId } = useParams(); + return ( + <> + + + + Assets + + + + + ); +} + +function BreadcrumbAssetOverview() { + const { organizationId, assetId } = useParams(); + const data = useLazyLoadQuery( + graphql` + query OrganizationBreadcrumbBreadcrumbAssetOverviewQuery($assetId: ID!) { + asset: node(id: $assetId) { + id + ... on Asset { + name + } + } + } + `, + { assetId: assetId! }, + { fetchPolicy: "store-or-network" }, + ); + + return ( + <> + + + + {data.asset?.name} + + + + + ); +} + export function BreadCrumb() { return ( @@ -544,6 +592,19 @@ export function BreadCrumb() { } /> + }> + } /> + + + + } + > + } /> + + } /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/console/src/pages/organizations/__generated__/OrganizationBreadcrumbBreadcrumbAssetOverviewQuery.graphql.ts b/apps/console/src/pages/organizations/__generated__/OrganizationBreadcrumbBreadcrumbAssetOverviewQuery.graphql.ts new file mode 100644 index 000000000..c26ee1720 --- /dev/null +++ b/apps/console/src/pages/organizations/__generated__/OrganizationBreadcrumbBreadcrumbAssetOverviewQuery.graphql.ts @@ -0,0 +1,127 @@ +/** + * @generated SignedSource<<7ee0fb485c12093374bdcbdf43d0a2df>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type OrganizationBreadcrumbBreadcrumbAssetOverviewQuery$variables = { + assetId: string; +}; +export type OrganizationBreadcrumbBreadcrumbAssetOverviewQuery$data = { + readonly asset: { + readonly id: string; + readonly name?: string; + }; +}; +export type OrganizationBreadcrumbBreadcrumbAssetOverviewQuery = { + response: OrganizationBreadcrumbBreadcrumbAssetOverviewQuery$data; + variables: OrganizationBreadcrumbBreadcrumbAssetOverviewQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "assetId" + } +], +v1 = [ + { + "kind": "Variable", + "name": "id", + "variableName": "assetId" + } +], +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v3 = { + "kind": "InlineFragment", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + } + ], + "type": "Asset", + "abstractKey": null +}; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "OrganizationBreadcrumbBreadcrumbAssetOverviewQuery", + "selections": [ + { + "alias": "asset", + "args": (v1/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v2/*: any*/), + (v3/*: any*/) + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "OrganizationBreadcrumbBreadcrumbAssetOverviewQuery", + "selections": [ + { + "alias": "asset", + "args": (v1/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + }, + (v2/*: any*/), + (v3/*: any*/) + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "41b1d32aad685d08edfdbf989207e758", + "id": null, + "metadata": {}, + "name": "OrganizationBreadcrumbBreadcrumbAssetOverviewQuery", + "operationKind": "query", + "text": "query OrganizationBreadcrumbBreadcrumbAssetOverviewQuery(\n $assetId: ID!\n) {\n asset: node(id: $assetId) {\n __typename\n id\n ... on Asset {\n name\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "4c3d736cb93af42b7dcb88cdcb293e8f"; + +export default node; diff --git a/apps/console/src/pages/organizations/assets/AssetPage.tsx b/apps/console/src/pages/organizations/assets/AssetPage.tsx new file mode 100644 index 000000000..67960823a --- /dev/null +++ b/apps/console/src/pages/organizations/assets/AssetPage.tsx @@ -0,0 +1,40 @@ +import { PageTemplateSkeleton } from "@/components/PageTemplate"; +import { Suspense } from "react"; +import { lazy } from "@probo/react-lazy"; +import { useLocation } from "react-router"; +import { ErrorBoundaryWithLocation } from "../ErrorBoundary"; + +const AssetView = lazy(() => import("./AssetView")); + +export function AssetViewSkeleton() { + return ( + +
+
+
+
+
+
+ {[1, 2].map((i) => ( +
+ ))} +
+
+ + ); +} + +export function AssetPage() { + const location = useLocation(); + + return ( + }> + + + + + ); +} diff --git a/apps/console/src/pages/organizations/assets/AssetView.tsx b/apps/console/src/pages/organizations/assets/AssetView.tsx new file mode 100644 index 000000000..6cf9bdc5a --- /dev/null +++ b/apps/console/src/pages/organizations/assets/AssetView.tsx @@ -0,0 +1,445 @@ +"use client"; + +import { Card } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { + graphql, + PreloadedQuery, + usePreloadedQuery, + useQueryLoader, + useMutation, +} from "react-relay"; +import { Suspense, useEffect, useState, useCallback } from "react"; +import type { AssetViewQuery as AssetViewQueryType } from "./__generated__/AssetViewQuery.graphql"; +import { useParams } from "react-router"; +import { PageTemplate } from "@/components/PageTemplate"; +import { AssetViewSkeleton } from "./AssetPage"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import PeopleSelector from "@/components/PeopleSelector"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { X } from "lucide-react"; + +const assetViewQuery = graphql` + query AssetViewQuery($assetId: ID!, $organizationId: ID!) { + node(id: $assetId) { + ... on Asset { + id + name + amount + criticity + assetType + dataTypesStored + owner { + id + fullName + } + vendors { + edges { + node { + id + name + } + } + } + createdAt + updatedAt + } + } + organization: node(id: $organizationId) { + ...PeopleSelector_organization + ... on Organization { + vendors(first: 100, orderBy: { direction: ASC, field: NAME }) @connection(key: "AssetView_vendors") { + edges { + node { + id + name + } + } + } + } + } + } +`; + +const updateAssetMutation = graphql` + mutation AssetViewUpdateAssetMutation($input: UpdateAssetInput!) { + updateAsset(input: $input) { + asset { + id + name + amount + criticity + assetType + dataTypesStored + owner { + id + fullName + } + vendors { + edges { + node { + id + name + } + } + } + updatedAt + } + } + } +`; + +type AssetType = "PHYSICAL" | "VIRTUAL"; +type CriticityLevel = "LOW" | "MEDIUM" | "HIGH"; + +interface Vendor { + readonly id: string; + readonly name: string; +} + +interface Organization { + readonly id?: string; + readonly vendors?: { + readonly edges: ReadonlyArray<{ + readonly node: Vendor; + } | null> | null; + } | null; +} + +interface Asset { + readonly id?: string; + readonly name?: string; + readonly amount?: number; + readonly criticity?: CriticityLevel; + readonly assetType?: AssetType; + readonly dataTypesStored?: string; + readonly owner?: { + readonly id: string; + readonly fullName: string; + } | null; + readonly vendors?: { + readonly edges: ReadonlyArray<{ + readonly node: { + readonly id: string; + readonly name: string; + }; + } | null> | null; + } | null; + readonly createdAt?: string; + readonly updatedAt?: string; +} + +function EditableField({ + label, + value, + onChange, + type = "text", + helpText, +}: { + label: string; + value: string | number; + onChange: (value: string | number) => void; + type?: string; + helpText?: string; +}) { + return ( +
+
+ +
+
+ onChange(type === "number" ? parseInt(e.target.value, 10) || 0 : e.target.value)} + /> + {helpText &&

{helpText}

} +
+
+ ); +} + +function AssetViewContent({ + queryRef, +}: { + queryRef: PreloadedQuery; +}) { + const data = usePreloadedQuery(assetViewQuery, queryRef); + const { organizationId } = useParams(); + const [editedFields, setEditedFields] = useState>(new Set()); + const asset = data.node as Asset | null; + const [formData, setFormData] = useState({ + name: asset?.name || "", + amount: asset?.amount || 0, + criticity: asset?.criticity || "LOW", + assetType: asset?.assetType || "VIRTUAL", + dataTypesStored: asset?.dataTypesStored || "", + ownerId: asset?.owner?.id || "", + selectedVendorIds: (asset?.vendors?.edges || []) + .map(edge => edge?.node?.id) + .filter((id): id is string => id != null) || [], + }); + const [commit] = useMutation(updateAssetMutation); + const [, loadQuery] = useQueryLoader(assetViewQuery); + const { toast } = useToast(); + const hasChanges = editedFields.size > 0; + + const handleSave = useCallback(() => { + const nodeId = data.node?.id; + if (!nodeId) return; + + commit({ + variables: { + input: { + id: nodeId, + name: formData.name, + amount: formData.amount, + criticity: formData.criticity, + assetType: formData.assetType, + dataTypesStored: formData.dataTypesStored, + ownerId: formData.ownerId, + vendorIds: formData.selectedVendorIds.length > 0 ? formData.selectedVendorIds : undefined, + }, + }, + onCompleted: () => { + toast({ + title: "Success", + description: "Changes saved successfully", + variant: "default", + }); + setEditedFields(new Set()); + }, + onError: (error) => { + const defaultErrorValues = { + title: "Error", + description: error.message || "Failed to save changes", + variant: "destructive" as const + }; + + if (error.message?.includes("concurrent modification")) { + toast({ + ...defaultErrorValues, + description: "Someone else modified this asset. Reloading latest data.", + }); + loadQuery({ assetId: nodeId, organizationId: organizationId! }); + } else { + toast(defaultErrorValues); + } + }, + }); + }, [commit, data.node?.id, formData, loadQuery, toast, organizationId]); + + const handleFieldChange = (field: keyof typeof formData, value: unknown) => { + setFormData((prev) => ({ + ...prev, + [field]: value, + })); + setEditedFields((prev) => new Set(prev).add(field)); + }; + + const handleVendorSelect = (vendorId: string) => { + handleFieldChange( + "selectedVendorIds", + formData.selectedVendorIds.includes(vendorId) + ? formData.selectedVendorIds.filter((id) => id !== vendorId) + : [...formData.selectedVendorIds, vendorId] + ); + }; + + const handleCancel = () => { + const asset = data.node as Asset | null; + setFormData({ + name: asset?.name || "", + amount: asset?.amount || 0, + criticity: asset?.criticity || "LOW", + assetType: asset?.assetType || "VIRTUAL", + dataTypesStored: asset?.dataTypesStored || "", + ownerId: asset?.owner?.id || "", + selectedVendorIds: (asset?.vendors?.edges || []) + .map((edge) => edge?.node?.id) + .filter((id): id is string => id != null) || [], + }); + setEditedFields(new Set()); + }; + + const organization = data.organization as Organization; + const vendors = (organization.vendors?.edges || []) + .map((edge) => edge?.node) + .filter((node): node is Vendor => node != null); + + return ( + +
+
+ handleFieldChange("name", value)} + helpText="Enter the name of the asset" + /> + + handleFieldChange("amount", value)} + helpText="Enter the amount of the asset" + /> + + +
+
+

Asset Details

+

+ Additional details about the asset +

+
+ +
+
+
+ +
+ handleFieldChange("ownerId", value)} + placeholder="Select asset owner" + /> +
+ +
+ + +
+ {formData.selectedVendorIds.map((vendorId) => { + const vendor = vendors.find((v) => v.id === vendorId); + if (!vendor) return null; + return ( + + {vendor.name} + + + ); + })} +
+
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+