diff --git a/.gitignore b/.gitignore index a3f7a51..42a6339 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ coverage # Vitest __screenshots__/ + +# Local AI instructions +.github/copilot-instructions.md +*.agent.md \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5811c57..a63d533 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,18 @@ "version": "0.0.1", "dependencies": { "@tailwindcss/vite": "^4.1.16", + "@tanstack/vue-table": "^8.21.3", "@unyt/datex": "0.0.13", - "@unyt/speck": "^0.0.11", - "@vueuse/core": "^14.0.0", + "@unyt/speck": "0.0.12", + "@vueuse/core": "^14.1.0", + "ag-grid-community": "^35.0.0", + "ag-grid-vue3": "^35.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-vue-next": "^0.548.0", "markdown-it": "^14.1.1", - "reka-ui": "^2.6.0", + "reka-ui": "^2.7.0", + "search-query-parser": "^1.6.0", "shadcn": "^3.5.0", "shadcn-ui": "^0.9.5", "tailwind-merge": "^3.3.1", @@ -2784,6 +2788,19 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-core": { "version": "3.13.22", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.22.tgz", @@ -2794,6 +2811,25 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/vue-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz", + "integrity": "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": ">=3.2" + } + }, "node_modules/@tanstack/vue-virtual": { "version": "3.13.22", "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.22.tgz", @@ -3181,9 +3217,9 @@ "license": "MIT" }, "node_modules/@unyt/speck": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@unyt/speck/-/speck-0.0.11.tgz", - "integrity": "sha512-NLONEkxU5aZR1il/q2YJqxY/2MiPsIR0PBmbZ/Z4cpqx3400m5WJlEbqs5Je7A4I282TMjyP2fwT5VqhU49IjA==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@unyt/speck/-/speck-0.0.12.tgz", + "integrity": "sha512-SDK1UD64FhKeDeVmHGFLSc6hmTbUzryHJfoEswE8Ce9SzlbJ6xcxnvxpw4v8fweicREaRReSsq4rHMr7QnmiFg==", "license": "MIT", "dependencies": { "github-slugger": "^2.0.0", @@ -3777,6 +3813,33 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/ag-charts-types": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-13.1.0.tgz", + "integrity": "sha512-DytRM3CXli+Y013SC1Mr8lQBrhVTACK+11ilDHOhwUM0sRpmGuR51XFGcBKOliW1Vas1AycP31Cm3Pp0jx3hqw==", + "license": "MIT" + }, + "node_modules/ag-grid-community": { + "version": "35.1.0", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-35.1.0.tgz", + "integrity": "sha512-yWFQfRNjv3KUBkHHzFdDOYGjPcDMU0B8Up4qG651diFlGRUGEGVs94SK73niWvk1FDZdpV9oWrwq3f30/qAoVg==", + "license": "MIT", + "dependencies": { + "ag-charts-types": "13.1.0" + } + }, + "node_modules/ag-grid-vue3": { + "version": "35.1.0", + "resolved": "https://registry.npmjs.org/ag-grid-vue3/-/ag-grid-vue3-35.1.0.tgz", + "integrity": "sha512-BvM7yrFxRB/r5hZ4xSyE6T2lU2Rj+Ls6RH5tTu/n8DmhCTmLj4QCEkoU7EuaE0/Az3uEHOubYMaCX4jcDf181A==", + "license": "MIT", + "dependencies": { + "ag-grid-community": "35.1.0" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -8351,6 +8414,12 @@ "node": ">=v12.22.7" } }, + "node_modules/search-query-parser": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/search-query-parser/-/search-query-parser-1.6.0.tgz", + "integrity": "sha512-bhf+phLlKF38nuniwLcVHWPArHGdzenlPhPi955CR3vm1QQifXIuPHwAffhjapojdVVzmv4hgIJ6NOX1d/w+Uw==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -9061,9 +9130,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.3.tgz", + "integrity": "sha512-NQjrZXDhXdBqX3R5Ek8qxXjtPBNEeQm+Ytv/7QpqY6I9aIui00stSZsyDS2nsP/M9OEYhAvkgqfSirgiV95+4g==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 9b570d1..cfa4dfb 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,18 @@ }, "dependencies": { "@tailwindcss/vite": "^4.1.16", + "@tanstack/vue-table": "^8.21.3", "@unyt/datex": "0.0.13", - "@unyt/speck": "^0.0.11", - "@vueuse/core": "^14.0.0", + "@unyt/speck": "0.0.12", + "@vueuse/core": "^14.1.0", + "ag-grid-community": "^35.0.0", + "ag-grid-vue3": "^35.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-vue-next": "^0.548.0", "markdown-it": "^14.1.1", - "reka-ui": "^2.6.0", + "reka-ui": "^2.7.0", + "search-query-parser": "^1.6.0", "shadcn": "^3.5.0", "shadcn-ui": "^0.9.5", "tailwind-merge": "^3.3.1", diff --git a/src/components/NetworkInspector/DataTable.vue b/src/components/NetworkInspector/DataTable.vue new file mode 100644 index 0000000..1489279 --- /dev/null +++ b/src/components/NetworkInspector/DataTable.vue @@ -0,0 +1,299 @@ + + + + + + + + + + + + + + + Columns + + + + toggleColumnVisibility(field, value)" + > + {{ field }} + + + + + + + + + + + + + diff --git a/src/components/NetworkInspector/HighlightedText.vue b/src/components/NetworkInspector/HighlightedText.vue new file mode 100644 index 0000000..b6fec14 --- /dev/null +++ b/src/components/NetworkInspector/HighlightedText.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/src/components/NetworkInspector/NetworkFilter.vue b/src/components/NetworkInspector/NetworkFilter.vue new file mode 100644 index 0000000..1e5467a --- /dev/null +++ b/src/components/NetworkInspector/NetworkFilter.vue @@ -0,0 +1,315 @@ + + + + + + + + + + + + + + + + + + + + + {{ suggestion.text }} + + + {{ suggestion.qualifier }}: + {{ suggestion.value }} + + + + + diff --git a/src/components/NetworkInspector/SortableHeader.vue b/src/components/NetworkInspector/SortableHeader.vue new file mode 100644 index 0000000..b4cec25 --- /dev/null +++ b/src/components/NetworkInspector/SortableHeader.vue @@ -0,0 +1,28 @@ + + + + + {{ label }} + + + + + diff --git a/src/components/NetworkInspector/TooltipWrapper.vue b/src/components/NetworkInspector/TooltipWrapper.vue new file mode 100644 index 0000000..7bb1574 --- /dev/null +++ b/src/components/NetworkInspector/TooltipWrapper.vue @@ -0,0 +1,26 @@ + + + + + + + + + + {{ tooltip }} + + + + diff --git a/src/components/NetworkInspector/cellRenderers/DirectionCell.vue b/src/components/NetworkInspector/cellRenderers/DirectionCell.vue new file mode 100644 index 0000000..431fe48 --- /dev/null +++ b/src/components/NetworkInspector/cellRenderers/DirectionCell.vue @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/src/components/NetworkInspector/cellRenderers/EndpointCell.vue b/src/components/NetworkInspector/cellRenderers/EndpointCell.vue new file mode 100644 index 0000000..7e0a2f0 --- /dev/null +++ b/src/components/NetworkInspector/cellRenderers/EndpointCell.vue @@ -0,0 +1,24 @@ + + + + + + + + + + null + + diff --git a/src/components/NetworkInspector/cellRenderers/InterfaceCell.vue b/src/components/NetworkInspector/cellRenderers/InterfaceCell.vue new file mode 100644 index 0000000..f567498 --- /dev/null +++ b/src/components/NetworkInspector/cellRenderers/InterfaceCell.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/src/components/NetworkInspector/cellRenderers/TypeCell.vue b/src/components/NetworkInspector/cellRenderers/TypeCell.vue new file mode 100644 index 0000000..cb21489 --- /dev/null +++ b/src/components/NetworkInspector/cellRenderers/TypeCell.vue @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + diff --git a/src/components/NetworkInspector/columns.ts b/src/components/NetworkInspector/columns.ts new file mode 100644 index 0000000..65f7617 --- /dev/null +++ b/src/components/NetworkInspector/columns.ts @@ -0,0 +1,95 @@ +import type { ColDef } from 'ag-grid-community'; +import type { NetworkBlockTableRow } from '@/types/NetworkInspector/TableRow'; +import type { ParsedSearchQuery } from '@/utils/searchParser'; +import { getSearchTermsForField } from '@/utils/searchParser'; +import DirectionCell from '@/components/NetworkInspector/cellRenderers/DirectionCell.vue'; +import InterfaceCell from '@/components/NetworkInspector/cellRenderers/InterfaceCell.vue'; +import TypeCell from '@/components/NetworkInspector/cellRenderers/TypeCell.vue'; +import EndpointCell from '@/components/NetworkInspector/cellRenderers/EndpointCell.vue'; + +// Format bytes with compact notation +const byteFormatter = new Intl.NumberFormat('en', { + notation: 'compact', + style: 'unit', + unit: 'byte', + unitDisplay: 'narrow', +}); + +function formatBytes(bytes: number): string { + return byteFormatter.format(bytes); +} + +export function createColumns(parsedQuery?: ParsedSearchQuery): ColDef[] { + return [ + { + field: 'direction', + headerName: 'Dir', + width: 70, + minWidth: 60, + cellRenderer: DirectionCell, + sortable: false, + suppressMovable: true, + lockPosition: 'left', + }, + { + field: 'interface', + headerName: 'Interface', + flex: 1, + minWidth: 120, + cellRenderer: InterfaceCell, + cellRendererParams: { + searchTerms: parsedQuery ? getSearchTermsForField(parsedQuery, 'interface') : [], + }, + }, + { + field: 'blockType', + headerName: 'Type', + flex: 1.2, + minWidth: 150, + cellRenderer: TypeCell, + cellRendererParams: { + searchTerms: parsedQuery ? getSearchTermsForField(parsedQuery, 'type') : [], + }, + }, + { + field: 'sender', + headerName: 'Sender', + flex: 1.5, + minWidth: 180, + cellRenderer: EndpointCell, + cellRendererParams: { + searchTerms: parsedQuery ? getSearchTermsForField(parsedQuery, 'sender') : [], + }, + }, + { + field: 'receiver', + headerName: 'Receiver', + flex: 1.5, + minWidth: 180, + cellRenderer: EndpointCell, + cellRendererParams: { + searchTerms: parsedQuery ? getSearchTermsForField(parsedQuery, 'receiver') : [], + }, + }, + { + field: 'timestamp', + headerName: 'Time', + width: 120, + minWidth: 100, + }, + { + field: 'size', + headerName: 'Size', + width: 90, + minWidth: 70, + valueFormatter: (params) => { + const size = params.value as number; + if (size === undefined || size === null) return ''; + return formatBytes(size); + }, + }, +]; +} + +// Default columns without search highlighting +export const columns = createColumns(); diff --git a/src/components/ui/alert-dialog/AlertDialog.vue b/src/components/ui/alert-dialog/AlertDialog.vue new file mode 100644 index 0000000..ff9accb --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialog.vue @@ -0,0 +1,15 @@ + + + + + + + diff --git a/src/components/ui/alert-dialog/AlertDialogAction.vue b/src/components/ui/alert-dialog/AlertDialogAction.vue new file mode 100644 index 0000000..09cf6fc --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogAction.vue @@ -0,0 +1,18 @@ + + + + + + + diff --git a/src/components/ui/alert-dialog/AlertDialogCancel.vue b/src/components/ui/alert-dialog/AlertDialogCancel.vue new file mode 100644 index 0000000..e261894 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogCancel.vue @@ -0,0 +1,25 @@ + + + + + + + diff --git a/src/components/ui/alert-dialog/AlertDialogContent.vue b/src/components/ui/alert-dialog/AlertDialogContent.vue new file mode 100644 index 0000000..7806192 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogContent.vue @@ -0,0 +1,38 @@ + + + + + + + + + + diff --git a/src/components/ui/alert-dialog/AlertDialogDescription.vue b/src/components/ui/alert-dialog/AlertDialogDescription.vue new file mode 100644 index 0000000..d7cb621 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogDescription.vue @@ -0,0 +1,22 @@ + + + + + + + diff --git a/src/components/ui/alert-dialog/AlertDialogFooter.vue b/src/components/ui/alert-dialog/AlertDialogFooter.vue new file mode 100644 index 0000000..c764e73 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogFooter.vue @@ -0,0 +1,21 @@ + + + + + + + diff --git a/src/components/ui/alert-dialog/AlertDialogHeader.vue b/src/components/ui/alert-dialog/AlertDialogHeader.vue new file mode 100644 index 0000000..b5e5540 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogHeader.vue @@ -0,0 +1,16 @@ + + + + + + + diff --git a/src/components/ui/alert-dialog/AlertDialogTitle.vue b/src/components/ui/alert-dialog/AlertDialogTitle.vue new file mode 100644 index 0000000..b829392 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogTitle.vue @@ -0,0 +1,20 @@ + + + + + + + diff --git a/src/components/ui/alert-dialog/AlertDialogTrigger.vue b/src/components/ui/alert-dialog/AlertDialogTrigger.vue new file mode 100644 index 0000000..f104dcc --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogTrigger.vue @@ -0,0 +1,12 @@ + + + + + + + diff --git a/src/components/ui/alert-dialog/index.ts b/src/components/ui/alert-dialog/index.ts new file mode 100644 index 0000000..cf1b45d --- /dev/null +++ b/src/components/ui/alert-dialog/index.ts @@ -0,0 +1,9 @@ +export { default as AlertDialog } from "./AlertDialog.vue" +export { default as AlertDialogAction } from "./AlertDialogAction.vue" +export { default as AlertDialogCancel } from "./AlertDialogCancel.vue" +export { default as AlertDialogContent } from "./AlertDialogContent.vue" +export { default as AlertDialogDescription } from "./AlertDialogDescription.vue" +export { default as AlertDialogFooter } from "./AlertDialogFooter.vue" +export { default as AlertDialogHeader } from "./AlertDialogHeader.vue" +export { default as AlertDialogTitle } from "./AlertDialogTitle.vue" +export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue" diff --git a/src/components/ui/button/index.ts b/src/components/ui/button/index.ts index 258018c..26e2c55 100644 --- a/src/components/ui/button/index.ts +++ b/src/components/ui/button/index.ts @@ -1,36 +1,38 @@ -import type { VariantProps } from 'class-variance-authority'; -import { cva } from 'class-variance-authority'; +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" -export { default as Button } from './Button.vue'; +export { default as Button } from "./Button.vue" export const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - { - variants: { - variant: { - default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', - destructive: - 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', - outline: - 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', - secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', - link: 'text-primary underline-offset-4 hover:underline', - }, - size: { - default: 'h-9 px-4 py-2 has-[>svg]:px-3', - sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', - lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', - icon: 'size-9', - 'icon-sm': 'size-8', - 'icon-lg': 'size-10', - }, - }, - defaultVariants: { - variant: 'default', - size: 'default', - }, + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + "default": "h-9 px-4 py-2 has-[>svg]:px-3", + "sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + "lg": "h-10 rounded-md px-6 has-[>svg]:px-4", + "icon": "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, }, -); - -export type ButtonVariants = VariantProps; + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) +export type ButtonVariants = VariantProps diff --git a/src/components/ui/tooltip/TooltipContent.vue b/src/components/ui/tooltip/TooltipContent.vue index b1748e8..cc3115f 100644 --- a/src/components/ui/tooltip/TooltipContent.vue +++ b/src/components/ui/tooltip/TooltipContent.vue @@ -37,7 +37,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits); diff --git a/src/composable/useBlockSimulator.ts b/src/composable/useBlockSimulator.ts new file mode 100644 index 0000000..72771ed --- /dev/null +++ b/src/composable/useBlockSimulator.ts @@ -0,0 +1,117 @@ +// Block type definitions with their corresponding GitHub raw URLs +export interface BlockType { + id: string; + label: string; + description: string; + url: string; +} + +export const BLOCK_TYPES: BlockType[] = [ + { + id: 'receivers', + label: 'Receivers', + description: 'Block with multiple receivers', + url: 'https://raw.githubusercontent.com/unyt-org/datex-core/main/crates/datex-core/tests/structs/receivers/block.bin' + }, + { + id: 'no_receivers', + label: 'No Receivers', + description: 'Block without receivers', + url: 'https://raw.githubusercontent.com/unyt-org/datex-core/main/crates/datex-core/tests/structs/no_receivers/block.bin' + }, + { + id: 'receivers_with_keys', + label: 'Receivers With Keys', + description: 'Block with encryption keys', + url: 'https://raw.githubusercontent.com/unyt-org/datex-core/main/crates/datex-core/tests/structs/receivers_with_keys/block.bin' + }, + { + id: 'single_receiver_request', + label: 'Single Receiver Request', + description: 'Request block with single receiver', + url: 'https://raw.githubusercontent.com/unyt-org/datex-core/main/crates/datex-core/tests/structs/single_receiver_request/block.bin' + }, + { + id: 'with_payload', + label: 'With Payload', + description: 'Block containing payload data', + url: 'https://raw.githubusercontent.com/unyt-org/datex-core/main/crates/datex-core/tests/structs/with_payload/block.bin' + } +]; + +// Cache for fetched block data +const blockCache = new Map(); + +/** + * Fetches a binary block from GitHub and converts it to Uint8Array + */ +async function fetchBlock(url: string): Promise { + // Check cache first + if (blockCache.has(url)) { + return blockCache.get(url)!; + } + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch block: ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + + // Cache the result + blockCache.set(url, uint8Array); + + return uint8Array; + } catch (error) { + console.error('Error fetching block:', error); + throw error; + } +} + +/** + * Sends a specific block type to the network inspector + */ +export async function sendBlock(blockType: BlockType, baseInterface: { impl: { receive: (socketUUID: string, data: Uint8Array) => void } }, socketUUID: string): Promise { + try { + const blockData = await fetchBlock(blockType.url); + baseInterface.impl.receive(socketUUID, blockData); + console.log(`Sent ${blockType.label} block:`, blockData); + } catch (error) { + console.error(`Failed to send ${blockType.label} block:`, error); + throw error; + } +} + +/** + * Composable for block simulation functionality + */ +export function useBlockSimulator() { + /** + * Sends a block by its ID + */ + async function sendBlockById(blockId: string, baseInterface: { impl: { receive: (socketUUID: string, data: Uint8Array) => void } }, socketUUID: string): Promise { + const blockType = BLOCK_TYPES.find(bt => bt.id === blockId); + if (!blockType) { + throw new Error(`Block type not found: ${blockId}`); + } + await sendBlock(blockType, baseInterface, socketUUID); + } + + /** + * Preload all block types into cache + */ + async function preloadBlocks(): Promise { + const promises = BLOCK_TYPES.map(bt => fetchBlock(bt.url)); + await Promise.all(promises); + console.log('All blocks preloaded'); + } + + return { + BLOCK_TYPES, + sendBlock, + sendBlockById, + preloadBlocks + }; +} diff --git a/src/composable/useNetworkInspector.ts b/src/composable/useNetworkInspector.ts new file mode 100644 index 0000000..7330896 --- /dev/null +++ b/src/composable/useNetworkInspector.ts @@ -0,0 +1,255 @@ +import { Datex } from '@/lib/runtime'; +import type { RawBlockEntry } from '@/types/NetworkInspector/BlockEntry'; +import { parseStructure, type ParsedSection, type StructureDefinition } from '@unyt/speck'; +import { computed, ref } from 'vue'; + +// Helper functions to extract metadata from parsed block structure (parse once, read many) +function getBlockType(parsedBlock: ParsedSection[]): string { + const blockHeader = parsedBlock.find((section) => section.name === 'Block Header'); + if (!blockHeader) return 'Unknown'; + + const flagsAndTimestamp = blockHeader.fields.find( + (field) => field.name === 'Flags and Timestamp', + ); + if (!flagsAndTimestamp || !('subFields' in flagsAndTimestamp)) return 'Unknown'; + + const blockType = flagsAndTimestamp.subFields.find((field: { name: string }) => field.name === 'Block Type'); + return (blockType && 'parsedValue' in blockType) ? blockType.parsedValue?.toString() || 'Unknown' : 'Unknown'; +} + +function getSender(parsedBlock: ParsedSection[]): string { + const routingHeader = parsedBlock.find((section) => section.name === 'Routing Header'); + if (!routingHeader) return 'Unknown'; + + const sender = routingHeader.fields.find((field) => field.name === 'Sender'); + return (sender && 'parsedValue' in sender) ? sender.parsedValue?.toString() || 'Unknown' : 'Unknown'; +} + +function getReceivers(parsedBlock: ParsedSection[]): string[] { + const routingHeader = parsedBlock.find((section) => section.name === 'Routing Header'); + if (!routingHeader) return []; + + const receivers = routingHeader.fields.filter((field) => field.name === 'Receivers'); + return receivers.map((field) => ('parsedValue' in field) ? field.parsedValue?.toString() || '' : ''); +} + +function getTimestamp(parsedBlock: ParsedSection[]): number { + const blockHeader = parsedBlock.find((section) => section.name === 'Block Header'); + if (!blockHeader) return 0; + + const flagsAndTimestamp = blockHeader.fields.find( + (field) => field.name === 'Flags and Timestamp', + ); + if (!flagsAndTimestamp || !('subFields' in flagsAndTimestamp)) return 0; + + const timestamp = flagsAndTimestamp.subFields.find( + (field) => field.name === 'Creation Timestamp', + ); + return (timestamp && 'parsedValue' in timestamp) ? Number(timestamp.parsedValue) || 0 : 0; +} + +function getBlockSize(parsedBlock: ParsedSection[]): number { + const routingHeader = parsedBlock.find((section) => section.name === 'Routing Header'); + if (!routingHeader) return 0; + + const blockSize = routingHeader.fields.find((field) => field.name === 'Block Size'); + return (blockSize && 'parsedValue' in blockSize) ? Number(blockSize.parsedValue) || 0 : 0; +} + +function getEncryptionType(parsedBlock: ParsedSection[]): string { + const routingHeader = parsedBlock.find((section) => section.name === 'Routing Header'); + if (!routingHeader) return 'Unknown'; + + const flags = routingHeader.fields.find((field) => field.name === 'Flags'); + if (!flags || !('subFields' in flags)) return 'Unknown'; + + const encryptionType = flags.subFields.find((field: { name: string }) => field.name === 'Encryption Type'); + return (encryptionType && 'parsedValue' in encryptionType) ? encryptionType.parsedValue?.toString() || 'Unknown' : 'Unknown'; +} + +function getSignatureType(parsedBlock: ParsedSection[]): string { + const routingHeader = parsedBlock.find((section) => section.name === 'Routing Header'); + if (!routingHeader) return 'Unknown'; + + const flags = routingHeader.fields.find((field) => field.name === 'Flags'); + if (!flags || !('subFields' in flags)) return 'Unknown'; + + const signatureType = flags.subFields.find((field: { name: string }) => field.name === 'Signature Type'); + return (signatureType && 'parsedValue' in signatureType) ? signatureType.parsedValue?.toString() || 'Unknown' : 'Unknown'; +} + +// Extract all metadata once from parsed block +function extractBlockMetadata(parsedBlock: ParsedSection[]) { + return { + blockType: getBlockType(parsedBlock), + sender: getSender(parsedBlock), + receivers: getReceivers(parsedBlock), + timestamp: getTimestamp(parsedBlock), + size: getBlockSize(parsedBlock), + encryptionType: getEncryptionType(parsedBlock), + signatureType: getSignatureType(parsedBlock), + }; +} + +// Storage configuration +const STORAGE_KEY = 'datex-workbench:network-inspector:blocks'; +const MAX_STORED_BLOCKS = 200; +const INITIAL_DISPLAYED_BLOCKS = 20; +const LOAD_MORE_INCREMENT = 20; + +// Serializable block entry for localStorage +interface StoredBlockEntry { + blockBase64: string; + direction: 'in' | 'out'; + socketUuid: string; + interfaceName: string; + capturedAt: number; +} + +// Utility functions for base64 conversion +function arrayBufferToBase64(buffer: Uint8Array): string { + let binary = ''; + const len = buffer.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(buffer[i]!); + } + return btoa(binary); +} + +function base64ToArrayBuffer(base64: string): Uint8Array { + const binary = atob(base64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +// Save blocks to localStorage +function saveBlocksToStorage(blocks: RawBlockEntry[]): void { + try { + const storedBlocks: StoredBlockEntry[] = blocks.map(block => ({ + blockBase64: arrayBufferToBase64(block.originalBinary), + direction: block.direction, + socketUuid: block.socketUuid, + interfaceName: block.interfaceName, + capturedAt: block.capturedAt, + })); + localStorage.setItem(STORAGE_KEY, JSON.stringify(storedBlocks)); + } catch (error) { + console.warn('Failed to save blocks to localStorage:', error); + } +} + +// Load blocks from localStorage +function loadBlocksFromStorage(definition: StructureDefinition): RawBlockEntry[] { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return []; + + const storedBlocks: StoredBlockEntry[] = JSON.parse(stored); + return storedBlocks.map(stored => { + const originalBinary = base64ToArrayBuffer(stored.blockBase64); + const parsedBlock = parseStructure(definition, originalBinary); + const metadata = extractBlockMetadata(parsedBlock); + return { + direction: stored.direction, + parsedBlock, + originalBinary, + socketUuid: stored.socketUuid, + interfaceName: stored.interfaceName, + capturedAt: stored.capturedAt, + ...metadata, + }; + }); + } catch (error) { + console.warn('Failed to load blocks from localStorage:', error); + return []; + } +} + +const definition = await ( + await fetch( + 'https://raw.githubusercontent.com/unyt-org/datex-specification/refs/heads/main/assets/structures/dxb.json', + ) +).json(); + +// Initialize blocks from storage +const blocks = ref(loadBlocksFromStorage(definition)); + +// Lazy loading state +const loadedBlocksCount = ref(INITIAL_DISPLAYED_BLOCKS); + +// Computed property for progressively displaying blocks +const displayedBlocks = computed(() => blocks.value.slice(0, loadedBlocksCount.value)); + +// Check if more blocks are available to load +const hasMoreBlocks = computed(() => loadedBlocksCount.value < blocks.value.length); + +// Load more blocks (called when user scrolls near bottom) +function loadMoreBlocks() { + if (hasMoreBlocks.value) { + const remaining = blocks.value.length - loadedBlocksCount.value; + loadedBlocksCount.value += Math.min(LOAD_MORE_INCREMENT, remaining); + } +} + +// Reset loaded count when blocks array significantly changes +function resetLoadedCount() { + loadedBlocksCount.value = Math.min(INITIAL_DISPLAYED_BLOCKS, blocks.value.length); +} + + +function sendTestBlock() { + return Datex.execute("@@local :: 1 + 41") +} + +Datex.comHub.registerIncomingBlockInterceptor((block: Uint8Array, socket_uuid: string) => { + const parsedBlock = parseStructure(definition, block); + console.log(parsedBlock, socket_uuid); + + // Extract metadata once at capture time + const metadata = extractBlockMetadata(parsedBlock); + + // Add new block at the beginning (top of list) + blocks.value.unshift({ + direction: 'in', + parsedBlock, + originalBinary: block, + socketUuid: socket_uuid, + interfaceName: "local", + capturedAt: Date.now(), + ...metadata, + }); + + // Keep only the most recent 200 blocks (FIFO rotation) + if (blocks.value.length > MAX_STORED_BLOCKS) { + blocks.value = blocks.value.slice(0, MAX_STORED_BLOCKS); + } + + // Reset loaded count to show new block while maintaining scroll position logic + // Only reset if we're at the top (showing initial blocks) + if (loadedBlocksCount.value <= INITIAL_DISPLAYED_BLOCKS) { + loadedBlocksCount.value = INITIAL_DISPLAYED_BLOCKS; + } else { + // User has scrolled down, increment to include new block + loadedBlocksCount.value = Math.min(loadedBlocksCount.value + 1, blocks.value.length); + } + + // Persist to localStorage + saveBlocksToStorage(blocks.value); +}); + +export function useNetworkInspector() { + return { + sendTestBlock, + blocks, + displayedBlocks, + hasMoreBlocks, + loadMoreBlocks, + resetLoadedCount, + loadedBlocksCount, + saveBlocksToStorage, + }; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 3877c89..32e922e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,16 @@ import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; +import type { Updater } from '@tanstack/vue-table'; +import type { Ref } from 'vue'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function valueUpdater>( + updaterOrValue: T, + ref: Ref, +): void { + ref.value = + typeof updaterOrValue === 'function' ? updaterOrValue(ref.value) : updaterOrValue; +} diff --git a/src/router/index.ts b/src/router/index.ts index 23307c0..1c6fba1 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -17,6 +17,11 @@ const router = createRouter({ name: 'blocks', component: () => import('@/views/BlockViewer/DatexBlockProtocolViewWrapper.vue'), }, + { + path: '/network', + name: 'network', + component: () => import('@/views/NetworkInspector/NetworkInspectorView.vue'), + }, { path: '', name: 'Editor', diff --git a/src/types/NetworkInspector/BlockEntry.ts b/src/types/NetworkInspector/BlockEntry.ts new file mode 100644 index 0000000..f7fa949 --- /dev/null +++ b/src/types/NetworkInspector/BlockEntry.ts @@ -0,0 +1,20 @@ +import type { ParsedSection } from '@unyt/speck'; + +export type BlockDirection = 'in' | 'out'; + +export interface RawBlockEntry { + direction: BlockDirection; + parsedBlock: ParsedSection[]; + originalBinary: Uint8Array; + socketUuid: string; + interfaceName: string; + capturedAt: number; + // Pre-parsed metadata (extracted once at capture time) + blockType: string; + sender: string; + receivers: string[]; + timestamp: number; + size: number; + encryptionType: string; + signatureType: string; +} diff --git a/src/types/NetworkInspector/TableRow.ts b/src/types/NetworkInspector/TableRow.ts new file mode 100644 index 0000000..9956fe0 --- /dev/null +++ b/src/types/NetworkInspector/TableRow.ts @@ -0,0 +1,14 @@ +import type { BlockDirection } from './BlockEntry'; + +export interface NetworkBlockTableRow { + direction: BlockDirection; + blockType: string; + sender: string; + receiver: string; + timestamp: string; + size: number; + isEncrypted: boolean; + isSigned: boolean; + interface: string; + capturedAt: number; +} diff --git a/src/utils/searchParser.ts b/src/utils/searchParser.ts new file mode 100644 index 0000000..e6e5e62 --- /dev/null +++ b/src/utils/searchParser.ts @@ -0,0 +1,282 @@ +/** + * Search query parser for GitHub-style key:value search syntax + * Now using search-query-parser library for more robust parsing + */ + +import * as searchQuery from 'search-query-parser'; + +// TypeScript type definitions for search-query-parser (no official types available) +declare module 'search-query-parser' { + export interface ParseOptions { + keywords?: string[]; + ranges?: string[]; + tokenize?: boolean; + alwaysArray?: boolean; + offsets?: boolean; + } + + export interface ParsedQuery { + text?: string | string[]; + [key: string]: string | string[] | { from: string; to: string } | undefined; + } + + export function parse(query: string, options?: ParseOptions): string | string[] | ParsedQuery; +} + +export interface ParsedSearchQuery { + type: string[]; + sender: string[]; + receiver: string[]; + interface: string[]; + plainText: string; +} + +/** + * Normalizes library output to always return an array + */ +function normalizeToArray(value: string | string[] | undefined): string[] { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +/** + * Parses a search query string into structured filters using search-query-parser library + * Example: "type:traceback sender:alice hello" + * Returns: { type: ['traceback'], sender: ['alice'], receiver: [], interface: [], plainText: 'hello' } + */ +export function parseSearchQuery(query: string): ParsedSearchQuery { + const result: ParsedSearchQuery = { + type: [], + sender: [], + receiver: [], + interface: [], + plainText: '' + }; + + if (!query || !query.trim()) { + return result; + } + + // Configure parser to recognize our keywords + const parsed = searchQuery.parse(query, { + keywords: ['type', 'sender', 'receiver', 'interface'], + alwaysArray: true, // Always return arrays for consistency + offsets: false // We don't need offset tracking + }); + + // If result is a string or string array, it means no keywords matched - treat as plainText + if (typeof parsed === 'string') { + result.plainText = parsed; + return result; + } + + if (Array.isArray(parsed)) { + result.plainText = parsed.join(' '); + return result; + } + + // Extract matched keywords + result.type = normalizeToArray(parsed.type as string | string[]); + result.sender = normalizeToArray(parsed.sender as string | string[]); + result.receiver = normalizeToArray(parsed.receiver as string | string[]); + result.interface = normalizeToArray(parsed.interface as string | string[]); + + // Extract plain text if present + const textValue = parsed.text; + if (textValue) { + result.plainText = Array.isArray(textValue) ? textValue.join(' ') : textValue; + } + + return result; +} + +/** + * Checks if a value matches any of the filter values (case-insensitive substring match) + */ +function matchesAny(value: string, filters: string[]): boolean { + if (filters.length === 0) return true; + + const lowerValue = value.toLowerCase(); + return filters.some(filter => lowerValue.includes(filter.toLowerCase())); +} + +/** + * Checks if a row matches the plainText search across all searchable fields + */ +function matchesPlainText(row: { blockType: string; sender: string; receiver: string; interface: string }, plainText: string): boolean { + if (!plainText) return true; + + const lowerPlainText = plainText.toLowerCase(); + const searchableFields = [ + row.blockType, + row.sender, + row.receiver, + row.interface + ]; + + return searchableFields.some(field => + field && field.toLowerCase().includes(lowerPlainText) + ); +} + +/** + * Filters an array of rows based on parsed search query + * Uses AND logic between different qualifiers and OR logic between same qualifiers + */ +export function filterRowsBySearch(rows: T[], parsedQuery: ParsedSearchQuery): T[] { + return rows.filter(row => { + // Check type filter (OR logic between multiple type values) + if (!matchesAny(row.blockType, parsedQuery.type)) return false; + + // Check sender filter (OR logic between multiple sender values) + if (!matchesAny(row.sender, parsedQuery.sender)) return false; + + // Check receiver filter (OR logic between multiple receiver values) + if (!matchesAny(row.receiver, parsedQuery.receiver)) return false; + + // Check interface filter (OR logic between multiple interface values) + if (!matchesAny(row.interface, parsedQuery.interface)) return false; + + // Check plain text search + if (!matchesPlainText(row, parsedQuery.plainText)) return false; + + return true; + }); +} + +/** + * Escapes HTML special characters to prevent XSS + */ +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * Highlights search terms in text with HTML mark tags + * Returns HTML string with highlighted matches + */ +export function highlightMatches(text: string, searchTerms: string[]): string { + if (!text || searchTerms.length === 0) { + return escapeHtml(text); + } + + // Escape the original text first + let result = escapeHtml(text); + + // Create a regex pattern for all search terms (case-insensitive) + // Sort by length descending to match longer terms first + const sortedTerms = [...searchTerms] + .filter(term => term.trim().length > 0) + .sort((a, b) => b.length - a.length); + + if (sortedTerms.length === 0) return result; + + // Build regex pattern + const pattern = sortedTerms + .map(term => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) // Escape special regex chars + .join('|'); + + const regex = new RegExp(`(${pattern})`, 'gi'); + + // Replace matches with highlighted version in blue + result = result.replace(regex, '$1'); + + return result; +} + +/** + * Get all relevant search terms for a specific field + */ +export function getSearchTermsForField( + parsedQuery: ParsedSearchQuery, + field: 'type' | 'sender' | 'receiver' | 'interface' +): string[] { + const terms = [...parsedQuery[field]]; + + // Also include plain text if present + if (parsedQuery.plainText) { + terms.push(parsedQuery.plainText); + } + + return terms; +} + +/** + * Token types for syntax highlighting in search input + */ +export type TokenType = 'qualifier' | 'colon' | 'value' | 'text' | 'whitespace'; + +export interface SearchToken { + type: TokenType; + text: string; +} + +/** + * Tokenizes search query for syntax highlighting + * Uses a simplified regex approach for visual feedback + */ +export function tokenizeSearchQuery(query: string): SearchToken[] { + if (!query) return []; + + const tokens: SearchToken[] = []; + // Match qualifier:value or qualifier:"quoted value" patterns + const pattern = /(\w+)(:)("(?:[^"\\]|\\.)*"|[^\s]+)|(\s+)|([^\s:]+)/g; + + let match; + + while ((match = pattern.exec(query)) !== null) { + if (match[1] && match[2] && match[3]) { + // Qualifier:value pattern + const qualifier = match[1]; + const validQualifiers = ['type', 'sender', 'receiver', 'interface']; + + if (validQualifiers.includes(qualifier.toLowerCase())) { + tokens.push({ type: 'qualifier', text: qualifier }); + tokens.push({ type: 'colon', text: match[2] }); + tokens.push({ type: 'value', text: match[3] }); + } else { + // Unknown qualifier - treat as plain text + tokens.push({ type: 'text', text: match[0] }); + } + } else if (match[4]) { + // Whitespace + tokens.push({ type: 'whitespace', text: match[4] }); + } else if (match[5]) { + // Plain text + tokens.push({ type: 'text', text: match[5] }); + } + } + + return tokens; +} + +/** + * Converts tokens to styled HTML for display + */ +export function tokensToStyledHtml(tokens: SearchToken[]): string { + return tokens.map(token => { + const escapedText = escapeHtml(token.text); + + switch (token.type) { + case 'qualifier': + return `${escapedText}`; + case 'colon': + return `${escapedText}`; + case 'value': + return `${escapedText}`; + case 'whitespace': + return escapedText; + case 'text': + return `${escapedText}`; + default: + return escapedText; + } + }).join(''); +} diff --git a/src/views/BlockViewer/DatexBlockProtocolViewWrapper.vue b/src/views/BlockViewer/DatexBlockProtocolViewWrapper.vue index 496fed3..4507483 100644 --- a/src/views/BlockViewer/DatexBlockProtocolViewWrapper.vue +++ b/src/views/BlockViewer/DatexBlockProtocolViewWrapper.vue @@ -6,7 +6,7 @@ import DatexBlockProtocolView from './DatexBlockProtocolView.vue'; const blockData: Uint8Array = new Uint8Array( await ( await fetch( - 'https://raw.githubusercontent.com/unyt-org/datex-core/main/tests/structs/receivers_with_keys/block.bin', + 'https://raw.githubusercontent.com/unyt-org/datex-core/main/crates/datex-core/tests/structs/receivers/block.bin', ) ).arrayBuffer(), ); diff --git a/src/views/NetworkInspector/NetworkInspectorView.vue b/src/views/NetworkInspector/NetworkInspectorView.vue new file mode 100644 index 0000000..cdb71ae --- /dev/null +++ b/src/views/NetworkInspector/NetworkInspectorView.vue @@ -0,0 +1,232 @@ + + + + + + Network Inspector + + + + + {{ blockType.label }} + + + + + TraceBack (Legacy) + + + + + + + + + + + + + + + + + + Delete Blocks? + + {{ deleteMessage }} + This action cannot be undone. + + + + Cancel + + Delete + + + + + + + + + + \ No newline at end of file