Add framewor overview page

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-02-09 21:35:13 -08:00
parent 91e2326826
commit 8ac4929f66
7 changed files with 475 additions and 22 deletions

View File

@@ -14,6 +14,7 @@
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",

View File

@@ -25,7 +25,7 @@ const NotFoundPage = lazy(() => import("./pages/NotFoundPage"));
const PeoplesPage = lazy(() => import("./pages/PeoplesPage"));
const VendorsPage = lazy(() => import("./pages/VendorsPage"));
const FrameworksPage = lazy(() => import("./pages/FrameworksPage"));
const FrameworkPage = lazy(() => import("./pages/FrameworkPage"));
function App() {
return (
<StrictMode>
@@ -41,6 +41,7 @@ function App() {
<Route path="/peoples" element={<PeoplesPage />} />
<Route path="/vendors" element={<VendorsPage />} />
<Route path="/frameworks" element={<FrameworksPage />} />
<Route path="/frameworks/:frameworkId" element={<FrameworkPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,206 @@
import { Suspense, useEffect } from "react";
import { useParams } from "react-router";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
} from "react-relay";
import {
Building2,
Computer,
Mail,
Shield,
Network,
Database,
FileText,
Share2,
AlertTriangle,
Users,
Clock,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import type { FrameworkPageQuery as FrameworkPageQueryType } from "./__generated__/FrameworkPageQuery.graphql";
const FrameworkPageQuery = graphql`
query FrameworkPageQuery($frameworkId: ID!) {
node(id: $frameworkId) {
id
... on Framework {
name
description
controls {
edges {
node {
id
name
description
state
}
}
}
}
}
}
`;
function FrameworkPageContent({
queryRef,
}: {
queryRef: PreloadedQuery<FrameworkPageQueryType>;
}) {
const data = usePreloadedQuery(FrameworkPageQuery, queryRef);
const framework = data.node;
const controls = framework.controls?.edges.map(edge => edge?.node) ?? [];
const controlsByCategory = {
office: controls.filter(c => c?.name.toLowerCase().includes('office')),
computer: controls.filter(c => c?.name.toLowerCase().includes('computer')),
employees: controls.filter(c => c?.name.toLowerCase().includes('employee')),
email: controls.filter(c => c?.name.toLowerCase().includes('email')),
code: controls.filter(c => c?.name.toLowerCase().includes('code')),
infrastructure: controls.filter(c => c?.name.toLowerCase().includes('infrastructure')),
network: controls.filter(c => c?.name.toLowerCase().includes('network')),
data: controls.filter(c => c?.name.toLowerCase().includes('data')),
logging: controls.filter(c => c?.name.toLowerCase().includes('log')),
incidents: controls.filter(c => c?.name.toLowerCase().includes('incident')),
vendors: controls.filter(c => c?.name.toLowerCase().includes('vendor')),
sharing: controls.filter(c => c?.name.toLowerCase().includes('share'))
};
const controlCards = [
{
icon: <Building2 className="w-4 h-4" />,
title: "Secure your offices and internet access",
controls: controlsByCategory.office,
completed: controlsByCategory.office.filter(c => c?.state === 'IMPLEMENTED').length,
total: controlsByCategory.office.length,
},
{
icon: <Computer className="w-4 h-4" />,
title: "Manage your computers",
controls: controlsByCategory.computer,
completed: controlsByCategory.computer.filter(c => c?.state === 'IMPLEMENTED').length,
total: controlsByCategory.computer.length,
},
];
const totalImplemented = controls.filter(c => c?.state === 'IMPLEMENTED').length;
return (
<div className="min-h-screen bg-background p-6">
{/* Header */}
<div className="space-y-4 mb-8">
<h1 className="text-2xl font-semibold">{framework.name}</h1>
<p className="text-muted-foreground max-w-3xl">
{framework.description}
</p>
</div>
{/* Timeline */}
<div className="mb-8 space-y-4">
<div className="flex items-center gap-2 text-sm">
<div className="bg-primary/10 text-primary px-3 py-1 rounded-md flex items-center gap-2">
<FileText className="w-4 h-4" />
Frame 153
</div>
<div className="bg-warning/10 text-warning px-3 py-1 rounded-md flex items-center gap-2">
<Clock className="w-4 h-4" />
12 hours left
</div>
</div>
<div className="bg-card p-4 rounded-lg space-y-4">
<h3 className="font-medium">Preparation phase</h3>
<Progress value={30} className="h-2" />
<div className="flex justify-between text-sm text-muted-foreground">
<span>Preparation</span>
<span>Observation period: 3 month</span>
<span>Audit: 6-9 days</span>
<span>Report: 10-14 days</span>
</div>
</div>
</div>
<div className="mb-6 flex justify-between items-center">
<h2 className="text-xl font-semibold">Controls</h2>
<div className="text-primary">{totalImplemented} out of {controls.length} validated</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{controlCards.map((card, index) => (
<Card key={index}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{card.icon}
{card.title}
</CardTitle>
<Avatar className="h-6 w-6">
<AvatarFallback>U</AvatarFallback>
</Avatar>
</CardHeader>
<CardContent>
<div className="flex gap-1 my-2">
{Array(card.total)
.fill(0)
.map((_, i) => (
<div
key={i}
className={`h-2 w-2 rounded-sm ${
i < card.completed ? "bg-primary" : "bg-muted"
}`}
/>
))}
</div>
<p className="text-xs text-muted-foreground">
{card.completed}/{card.total} Controls validated
</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}
function FrameworkPageFallback() {
return (
<div className="min-h-screen bg-background p-6">
<div className="mb-8">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded mt-2" />
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="relative mb-6">
<div className="bg-muted w-24 h-24 rounded-full animate-pulse mb-4" />
<div className="h-6 w-48 bg-muted animate-pulse rounded mb-2" />
<div className="h-20 w-full bg-muted animate-pulse rounded" />
</div>
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
export default function FrameworkPage() {
const { frameworkId } = useParams();
const [queryRef, loadQuery] = useQueryLoader<FrameworkPageQueryType>(FrameworkPageQuery);
useEffect(() => {
loadQuery({ frameworkId });
}, [loadQuery, frameworkId]);
return (
<Suspense fallback={<FrameworkPageFallback />}>
{queryRef && <FrameworkPageContent queryRef={queryRef} />}
</Suspense>
);
}

View File

@@ -8,6 +8,7 @@ import {
import { Globe2, CheckCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Link } from "react-router";
import type { FrameworksPageQuery as FrameworksPageQueryType } from "./__generated__/FrameworksPageQuery.graphql";
const FrameworksPageQuery = graphql`
@@ -60,29 +61,31 @@ function FrameworksPageContent({
<div className="grid gap-6 md:grid-cols-2">
{frameworks.map((framework) => (
<Card key={framework?.id} className="bg-card/50">
<CardContent className="p-6">
<div className="relative mb-6">
<Badge className="absolute right-0 top-0 bg-green-500/10 text-green-500 hover:bg-green-500/20">
Active
</Badge>
<div className="bg-green-500/10 w-24 h-24 rounded-full flex items-center justify-center mb-4">
<Globe2 className="w-12 h-12 text-green-500" />
<div className="absolute text-xs font-medium text-green-500">{framework?.name}</div>
<Link key={framework?.id} to={`/frameworks/${framework.id}`}>
<Card className="bg-card/50 hover:bg-card/70 transition-colors">
<CardContent className="p-6">
<div className="relative mb-6">
<Badge className="absolute right-0 top-0 bg-green-500/10 text-green-500 hover:bg-green-500/20">
Active
</Badge>
<div className="bg-green-500/10 w-24 h-24 rounded-full flex items-center justify-center mb-4">
<Globe2 className="w-12 h-12 text-green-500" />
<div className="absolute text-xs font-medium text-green-500">{framework?.name}</div>
</div>
<h2 className="text-xl font-semibold mb-2">{framework?.name}</h2>
<p className="text-sm text-muted-foreground">
{framework?.description}
</p>
</div>
<h2 className="text-xl font-semibold mb-2">{framework?.name}</h2>
<p className="text-sm text-muted-foreground">
{framework?.description}
</p>
</div>
<div className="flex items-center text-muted-foreground text-sm">
<div className="flex items-center">
<CheckCircle className="w-4 h-4 mr-2" />
{framework?.controls?.edges.length} Controls
<div className="flex items-center text-muted-foreground text-sm">
<div className="flex items-center">
<CheckCircle className="w-4 h-4 mr-2" />
{framework?.controls?.edges.length} Controls
</div>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</Link>
))}
</div>
</div>

View File

@@ -0,0 +1,191 @@
/**
* @generated SignedSource<<51618c4ff52c6da6eb39cc0756d037e4>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ControlState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
export type FrameworkPageQuery$variables = {
frameworkId: string;
};
export type FrameworkPageQuery$data = {
readonly node: {
readonly controls?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly description: string;
readonly id: string;
readonly name: string;
readonly state: ControlState;
};
}>;
};
readonly description?: string;
readonly id: string;
readonly name?: string;
};
};
export type FrameworkPageQuery = {
response: FrameworkPageQuery$data;
variables: FrameworkPageQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "frameworkId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "frameworkId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
v5 = {
"kind": "InlineFragment",
"selections": [
(v3/*: any*/),
(v4/*: any*/),
{
"alias": null,
"args": null,
"concreteType": "ControlConnection",
"kind": "LinkedField",
"name": "controls",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "ControlEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Control",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Framework",
"abstractKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "FrameworkPageQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v5/*: any*/)
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "FrameworkPageQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v2/*: any*/),
(v5/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "20c37c26ec0c229a526ba1f84a0bb22e",
"id": null,
"metadata": {},
"name": "FrameworkPageQuery",
"operationKind": "query",
"text": "query FrameworkPageQuery(\n $frameworkId: ID!\n) {\n node(id: $frameworkId) {\n __typename\n id\n ... on Framework {\n name\n description\n controls {\n edges {\n node {\n id\n name\n description\n state\n }\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "c018807d1942d694eceae254a3078c4d";
export default node;

25
package-lock.json generated
View File

@@ -22,6 +22,7 @@
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
@@ -2699,6 +2700,30 @@
}
}
},
"node_modules/@radix-ui/react-progress": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz",
"integrity": "sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-primitive": "2.0.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz",