diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 00000000..16615f68 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/base.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "$/components", + "utils": "$/lib/cn", + "ui": "$/components/atoms", + "lib": "$/lib", + "hooks": "$/hooks" + }, + "registries": { + "@bklit": "https://ui.bklit.com/r/{name}.json" + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8d3503f3..17c9ef84 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@hookform/resolvers": "^5.7.1", "@monaco-editor/react": "^4.7.0", + "@number-flow/react": "^0.6.2", "@radix-ui/react-checkbox": "^1", "@radix-ui/react-dialog": "^1", "@radix-ui/react-dropdown-menu": "^2", @@ -25,12 +26,22 @@ "@tanstack/react-query": "^5", "@tanstack/react-router": "^1", "@tanstack/react-table": "^9", + "@visx/curve": "^4.0.1-alpha.0", + "@visx/event": "^4.0.1-alpha.0", + "@visx/grid": "^4.0.1-alpha.0", + "@visx/responsive": "^4.0.1-alpha.0", + "@visx/scale": "^4.0.1-alpha.0", + "@visx/shape": "^4.0.1-alpha.0", "clsx": "^2", + "d3-array": "^3.2.4", + "d3-shape": "^3.2.0", "lucide-react": "^1", "monaco-editor": "^0.56.0", + "motion": "^13.1.1", "react": "^19", "react-dom": "^19", "react-hook-form": "^7", + "react-icons": "^5.7.0", "tailwind-merge": "^3", "zod": "^4" }, @@ -54,7 +65,7 @@ "vitest": "^4" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@adobe/css-tools": { @@ -524,6 +535,20 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@number-flow/react": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@number-flow/react/-/react-0.6.2.tgz", + "integrity": "sha512-WjZuV4aA+vhRCgCF+adGLgFVVAJ8vdvq6EchRT2FiBIgElTXtDOA3YgkcMr9UHpvpS9v4H70AB5wZv0D9jc3QA==", + "license": "MIT", + "dependencies": { + "esm-env": "^1.1.4", + "number-flow": "0.6.2" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, "node_modules/@oxc-project/types": { "version": "0.143.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", @@ -1555,9 +1580,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1575,9 +1597,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1595,9 +1614,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1615,9 +1631,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1635,9 +1648,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1655,9 +1665,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1871,9 +1878,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1891,9 +1895,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1911,9 +1912,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1931,9 +1929,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2427,6 +2422,84 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", + "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", + "license": "MIT" + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", + "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", + "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2441,6 +2514,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.1.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", @@ -2455,7 +2540,6 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2818,6 +2902,147 @@ "node": ">=16.20.0" } }, + "node_modules/@visx/curve": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz", + "integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==", + "license": "MIT", + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/event": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz", + "integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/point": "4.0.1-alpha.0" + } + }, + "node_modules/@visx/grid": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz", + "integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/point": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/group": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz", + "integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/point": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz", + "integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==", + "license": "MIT" + }, + "node_modules/@visx/responsive": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz", + "integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==", + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/scale": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz", + "integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==", + "license": "MIT", + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/shape": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz", + "integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==", + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/vendor": "4.0.0-alpha.0", + "classnames": "^2.3.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/vendor": { + "version": "4.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz", + "integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==", + "license": "MIT and ISC", + "dependencies": { + "@types/d3-array": "3.0.3", + "@types/d3-color": "3.1.0", + "@types/d3-delaunay": "6.0.1", + "@types/d3-format": "3.0.1", + "@types/d3-geo": "3.1.0", + "@types/d3-interpolate": "3.0.1", + "@types/d3-path": "3.1.1", + "@types/d3-scale": "4.0.2", + "@types/d3-shape": "3.1.7", + "@types/d3-time": "3.0.0", + "@types/d3-time-format": "2.1.0", + "d3-array": "3.2.1", + "d3-color": "3.1.0", + "d3-delaunay": "6.0.2", + "d3-format": "3.1.0", + "d3-geo": "3.1.0", + "d3-interpolate": "3.0.1", + "d3-path": "3.1.0", + "d3-scale": "4.0.2", + "d3-shape": "3.2.0", + "d3-time": "3.1.0", + "d3-time-format": "4.1.0", + "internmap": "2.0.3" + } + }, + "node_modules/@visx/vendor/node_modules/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", @@ -3032,6 +3257,12 @@ "node": ">=18" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3079,9 +3310,135 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", + "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -3103,6 +3460,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3179,6 +3545,12 @@ "dev": true, "license": "MIT" }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3217,6 +3589,29 @@ } } }, + "node_modules/framer-motion": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.1.tgz", + "integrity": "sha512-B/xn2TPS4f61cEBLFjiYlQFnBZUW1YVj/LM+C+N4OP8Rs95VLEI2ot/RlfBg111la/EiyECFaJJi/A3FWA8MUA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^13.1.1", + "motion-utils": "^13.0.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -3281,6 +3676,15 @@ "node": ">=8" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -3513,9 +3917,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3537,9 +3938,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3561,9 +3959,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3585,9 +3980,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3643,6 +4035,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", @@ -3721,6 +4119,43 @@ "marked": "14.0.0" } }, + "node_modules/motion": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/motion/-/motion-13.1.1.tgz", + "integrity": "sha512-WNZoK6xiF+kkTqkZ5K7FDDh6A8BG4i5Hc7KXtW8gtTxkpJFds+hIOrDaQGKjQj/AE/i4hJqAaUHEqp/Qo02y6Q==", + "license": "MIT", + "dependencies": { + "framer-motion": "^13.1.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.1.1.tgz", + "integrity": "sha512-XSf8VYWSB6G/0IY3rWVbyLcxWXtAVHkN1PQE2agTaCv3u8RGvbwu56TyyR/MNzBqqNavEBTZzErcxI1TxBrjcA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^13.0.0" + } + }, + "node_modules/motion-utils": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz", + "integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.17", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", @@ -3740,6 +4175,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/number-flow": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/number-flow/-/number-flow-0.6.2.tgz", + "integrity": "sha512-MCnImG4Q5vPwhSXnov56nOuyyKn6LC+Qd7II1UiKc+ACRtug5iAtn0+CwXNxM38AC5lSowEY+oYEtZX2qMnUyw==", + "license": "MIT", + "dependencies": { + "esm-env": "^1.1.4" + } + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -3917,6 +4361,15 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-icons": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -4017,6 +4470,12 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", @@ -4614,9 +5073,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4638,9 +5094,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4662,9 +5115,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4686,9 +5136,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/package.json b/frontend/package.json index d950126b..054b343c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "dependencies": { "@hookform/resolvers": "^5.7.1", "@monaco-editor/react": "^4.7.0", + "@number-flow/react": "^0.6.2", "@radix-ui/react-checkbox": "^1", "@radix-ui/react-dialog": "^1", "@radix-ui/react-dropdown-menu": "^2", @@ -36,12 +37,22 @@ "@tanstack/react-query": "^5", "@tanstack/react-router": "^1", "@tanstack/react-table": "^9", + "@visx/curve": "^4.0.1-alpha.0", + "@visx/event": "^4.0.1-alpha.0", + "@visx/grid": "^4.0.1-alpha.0", + "@visx/responsive": "^4.0.1-alpha.0", + "@visx/scale": "^4.0.1-alpha.0", + "@visx/shape": "^4.0.1-alpha.0", "clsx": "^2", + "d3-array": "^3.2.4", + "d3-shape": "^3.2.0", "lucide-react": "^1", "monaco-editor": "^0.56.0", + "motion": "^13.1.1", "react": "^19", "react-dom": "^19", "react-hook-form": "^7", + "react-icons": "^5.7.0", "tailwind-merge": "^3", "zod": "^4" }, diff --git a/frontend/public/img/osquery-control-tower-simple.png b/frontend/public/img/osquery-control-tower-simple.png new file mode 100644 index 00000000..1001ecbe Binary files /dev/null and b/frontend/public/img/osquery-control-tower-simple.png differ diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d6c13455..0540995f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3,6 +3,8 @@ * Extended in with typed apiFetch, AuthError, and ApiError. */ +import { isMockDataEnabled, resolveMockApiRequest } from './mock-data'; + let csrfTokenInMemory: string | null = null; export function setCsrfToken(t: string | null) { @@ -14,6 +16,10 @@ export function getCsrfToken(): string | null { } export function isAuthenticated(): boolean { + // Fixture-backed development previews do not need a live login session. + // Production, tests, and VITE_USE_MOCK_DATA=false retain the real cookie gate. + if (isMockDataEnabled()) return true; + // Mirror the apiFetch fallback: if memory is empty but the browser // still holds the osctrl_csrf cookie, prime from it before answering // false. Without this, every soft navigation through a route with a @@ -116,6 +122,11 @@ export async function apiFetch( } } + const mockResponse = resolveMockApiRequest(path, { ...init, method, headers }); + if (mockResponse.matched) { + return mockResponse.data as T; + } + const res = await fetch(path, { credentials: 'include', ...init, diff --git a/frontend/src/api/mock-data.ts b/frontend/src/api/mock-data.ts new file mode 100644 index 00000000..eff8b4e4 --- /dev/null +++ b/frontend/src/api/mock-data.ts @@ -0,0 +1,781 @@ +/** + * Development-only API fixtures for visual review. + * + * The adapter is enabled by default in `vite` development mode and is never + * included in test or production behavior. Set VITE_USE_MOCK_DATA=false to + * bypass it. Removing it later is intentionally small: delete this file and + * the import/call in client.ts. + */ + +import type { + AdminTag, + CarveDetail, + DistributedQuery, + NodePosture, + NodesPagedResponse, + OsqueryNode, + OsqueryTable, + PostureScore, + QueryResultsResponse, + SavedQuery, +} from './types'; +import type { AuditLogsPagedResponse } from './audit'; +import type { NodeActivityBucket, NodeTileSeries, StatsResponse } from './stats'; + +export type MockApiResult = + | { matched: true; data: unknown } + | { matched: false }; + +const enabled = + import.meta.env.MODE === 'development' && + import.meta.env.VITE_USE_MOCK_DATA !== 'false'; + +export function isMockDataEnabled(): boolean { + return enabled; +} + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; +const now = Date.now(); + +function ago(milliseconds: number) { + return new Date(now - milliseconds).toISOString(); +} + +const ALL_PLATFORMS = ['darwin', 'linux', 'windows']; + +function mockOsqueryTable( + name: string, + description: string, + columns: Array<[name: string, type: string, description: string]>, + platforms = ALL_PLATFORMS, +): OsqueryTable { + return { + name, + description, + url: `https://osquery.io/schema/5.23.1/#${name}`, + platforms, + filter: platforms.map((platform) => `filter-${platform}`).join(' '), + columns: columns.map(([columnName, type, columnDescription]) => ({ + name: columnName, + type, + description: columnDescription, + })), + }; +} + +const mockOsqueryTables: OsqueryTable[] = [ + mockOsqueryTable('osquery_info', 'Build, platform, and runtime information for the running osquery process.', [ + ['version', 'text', 'Osquery version'], + ['pid', 'bigint', 'Process identifier'], + ['uuid', 'text', 'Unique process instance identifier'], + ['instance_id', 'text', 'Ephemeral instance identifier'], + ['config_hash', 'text', 'Hash of the active configuration'], + ['build_platform', 'text', 'Platform used to build osquery'], + ['start_time', 'bigint', 'Process start time'], + ['watcher', 'integer', 'Whether the watcher process is enabled'], + ]), + mockOsqueryTable('processes', 'All running processes on the host.', [ + ['pid', 'bigint', 'Process identifier'], + ['name', 'text', 'Process name'], + ['path', 'text', 'Path to the executable'], + ['cmdline', 'text', 'Complete command line'], + ['cwd', 'text', 'Current working directory'], + ['uid', 'bigint', 'User identifier'], + ['gid', 'bigint', 'Group identifier'], + ['parent', 'bigint', 'Parent process identifier'], + ['state', 'text', 'Process state'], + ['start_time', 'bigint', 'Process start time'], + ['total_size', 'bigint', 'Total virtual memory size'], + ['resident_size', 'bigint', 'Resident memory size'], + ['threads', 'integer', 'Number of threads'], + ]), + mockOsqueryTable('users', 'Local user accounts present on the host.', [ + ['uid', 'bigint', 'User identifier'], + ['gid', 'bigint', 'Primary group identifier'], + ['username', 'text', 'Login name'], + ['description', 'text', 'Account description'], + ['directory', 'text', 'Home directory'], + ['shell', 'text', 'Login shell'], + ['uuid', 'text', 'Account UUID'], + ['type', 'text', 'Account type'], + ]), + mockOsqueryTable('listening_ports', 'Processes with listening network sockets.', [ + ['pid', 'bigint', 'Process identifier'], + ['port', 'integer', 'Listening port'], + ['protocol', 'integer', 'Transport protocol'], + ['family', 'integer', 'Network family'], + ['address', 'text', 'Listening address'], + ['fd', 'bigint', 'Socket file descriptor'], + ['socket', 'bigint', 'Socket inode or handle'], + ['path', 'text', 'UNIX socket path'], + ]), + mockOsqueryTable('interface_addresses', 'IP addresses assigned to network interfaces.', [ + ['interface', 'text', 'Interface name'], + ['address', 'text', 'Assigned address'], + ['mask', 'text', 'Network mask'], + ['broadcast', 'text', 'Broadcast address'], + ['point_to_point', 'text', 'Point-to-point destination'], + ['type', 'text', 'Address type'], + ['friendly_name', 'text', 'Human-readable interface name'], + ]), + mockOsqueryTable('system_info', 'Core hardware and operating-system identity.', [ + ['hostname', 'text', 'System hostname'], + ['uuid', 'text', 'Hardware UUID'], + ['cpu_type', 'text', 'CPU architecture'], + ['cpu_brand', 'text', 'CPU brand string'], + ['physical_memory', 'bigint', 'Installed physical memory'], + ['hardware_vendor', 'text', 'Hardware vendor'], + ['hardware_model', 'text', 'Hardware model'], + ['hardware_serial', 'text', 'Hardware serial number'], + ['computer_name', 'text', 'Configured computer name'], + ['local_hostname', 'text', 'Local hostname'], + ]), + mockOsqueryTable('uptime', 'Time elapsed since the host last booted.', [ + ['days', 'integer', 'Whole days since boot'], + ['hours', 'integer', 'Hour component'], + ['minutes', 'integer', 'Minute component'], + ['seconds', 'integer', 'Second component'], + ['total_seconds', 'bigint', 'Total seconds since boot'], + ]), + mockOsqueryTable('logged_in_users', 'Users currently logged into the host.', [ + ['type', 'text', 'Login record type'], + ['user', 'text', 'Username'], + ['tty', 'text', 'Terminal device'], + ['host', 'text', 'Remote host'], + ['time', 'bigint', 'Login time'], + ['pid', 'bigint', 'Login process identifier'], + ]), + mockOsqueryTable('deb_packages', 'Installed Debian packages and package metadata.', [ + ['name', 'text', 'Package name'], + ['version', 'text', 'Installed version'], + ['source', 'text', 'Source package'], + ['size', 'bigint', 'Installed size'], + ['arch', 'text', 'Package architecture'], + ['status', 'text', 'Package status'], + ['maintainer', 'text', 'Package maintainer'], + ['section', 'text', 'Repository section'], + ], ['linux']), + mockOsqueryTable('launchd', 'macOS launch agents and daemons.', [ + ['name', 'text', 'Service name'], + ['path', 'text', 'Property list path'], + ['label', 'text', 'Launchd label'], + ['program', 'text', 'Program path'], + ['run_at_load', 'integer', 'Whether the service runs at load'], + ['keep_alive', 'integer', 'Whether launchd keeps the service alive'], + ['username', 'text', 'Configured user'], + ['groupname', 'text', 'Configured group'], + ], ['darwin']), + mockOsqueryTable('programs', 'Installed Windows applications.', [ + ['name', 'text', 'Application name'], + ['version', 'text', 'Installed version'], + ['install_location', 'text', 'Install directory'], + ['install_source', 'text', 'Installer source'], + ['publisher', 'text', 'Software publisher'], + ['install_date', 'text', 'Installation date'], + ['identifying_number', 'text', 'Product identifier'], + ], ['windows']), +]; + +const mockTags: AdminTag[] = [ + { + id: 1, + created_at: ago(90 * DAY), + updated_at: ago(5 * DAY), + name: 'production', + description: 'Production workloads', + color: '#3e63dd', + icon: 'server', + created_by: 'admin', + custom_tag: '', + auto_tag: false, + environment_id: 1, + tag_type: 0, + cohort: true, + }, + { + id: 2, + created_at: ago(60 * DAY), + updated_at: ago(12 * DAY), + name: 'engineering', + description: 'Engineering endpoints', + color: '#12a594', + icon: 'code', + created_by: 'admin', + custom_tag: '', + auto_tag: false, + environment_id: 1, + tag_type: 0, + cohort: false, + }, + { + id: 3, + created_at: ago(45 * DAY), + updated_at: ago(2 * DAY), + name: 'needs-attention', + description: 'Endpoints requiring follow-up', + color: '#e5484d', + icon: 'alert-triangle', + created_by: 'admin', + custom_tag: '', + auto_tag: true, + environment_id: 1, + tag_type: 1, + cohort: false, + }, + { + id: 4, + created_at: ago(30 * DAY), + updated_at: ago(8 * DAY), + name: 'finance', + description: 'Finance team devices', + color: '#8e4ec6', + icon: 'briefcase', + created_by: 'admin', + custom_tag: '', + auto_tag: false, + environment_id: 1, + tag_type: 0, + cohort: false, + }, +]; + +function makeNode( + id: number, + hostname: string, + platform: 'linux' | 'darwin' | 'windows', + version: string, + lastSeenHours: number, + tags: AdminTag[], + countryCode: string, + risk: 'low' | 'medium' | 'high' = 'low', +): OsqueryNode { + const platformVersion = + platform === 'darwin' ? '15.6' : platform === 'windows' ? '11 24H2' : 'Ubuntu 24.04'; + const active = lastSeenHours < 72; + return { + id, + created_at: ago((120 - id * 4) * DAY), + updated_at: ago(lastSeenHours * HOUR), + node_key: `mock-node-key-${id}`, + uuid: `mock-node-${String(id).padStart(3, '0')}`, + platform, + platform_version: platformVersion, + osquery_version: version, + hostname, + localname: hostname.split('.')[0], + ip_address: `10.24.${Math.floor(id / 10)}.${20 + id}`, + username: id % 2 === 0 ? 'svc-osquery' : 'local-user', + osquery_user: 'root', + environment: 'dev', + cpu: platform === 'darwin' ? 'Apple M3 Pro' : 'Intel Xeon Gold 6338N', + memory: String((16 + (id % 4) * 16) * 1024 * 1024 * 1024), + hardware_serial: `OSCTRL-MOCK-${String(id).padStart(5, '0')}`, + daemon_hash: `daemon-${id.toString(16).padStart(8, '0')}`, + config_hash: `config-${(id * 17).toString(16).padStart(8, '0')}`, + bytes_received: 245_000 + id * 187_321, + last_seen: ago(lastSeenHours * HOUR), + user_id: 1, + environment_id: 1, + extra_data: '', + country_code: countryCode, + tags, + uptime: { + days: 3 + id * 2, + hours: (id * 3) % 24, + minutes: id * 4, + seconds: id, + total_seconds: (3 + id * 2) * 86_400, + last_seen: ago(Math.min(lastSeenHours, 4) * HOUR), + }, + posture: { risk_level: risk }, + health: { + status: !active ? 'offline' : risk === 'high' ? 'at_risk' : risk === 'medium' ? 'attention' : 'healthy', + reason: !active + ? 'No check-in within the active window' + : risk === 'high' + ? 'Critical posture controls failed' + : risk === 'medium' + ? 'Review recommended' + : 'All signals normal', + signals: !active ? ['stale_checkin'] : risk === 'low' ? ['recent_checkin'] : ['posture_findings'], + }, + system_info: { + system: { + hardware_vendor: platform === 'darwin' ? 'Apple Inc.' : platform === 'windows' ? 'Dell Inc.' : 'Amazon EC2', + hardware_model: platform === 'darwin' ? 'MacBookPro18,3' : 'Virtual Machine', + hardware_serial: `OSCTRL-MOCK-${String(id).padStart(5, '0')}`, + cpu_brand: platform === 'darwin' ? 'Apple M3 Pro' : 'Intel Xeon Gold 6338N', + cpu_physical_cores: '8', + cpu_logical_cores: '16', + physical_memory: String((16 + (id % 4) * 16) * 1024 * 1024 * 1024), + computer_name: hostname, + local_hostname: hostname.split('.')[0], + }, + os: { + name: platform === 'darwin' ? 'macOS' : platform === 'windows' ? 'Windows 11' : 'Ubuntu', + version: platformVersion, + platform, + }, + osquery: { + version, + build_platform: platform, + config_valid: '1', + start_time: String(Math.floor((now - (id + 3) * DAY) / 1000)), + }, + }, + }; +} + +const mockNodes: OsqueryNode[] = [ + makeNode(1, 'api-prod-01.nyc', 'linux', '5.23.1', 0.1, [mockTags[0]], 'US'), + makeNode(2, 'web-prod-02.iad', 'linux', '5.23.1', 0.4, [mockTags[0]], 'US'), + makeNode(3, 'mbp-sloane', 'darwin', '5.22.0', 1.2, [mockTags[1]], 'US'), + makeNode(4, 'fin-wks-044', 'windows', '5.21.0', 3, [mockTags[3]], 'GB', 'medium'), + makeNode(5, 'buildkite-linux-07', 'linux', '5.23.1', 7, [mockTags[1]], 'DE'), + makeNode(6, 'mbp-keiko', 'darwin', '5.23.1', 13, [mockTags[1]], 'JP'), + makeNode(7, 'edge-prod-03.sfo', 'linux', '5.20.0', 28, [mockTags[0], mockTags[2]], 'US', 'high'), + makeNode(8, 'design-win-12', 'windows', '5.22.0', 51, [], 'CA'), + makeNode(9, 'worker-prod-18', 'linux', '5.23.1', 80, [mockTags[0], mockTags[2]], 'US', 'medium'), + makeNode(10, 'mbp-archive', 'darwin', '5.19.0', 118, [mockTags[2]], 'AU', 'high'), + makeNode(11, 'acct-win-07', 'windows', '5.21.0', 190, [mockTags[3], mockTags[2]], 'US', 'high'), + makeNode(12, 'legacy-db-02', 'linux', '5.18.1', 264, [mockTags[0], mockTags[2]], 'NL', 'high'), +]; + +function makeQuery( + id: number, + name: string, + query: string, + state: 'active' | 'completed' | 'expired' | 'deleted', + expected: number, + executions: number, + errors = 0, + type = 'query', +): DistributedQuery { + return { + id, + created_at: ago((id + 1) * HOUR), + updated_at: ago(id * 17 * 60 * 1000), + name, + creator: id % 2 ? 'admin' : 'security-bot', + query, + expected, + executions, + errors, + active: state === 'active', + hidden: false, + protected: false, + completed: state === 'completed', + deleted: state === 'deleted', + expired: state === 'expired', + type, + path: type === 'carve' ? query : '', + environment_id: 1, + extra_data: '', + expiration: ago(-12 * HOUR), + target: expected === mockNodes.length ? 'all' : 'selected', + targets: expected === mockNodes.length ? [{ type: 'environment', value: 'dev' }] : [{ type: 'platform', value: 'linux' }], + }; +} + +let mockQueries: DistributedQuery[] = [ + makeQuery(1, 'incident-network-connections', 'SELECT * FROM process_open_sockets;', 'active', 12, 9, 1), + makeQuery(2, 'browser-extension-audit', 'SELECT * FROM chrome_extensions;', 'active', 7, 4), + makeQuery(3, 'unsigned-kernel-modules', 'SELECT * FROM kernel_modules WHERE signed = 0;', 'completed', 8, 8), + makeQuery(4, 'launch-items-review', 'SELECT * FROM launchd;', 'completed', 2, 2), + makeQuery(5, 'stale-local-admins', 'SELECT * FROM users WHERE uid = 0;', 'expired', 12, 10, 2), +]; + +let mockSavedQueries: SavedQuery[] = [ + { id: 1, created_at: ago(48 * DAY), updated_at: ago(4 * DAY), name: 'Listening ports', creator: 'admin', query: 'SELECT pid, port, protocol, address FROM listening_ports;', environment_id: 1 }, + { id: 2, created_at: ago(34 * DAY), updated_at: ago(8 * DAY), name: 'Recent user sessions', creator: 'admin', query: 'SELECT * FROM logged_in_users;', environment_id: 1 }, + { id: 3, created_at: ago(21 * DAY), updated_at: ago(2 * DAY), name: 'Unsigned binaries', creator: 'security-bot', query: 'SELECT path FROM signature WHERE signed = 0;', environment_id: 1 }, + { id: 4, created_at: ago(9 * DAY), updated_at: ago(9 * DAY), name: 'Disk encryption status', creator: 'admin', query: 'SELECT * FROM disk_encryption;', environment_id: 1 }, +]; + +let mockCarves: DistributedQuery[] = [ + { ...makeQuery(21, 'collect-suspicious-binary', '/tmp/update-agent', 'active', 2, 1, 0, 'carve'), carve_status: 'ACTIVE' }, + { ...makeQuery(22, 'browser-artifacts', '/Users/*/Library/Application Support/Google/Chrome/Default/History', 'completed', 3, 3, 0, 'carve'), carve_status: 'COMPLETED' }, + { ...makeQuery(23, 'nginx-access-logs', '/var/log/nginx/access.log', 'expired', 4, 2, 1, 'carve'), carve_status: 'EXPIRED' }, +]; + +const mockAudit: AuditLogsPagedResponse['items'] = [ + { id: 1, created_at: ago(4 * 60 * 1000), service: 'osctrl-api', username: 'admin', line: 'ran query incident-network-connections against 12 nodes', log_type: 4, severity: 1, source_ip: '127.0.0.1', environment_id: 1, env_uuid: 'dev' }, + { id: 2, created_at: ago(19 * 60 * 1000), service: 'osctrl-api', username: 'security-bot', line: 'tagged edge-prod-03.sfo with needs-attention', log_type: 6, severity: 1, source_ip: '10.24.0.8', environment_id: 1, env_uuid: 'dev' }, + { id: 3, created_at: ago(42 * 60 * 1000), service: 'osctrl-tls', username: 'node', line: 'node mbp-sloane enrolled successfully', log_type: 3, severity: 0, source_ip: '10.24.0.23', environment_id: 1, env_uuid: 'dev' }, + { id: 4, created_at: ago(2 * HOUR), service: 'osctrl-api', username: 'admin', line: 'created carve collect-suspicious-binary', log_type: 5, severity: 1, source_ip: '127.0.0.1', environment_id: 1, env_uuid: 'dev' }, + { id: 5, created_at: ago(5 * HOUR), service: 'osctrl-api', username: 'admin', line: 'updated environment dev configuration', log_type: 7, severity: 1, source_ip: '127.0.0.1', environment_id: 1, env_uuid: 'dev' }, + { id: 6, created_at: ago(9 * HOUR), service: 'osctrl-api', username: 'admin', line: 'logged in', log_type: 1, severity: 0, source_ip: '127.0.0.1', environment_id: 0 }, +]; + +function activeNode(node: OsqueryNode) { + return Date.parse(node.last_seen) >= now - 72 * HOUR; +} + +function platformBucket(node: OsqueryNode) { + if (node.platform === 'darwin') return 'darwin'; + if (node.platform === 'windows') return 'windows'; + if (node.platform === 'linux') return 'linux'; + return 'other'; +} + +function platformCounts(nodes: OsqueryNode[]) { + const counts = { linux: 0, darwin: 0, windows: 0, other: 0 }; + for (const node of nodes) counts[platformBucket(node)] += 1; + return counts; +} + +function tileSeries(seed = 1, days = 2): NodeTileSeries { + const length = Math.max(24, days * 24); + const make = (offset: number, scale: number) => + Array.from({ length }, (_, index) => { + const wave = Math.sin((index + seed + offset) / 4) + 1; + return index % (9 + offset) === 0 ? 0 : Math.round(wave * scale + ((index + seed) % 3)); + }); + const config = make(1, 2); + const status = make(2, 8); + // Reported errors are a subset of status logs, not an additional activity + // category, so they should not be added to the aggregate total below. + const status_error = status.map((count, index) => + count > 0 && (index + seed) % 11 === 0 ? 1 : 0, + ); + const result = make(3, 5); + const query_read = make(4, 2); + const query_write = make(5, 1); + const enroll = make(6, 0.35); + const total = status.map((_, index) => + status[index] + result[index] + config[index] + query_read[index] + query_write[index] + enroll[index], + ); + return { + start: new Date(now - (length - 1) * HOUR).toISOString(), + bucket_seconds: 3600, + enroll, + status_error, + config, + status, + result, + query_read, + query_write, + total, + }; +} + +const NODE_ACTIVITY_INTERVAL_HOURS: Record = { + '3h': 3, + '6h': 6, + '12h': 12, + '1d': 24, + '2d': 48, + '3d': 72, + '7d': 168, +}; + +function nodeActivity(seed = 1, hours = 24, bucketSeconds = 3600): NodeActivityBucket[] { + const bucketMs = bucketSeconds * 1000; + const length = Math.max(1, Math.round((hours * HOUR) / bucketMs)); + const lastBucketStart = Math.floor(now / bucketMs) * bucketMs; + + return Array.from({ length }, (_, index) => ({ + bucket_start: new Date(lastBucketStart - (length - 1 - index) * bucketMs).toISOString(), + status: (index * seed) % 17, + result: (index + seed) % 9, + query: index % 6 === 0 ? 2 : 0, + carve: index % 17 === 0 ? 1 : 0, + config: index % 4 === 0 ? 1 : 0, + })); +} + +function page(items: T[], search: URLSearchParams) { + const pageNumber = Math.max(1, Number(search.get('page') ?? 1)); + const pageSize = Math.max(1, Number(search.get('page_size') ?? 50)); + const start = (pageNumber - 1) * pageSize; + return { + items: items.slice(start, start + pageSize), + page: pageNumber, + page_size: pageSize, + total_items: items.length, + total_pages: Math.max(1, Math.ceil(items.length / pageSize)), + }; +} + +function body(init: RequestInit): Partial { + if (typeof init.body !== 'string') return {}; + try { + return JSON.parse(init.body) as Partial; + } catch { + return {}; + } +} + +function hit(data: unknown): MockApiResult { + return { matched: true, data }; +} + +function queryStateMatches(query: DistributedQuery, target: string) { + if (target === 'all' || target === 'all-full') return true; + if (target === 'active') return query.active; + if (target === 'completed') return query.completed; + if (target === 'expired') return query.expired; + if (target === 'deleted') return query.deleted; + if (target === 'hidden') return query.hidden; + return true; +} + +export function resolveMockApiRequest(path: string, init: RequestInit = {}): MockApiResult { + if (!enabled) return { matched: false }; + + const url = new URL(path, 'http://osctrl.mock'); + const pathname = url.pathname; + const method = (init.method ?? 'GET').toUpperCase(); + + if (method === 'GET' && pathname === '/api/v1/users/me') { + return hit({ + id: 1, + username: 'admin', + fullname: 'Local preview', + admin: true, + service: false, + created_at: ago(180 * DAY), + updated_at: ago(2 * DAY), + last_login: ago(18 * 60 * 1000), + token_expire: new Date(now + 30 * DAY).toISOString(), + environments: [], + }); + } + + if (method === 'GET' && pathname === '/api/v1/osquery/tables') { + return hit(mockOsqueryTables); + } + + // Dashboard and activity surfaces. + if (method === 'GET' && pathname === '/api/v1/stats') { + const active = mockNodes.filter(activeNode).length; + const activeQueries = mockQueries.filter((query) => query.active).length; + const activeCarves = mockCarves.filter((carve) => carve.active).length; + const stats: StatsResponse = { + total_nodes: mockNodes.length, + active_nodes: active, + inactive_nodes: mockNodes.length - active, + inactive_hours: 72, + total_active_queries: activeQueries, + total_active_carves: activeCarves, + platform_counts: platformCounts(mockNodes), + environments: [{ + uuid: 'dev', + name: 'dev', + active, + inactive: mockNodes.length - active, + total: mockNodes.length, + active_queries: activeQueries, + active_carves: activeCarves, + platform_counts: platformCounts(mockNodes), + }], + }; + return hit(stats); + } + if (method === 'GET' && pathname === '/api/v1/stats/osquery-versions') { + const counts = new Map(); + for (const node of mockNodes) counts.set(node.osquery_version, (counts.get(node.osquery_version) ?? 0) + 1); + return hit([...counts].map(([version, count]) => ({ version, count })).sort((a, b) => b.count - a.count)); + } + if (method === 'GET' && pathname.includes('/api/v1/stats/activity/env-tiles/')) { + return hit(tileSeries(4, Number(url.searchParams.get('days') ?? 2))); + } + if (method === 'GET' && pathname.includes('/api/v1/stats/activity/node-tiles-batch/')) { + const uuids = (url.searchParams.get('uuids') ?? '').split(',').filter(Boolean); + const days = Number(url.searchParams.get('days') ?? 2); + return hit(Object.fromEntries(uuids.map((uuid, index) => [uuid, tileSeries(index + 1, days)]))); + } + if (method === 'GET' && pathname.includes('/api/v1/stats/activity/node-tiles/')) { + const uuid = pathname.split('/').at(-1) ?? ''; + const seed = mockNodes.findIndex((node) => node.uuid === uuid) + 1; + return hit(tileSeries(Math.max(seed, 1), Number(url.searchParams.get('days') ?? 2))); + } + if (method === 'GET' && pathname.includes('/api/v1/stats/activity/node-batch/')) { + const uuids = (url.searchParams.get('uuids') ?? '').split(',').filter(Boolean); + return hit(Object.fromEntries(uuids.map((uuid, index) => [uuid, nodeActivity(index + 1)]))); + } + if (method === 'GET' && pathname.includes('/api/v1/stats/activity/node/')) { + const uuid = pathname.split('/').at(-1) ?? ''; + const seed = mockNodes.findIndex((node) => node.uuid === uuid) + 1; + const hours = NODE_ACTIVITY_INTERVAL_HOURS[url.searchParams.get('interval') ?? '1d'] ?? 24; + const requestedBucketSeconds = Number(url.searchParams.get('bucket_seconds') ?? 3600); + const bucketSeconds = Number.isFinite(requestedBucketSeconds) && requestedBucketSeconds > 0 + ? requestedBucketSeconds + : 3600; + return hit(nodeActivity(Math.max(seed, 1), hours, bucketSeconds)); + } + + // Nodes list, detail, logs, and posture. + const nodeDetail = pathname.match(/^\/api\/v1\/nodes\/[^/]+\/node\/([^/]+)$/); + if (method === 'GET' && nodeDetail) { + return hit(mockNodes.find((node) => node.uuid === decodeURIComponent(nodeDetail[1])) ?? mockNodes[0]); + } + const nodeList = pathname.match(/^\/api\/v1\/nodes\/([^/]+)$/); + if (method === 'GET' && nodeList) { + const q = (url.searchParams.get('q') ?? '').toLowerCase(); + const status = url.searchParams.get('status'); + const platform = url.searchParams.get('platform'); + const filtered = mockNodes.filter((node) => { + if (q && !`${node.hostname} ${node.localname} ${node.ip_address} ${node.uuid}`.toLowerCase().includes(q)) return false; + if (status === 'active' && !activeNode(node)) return false; + if (status === 'inactive' && activeNode(node)) return false; + if (platform && platformBucket(node) !== platform) return false; + return true; + }); + return hit(page(filtered, url.searchParams) satisfies NodesPagedResponse); + } + const nodeLogs = pathname.match(/^\/api\/v1\/logs\/(status|result)\/[^/]+\/([^/]+)$/); + if (method === 'GET' && nodeLogs) { + const type = nodeLogs[1] as 'status' | 'result'; + const uuid = decodeURIComponent(nodeLogs[2]); + const items = type === 'status' + ? [ + { created_at: ago(8 * 60 * 1000), severity: '0', filename: 'scheduler.cpp', line: 'Scheduled query completed successfully', message: 'schedule executed' }, + { created_at: ago(42 * 60 * 1000), severity: '1', filename: 'watcher.cpp', line: 'Performance limit approached', message: 'watchdog memory threshold at 82%' }, + ] + : [ + { created_at: ago(12 * 60 * 1000), name: 'uptime', action: 'added', columns: { days: '14', hours: '6', minutes: '22' } }, + { created_at: ago(65 * 60 * 1000), name: 'listening_ports', action: 'added', columns: { pid: '912', port: '443', protocol: '6' } }, + ]; + return hit({ items, type, uuid, env: 'dev', limit: 100 }); + } + if (method === 'GET' && /\/posture\/score$/.test(pathname)) { + const uuid = pathname.split('/').at(-3) ?? mockNodes[0].uuid; + const score: PostureScore = { + node_uuid: uuid, + timestamp: ago(2 * HOUR), + total_score: 24, + risk_level: 'medium', + pass_count: 8, + warn_count: 2, + fail_count: 1, + controls: [ + { category: 'hardening', control_id: 'OSCTRL-1', framework: 'CIS', title: 'Disk encryption enabled', description: 'System disk should be encrypted.', status: 'pass', severity: 'high', score: 0, max_score: 20, detail: 'Encryption is enabled.' }, + { category: 'access', control_id: 'OSCTRL-2', framework: 'CIS', title: 'No unexpected local admins', description: 'Administrative access should be constrained.', status: 'warn', severity: 'medium', score: 8, max_score: 12, detail: 'One account requires review.' }, + ], + }; + return hit(score); + } + if (method === 'GET' && /\/posture$/.test(pathname)) { + const uuid = pathname.split('/').at(-2) ?? mockNodes[0].uuid; + const posture: NodePosture[] = [ + { id: 1, created_at: ago(3 * DAY), updated_at: ago(2 * HOUR), node_uuid: uuid, environment: 'dev', category: 'uptime', query_name: 'osctrl:posture:uptime', row_count: 1, summary: '14 days, 6 hours', first_seen: ago(3 * DAY), last_seen: ago(2 * HOUR) }, + { id: 2, created_at: ago(3 * DAY), updated_at: ago(2 * HOUR), node_uuid: uuid, environment: 'dev', category: 'disk_encryption', query_name: 'osctrl:posture:disk_encryption', row_count: 1, summary: 'encrypted', first_seen: ago(3 * DAY), last_seen: ago(2 * HOUR) }, + ]; + return hit(posture); + } + if (method === 'POST' && /\/api\/v1\/nodes\/[^/]+\/(delete|tag)$/.test(pathname)) { + return hit({ message: 'Mock action completed' }); + } + + // Distributed queries and their results. + const queryList = pathname.match(/^\/api\/v1\/queries\/[^/]+\/list\/([^/]+)$/); + if (method === 'GET' && queryList) { + const target = decodeURIComponent(queryList[1]); + const q = (url.searchParams.get('q') ?? '').toLowerCase(); + const filtered = mockQueries.filter((query) => queryStateMatches(query, target) && (!q || `${query.name} ${query.creator} ${query.query}`.toLowerCase().includes(q))); + return hit(page(filtered, url.searchParams)); + } + const queryResults = pathname.match(/^\/api\/v1\/queries\/[^/]+\/results\/([^/]+)$/); + if (method === 'GET' && queryResults) { + const name = decodeURIComponent(queryResults[1]); + const items = mockNodes.slice(0, 7).map((node, index) => ({ + id: index + 1, + created_at: ago((index + 1) * 4 * 60 * 1000), + uuid: node.uuid, + environment: 'dev', + name, + data: JSON.stringify({ hostname: node.hostname, platform: node.platform, pid: 400 + index, path: `/usr/bin/mock-${index}` }), + status: index === 5 ? 1 : 0, + })); + return hit({ ...page(items, url.searchParams), since: ago(HOUR) } satisfies QueryResultsResponse); + } + const queryAction = pathname.match(/^\/api\/v1\/queries\/[^/]+\/(delete|expire|complete)\/([^/]+)$/); + if (method === 'POST' && queryAction) { + const name = decodeURIComponent(queryAction[2]); + mockQueries = mockQueries.map((query) => query.name === name ? { ...query, active: false, [queryAction[1] === 'delete' ? 'deleted' : queryAction[1] === 'expire' ? 'expired' : 'completed']: true } : query); + return hit({ message: 'Mock query updated' }); + } + const queryDetail = pathname.match(/^\/api\/v1\/queries\/[^/]+\/([^/]+)$/); + if (method === 'GET' && queryDetail) { + return hit(mockQueries.find((query) => query.name === decodeURIComponent(queryDetail[1])) ?? mockQueries[0]); + } + if (method === 'POST' && /^\/api\/v1\/queries\/[^/]+$/.test(pathname)) { + return hit({ query_name: 'mock-live-query' }); + } + + // Saved queries, including in-memory CRUD for review interactions. + const savedPath = pathname.match(/^\/api\/v1\/saved-queries\/[^/]+(?:\/([^/]+))?$/); + if (savedPath) { + const name = savedPath[1] ? decodeURIComponent(savedPath[1]) : undefined; + if (method === 'GET' && !name) { + const q = (url.searchParams.get('q') ?? '').toLowerCase(); + return hit(page(mockSavedQueries.filter((item) => !q || `${item.name} ${item.creator} ${item.query}`.toLowerCase().includes(q)), url.searchParams)); + } + if (method === 'POST' && !name) { + const request = body<{ name: string; query: string }>(init); + const item: SavedQuery = { id: Date.now(), created_at: new Date().toISOString(), updated_at: new Date().toISOString(), name: request.name ?? 'Untitled query', creator: 'admin', query: request.query ?? 'SELECT 1;', environment_id: 1 }; + mockSavedQueries = [item, ...mockSavedQueries]; + return hit(item); + } + if (method === 'PATCH' && name) { + const request = body<{ query: string }>(init); + mockSavedQueries = mockSavedQueries.map((item) => item.name === name ? { ...item, query: request.query ?? item.query, updated_at: new Date().toISOString() } : item); + return hit(mockSavedQueries.find((item) => item.name === name)); + } + if (method === 'DELETE' && name) { + mockSavedQueries = mockSavedQueries.filter((item) => item.name !== name); + return hit({ message: 'Mock saved query deleted' }); + } + } + + // Carves and carve detail. + const carvePath = pathname.match(/^\/api\/v1\/carves\/[^/]+(?:\/([^/]+))?$/); + if (carvePath) { + const name = carvePath[1] ? decodeURIComponent(carvePath[1]) : undefined; + if (method === 'GET' && !name) { + const target = url.searchParams.get('target') ?? 'all'; + const q = (url.searchParams.get('q') ?? '').toLowerCase(); + return hit(page(mockCarves.filter((carve) => queryStateMatches(carve, target) && (!q || `${carve.name} ${carve.path}`.toLowerCase().includes(q))), url.searchParams)); + } + if (method === 'GET' && name) { + const query = mockCarves.find((carve) => carve.name === name) ?? mockCarves[0]; + const detail: CarveDetail = { + query, + files: mockNodes.slice(0, Math.min(query.executions, 3)).map((node, index) => ({ + carve_id: `mock-carve-${index + 1}`, + session_id: `session-${index + 1}`, + uuid: node.uuid, + path: query.path, + status: query.completed ? 'COMPLETED' : 'PENDING', + carve_size: 1_240_000 + index * 820_000, + block_size: 8192, + total_blocks: 152 + index * 100, + completed_blocks: query.completed ? 152 + index * 100 : 94, + archived: query.completed, + created_at: query.created_at, + completed_at: query.completed ? query.updated_at : '', + })), + }; + return hit(detail); + } + if (method === 'POST' && !name) return hit({ query_name: 'mock-carve' }); + } + const carveAction = pathname.match(/^\/api\/v1\/carves\/[^/]+\/(delete|expire|complete)\/([^/]+)$/); + if (method === 'POST' && carveAction) { + mockCarves = mockCarves.map((carve) => carve.name === decodeURIComponent(carveAction[2]) ? { ...carve, active: false, [carveAction[1] === 'delete' ? 'deleted' : carveAction[1] === 'expire' ? 'expired' : 'completed']: true } : carve); + return hit({ message: 'Mock carve updated' }); + } + + // Tags and audit activity. + if (method === 'GET' && /^\/api\/v1\/tags(?:\/[^/]+)?$/.test(pathname)) return hit(mockTags); + if (method === 'POST' && /^\/api\/v1\/tags\/[^/]+\/(add|edit|remove)$/.test(pathname)) return hit({ data: 'Mock tag action completed' }); + if (method === 'GET' && pathname === '/api/v1/audit-logs') { + const qType = url.searchParams.get('type'); + const filtered = qType ? mockAudit.filter((item) => item.log_type === Number(qType)) : mockAudit; + return hit(page(filtered, url.searchParams)); + } + + return { matched: false }; +} diff --git a/frontend/src/api/stats.ts b/frontend/src/api/stats.ts index b8915190..4d66b1ce 100644 --- a/frontend/src/api/stats.ts +++ b/frontend/src/api/stats.ts @@ -121,9 +121,9 @@ export function getNodeActivity( ): Promise { const sp = new URLSearchParams(); sp.set('interval', interval); - // Optional hourly override: the per-node heatmap aligns to an hourly grid so - // it can merge in the Redis-backed config series (hourly). Omitted for the - // Nodes-table sparklines, which keep the interval's native bucket size. + // Optional resolution override: the per-node heatmap requests a fixed number + // of cells and merges the result with the Redis-backed hourly config series. + // Omitted for the Nodes-table sparklines, which keep the native bucket size. if (bucketSeconds) sp.set('bucket_seconds', String(bucketSeconds)); return apiFetch( `/api/v1/stats/activity/node/${encodeURIComponent(env)}/${encodeURIComponent(uuid)}?${sp.toString()}`, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 420a19b4..6eea1182 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -563,13 +563,23 @@ export interface OsqueryTableColumn { name: string; description: string; type: string; + notes?: string; + hidden?: boolean; + required?: boolean; + index?: boolean; } export interface OsqueryTable { name: string; + description: string; url: string; platforms: string[]; filter: string; + evented?: boolean; + cacheable?: boolean; + notes?: string; + examples?: string[]; + columns: OsqueryTableColumn[]; } export interface NodePosture { diff --git a/frontend/src/components/atoms/Button.tsx b/frontend/src/components/atoms/Button.tsx index 3fa9e5c6..e120a05d 100644 --- a/frontend/src/components/atoms/Button.tsx +++ b/frontend/src/components/atoms/Button.tsx @@ -9,21 +9,24 @@ interface ButtonProps extends ButtonHTMLAttributes { size?: ButtonSize; } +interface ButtonStyleOptions { + variant?: ButtonVariant; + size?: ButtonSize; + className?: string; +} + const variantClasses: Record = { primary: [ - 'bg-gradient-to-b from-[color:var(--signal-bright)] to-[color:var(--signal)]', - 'text-[#051010]', + 'bg-[color:var(--accent)] hover:bg-[color:var(--accent-hover)]', + 'text-[color:var(--accent-contrast)]', 'font-semibold', - 'border border-[color:var(--signal)]/60', - 'shadow-[inset_0_1px_0_rgba(255,255,255,0.25),0_1px_14px_-2px_var(--signal-glow)]', - 'hover:brightness-110', - '[data-theme=light]:text-white', + 'border border-[color:var(--accent)]', ].join(' '), ghost: [ - 'bg-[color:var(--bg-2)]', - 'text-[color:var(--text-1)]', + 'bg-[color:var(--bg-1)]', + 'text-[color:var(--text-2)]', 'border border-[color:var(--border)]', - 'hover:bg-[color:var(--bg-3)] hover:border-[color:var(--border-strong)]', + 'hover:bg-[color:var(--bg-3)] hover:text-[color:var(--text-1)] hover:border-[color:var(--border-strong)]', ].join(' '), danger: [ 'bg-[color:var(--danger)]/10', @@ -34,26 +37,34 @@ const variantClasses: Record = { }; const sizeClasses: Record = { - sm: 'px-2.5 py-1 text-xs rounded-md', - md: 'px-3.5 py-2 text-sm rounded-lg', - lg: 'px-5 py-2.5 text-base rounded-lg', + sm: 'h-6 px-2 text-xs rounded-md', + md: 'h-8 px-3 text-sm rounded-md', + lg: 'h-10 px-4 text-sm rounded-lg', }; +export function buttonClasses({ + variant = 'primary', + size = 'md', + className, +}: ButtonStyleOptions = {}) { + return cn( + 'inline-flex items-center justify-center gap-2 font-medium', + 'transition-[background-color,border-color,color] duration-[100ms] ease-out', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-soft)] focus-visible:border-[color:var(--accent)]', + 'disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none', + variantClasses[variant], + sizeClasses[size], + className, + ); +} + export const Button = forwardRef( ({ variant = 'primary', size = 'md', className, disabled, children, ...props }, ref) => { return ( Environments {envs.length === 0 && !isLoading && ( -
+
No environments configured.
)} handleSelect(v)} > {envs.map((e) => ( @@ -111,7 +121,7 @@ export function EnvSwitcher({ compact }: { compact?: boolean } = {}) { : 'bg-[color:var(--text-3)]', )} /> - {e.name} + {e.name} ))} diff --git a/frontend/src/components/chrome/SideNav.tsx b/frontend/src/components/chrome/SideNav.tsx index 74aeaac4..4fd5bf4e 100644 --- a/frontend/src/components/chrome/SideNav.tsx +++ b/frontend/src/components/chrome/SideNav.tsx @@ -1,5 +1,24 @@ +import { useEffect, useRef, useState } from 'react'; import { Link, useRouterState, useParams } from '@tanstack/react-router'; import { useQuery } from '@tanstack/react-query'; +import { + Archive, + Bookmark, + Boxes, + DatabaseBackup, + Download, + FileSearch, + FileStack, + LayoutDashboard, + ListChecks, + Monitor, + Settings, + ShieldCheck, + SlidersHorizontal, + Tag, + UserRound, + Users, +} from 'lucide-react'; import { cn } from '$/lib/cn'; import { Logo } from '$/components/atoms/Logo'; import { EnvSwitcher } from './EnvSwitcher'; @@ -7,44 +26,96 @@ import { listEnvironments } from '$/api/environments'; import { getMe } from '$/api/users'; import { getFeatures } from '$/api/features'; import type { EnvAccess } from '$/api/types'; +import { readActiveEnvironment, writeActiveEnvironment } from '$/lib/environment-scope'; +import { + SIDE_NAV_PREVIEW_ID, + SideNavPreview, + type SideNavPreviewKind, +} from './SideNavPreview'; interface NavItemProps { active?: boolean; to?: string; href?: string; icon: React.ReactNode; + tone?: NavIconTone; collapsed?: boolean; + previewExpanded?: boolean; children: React.ReactNode; } -function NavItem({ active, to, href, icon, collapsed, children }: NavItemProps) { +type NavIconTone = + | 'blue' + | 'sky' + | 'teal' + | 'violet' + | 'rose' + | 'green' + | 'amber' + | 'neutral'; + +const navIconToneVariables: Record = { + blue: 'var(--accent)', + sky: 'var(--info)', + teal: 'var(--nav-teal)', + violet: 'var(--nav-violet)', + rose: 'var(--nav-rose)', + green: 'var(--success)', + amber: 'var(--warning)', + neutral: 'var(--text-2)', +}; + +function NavItem({ + active, + to, + href, + icon, + tone = 'neutral', + collapsed, + previewExpanded, + children, +}: NavItemProps) { const className = cn( - 'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm', + 'flex h-7 items-center gap-2 rounded-md px-1.5 text-[13px] font-medium', collapsed && 'justify-center', - 'transition-colors duration-[120ms] ease-out', - 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--signal)]', + 'transition-colors duration-[100ms] ease-out', + 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--accent)]', active - ? [ - 'text-[color:var(--text-1)]', - 'bg-[linear-gradient(90deg,rgba(var(--halo-r),var(--halo-g),var(--halo-b),0.12),rgba(var(--halo-r),var(--halo-g),var(--halo-b),0)_60%),var(--bg-2)]', - 'shadow-[inset_2px_0_0_var(--signal)]', - ].join(' ') - : 'text-[color:var(--text-2)] hover:text-[color:var(--text-1)] hover:bg-[color:var(--bg-2)]', + ? 'bg-[color:var(--bg-3)] text-[color:var(--text-1)]' + : 'text-[color:var(--text-2)] hover:bg-[color:var(--bg-3)] hover:text-[color:var(--text-1)]', ); // In collapsed (icon-rail) mode the label moves to a native tooltip + // sr-only text, so the item stays accessible and hover-discoverable. const title = collapsed && typeof children === 'string' ? children : undefined; + const iconColor = navIconToneVariables[tone]; const content = ( <> - {icon} + + {icon} + {children} ); if (to) { return ( - + {content} ); @@ -54,6 +125,8 @@ function NavItem({ active, to, href, icon, collapsed, children }: NavItemProps) @@ -64,7 +137,7 @@ function NavItem({ active, to, href, icon, collapsed, children }: NavItemProps) function SectionLabel({ children }: { children: React.ReactNode }) { return ( -
+
{children}
); @@ -74,11 +147,17 @@ interface SideNavProps { className?: string; /** Desktop icon-rail mode: labels collapse to tooltips, rail narrows. */ collapsed?: boolean; - /** Renders the collapse/expand chevron at the rail's foot when provided. */ - onToggleCollapse?: () => void; + /** Context previews are desktop-only; the mobile drawer navigates directly. */ + previewsEnabled?: boolean; +} + +interface OpenPreview { + kind: SideNavPreviewKind; + anchor: { top: number; right: number }; + focusFirstItem: boolean; } -export function SideNav({ className, collapsed, onToggleCollapse }: SideNavProps = {}) { +export function SideNav({ className, collapsed, previewsEnabled = true }: SideNavProps = {}) { const routerState = useRouterState(); const pathname = routerState.location.pathname; const params = useParams({ strict: false }); @@ -96,7 +175,11 @@ export function SideNav({ className, collapsed, onToggleCollapse }: SideNavProps staleTime: 60_000, }); const urlEnv = (params as { env?: string }).env; - const currentEnv = urlEnv ?? envs?.[0]?.name ?? 'dev'; + const currentEnv = urlEnv ?? readActiveEnvironment() ?? envs?.[0]?.name ?? 'dev'; + + useEffect(() => { + if (urlEnv) writeActiveEnvironment(urlEnv); + }, [urlEnv]); // Resolve "who am I" + my per-env access map. Drives the nav // gating: items the operator has no access to are hidden. Super- @@ -122,7 +205,9 @@ export function SideNav({ className, collapsed, onToggleCollapse }: SideNavProps // currentEnv is the SPA's name-of-env; permissions are keyed by // env UUID. We need to translate name → UUID via the envs list. // Fall back to "no access" when the lookup hasn't resolved yet. - const envUuid = envs?.find((e) => e.name === currentEnv)?.uuid; + const envUuid = envs?.find( + (e) => e.name === currentEnv || e.uuid === currentEnv, + )?.uuid; const myEnvAccess: EnvAccess | undefined = envUuid ? me?.permissions?.[envUuid] : undefined; // Super-admins bypass per-env checks. For everyone else, "can see @@ -172,41 +257,111 @@ export function SideNav({ className, collapsed, onToggleCollapse }: SideNavProps const dashboardPath = `/_app/env/${currentEnv}`; const isDashboardActive = pathname === dashboardPath || pathname === dashboardPath + '/'; + const [preview, setPreview] = useState(null); + const openTimer = useRef(null); + const closeTimer = useRef(null); + const previewTrigger = useRef(null); + + function clearOpenTimer() { + if (openTimer.current != null) window.clearTimeout(openTimer.current); + openTimer.current = null; + } + + function clearCloseTimer() { + if (closeTimer.current != null) window.clearTimeout(closeTimer.current); + closeTimer.current = null; + } + + function openPreview( + kind: SideNavPreviewKind, + triggerContainer: HTMLElement, + options: { immediate?: boolean; focusFirstItem?: boolean } = {}, + ) { + if (!previewsEnabled) return; + clearOpenTimer(); + clearCloseTimer(); + const show = () => { + const rect = triggerContainer.getBoundingClientRect(); + previewTrigger.current = triggerContainer.querySelector('a'); + setPreview({ + kind, + anchor: { top: rect.top, right: rect.right }, + focusFirstItem: options.focusFirstItem ?? false, + }); + }; + if (options.immediate) show(); + else openTimer.current = window.setTimeout(show, 140); + } + + function schedulePreviewClose() { + clearOpenTimer(); + clearCloseTimer(); + closeTimer.current = window.setTimeout(() => setPreview(null), 220); + } + + function dismissPreview({ restoreFocus = false } = {}) { + clearOpenTimer(); + clearCloseTimer(); + setPreview(null); + if (restoreFocus) previewTrigger.current?.focus(); + } + + function previewTriggerProps(kind: SideNavPreviewKind) { + return { + onMouseEnter: (event: React.MouseEvent) => openPreview(kind, event.currentTarget), + onMouseLeave: schedulePreviewClose, + onFocus: (event: React.FocusEvent) => { + openPreview(kind, event.currentTarget, { immediate: true }); + }, + onBlur: schedulePreviewClose, + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key === 'ArrowRight') { + event.preventDefault(); + openPreview(kind, event.currentTarget, { immediate: true, focusFirstItem: true }); + } + if (event.key === 'Escape' && preview?.kind === kind) { + event.preventDefault(); + dismissPreview(); + } + }, + }; + } + + useEffect(() => { + dismissPreview(); + }, [pathname, currentEnv]); + + useEffect(() => () => { + clearOpenTimer(); + clearCloseTimer(); + }, []); + return ( ); diff --git a/frontend/src/components/chrome/SideNavPreview.test.tsx b/frontend/src/components/chrome/SideNavPreview.test.tsx new file mode 100644 index 00000000..b1e2f221 --- /dev/null +++ b/frontend/src/components/chrome/SideNavPreview.test.tsx @@ -0,0 +1,168 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DistributedQuery, NodesPagedResponse, OsqueryNode, QueriesPagedResponse } from '$/api/types'; +import { SideNavPreview } from './SideNavPreview'; + +const mockListNodes = vi.fn(); +const mockListQueries = vi.fn(); + +vi.mock('$/api/nodes', () => ({ + listNodes: (...args: unknown[]) => mockListNodes(...args), +})); + +vi.mock('$/api/queries', () => ({ + listQueries: (...args: unknown[]) => mockListQueries(...args), +})); + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ + children, + to, + params, + ...props + }: { + children: ReactNode; + to: string; + params?: Record; + [key: string]: unknown; + }) => { + const href = Object.entries(params ?? {}).reduce( + (path, [key, value]) => path.replace(`$${key}`, value), + to, + ); + return
{children}; + }, +})); + +function makeNode(overrides: Partial = {}): OsqueryNode { + return { + id: 1, + created_at: '2026-08-23T10:00:00Z', + updated_at: '2026-08-23T10:00:00Z', + uuid: 'node-001', + platform: 'linux', + platform_version: '6.8', + osquery_version: '5.18', + hostname: 'edge-linux-01', + localname: 'edge-linux-01', + ip_address: '10.0.0.8', + username: 'root', + osquery_user: 'root', + environment: 'dev', + cpu: '', + memory: '', + hardware_serial: '', + daemon_hash: '', + config_hash: '', + bytes_received: 0, + last_seen: new Date(Date.now() - 5 * 60_000).toISOString(), + user_id: 1, + environment_id: 1, + extra_data: '', + ...overrides, + }; +} + +function nodesResponse(items: OsqueryNode[]): NodesPagedResponse { + return { items, page: 1, page_size: items.length, total_items: items.length, total_pages: 1 }; +} + +function makeQuery(overrides: Partial = {}): DistributedQuery { + return { + id: 1, + created_at: new Date(Date.now() - 10 * 60_000).toISOString(), + updated_at: '2026-08-23T10:00:00Z', + name: 'running-processes', + creator: 'operator', + query: 'select * from processes;', + expected: 12, + executions: 8, + errors: 0, + active: true, + hidden: false, + protected: false, + completed: false, + deleted: false, + expired: false, + type: 'query', + path: '', + environment_id: 1, + extra_data: '', + expiration: '', + target: 'all', + ...overrides, + }; +} + +function queriesResponse(items: DistributedQuery[]): QueriesPagedResponse { + return { items, page: 1, page_size: items.length, total_items: items.length, total_pages: 1 }; +} + +function renderPreview(kind: 'nodes' | 'queries', focusFirstItem = false) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe('SideNavPreview', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('puts problem nodes ahead of recently seen nodes without loading query data', async () => { + const atRisk = makeNode({ + uuid: 'node-risk', + hostname: 'finance-mac-07', + platform: 'darwin', + health: { status: 'at_risk', reason: 'Posture checks are failing' }, + }); + const recent = makeNode({ uuid: 'node-recent', hostname: 'build-linux-03' }); + mockListNodes.mockImplementation(({ status }: { status: string }) => Promise.resolve( + status === 'inactive' ? nodesResponse([atRisk]) : nodesResponse([atRisk, recent]), + )); + + renderPreview('nodes'); + + expect(await screen.findByText('finance-mac-07')).toBeInTheDocument(); + expect(screen.getByText('Posture checks are failing')).toBeInTheDocument(); + expect(screen.getByText('build-linux-03')).toBeInTheDocument(); + expect(screen.getByText('At risk')).toBeInTheDocument(); + expect(mockListNodes).toHaveBeenCalledTimes(2); + expect(mockListQueries).not.toHaveBeenCalled(); + }); + + it('surfaces query errors and supports keyboard entry into the preview', async () => { + const failing = makeQuery({ id: 9, name: 'network-connections', errors: 2 }); + const completed = makeQuery({ + id: 10, + name: 'listening-ports', + active: false, + completed: true, + }); + mockListQueries.mockImplementation(({ target }: { target: string }) => Promise.resolve( + target === 'active' ? queriesResponse([failing]) : queriesResponse([failing, completed]), + )); + + renderPreview('queries', true); + + expect(await screen.findByText('network-connections')).toBeInTheDocument(); + expect(screen.getByText('2 errors')).toBeInTheDocument(); + expect(screen.getByText('listening-ports')).toBeInTheDocument(); + await waitFor(() => expect(document.activeElement).toHaveTextContent('View all')); + expect(mockListQueries).toHaveBeenCalledTimes(2); + expect(mockListNodes).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/chrome/SideNavPreview.tsx b/frontend/src/components/chrome/SideNavPreview.tsx new file mode 100644 index 00000000..6e1aa1bf --- /dev/null +++ b/frontend/src/components/chrome/SideNavPreview.tsx @@ -0,0 +1,308 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { ArrowRight, FileSearch, Monitor } from 'lucide-react'; +import { listNodes } from '$/api/nodes'; +import { listQueries } from '$/api/queries'; +import type { DistributedQuery, OsqueryNode } from '$/api/types'; +import { PlatformIcon, type PlatformId } from '$/components/data/PlatformIcon'; +import { StatusPip, type PipVariant } from '$/components/data/StatusPip'; +import { formatRelative } from '$/lib/time'; + +export type SideNavPreviewKind = 'nodes' | 'queries'; + +export const SIDE_NAV_PREVIEW_ID = 'side-nav-context-preview'; + +interface SideNavPreviewProps { + kind: SideNavPreviewKind; + env: string; + anchor: { top: number; right: number }; + focusFirstItem?: boolean; + onDismiss: () => void; + onInteractionStart: () => void; + onInteractionEnd: () => void; +} + +const PANEL_HEIGHT = 440; +const PANEL_GUTTER = 8; + +function platformId(value: string): PlatformId { + const normalized = value.toLowerCase(); + if (normalized.includes('darwin') || normalized.includes('mac')) return 'darwin'; + if (normalized.includes('windows')) return 'windows'; + if (normalized.includes('freebsd') || normalized.includes('free bsd')) return 'freebsd'; + if (normalized.includes('linux')) return 'linux'; + return 'all'; +} + +function uniqueNodes(nodes: OsqueryNode[]): OsqueryNode[] { + return [...new Map(nodes.map((node) => [node.uuid, node])).values()]; +} + +function isAttentionNode(node: OsqueryNode): boolean { + return node.health?.status === 'attention' + || node.health?.status === 'at_risk' + || node.health?.status === 'offline'; +} + +function nodeState(node: OsqueryNode, attention: boolean): { label: string; variant: PipVariant } { + if (node.health?.status === 'at_risk') return { label: 'At risk', variant: 'danger' }; + if (node.health?.status === 'attention') return { label: 'Attention', variant: 'warning' }; + if (node.health?.status === 'offline') return { label: 'Offline', variant: 'dim' }; + if (attention) return { label: 'Inactive', variant: 'dim' }; + return { label: 'Seen recently', variant: 'success' }; +} + +function queryState(query: DistributedQuery): { label: string; variant: PipVariant } { + if (query.errors > 0) { + return { + label: `${query.errors} ${query.errors === 1 ? 'error' : 'errors'}`, + variant: 'danger', + }; + } + if (query.active) return { label: `${query.executions}/${query.expected} responses`, variant: 'info' }; + if (query.completed) return { label: 'Completed', variant: 'success' }; + if (query.expired) return { label: 'Expired', variant: 'warning' }; + return { label: 'Pending', variant: 'dim' }; +} + +function PreviewLoading() { + return ( +
+ {[0, 1, 2].map((index) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +function SectionHeading({ children, count }: { children: React.ReactNode; count?: number }) { + return ( +
+

{children}

+ {count != null && ( + {count} + )} +
+ ); +} + +function EmptySection({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function NodeRow({ node, env, attention }: { node: OsqueryNode; env: string; attention: boolean }) { + const state = nodeState(node, attention); + return ( + + + + + + + {node.hostname || node.localname || node.uuid.slice(0, 8)} + + + {attention && node.health?.reason + ? node.health.reason + : `Last seen ${formatRelative(node.last_seen)}`} + + + + + {state.label} + + + ); +} + +function QueryRow({ query, env }: { query: DistributedQuery; env: string }) { + const state = queryState(query); + return ( + + + + + + + {query.name} + + + {query.creator ? `${query.creator} · ` : ''}{formatRelative(query.created_at)} + + + + + {state.label} + + + ); +} + +function NodesPreview({ env }: { env: string }) { + const recentQuery = useQuery({ + queryKey: ['nodes', env, 'nav-preview', 'recent'], + queryFn: () => listNodes({ env, status: 'all', sort: 'lastseen', dir: 'desc', page: 1, pageSize: 8 }), + staleTime: 30_000, + }); + const inactiveQuery = useQuery({ + queryKey: ['nodes', env, 'nav-preview', 'inactive'], + queryFn: () => listNodes({ env, status: 'inactive', sort: 'lastseen', dir: 'desc', page: 1, pageSize: 4 }), + staleTime: 30_000, + }); + + const allAttentionNodes = useMemo( + () => uniqueNodes([ + ...(recentQuery.data?.items.filter(isAttentionNode) ?? []), + ...(inactiveQuery.data?.items ?? []), + ]), + [inactiveQuery.data?.items, recentQuery.data?.items], + ); + const attentionNodes = allAttentionNodes.slice(0, 3); + const attentionIds = new Set(allAttentionNodes.map((node) => node.uuid)); + const recentNodes = (recentQuery.data?.items ?? []) + .filter((node) => !attentionIds.has(node.uuid)) + .slice(0, 3); + + if (recentQuery.isLoading || inactiveQuery.isLoading) return ; + if (recentQuery.isError && inactiveQuery.isError) { + return Node activity is unavailable right now.; + } + + return ( +
+ Needs attention + {attentionNodes.length > 0 + ? attentionNodes.map((node) => ) + : No nodes need attention.} + +
+ Recently seen + {recentNodes.length > 0 + ? recentNodes.map((node) => ) + : No recent node activity.} +
+ ); +} + +function QueriesPreview({ env }: { env: string }) { + const activeQuery = useQuery({ + queryKey: ['queries', env, 'nav-preview', 'active'], + queryFn: () => listQueries({ env, target: 'active', sort: 'created', dir: 'desc', page: 1, pageSize: 5 }), + staleTime: 30_000, + }); + const recentQuery = useQuery({ + queryKey: ['queries', env, 'nav-preview', 'recent'], + queryFn: () => listQueries({ env, target: 'all', sort: 'created', dir: 'desc', page: 1, pageSize: 6 }), + staleTime: 30_000, + }); + + const active = [...(activeQuery.data?.items ?? [])] + .sort((a, b) => b.errors - a.errors) + .slice(0, 3); + const activeIds = new Set(active.map((query) => query.id)); + const recent = (recentQuery.data?.items ?? []) + .filter((query) => !activeIds.has(query.id)) + .slice(0, 3); + + if (activeQuery.isLoading || recentQuery.isLoading) return ; + if (activeQuery.isError && recentQuery.isError) { + return Query activity is unavailable right now.; + } + + return ( +
+ Running now + {active.length > 0 + ? active.map((query) => ) + : No queries are running.} + +
+ Recent + {recent.length > 0 + ? recent.map((query) => ) + : No recent queries.} +
+ ); +} + +export function SideNavPreview({ + kind, + env, + anchor, + focusFirstItem, + onDismiss, + onInteractionStart, + onInteractionEnd, +}: SideNavPreviewProps) { + const panelRef = useRef(null); + const title = kind === 'nodes' ? 'Nodes' : 'Queries'; + const destination = kind === 'nodes' + ? `/_app/env/${env}/nodes` + : `/_app/env/${env}/queries`; + const Icon = kind === 'nodes' ? Monitor : FileSearch; + const top = Math.max(PANEL_GUTTER, Math.min(anchor.top - PANEL_GUTTER, window.innerHeight - PANEL_HEIGHT - PANEL_GUTTER)); + + useEffect(() => { + if (!focusFirstItem) return; + panelRef.current?.querySelector('[data-preview-item]')?.focus(); + }, [focusFirstItem, kind]); + + return createPortal( + , + document.body, + ); +} diff --git a/frontend/src/components/chrome/ThemeToggle.tsx b/frontend/src/components/chrome/ThemeToggle.tsx index 1f059d8c..f9f9434e 100644 --- a/frontend/src/components/chrome/ThemeToggle.tsx +++ b/frontend/src/components/chrome/ThemeToggle.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { Moon, Sun } from 'lucide-react'; import { cn } from '$/lib/cn'; import { toggleTheme, getInitialTheme, applyTheme } from '$/lib/theme'; import type { Theme } from '$/lib/design-tokens'; @@ -13,38 +14,25 @@ export function ThemeToggle() { applyTheme(current); }, [current]); - function handleToggle(theme: 'dark' | 'light') { - if (theme === current) return; + function handleToggle() { const next = toggleTheme(); setCurrent(next); } return ( -
- {(['dark', 'light'] as const).map((theme) => ( - - ))} -
+ {current === 'dark' ? : } + ); } diff --git a/frontend/src/components/chrome/TopBar.tsx b/frontend/src/components/chrome/TopBar.tsx index 072f45f0..3e9e15e6 100644 --- a/frontend/src/components/chrome/TopBar.tsx +++ b/frontend/src/components/chrome/TopBar.tsx @@ -1,4 +1,5 @@ import { cn } from '$/lib/cn'; +import { Menu, PanelLeftClose, PanelLeftOpen, Search } from 'lucide-react'; import { ThemeToggle } from './ThemeToggle'; import { UserMenu } from './UserMenu'; @@ -12,6 +13,8 @@ interface TopBarProps { username?: string; onCommandPalette?: () => void; onMenuToggle?: () => void; + desktopNavCollapsed?: boolean; + onDesktopNavToggle?: () => void; } export function TopBar({ @@ -19,12 +22,14 @@ export function TopBar({ username, onCommandPalette, onMenuToggle, + desktopNavCollapsed, + onDesktopNavToggle, }: TopBarProps) { return (
- - - + + + )} + + {/* Persistent desktop sidebar control. Keeping this in the toolbar + means it remains discoverable even when the rail is collapsed. */} + {onDesktopNavToggle && ( + )} {/* Breadcrumbs */} -