mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-13 17:21:48 +02:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e9a040174 | |||
| a5332bb0cc | |||
| b3963cc34b | |||
| ddb132f9fa | |||
| cdc50ed47a | |||
| e2441ce284 | |||
| 0b6a3234a5 | |||
| ae8599c723 | |||
| 938e9b0d49 | |||
| 05e4ad3200 | |||
| 9eb55ba68c | |||
| 6d14a4df49 | |||
| 94949aa3fd | |||
| df098f55ba | |||
| facbb8f0a4 | |||
| 36fbd8818c | |||
| 91883397e6 | |||
| fd1813f3a7 | |||
| 0d820df797 | |||
| 76aea311a4 | |||
| 1a3cf2094b | |||
| 09cb20a084 | |||
| d1fb2e19d3 | |||
| 2934bbdd20 | |||
| 2b46e8eaba | |||
| 3b89104a59 | |||
| 5bf8b336c5 | |||
| 21a144753d | |||
| c1b8dfc863 | |||
| 5efcd4479a | |||
| e4e8b33e9f | |||
| af13790c93 | |||
| a163cc3678 | |||
| 1dfb3408e8 | |||
| 67fb2beba1 | |||
| c500979099 | |||
| 18d380ce30 | |||
| 81274960f6 |
@@ -14,12 +14,13 @@ body:
|
|||||||
label: Environment
|
label: Environment
|
||||||
description: Please fill out the relevant details below for your environment.
|
description: Please fill out the relevant details below for your environment.
|
||||||
value: |
|
value: |
|
||||||
- OS Type & Version: (e.g., Ubuntu 22.04)
|
- OS Type & Version:
|
||||||
- Pangolin Version:
|
- Pangolin Version:
|
||||||
|
- Edition (Community or Enterprise):
|
||||||
- Gerbil Version:
|
- Gerbil Version:
|
||||||
- Traefik Version:
|
- Traefik Version:
|
||||||
- Newt Version:
|
- Newt Version:
|
||||||
- Olm Version: (if applicable)
|
- Client Version:
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { APP_PATH } from "@server/lib/consts";
|
import { APP_PATH } from "./server/lib/consts";
|
||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
golang.org/x/term v0.42.0
|
golang.org/x/term v0.43.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +33,6 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
golang.org/x/sync v0.15.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-4
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
|||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
@@ -1164,6 +1164,9 @@
|
|||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
"siteLabelsDescription": "Manage labels associated with this site.",
|
||||||
"labelsNotFound": "Labels not found",
|
"labelsNotFound": "Labels not found",
|
||||||
"labelSearch": "Search labels",
|
"labelSearch": "Search labels",
|
||||||
|
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
||||||
|
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
||||||
|
"accessLabelFilterClear": "Clear label filters",
|
||||||
"selectColor": "Select color",
|
"selectColor": "Select color",
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
"createNewLabel": "Create new org label \"{label}\"",
|
||||||
"inviteInvalidDescription": "The invite link is invalid.",
|
"inviteInvalidDescription": "The invite link is invalid.",
|
||||||
@@ -1351,6 +1354,29 @@
|
|||||||
"otpAuthBack": "Back to Password",
|
"otpAuthBack": "Back to Password",
|
||||||
"navbar": "Navigation Menu",
|
"navbar": "Navigation Menu",
|
||||||
"navbarDescription": "Main navigation menu for the application",
|
"navbarDescription": "Main navigation menu for the application",
|
||||||
|
"commandPaletteTitle": "Command palette",
|
||||||
|
"commandPaletteDescription": "Search for pages, organizations, resources, and actions",
|
||||||
|
"commandPaletteSearchPlaceholder": "Search pages, resources, actions...",
|
||||||
|
"commandPaletteNoResults": "No results found.",
|
||||||
|
"commandPaletteSearching": "Searching...",
|
||||||
|
"commandPaletteNavigation": "Navigation",
|
||||||
|
"commandPaletteOrganizations": "Organizations",
|
||||||
|
"commandPaletteSites": "Sites",
|
||||||
|
"commandPaletteResources": "Resources",
|
||||||
|
"commandPaletteUsers": "Users",
|
||||||
|
"commandPaletteClients": "Machine clients",
|
||||||
|
"commandPaletteActions": "Actions",
|
||||||
|
"commandPaletteCreateSite": "Create site",
|
||||||
|
"commandPaletteCreateProxyResource": "Create public resource",
|
||||||
|
"commandPaletteCreateUser": "Create user",
|
||||||
|
"commandPaletteCreateApiKey": "Create API key",
|
||||||
|
"commandPaletteCreateMachineClient": "Create machine client",
|
||||||
|
"commandPaletteCreateAlertRule": "Create alert rule",
|
||||||
|
"commandPaletteCreateIdentityProvider": "Create identity provider",
|
||||||
|
"commandPaletteToggleTheme": "Toggle theme",
|
||||||
|
"commandPaletteChooseOrganization": "Choose organization",
|
||||||
|
"commandPaletteShortcutMac": "⌘K",
|
||||||
|
"commandPaletteShortcutWindows": "Ctrl K",
|
||||||
"navbarDocsLink": "Documentation",
|
"navbarDocsLink": "Documentation",
|
||||||
"otpErrorEnable": "Unable to enable 2FA",
|
"otpErrorEnable": "Unable to enable 2FA",
|
||||||
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
|
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
|
||||||
@@ -1646,6 +1672,7 @@
|
|||||||
"certificateStatus": "Certificate",
|
"certificateStatus": "Certificate",
|
||||||
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
|
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
|
"loadingEllipsis": "Loading...",
|
||||||
"loadingAnalytics": "Loading Analytics",
|
"loadingAnalytics": "Loading Analytics",
|
||||||
"restart": "Restart",
|
"restart": "Restart",
|
||||||
"domains": "Domains",
|
"domains": "Domains",
|
||||||
|
|||||||
+1
-6
@@ -5,12 +5,7 @@ const withNextIntl = createNextIntlPlugin();
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
eslint: {
|
reactCompiler: true,
|
||||||
ignoreDuringBuilds: true
|
|
||||||
},
|
|
||||||
experimental: {
|
|
||||||
reactCompiler: true
|
|
||||||
},
|
|
||||||
output: "standalone"
|
output: "standalone"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Generated
+2193
-3403
File diff suppressed because it is too large
Load Diff
+52
-50
@@ -32,10 +32,10 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||||
"@aws-sdk/client-s3": "3.1011.0",
|
"@aws-sdk/client-s3": "3.1047.0",
|
||||||
"@faker-js/faker": "10.3.0",
|
"@faker-js/faker": "10.4.0",
|
||||||
"@headlessui/react": "2.2.9",
|
"@headlessui/react": "2.2.10",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@node-rs/argon2": "2.0.2",
|
"@node-rs/argon2": "2.0.2",
|
||||||
@@ -59,16 +59,17 @@
|
|||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@radix-ui/react-toast": "1.2.15",
|
"@radix-ui/react-toast": "1.2.15",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@react-email/components": "1.0.8",
|
"@react-email/body": "0.3.0",
|
||||||
"@react-email/render": "2.0.4",
|
"@react-email/components": "1.0.12",
|
||||||
"@react-email/tailwind": "2.0.5",
|
"@react-email/render": "2.0.8",
|
||||||
|
"@react-email/tailwind": "2.0.7",
|
||||||
"@simplewebauthn/browser": "13.3.0",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
"@simplewebauthn/server": "13.3.0",
|
"@simplewebauthn/server": "13.3.0",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.100.10",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.15.0",
|
"axios": "1.16.1",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
"canvas-confetti": "1.9.4",
|
"canvas-confetti": "1.9.4",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
@@ -80,76 +81,76 @@
|
|||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.3.0",
|
"express-rate-limit": "8.5.2",
|
||||||
"glob": "13.0.6",
|
"glob": "13.0.6",
|
||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"ioredis": "5.10.0",
|
"ioredis": "5.10.1",
|
||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.1.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lucide-react": "0.577.0",
|
"lucide-react": "0.577.0",
|
||||||
"maxmind": "5.0.5",
|
"maxmind": "5.0.6",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.5.15",
|
"next": "16.2.6",
|
||||||
"next-intl": "4.8.3",
|
"next-intl": "4.12.0",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "8.0.7",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.20.0",
|
"pg": "8.20.0",
|
||||||
"posthog-node": "5.28.0",
|
"posthog-node": "5.34.1",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.6",
|
||||||
"react-day-picker": "9.14.0",
|
"react-day-picker": "9.14.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.6",
|
||||||
"react-easy-sort": "1.8.0",
|
"react-easy-sort": "1.8.0",
|
||||||
"react-hook-form": "7.71.2",
|
"react-hook-form": "7.75.0",
|
||||||
"react-icons": "5.6.0",
|
"react-icons": "5.6.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "3.8.1",
|
||||||
"reodotdev": "1.1.0",
|
"reodotdev": "1.1.0",
|
||||||
"resend": "6.9.2",
|
"resend": "6.12.3",
|
||||||
"semver": "7.7.4",
|
"semver": "7.8.0",
|
||||||
"sshpk": "1.18.0",
|
"sshpk": "1.18.0",
|
||||||
"stripe": "20.4.1",
|
"stripe": "20.4.1",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.6.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"use-debounce": "10.1.0",
|
"use-debounce": "10.1.1",
|
||||||
"uuid": "13.0.0",
|
"uuid": "14.0.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"visionscarto-world-atlas": "1.0.0",
|
"visionscarto-world-atlas": "1.0.0",
|
||||||
"winston": "3.19.0",
|
"winston": "3.19.0",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
"ws": "8.19.0",
|
"ws": "8.20.1",
|
||||||
"yaml": "2.8.3",
|
"yaml": "2.9.0",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "4.3.6",
|
"zod": "4.4.3",
|
||||||
"zod-validation-error": "5.0.0"
|
"zod-validation-error": "5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.54.1",
|
"@dotenvx/dotenvx": "1.66.0",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@react-email/preview-server": "5.2.10",
|
"@react-email/ui": "^6.1.4",
|
||||||
"@tailwindcss/postcss": "4.2.2",
|
"@tailwindcss/postcss": "4.3.0",
|
||||||
"@tanstack/react-query-devtools": "5.91.3",
|
"@tanstack/react-query-devtools": "5.100.10",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/crypto-js": "4.2.2",
|
"@types/crypto-js": "4.2.2",
|
||||||
"@types/d3": "7.4.3",
|
"@types/d3": "7.4.3",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/express-session": "1.18.2",
|
"@types/express-session": "1.19.0",
|
||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "9.0.10",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "25.3.5",
|
"@types/node": "25.8.0",
|
||||||
"@types/nodemailer": "7.0.11",
|
"@types/nodemailer": "8.0.0",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
"@types/pg": "8.18.0",
|
"@types/pg": "8.20.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
@@ -160,21 +161,22 @@
|
|||||||
"@types/yargs": "17.0.35",
|
"@types/yargs": "17.0.35",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"esbuild": "0.27.4",
|
"esbuild": "0.28.0",
|
||||||
"esbuild-node-externals": "1.20.1",
|
"esbuild-node-externals": "1.22.0",
|
||||||
"eslint": "10.0.3",
|
"eslint": "10.3.0",
|
||||||
"eslint-config-next": "16.1.7",
|
"eslint-config-next": "16.2.6",
|
||||||
"postcss": "8.5.8",
|
"postcss": "8.5.14",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.3",
|
||||||
"react-email": "5.2.10",
|
"react-email": "6.1.4",
|
||||||
"tailwindcss": "4.2.2",
|
"tailwindcss": "4.3.0",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.17",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.22.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.56.1"
|
"typescript-eslint": "8.59.3"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "0.27.4",
|
"esbuild": "0.28.0",
|
||||||
"dompurify": "3.3.2"
|
"dompurify": "3.4.0",
|
||||||
|
"postcss": "8.5.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
#! /usr/bin/env node
|
#! /usr/bin/env node
|
||||||
import "./extendZod.ts";
|
import "./extendZod";
|
||||||
|
|
||||||
import { runSetupFunctions } from "./setup";
|
import { runSetupFunctions } from "./setup";
|
||||||
import { createApiServer } from "./apiServer";
|
import { createApiServer } from "./apiServer";
|
||||||
|
|||||||
@@ -154,8 +154,19 @@ class AdaptiveCache {
|
|||||||
keys(): string[] {
|
keys(): string[] {
|
||||||
return localCache.keys();
|
return localCache.keys();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get keys with a specific prefix
|
||||||
|
* @param prefix - Key prefix to match
|
||||||
|
* @returns Array of matching keys
|
||||||
|
*/
|
||||||
|
async keysWithPrefix(prefix: string): Promise<string[]> {
|
||||||
|
const allKeys = localCache.keys();
|
||||||
|
return allKeys.filter((key) => key.startsWith(prefix));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const cache = new AdaptiveCache();
|
export const cache = new AdaptiveCache();
|
||||||
|
export const regionalCache = cache; // Alias for compatability with the private version
|
||||||
export default cache;
|
export default cache;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function getFirstString(value: unknown): string | undefined {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value) && typeof value[0] === "string") {
|
||||||
|
return value[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, logsDb, statusHistory } from "@server/db";
|
import { db, logsDb, statusHistory } from "@server/db";
|
||||||
import { and, eq, gte, asc } from "drizzle-orm";
|
import { and, eq, gte, asc } from "drizzle-orm";
|
||||||
import cache from "@server/lib/cache";
|
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||||
|
|
||||||
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export async function invalidateStatusHistoryCache(
|
|||||||
entityId: number
|
entityId: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
||||||
const keys = cache.keys().filter((k) => k.startsWith(prefix));
|
const keys = await cache.keysWithPrefix(prefix);
|
||||||
if (keys.length > 0) {
|
if (keys.length > 0) {
|
||||||
await cache.del(keys);
|
await cache.del(keys);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { resourceAccessToken, resources, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyAccessTokenAccess(
|
export async function verifyApiKeyAccessTokenAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,7 @@ export async function verifyApiKeyAccessTokenAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const accessTokenId = req.params.accessTokenId;
|
const accessTokenId = getFirstString(req.params.accessTokenId);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -20,6 +21,12 @@ export async function verifyApiKeyAccessTokenAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!accessTokenId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [accessToken] = await db
|
const [accessToken] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resourceAccessToken)
|
.from(resourceAccessToken)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { apiKeys, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq, or } from "drizzle-orm";
|
import { and, eq, or } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyApiKeyAccess(
|
export async function verifyApiKeyApiKeyAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,8 +15,10 @@ export async function verifyApiKeyApiKeyAccess(
|
|||||||
const { apiKey: callerApiKey } = req;
|
const { apiKey: callerApiKey } = req;
|
||||||
|
|
||||||
const apiKeyId =
|
const apiKeyId =
|
||||||
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
|
getFirstString(req.params.apiKeyId) ||
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.body.apiKeyId) ||
|
||||||
|
getFirstString(req.query.apiKeyId);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!callerApiKey) {
|
if (!callerApiKey) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db, domains, orgDomains, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyDomainAccess(
|
export async function verifyApiKeyDomainAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,8 +13,10 @@ export async function verifyApiKeyDomainAccess(
|
|||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const domainId =
|
const domainId =
|
||||||
req.params.domainId || req.body.domainId || req.query.domainId;
|
getFirstString(req.params.domainId) ||
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.body.domainId) ||
|
||||||
|
getFirstString(req.query.domainId);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -27,6 +30,12 @@ export async function verifyApiKeyDomainAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (apiKey.isRoot) {
|
if (apiKey.isRoot) {
|
||||||
// Root keys can access any domain in any org
|
// Root keys can access any domain in any org
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { idp, idpOrg, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyIdpAccess(
|
export async function verifyApiKeyIdpAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,8 +13,12 @@ export async function verifyApiKeyIdpAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
|
const idpIdRaw =
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.params.idpId) ||
|
||||||
|
getFirstString(req.body.idpId) ||
|
||||||
|
getFirstString(req.query.idpId);
|
||||||
|
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -27,7 +32,7 @@ export async function verifyApiKeyIdpAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!idpId) {
|
if (Number.isNaN(idpId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID")
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyOrgAccess(
|
export async function verifyApiKeyOrgAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,7 @@ export async function verifyApiKeyOrgAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKeyId = req.apiKey?.apiKeyId;
|
const apiKeyId = req.apiKey?.apiKeyId;
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!apiKeyId) {
|
if (!apiKeyId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -45,7 +46,7 @@ export async function verifyApiKeyOrgAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!req.apiKeyOrg) {
|
if (!req.apiKeyOrg) {
|
||||||
next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"Key does not have access to this organization"
|
"Key does not have access to this organization"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { siteResources, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeySiteResourceAccess(
|
export async function verifyApiKeySiteResourceAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,8 @@ export async function verifyApiKeySiteResourceAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const siteResourceId = parseInt(req.params.siteResourceId);
|
const siteResourceIdRaw = getFirstString(req.params.siteResourceId);
|
||||||
|
const siteResourceId = Number.parseInt(siteResourceIdRaw ?? "", 10);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -20,7 +22,7 @@ export async function verifyApiKeySiteResourceAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!siteResourceId) {
|
if (Number.isNaN(siteResourceId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { resources, targets, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyTargetAccess(
|
export async function verifyApiKeyTargetAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,8 @@ export async function verifyApiKeyTargetAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const targetId = parseInt(req.params.targetId);
|
const targetIdRaw = getFirstString(req.params.targetId);
|
||||||
|
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -20,7 +22,7 @@ export async function verifyApiKeyTargetAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNaN(targetId)) {
|
if (Number.isNaN(targetId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyAccessTokenAccess(
|
export async function verifyAccessTokenAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,7 +15,7 @@ export async function verifyAccessTokenAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const accessTokenId = req.params.accessTokenId;
|
const accessTokenId = getFirstString(req.params.accessTokenId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -22,6 +23,12 @@ export async function verifyAccessTokenAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!accessTokenId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [accessToken] = await db
|
const [accessToken] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resourceAccessToken)
|
.from(resourceAccessToken)
|
||||||
@@ -87,7 +94,7 @@ export async function verifyAccessTokenAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"User does not have access to this organization"
|
"User does not have access to this organization"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyAccess(
|
export async function verifyApiKeyAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,9 +15,24 @@ export async function verifyApiKeyAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const apiKeyId =
|
const apiKeyIdFromParams = getFirstString(req.params?.apiKeyId);
|
||||||
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
|
const apiKeyIdFromBody = getFirstString(req.body?.apiKeyId);
|
||||||
const orgId = req.params.orgId;
|
|
||||||
|
if (
|
||||||
|
apiKeyIdFromParams &&
|
||||||
|
apiKeyIdFromBody &&
|
||||||
|
apiKeyIdFromParams !== apiKeyIdFromBody
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"API key ID provided in both URL and body with different values"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyId = apiKeyIdFromParams || apiKeyIdFromBody;
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -104,10 +120,7 @@ export async function verifyApiKeyAccess(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
|
||||||
req.userOrg.userId,
|
|
||||||
orgId
|
|
||||||
);
|
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyDomainAccess(
|
export async function verifyDomainAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,9 +15,8 @@ export async function verifyDomainAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const domainId =
|
const domainId = getFirstString(req.params.domainId);
|
||||||
req.params.domainId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const orgId = req.params.orgId;
|
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -62,10 +62,7 @@ export async function verifyDomainAccess(
|
|||||||
.select()
|
.select()
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
||||||
eq(userOrgs.userId, userId),
|
|
||||||
eq(userOrgs.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
req.userOrg = userOrgRole[0];
|
req.userOrg = userOrgRole[0];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyLimits(
|
export async function verifyLimits(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -13,7 +14,10 @@ export async function verifyLimits(
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgId = req.userOrgId || req.apiKeyOrg?.orgId || req.params.orgId;
|
const orgId =
|
||||||
|
req.userOrgId ||
|
||||||
|
req.apiKeyOrg?.orgId ||
|
||||||
|
getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail
|
return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyOrgAccess(
|
export async function verifyOrgAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -13,7 +14,7 @@ export async function verifyOrgAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { db, userOrgs, siteProvisioningKeys, siteProvisioningKeyOrg } from "@server/db";
|
import {
|
||||||
|
db,
|
||||||
|
userOrgs,
|
||||||
|
siteProvisioningKeys,
|
||||||
|
siteProvisioningKeyOrg
|
||||||
|
} from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifySiteProvisioningKeyAccess(
|
export async function verifySiteProvisioningKeyAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -13,8 +19,10 @@ export async function verifySiteProvisioningKeyAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const siteProvisioningKeyId = req.params.siteProvisioningKeyId;
|
const siteProvisioningKeyId = getFirstString(
|
||||||
const orgId = req.params.orgId;
|
req.params.siteProvisioningKeyId
|
||||||
|
);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -80,10 +88,7 @@ export async function verifySiteProvisioningKeyAccess(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(userOrgs.userId, userId),
|
eq(userOrgs.userId, userId),
|
||||||
eq(
|
eq(userOrgs.orgId, row.siteProvisioningKeyOrg.orgId)
|
||||||
userOrgs.orgId,
|
|
||||||
row.siteProvisioningKeyOrg.orgId
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import { canUserAccessResource } from "../auth/canUserAccessResource";
|
import { canUserAccessResource } from "../auth/canUserAccessResource";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyTargetAccess(
|
export async function verifyTargetAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,7 +15,8 @@ export async function verifyTargetAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const targetId = parseInt(req.params.targetId);
|
const targetIdRaw = getFirstString(req.params.targetId);
|
||||||
|
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { userOrgs } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyUserIsOrgOwner(
|
export async function verifyUserIsOrgOwner(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -11,7 +12,7 @@ export async function verifyUserIsOrgOwner(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
+145
-1
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { redisManager } from "@server/private/lib/redis";
|
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
||||||
|
|
||||||
// Create local cache with maxKeys limit to prevent memory leaks
|
// Create local cache with maxKeys limit to prevent memory leaks
|
||||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||||
@@ -298,3 +298,147 @@ class AdaptiveCache {
|
|||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const cache = new AdaptiveCache();
|
export const cache = new AdaptiveCache();
|
||||||
export default cache;
|
export default cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regional adaptive cache backed by the in-cluster Redis instance.
|
||||||
|
* Falls back to a local NodeCache when the regional Redis is unavailable.
|
||||||
|
* Use this for data that is regional in nature (e.g. status history) so
|
||||||
|
* reads are served from the same cluster the user is hitting.
|
||||||
|
*/
|
||||||
|
const regionalLocalCache = new NodeCache({
|
||||||
|
stdTTL: 3600,
|
||||||
|
checkperiod: 120,
|
||||||
|
maxKeys: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
class RegionalAdaptiveCache {
|
||||||
|
private useRedis(): boolean {
|
||||||
|
return (
|
||||||
|
regionalRedisManager.isRedisEnabled() &&
|
||||||
|
regionalRedisManager.getHealthStatus().isHealthy
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: any, ttl?: number): Promise<boolean> {
|
||||||
|
const effectiveTtl = ttl === 0 ? undefined : ttl;
|
||||||
|
const redisTtl = ttl === 0 ? undefined : (ttl ?? 3600);
|
||||||
|
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
const success = await regionalRedisManager.set(
|
||||||
|
key,
|
||||||
|
serialized,
|
||||||
|
redisTtl
|
||||||
|
);
|
||||||
|
if (success) {
|
||||||
|
logger.debug(`[regional] Set key in Redis: ${key}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis set error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = regionalLocalCache.set(key, value, effectiveTtl || 0);
|
||||||
|
if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T = any>(key: string): Promise<T | undefined> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const value = await regionalRedisManager.get(key);
|
||||||
|
if (value !== null) {
|
||||||
|
logger.debug(`[regional] Cache hit in Redis: ${key}`);
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
}
|
||||||
|
logger.debug(`[regional] Cache miss in Redis: ${key}`);
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis get error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = regionalLocalCache.get<T>(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
logger.debug(`[regional] Cache hit in local cache: ${key}`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`[regional] Cache miss in local cache: ${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string | string[]): Promise<number> {
|
||||||
|
const keys = Array.isArray(key) ? key : [key];
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
for (const k of keys) {
|
||||||
|
const success = await regionalRedisManager.del(k);
|
||||||
|
if (success) {
|
||||||
|
deletedCount++;
|
||||||
|
logger.debug(`[regional] Deleted key from Redis: ${k}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deletedCount === keys.length) return deletedCount;
|
||||||
|
deletedCount = 0;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[regional] Redis del error:`, error);
|
||||||
|
deletedCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
const count = regionalLocalCache.del(k);
|
||||||
|
if (count > 0) {
|
||||||
|
deletedCount++;
|
||||||
|
logger.debug(`[regional] Deleted key from local cache: ${k}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
async has(key: string): Promise<boolean> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const value = await regionalRedisManager.get(key);
|
||||||
|
return value !== null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis has error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return regionalLocalCache.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns keys matching the given prefix from whichever backend is active.
|
||||||
|
* Redis uses a KEYS scan; local cache filters in-memory keys.
|
||||||
|
*/
|
||||||
|
async keysWithPrefix(prefix: string): Promise<string[]> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
return await regionalRedisManager.keys(`${prefix}*`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[regional] Redis keys error:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return regionalLocalCache.keys().filter((k) => k.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentBackend(): "redis" | "local" {
|
||||||
|
return this.useRedis() ? "redis" : "local";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const regionalCache = new RegionalAdaptiveCache();
|
||||||
|
|||||||
@@ -73,6 +73,25 @@ export const privateConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
rejectUnauthorized: z.boolean().optional().default(true)
|
rejectUnauthorized: z.boolean().optional().default(true)
|
||||||
})
|
})
|
||||||
|
.optional(),
|
||||||
|
regional_redis: z
|
||||||
|
.object({
|
||||||
|
host: z.string(),
|
||||||
|
port: portSchema,
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("REGIONAL_REDIS_PASSWORD")),
|
||||||
|
db: z.int().nonnegative().optional().default(0),
|
||||||
|
tls: z
|
||||||
|
.object({
|
||||||
|
rejectUnauthorized: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(true)
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
+164
-4
@@ -109,14 +109,14 @@ class RedisManager {
|
|||||||
password: redisConfig.password,
|
password: redisConfig.password,
|
||||||
db: redisConfig.db
|
db: redisConfig.db
|
||||||
};
|
};
|
||||||
|
|
||||||
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
||||||
if (redisConfig.tls) {
|
if (redisConfig.tls) {
|
||||||
opts.tls = {
|
opts.tls = {
|
||||||
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,14 +135,14 @@ class RedisManager {
|
|||||||
password: replica.password,
|
password: replica.password,
|
||||||
db: replica.db || redisConfig.db
|
db: replica.db || redisConfig.db
|
||||||
};
|
};
|
||||||
|
|
||||||
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
||||||
if (redisConfig.tls) {
|
if (redisConfig.tls) {
|
||||||
opts.tls = {
|
opts.tls = {
|
||||||
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,3 +855,163 @@ class RedisManager {
|
|||||||
export const redisManager = new RedisManager();
|
export const redisManager = new RedisManager();
|
||||||
export const redis = redisManager.getClient();
|
export const redis = redisManager.getClient();
|
||||||
export default redisManager;
|
export default redisManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight Redis manager for the regional (in-cluster) Redis instance.
|
||||||
|
* Connects only when `redis.regional_redis` is present in the private config
|
||||||
|
* and `flags.enable_redis` is true. No pub/sub — designed for low-latency
|
||||||
|
* caching of regionally-scoped data.
|
||||||
|
*/
|
||||||
|
class RegionalRedisManager {
|
||||||
|
private writeClient: Redis | null = null;
|
||||||
|
private readClient: Redis | null = null;
|
||||||
|
private isEnabled: boolean = false;
|
||||||
|
private isHealthy: boolean = false;
|
||||||
|
private connectionTimeout: number = 5000;
|
||||||
|
private commandTimeout: number = 5000;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (build === "oss") return;
|
||||||
|
|
||||||
|
const cfg = privateConfig.getRawPrivateConfig();
|
||||||
|
if (!cfg.flags.enable_redis || !cfg.redis?.regional_redis) return;
|
||||||
|
|
||||||
|
this.isEnabled = true;
|
||||||
|
this.initializeClients();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfig(): RedisOptions {
|
||||||
|
const r = privateConfig.getRawPrivateConfig().redis!.regional_redis!;
|
||||||
|
const opts: RedisOptions = {
|
||||||
|
host: r.host,
|
||||||
|
port: r.port,
|
||||||
|
password: r.password,
|
||||||
|
db: r.db
|
||||||
|
};
|
||||||
|
if (r.tls) {
|
||||||
|
opts.tls = { rejectUnauthorized: r.tls.rejectUnauthorized ?? true };
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeClients(): void {
|
||||||
|
const cfg = this.getConfig();
|
||||||
|
const baseOpts = {
|
||||||
|
...cfg,
|
||||||
|
enableReadyCheck: false,
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
keepAlive: 10000,
|
||||||
|
connectTimeout: this.connectionTimeout,
|
||||||
|
commandTimeout: this.commandTimeout
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.writeClient = new Redis(baseOpts);
|
||||||
|
// redis-1 (replica) handles reads; fall back to primary if not resolvable
|
||||||
|
this.readClient = new Redis({
|
||||||
|
...baseOpts,
|
||||||
|
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
|
||||||
|
// Derive replica hostname from the headless service pattern:
|
||||||
|
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
|
||||||
|
// If it doesn't look like a k8s service, just use the same host
|
||||||
|
return h + rest;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// For simplicity use same host for both; callers can always read from primary
|
||||||
|
// The real replica routing is handled by the StatefulSet headless service
|
||||||
|
this.readClient = this.writeClient;
|
||||||
|
|
||||||
|
this.writeClient.on("ready", () => {
|
||||||
|
logger.info("Regional Redis client ready");
|
||||||
|
this.isHealthy = true;
|
||||||
|
});
|
||||||
|
this.writeClient.on("error", (err) => {
|
||||||
|
logger.error("Regional Redis client error:", err);
|
||||||
|
this.isHealthy = false;
|
||||||
|
});
|
||||||
|
this.writeClient.on("reconnecting", () => {
|
||||||
|
logger.info("Regional Redis client reconnecting...");
|
||||||
|
this.isHealthy = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Regional Redis client initialized");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to initialize regional Redis client:", error);
|
||||||
|
this.isEnabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isRedisEnabled(): boolean {
|
||||||
|
return this.isEnabled && this.writeClient !== null && this.isHealthy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getHealthStatus() {
|
||||||
|
return { isEnabled: this.isEnabled, isHealthy: this.isHealthy };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async set(
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
ttl?: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||||
|
try {
|
||||||
|
if (ttl) {
|
||||||
|
await this.writeClient.setex(key, ttl, value);
|
||||||
|
} else {
|
||||||
|
await this.writeClient.set(key, value);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis SET error:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(key: string): Promise<string | null> {
|
||||||
|
if (!this.isRedisEnabled() || !this.readClient) return null;
|
||||||
|
try {
|
||||||
|
return await this.readClient.get(key);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis GET error:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async del(key: string): Promise<boolean> {
|
||||||
|
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||||
|
try {
|
||||||
|
await this.writeClient.del(key);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis DEL error:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async keys(pattern: string): Promise<string[]> {
|
||||||
|
if (!this.isRedisEnabled() || !this.readClient) return [];
|
||||||
|
try {
|
||||||
|
return await this.readClient.keys(pattern);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis KEYS error:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disconnect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (this.writeClient) {
|
||||||
|
await this.writeClient.quit();
|
||||||
|
this.writeClient = null;
|
||||||
|
}
|
||||||
|
this.readClient = null;
|
||||||
|
logger.info("Regional Redis client disconnected");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error disconnecting regional Redis client:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const regionalRedisManager = new RegionalRedisManager();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyCertificateAccess(
|
export async function verifyCertificateAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -27,11 +28,43 @@ export async function verifyCertificateAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Assume user/org access is already verified
|
// Assume user/org access is already verified
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const certId =
|
|
||||||
req.params.certId || req.body?.certId || req.query?.certId;
|
const certIdFromParams = getFirstString(req.params?.certId);
|
||||||
let domainId =
|
const certIdFromBody = getFirstString(req.body?.certId);
|
||||||
req.params.domainId || req.body?.domainId || req.query?.domainId;
|
|
||||||
|
if (
|
||||||
|
certIdFromParams &&
|
||||||
|
certIdFromBody &&
|
||||||
|
certIdFromParams !== certIdFromBody
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Certificate ID provided in both URL and body with different values"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const certId = certIdFromParams || certIdFromBody;
|
||||||
|
|
||||||
|
const domainIdFromParams = getFirstString(req.params?.domainId);
|
||||||
|
const domainIdFromBody = getFirstString(req.body?.domainId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
domainIdFromParams &&
|
||||||
|
domainIdFromBody &&
|
||||||
|
domainIdFromParams !== domainIdFromBody
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Domain ID provided in both URL and body with different values"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let domainId = domainIdFromParams || domainIdFromBody;
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -65,7 +98,7 @@ export async function verifyCertificateAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
domainId = cert.domainId;
|
domainId = cert.domainId ?? undefined;
|
||||||
if (!domainId) {
|
if (!domainId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { and, eq } from "drizzle-orm";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyIdpAccess(
|
export async function verifyIdpAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -25,8 +26,12 @@ export async function verifyIdpAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
|
const idpIdRaw =
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.params.idpId) ||
|
||||||
|
getFirstString(req.body?.idpId) ||
|
||||||
|
getFirstString(req.query?.idpId);
|
||||||
|
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -40,7 +45,7 @@ export async function verifyIdpAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!idpId) {
|
if (Number.isNaN(idpId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { and, eq } from "drizzle-orm";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyRemoteExitNodeAccess(
|
export async function verifyRemoteExitNodeAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -25,11 +26,11 @@ export async function verifyRemoteExitNodeAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId; // Assuming you have user information in the request
|
const userId = req.user!.userId; // Assuming you have user information in the request
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const remoteExitNodeId =
|
const remoteExitNodeId =
|
||||||
req.params.remoteExitNodeId ||
|
getFirstString(req.params.remoteExitNodeId) ||
|
||||||
req.body.remoteExitNodeId ||
|
getFirstString(req.body?.remoteExitNodeId) ||
|
||||||
req.query.remoteExitNodeId;
|
getFirstString(req.query?.remoteExitNodeId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -37,6 +38,15 @@ export async function verifyRemoteExitNodeAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!orgId || !remoteExitNodeId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid organization or remote exit node ID"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [remoteExitNode] = await db
|
const [remoteExitNode] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -16,40 +16,44 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { response as sendResponse } from "@server/lib/response";
|
import { response as sendResponse } from "@server/lib/response";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
||||||
|
|
||||||
export interface CreateNewLicenseResponse {
|
export interface CreateNewLicenseResponse {
|
||||||
data: Data
|
data: Data;
|
||||||
success: boolean
|
success: boolean;
|
||||||
error: boolean
|
error: boolean;
|
||||||
message: string
|
message: string;
|
||||||
status: number
|
status: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Data {
|
export interface Data {
|
||||||
licenseKey: LicenseKey
|
licenseKey: LicenseKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LicenseKey {
|
export interface LicenseKey {
|
||||||
id: number
|
id: number;
|
||||||
instanceName: any
|
instanceName: any;
|
||||||
instanceId: string
|
instanceId: string;
|
||||||
licenseKey: string
|
licenseKey: string;
|
||||||
tier: string
|
tier: string;
|
||||||
type: string
|
type: string;
|
||||||
quantity: number
|
quantity: number;
|
||||||
quantity_2: number
|
quantity_2: number;
|
||||||
isValid: boolean
|
isValid: boolean;
|
||||||
updatedAt: string
|
updatedAt: string;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
expiresAt: string
|
expiresAt: string;
|
||||||
paidFor: boolean
|
paidFor: boolean;
|
||||||
orgId: string
|
orgId: string;
|
||||||
metadata: string
|
metadata: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createNewLicense(orgId: string, licenseData: any): Promise<CreateNewLicenseResponse> {
|
export async function createNewLicense(
|
||||||
|
orgId: string,
|
||||||
|
licenseData: any
|
||||||
|
): Promise<CreateNewLicenseResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
|
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
|
||||||
@@ -80,7 +84,7 @@ export async function generateNewLicense(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const { orgId } = req.params;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { response as sendResponse } from "@server/lib/response";
|
import { response as sendResponse } from "@server/lib/response";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import {
|
import {
|
||||||
GeneratedLicenseKey,
|
GeneratedLicenseKey,
|
||||||
@@ -55,7 +56,7 @@ export async function listSaasLicenseKeys(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const { orgId } = req.params;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { UserType } from "@server/types/UserTypes";
|
|||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
import { verifyTotpCode } from "@server/auth/totp";
|
import { verifyTotpCode } from "@server/auth/totp";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
// The RP ID is the domain name of your application
|
// The RP ID is the domain name of your application
|
||||||
const rpID = (() => {
|
const rpID = (() => {
|
||||||
@@ -406,7 +407,12 @@ export async function deleteSecurityKey(
|
|||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const { credentialId: encodedCredentialId } = req.params;
|
const encodedCredentialId = getFirstString(req.params.credentialId);
|
||||||
|
if (!encodedCredentialId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid credential ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
const credentialId = decodeURIComponent(encodedCredentialId);
|
const credentialId = decodeURIComponent(encodedCredentialId);
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,27 @@ const listClientsSchema = z.object({
|
|||||||
description:
|
description:
|
||||||
"Filter by client status. Can be a comma-separated list of values. Defaults to 'active'."
|
"Filter by client status. Can be a comma-separated list of values. Defaults to 'active'."
|
||||||
})
|
})
|
||||||
)
|
),
|
||||||
|
labels: z
|
||||||
|
.preprocess((val) => {
|
||||||
|
if (val === undefined || val === null || val === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
// the array is returned as this
|
||||||
|
if (typeof val === "string") {
|
||||||
|
return val.split(",");
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, z.array(z.string()))
|
||||||
|
.optional()
|
||||||
|
.catch([])
|
||||||
|
.openapi({
|
||||||
|
type: "array",
|
||||||
|
description: "Filter by client labels"
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
function queryClientsBase() {
|
function queryClientsBase() {
|
||||||
@@ -210,8 +230,16 @@ export async function listClients(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { page, pageSize, online, query, status, sort_by, order } =
|
const {
|
||||||
parsedQuery.data;
|
page,
|
||||||
|
pageSize,
|
||||||
|
online,
|
||||||
|
query,
|
||||||
|
status,
|
||||||
|
sort_by,
|
||||||
|
order,
|
||||||
|
labels: labelFilter
|
||||||
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -298,6 +326,22 @@ export async function listClients(
|
|||||||
conditions.push(or(...filterAggregates));
|
conditions.push(or(...filterAggregates));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||||
|
conditions.push(
|
||||||
|
inArray(
|
||||||
|
clients.clientId,
|
||||||
|
db
|
||||||
|
.select({ id: clientLabels.clientId })
|
||||||
|
.from(clientLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, clientLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(inArray(labels.name, labelFilter))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
const queryList = [
|
const queryList = [
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { domain } from "zod/v4/core/regexes";
|
|
||||||
|
|
||||||
const getDomainSchema = z.strictObject({
|
const getDomainSchema = z.strictObject({
|
||||||
domainId: z.string().optional(),
|
domainId: z.string().optional(),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { response } from "@server/lib/response";
|
import { response } from "@server/lib/response";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function getUserResources(
|
export async function getUserResources(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -26,7 +27,7 @@ export async function getUserResources(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const { orgId } = req.params;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -35,6 +36,12 @@ export async function getUserResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check user is in organization and get their role IDs
|
// Check user is in organization and get their role IDs
|
||||||
const [userOrg] = await db
|
const [userOrg] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const listResourcesSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
query: z.string().optional(),
|
query: z.string().optional(),
|
||||||
sort_by: z
|
sort_by: z
|
||||||
.enum(["name"])
|
.literal("name")
|
||||||
.optional()
|
.optional()
|
||||||
.catch(undefined)
|
.catch(undefined)
|
||||||
.openapi({
|
.openapi({
|
||||||
@@ -123,7 +123,27 @@ const listResourcesSchema = z.object({
|
|||||||
type: "integer",
|
type: "integer",
|
||||||
description:
|
description:
|
||||||
"When set, only resources that have at least one target on this site are returned"
|
"When set, only resources that have at least one target on this site are returned"
|
||||||
})
|
}),
|
||||||
|
labels: z
|
||||||
|
.preprocess((val) => {
|
||||||
|
if (val === undefined || val === null || val === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
// the array is returned as this
|
||||||
|
if (typeof val === "string") {
|
||||||
|
return val.split(",");
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, z.array(z.string()))
|
||||||
|
.optional()
|
||||||
|
.catch([])
|
||||||
|
.openapi({
|
||||||
|
type: "array",
|
||||||
|
description: "Filter by resource labels"
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// grouped by resource with targets[])
|
// grouped by resource with targets[])
|
||||||
@@ -261,7 +281,8 @@ export async function listResources(
|
|||||||
healthStatus,
|
healthStatus,
|
||||||
sort_by,
|
sort_by,
|
||||||
order,
|
order,
|
||||||
siteId
|
siteId,
|
||||||
|
labels: labelFilter
|
||||||
} = parsedQuery.data;
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
||||||
@@ -379,6 +400,23 @@ export async function listResources(
|
|||||||
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
||||||
conditions.push(inArray(resources.resourceId, resourcesWithSite));
|
conditions.push(inArray(resources.resourceId, resourcesWithSite));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||||
|
conditions.push(
|
||||||
|
inArray(
|
||||||
|
resources.resourceId,
|
||||||
|
db
|
||||||
|
.select({ id: resourceLabels.resourceId })
|
||||||
|
.from(resourceLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, resourceLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(inArray(labels.name, labelFilter))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
const queryList = [
|
const queryList = [
|
||||||
|
|||||||
@@ -187,6 +187,26 @@ const listSitesSchema = z.object({
|
|||||||
type: "string",
|
type: "string",
|
||||||
enum: ["pending", "approved"],
|
enum: ["pending", "approved"],
|
||||||
description: "Filter by site status"
|
description: "Filter by site status"
|
||||||
|
}),
|
||||||
|
labels: z
|
||||||
|
.preprocess((val) => {
|
||||||
|
if (val === undefined || val === null || val === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
// the array is returned as this
|
||||||
|
if (typeof val === "string") {
|
||||||
|
return val.split(",");
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, z.array(z.string()))
|
||||||
|
.optional()
|
||||||
|
.catch([])
|
||||||
|
.openapi({
|
||||||
|
type: "array",
|
||||||
|
description: "Filter by site labels"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,8 +339,16 @@ export async function listSites(
|
|||||||
tierMatrix.labels
|
tierMatrix.labels
|
||||||
);
|
);
|
||||||
|
|
||||||
const { pageSize, page, query, sort_by, order, online, status } =
|
const {
|
||||||
parsedQuery.data;
|
pageSize,
|
||||||
|
page,
|
||||||
|
query,
|
||||||
|
sort_by,
|
||||||
|
order,
|
||||||
|
online,
|
||||||
|
status,
|
||||||
|
labels: labelFilter
|
||||||
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||||
|
|
||||||
@@ -337,6 +365,23 @@ export async function listSites(
|
|||||||
if (typeof status !== "undefined") {
|
if (typeof status !== "undefined") {
|
||||||
conditions.push(eq(sites.status, status));
|
conditions.push(eq(sites.status, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||||
|
conditions.push(
|
||||||
|
inArray(
|
||||||
|
sites.siteId,
|
||||||
|
db
|
||||||
|
.select({ id: siteLabels.siteId })
|
||||||
|
.from(siteLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, siteLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(inArray(labels.name, labelFilter))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
const queryList = [
|
const queryList = [
|
||||||
@@ -366,7 +411,9 @@ export async function listSites(
|
|||||||
|
|
||||||
// we need to add `as` so that drizzle filters the result as a subquery
|
// we need to add `as` so that drizzle filters the result as a subquery
|
||||||
const countQuery = db.$count(
|
const countQuery = db.$count(
|
||||||
querySitesBase().where(and(...conditions)).as("filtered_sites")
|
querySitesBase()
|
||||||
|
.where(and(...conditions))
|
||||||
|
.as("filtered_sites")
|
||||||
);
|
);
|
||||||
|
|
||||||
const siteListQuery = baseQuery
|
const siteListQuery = baseQuery
|
||||||
|
|||||||
@@ -85,7 +85,27 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
|||||||
type: "integer",
|
type: "integer",
|
||||||
description:
|
description:
|
||||||
"When set, only site resources associated with this site (via network) are returned"
|
"When set, only site resources associated with this site (via network) are returned"
|
||||||
})
|
}),
|
||||||
|
labels: z
|
||||||
|
.preprocess((val) => {
|
||||||
|
if (val === undefined || val === null || val === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
// the array is returned as this
|
||||||
|
if (typeof val === "string") {
|
||||||
|
return val.split(",");
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, z.array(z.string()))
|
||||||
|
.optional()
|
||||||
|
.catch([])
|
||||||
|
.openapi({
|
||||||
|
type: "array",
|
||||||
|
description: "Filter by resource labels"
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||||
@@ -239,8 +259,16 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
const { page, pageSize, query, mode, sort_by, order, siteId } =
|
const {
|
||||||
parsedQuery.data;
|
page,
|
||||||
|
pageSize,
|
||||||
|
query,
|
||||||
|
mode,
|
||||||
|
sort_by,
|
||||||
|
order,
|
||||||
|
siteId,
|
||||||
|
labels: labelFilter
|
||||||
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||||
orgId,
|
orgId,
|
||||||
@@ -276,6 +304,22 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
conditions.push(eq(siteResources.mode, mode));
|
conditions.push(eq(siteResources.mode, mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||||
|
conditions.push(
|
||||||
|
inArray(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
db
|
||||||
|
.select({ id: siteResourceLabels.siteResourceId })
|
||||||
|
.from(siteResourceLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, siteResourceLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(inArray(labels.name, labelFilter))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
const queryList = [
|
const queryList = [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useState, useRef, useEffect, useTransition } from "react";
|
import { useState, useTransition, useMemo } from "react";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
@@ -20,6 +20,9 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
|||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { logQueries } from "@app/lib/queries";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -30,23 +33,8 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const [rows, setRows] = useState<any[]>([]);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
const [filterAttributes, setFilterAttributes] = useState<{
|
|
||||||
actors: string[];
|
|
||||||
resources: {
|
|
||||||
id: number;
|
|
||||||
name: string | null;
|
|
||||||
}[];
|
|
||||||
locations: string[];
|
|
||||||
}>({
|
|
||||||
actors: [],
|
|
||||||
resources: [],
|
|
||||||
locations: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter states - unified object for all filters
|
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -61,40 +49,21 @@ export default function GeneralPage() {
|
|||||||
actor: searchParams.get("actor") || undefined
|
actor: searchParams.get("actor") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Initialize page size from storage or default
|
|
||||||
const [pageSize, setPageSize] = useStoredPageSize("access-audit-logs", 20);
|
const [pageSize, setPageSize] = useStoredPageSize("access-audit-logs", 20);
|
||||||
|
|
||||||
// Set default date range to last 24 hours
|
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
// if the time is in the url params, use that instead
|
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: new Date(startParam) },
|
||||||
date: new Date(startParam)
|
endDate: { date: new Date(endParam) }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: new Date(endParam)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const lastWeek = getSevenDaysAgo();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: getSevenDaysAgo() },
|
||||||
date: lastWeek
|
endDate: { date: new Date() }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: now
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,75 +72,95 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
// Trigger search with default values on component mount
|
const queryFilters = useMemo(() => {
|
||||||
useEffect(() => {
|
let timeStart: string | undefined;
|
||||||
const defaultRange = getDefaultDateRange();
|
let timeEnd: string | undefined;
|
||||||
queryDateTime(
|
|
||||||
defaultRange.startDate,
|
if (dateRange.startDate?.date) {
|
||||||
defaultRange.endDate,
|
const dt = new Date(dateRange.startDate.date);
|
||||||
0,
|
if (dateRange.startDate.time) {
|
||||||
pageSize
|
const [h, m, s] = dateRange.startDate.time
|
||||||
);
|
.split(":")
|
||||||
}, [orgId]); // Re-run if orgId changes
|
.map(Number);
|
||||||
|
dt.setHours(h, m, s || 0);
|
||||||
|
}
|
||||||
|
timeStart = dt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateRange.endDate?.date) {
|
||||||
|
const dt = new Date(dateRange.endDate.date);
|
||||||
|
if (dateRange.endDate.time) {
|
||||||
|
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||||
|
dt.setHours(h, m, s || 0);
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
dt.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
timeEnd = dt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeStart,
|
||||||
|
timeEnd,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
...filters,
|
||||||
|
resourceId: filters.resourceId
|
||||||
|
? Number(filters.resourceId)
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
}, [dateRange, currentPage, pageSize, filters]);
|
||||||
|
|
||||||
|
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||||
|
...logQueries.access({
|
||||||
|
orgId: orgId as string,
|
||||||
|
filters: queryFilters
|
||||||
|
}),
|
||||||
|
enabled: isPaidUser(tierMatrix.accessLogs) && build !== "oss"
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = isLoading ? generateSampleAccessLogs() : (data?.log ?? []);
|
||||||
|
const totalCount = data?.pagination?.total ?? 0;
|
||||||
|
const filterAttributes = data?.filterAttributes ?? {
|
||||||
|
actors: [],
|
||||||
|
resources: [],
|
||||||
|
locations: []
|
||||||
|
};
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
// put the search params in the url for the time
|
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
queryDateTime(startDate, endDate, 0, pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page changes
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
newPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page size changes
|
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0); // Reset to first page when changing page size
|
setCurrentPage(0);
|
||||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter changes generically
|
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
// Create new filters object with updated value
|
const newFilters = { ...filters, [filterType]: value };
|
||||||
const newFilters = {
|
|
||||||
...filters,
|
|
||||||
[filterType]: value
|
|
||||||
};
|
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
|
|
||||||
// Update URL params
|
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
0,
|
|
||||||
pageSize,
|
|
||||||
newFilters
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -193,114 +182,8 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryDateTime = async (
|
|
||||||
startDate: DateTimeValue,
|
|
||||||
endDate: DateTimeValue,
|
|
||||||
page: number = currentPage,
|
|
||||||
size: number = pageSize,
|
|
||||||
filtersParam?: {
|
|
||||||
action?: string;
|
|
||||||
type?: string;
|
|
||||||
resourceId?: string;
|
|
||||||
location?: string;
|
|
||||||
actor?: string;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
|
||||||
if (!isPaidUser(tierMatrix.accessLogs) || build === "oss") {
|
|
||||||
console.log(
|
|
||||||
"Access denied: subscription inactive or license locked"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the provided filters or fall back to current state
|
|
||||||
const activeFilters = filtersParam || filters;
|
|
||||||
|
|
||||||
// Convert the date/time values to API parameters
|
|
||||||
const params: any = {
|
|
||||||
limit: size,
|
|
||||||
offset: page * size,
|
|
||||||
...activeFilters
|
|
||||||
};
|
|
||||||
|
|
||||||
if (startDate?.date) {
|
|
||||||
const startDateTime = new Date(startDate.date);
|
|
||||||
if (startDate.time) {
|
|
||||||
const [hours, minutes, seconds] = startDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
}
|
|
||||||
params.timeStart = startDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate?.date) {
|
|
||||||
const endDateTime = new Date(endDate.date);
|
|
||||||
if (endDate.time) {
|
|
||||||
const [hours, minutes, seconds] = endDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
} else {
|
|
||||||
// If no time is specified, set to NOW
|
|
||||||
const now = new Date();
|
|
||||||
endDateTime.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
params.timeEnd = endDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await api.get(`/org/${orgId}/logs/access`, { params });
|
|
||||||
if (res.status === 200) {
|
|
||||||
setRows(res.data.data.log || []);
|
|
||||||
setTotalCount(res.data.data.pagination?.total || 0);
|
|
||||||
setFilterAttributes(res.data.data.filterAttributes);
|
|
||||||
console.log("Fetched logs:", res.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("Failed to filter logs"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshData = async () => {
|
|
||||||
console.log("Data refreshed");
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
// Refresh data with current date range and pagination
|
|
||||||
await queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
currentPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("refreshError"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
// Prepare query params for export
|
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -316,7 +199,6 @@ export default function GeneralPage() {
|
|||||||
params
|
params
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a URL for the blob and trigger a download
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -334,7 +216,6 @@ export default function GeneralPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
// Parse the Blob as JSON
|
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -351,7 +232,7 @@ export default function GeneralPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "timestamp",
|
accessorKey: "timestamp",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -366,7 +247,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("action")}</span>
|
<span>{t("action")}</span>
|
||||||
@@ -379,7 +260,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("action", value)
|
handleFilterChange("action", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -396,13 +276,11 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
header: ({ column }) => {
|
header: () => t("ip")
|
||||||
return t("ip");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "location",
|
accessorKey: "location",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("location")}</span>
|
<span>{t("location")}</span>
|
||||||
@@ -417,7 +295,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("location", value)
|
handleFilterChange("location", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -442,7 +319,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "resourceName",
|
accessorKey: "resourceName",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("resource")}</span>
|
<span>{t("resource")}</span>
|
||||||
@@ -455,7 +332,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("resourceId", value)
|
handleFilterChange("resourceId", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -481,7 +357,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "type",
|
accessorKey: "type",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("type")}</span>
|
<span>{t("type")}</span>
|
||||||
@@ -500,7 +376,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("type", value)
|
handleFilterChange("type", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -518,7 +393,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actor",
|
accessorKey: "actor",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("actor")}</span>
|
<span>{t("actor")}</span>
|
||||||
@@ -531,7 +406,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("actor", value)
|
handleFilterChange("actor", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -559,16 +433,12 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actorId",
|
accessorKey: "actorId",
|
||||||
header: ({ column }) => {
|
header: () => t("actorId"),
|
||||||
return t("actorId");
|
cell: ({ row }) => (
|
||||||
},
|
<span className="flex items-center gap-1">
|
||||||
cell: ({ row }) => {
|
{row.original.actorId || "-"}
|
||||||
return (
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
)
|
||||||
{row.original.actorId || "-"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -614,13 +484,10 @@ export default function GeneralPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
data={rows}
|
data={rows}
|
||||||
title={t("accessLogs")}
|
title={t("accessLogs")}
|
||||||
onRefresh={refreshData}
|
onRefresh={() => refetch()}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isFetching}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
// isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan
|
|
||||||
// !isPaidUser(tierMatrix.accessLogs) || build === "oss"
|
|
||||||
// }
|
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
dateRange={{
|
dateRange={{
|
||||||
start: dateRange.startDate,
|
start: dateRange.startDate,
|
||||||
@@ -630,14 +497,12 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
// Server-side pagination props
|
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
// Row expansion props
|
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
|
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
|
||||||
@@ -645,3 +510,41 @@ export default function GeneralPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] {
|
||||||
|
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
||||||
|
const types = ["password", "pincode", "login", "whitelistedEmail", "ssh"];
|
||||||
|
const actors = [
|
||||||
|
"alice@example.com",
|
||||||
|
"bob@example.com",
|
||||||
|
"carol@example.com",
|
||||||
|
null
|
||||||
|
];
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const action = Math.random() > 0.3;
|
||||||
|
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: Math.floor(
|
||||||
|
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||||
|
),
|
||||||
|
action,
|
||||||
|
orgId: "sample-org",
|
||||||
|
actorType: actor ? "user" : null,
|
||||||
|
actor,
|
||||||
|
actorId: actor ? `user-${i}` : null,
|
||||||
|
resourceId: Math.floor(Math.random() * 5) + 1,
|
||||||
|
resourceNiceId: `resource-${(i % 3) + 1}`,
|
||||||
|
resourceName: `Resource ${(i % 3) + 1}`,
|
||||||
|
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
||||||
|
location: locations[Math.floor(Math.random() * locations.length)],
|
||||||
|
userAgent: "Mozilla/5.0",
|
||||||
|
metadata: null,
|
||||||
|
type: types[Math.floor(Math.random() * types.length)]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
import { logQueries } from "@app/lib/queries";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import type { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Key, User } from "lucide-react";
|
import { Key, User } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -28,18 +31,8 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const [rows, setRows] = useState<any[]>([]);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
const [filterAttributes, setFilterAttributes] = useState<{
|
|
||||||
actors: string[];
|
|
||||||
actions: string[];
|
|
||||||
}>({
|
|
||||||
actors: [],
|
|
||||||
actions: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter states - unified object for all filters
|
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
actor?: string;
|
actor?: string;
|
||||||
@@ -48,40 +41,21 @@ export default function GeneralPage() {
|
|||||||
actor: searchParams.get("actor") || undefined
|
actor: searchParams.get("actor") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Initialize page size from storage or default
|
|
||||||
const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20);
|
const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20);
|
||||||
|
|
||||||
// Set default date range to last 24 hours
|
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
// if the time is in the url params, use that instead
|
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: new Date(startParam) },
|
||||||
date: new Date(startParam)
|
endDate: { date: new Date(endParam) }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: new Date(endParam)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const lastWeek = getSevenDaysAgo();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: getSevenDaysAgo() },
|
||||||
date: lastWeek
|
endDate: { date: new Date() }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: now
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,78 +64,90 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
// Trigger search with default values on component mount
|
const queryFilters = useMemo(() => {
|
||||||
useEffect(() => {
|
let timeStart: string | undefined;
|
||||||
if (build === "oss") {
|
let timeEnd: string | undefined;
|
||||||
return;
|
|
||||||
|
if (dateRange.startDate?.date) {
|
||||||
|
const dt = new Date(dateRange.startDate.date);
|
||||||
|
if (dateRange.startDate.time) {
|
||||||
|
const [h, m, s] = dateRange.startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
dt.setHours(h, m, s || 0);
|
||||||
|
}
|
||||||
|
timeStart = dt.toISOString();
|
||||||
}
|
}
|
||||||
const defaultRange = getDefaultDateRange();
|
|
||||||
queryDateTime(
|
if (dateRange.endDate?.date) {
|
||||||
defaultRange.startDate,
|
const dt = new Date(dateRange.endDate.date);
|
||||||
defaultRange.endDate,
|
if (dateRange.endDate.time) {
|
||||||
0,
|
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||||
pageSize
|
dt.setHours(h, m, s || 0);
|
||||||
);
|
} else {
|
||||||
}, [orgId]); // Re-run if orgId changes
|
const now = new Date();
|
||||||
|
dt.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
timeEnd = dt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeStart,
|
||||||
|
timeEnd,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
...filters
|
||||||
|
};
|
||||||
|
}, [dateRange, currentPage, pageSize, filters]);
|
||||||
|
|
||||||
|
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||||
|
...logQueries.action({
|
||||||
|
orgId: orgId as string,
|
||||||
|
filters: queryFilters
|
||||||
|
}),
|
||||||
|
enabled: isPaidUser(tierMatrix.actionLogs) && build !== "oss"
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = isLoading ? generateSampleActionLogs() : (data?.log ?? []);
|
||||||
|
const totalCount = data?.pagination?.total ?? 0;
|
||||||
|
const filterAttributes = {
|
||||||
|
actors: data?.filterAttributes?.actors ?? []
|
||||||
|
};
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
// put the search params in the url for the time
|
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
queryDateTime(startDate, endDate, 0, pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page changes
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
newPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page size changes
|
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0); // Reset to first page when changing page size
|
setCurrentPage(0);
|
||||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter changes generically
|
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
// Create new filters object with updated value
|
const newFilters = { ...filters, [filterType]: value };
|
||||||
const newFilters = {
|
|
||||||
...filters,
|
|
||||||
[filterType]: value
|
|
||||||
};
|
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
|
|
||||||
// Update URL params
|
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
0,
|
|
||||||
pageSize,
|
|
||||||
newFilters
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -183,110 +169,8 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryDateTime = async (
|
|
||||||
startDate: DateTimeValue,
|
|
||||||
endDate: DateTimeValue,
|
|
||||||
page: number = currentPage,
|
|
||||||
size: number = pageSize,
|
|
||||||
filtersParam?: {
|
|
||||||
action?: string;
|
|
||||||
actor?: string;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
|
||||||
if (!isPaidUser(tierMatrix.actionLogs)) {
|
|
||||||
console.log(
|
|
||||||
"Access denied: subscription inactive or license locked"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the provided filters or fall back to current state
|
|
||||||
const activeFilters = filtersParam || filters;
|
|
||||||
|
|
||||||
// Convert the date/time values to API parameters
|
|
||||||
const params: any = {
|
|
||||||
limit: size,
|
|
||||||
offset: page * size,
|
|
||||||
...activeFilters
|
|
||||||
};
|
|
||||||
|
|
||||||
if (startDate?.date) {
|
|
||||||
const startDateTime = new Date(startDate.date);
|
|
||||||
if (startDate.time) {
|
|
||||||
const [hours, minutes, seconds] = startDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
}
|
|
||||||
params.timeStart = startDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate?.date) {
|
|
||||||
const endDateTime = new Date(endDate.date);
|
|
||||||
if (endDate.time) {
|
|
||||||
const [hours, minutes, seconds] = endDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
} else {
|
|
||||||
// If no time is specified, set to NOW
|
|
||||||
const now = new Date();
|
|
||||||
endDateTime.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
params.timeEnd = endDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await api.get(`/org/${orgId}/logs/action`, { params });
|
|
||||||
if (res.status === 200) {
|
|
||||||
setRows(res.data.data.log || []);
|
|
||||||
setTotalCount(res.data.data.pagination?.total || 0);
|
|
||||||
setFilterAttributes(res.data.data.filterAttributes);
|
|
||||||
console.log("Fetched logs:", res.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("Failed to filter logs"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshData = async () => {
|
|
||||||
console.log("Data refreshed");
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
// Refresh data with current date range and pagination
|
|
||||||
await queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
currentPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("refreshError"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
// Prepare query params for export
|
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -302,7 +186,6 @@ export default function GeneralPage() {
|
|||||||
params
|
params
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a URL for the blob and trigger a download
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -320,7 +203,6 @@ export default function GeneralPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
// Parse the Blob as JSON
|
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -337,7 +219,7 @@ export default function GeneralPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "timestamp",
|
accessorKey: "timestamp",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -352,22 +234,16 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("action")}</span>
|
<span>{t("action")}</span>
|
||||||
<ColumnFilter
|
<ColumnFilter
|
||||||
options={filterAttributes.actions.map((action) => ({
|
options={[]}
|
||||||
label:
|
|
||||||
action.charAt(0).toUpperCase() +
|
|
||||||
action.slice(1),
|
|
||||||
value: action
|
|
||||||
}))}
|
|
||||||
selectedValue={filters.action}
|
selectedValue={filters.action}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("action", value)
|
handleFilterChange("action", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -385,7 +261,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actor",
|
accessorKey: "actor",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("actor")}</span>
|
<span>{t("actor")}</span>
|
||||||
@@ -398,7 +274,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("actor", value)
|
handleFilterChange("actor", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -420,7 +295,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actorId",
|
accessorKey: "actorId",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("actorId");
|
return t("actorId");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -469,12 +344,9 @@ export default function GeneralPage() {
|
|||||||
title={t("actionLogs")}
|
title={t("actionLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="action"
|
searchColumn="action"
|
||||||
onRefresh={refreshData}
|
onRefresh={() => refetch()}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isFetching}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
// isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan
|
|
||||||
// !isPaidUser(tierMatrix.logExport) || build === "oss"
|
|
||||||
// }
|
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
dateRange={{
|
dateRange={{
|
||||||
@@ -485,14 +357,12 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
// Server-side pagination props
|
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
// Row expansion props
|
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
|
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
|
||||||
@@ -500,3 +370,39 @@ export default function GeneralPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateSampleActionLogs(): QueryActionAuditLogResponse["log"] {
|
||||||
|
const actions = [
|
||||||
|
"createResource",
|
||||||
|
"deleteResource",
|
||||||
|
"updateResource",
|
||||||
|
"createSite",
|
||||||
|
"deleteSite",
|
||||||
|
"inviteUser",
|
||||||
|
"removeUser"
|
||||||
|
];
|
||||||
|
const actors = [
|
||||||
|
"alice@example.com",
|
||||||
|
"bob@example.com",
|
||||||
|
"carol@example.com"
|
||||||
|
];
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: Math.floor(
|
||||||
|
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||||
|
),
|
||||||
|
action: actions[Math.floor(Math.random() * actions.length)],
|
||||||
|
orgId: "sample-org",
|
||||||
|
actorType: "user",
|
||||||
|
actor,
|
||||||
|
actorId: `user-${i}`,
|
||||||
|
metadata: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,26 +9,20 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
import { logQueries } from "@app/lib/queries";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import type { QueryConnectionAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { ArrowUpRight, Laptop, User } from "lucide-react";
|
import { ArrowUpRight, Laptop, User } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
|
||||||
function formatBytes(bytes: number | null): string {
|
|
||||||
if (bytes === null || bytes === undefined) return "-";
|
|
||||||
if (bytes === 0) return "0 B";
|
|
||||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
const value = bytes / Math.pow(1024, i);
|
|
||||||
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(startedAt: number, endedAt: number | null): string {
|
function formatDuration(startedAt: number, endedAt: number | null): string {
|
||||||
if (endedAt === null || endedAt === undefined) return "Active";
|
if (endedAt === null || endedAt === undefined) return "Active";
|
||||||
@@ -54,24 +48,8 @@ export default function ConnectionLogsPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const [rows, setRows] = useState<any[]>([]);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
const [filterAttributes, setFilterAttributes] = useState<{
|
|
||||||
protocols: string[];
|
|
||||||
destAddrs: string[];
|
|
||||||
clients: { id: number; name: string }[];
|
|
||||||
resources: { id: number; name: string | null }[];
|
|
||||||
users: { id: string; email: string | null }[];
|
|
||||||
}>({
|
|
||||||
protocols: [],
|
|
||||||
destAddrs: [],
|
|
||||||
clients: [],
|
|
||||||
resources: [],
|
|
||||||
users: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter states - unified object for all filters
|
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
destAddr?: string;
|
destAddr?: string;
|
||||||
@@ -86,43 +64,24 @@ export default function ConnectionLogsPage() {
|
|||||||
userId: searchParams.get("userId") || undefined
|
userId: searchParams.get("userId") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Initialize page size from storage or default
|
|
||||||
const [pageSize, setPageSize] = useStoredPageSize(
|
const [pageSize, setPageSize] = useStoredPageSize(
|
||||||
"connection-audit-logs",
|
"connection-audit-logs",
|
||||||
20
|
20
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set default date range to last 7 days
|
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
// if the time is in the url params, use that instead
|
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: new Date(startParam) },
|
||||||
date: new Date(startParam)
|
endDate: { date: new Date(endParam) }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: new Date(endParam)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const lastWeek = getSevenDaysAgo();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: getSevenDaysAgo() },
|
||||||
date: lastWeek
|
endDate: { date: new Date() }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: now
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,78 +90,100 @@ export default function ConnectionLogsPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
// Trigger search with default values on component mount
|
const queryFilters = useMemo(() => {
|
||||||
useEffect(() => {
|
let timeStart: string | undefined;
|
||||||
if (build === "oss") {
|
let timeEnd: string | undefined;
|
||||||
return;
|
|
||||||
|
if (dateRange.startDate?.date) {
|
||||||
|
const dt = new Date(dateRange.startDate.date);
|
||||||
|
if (dateRange.startDate.time) {
|
||||||
|
const [h, m, s] = dateRange.startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
dt.setHours(h, m, s || 0);
|
||||||
|
}
|
||||||
|
timeStart = dt.toISOString();
|
||||||
}
|
}
|
||||||
const defaultRange = getDefaultDateRange();
|
|
||||||
queryDateTime(
|
if (dateRange.endDate?.date) {
|
||||||
defaultRange.startDate,
|
const dt = new Date(dateRange.endDate.date);
|
||||||
defaultRange.endDate,
|
if (dateRange.endDate.time) {
|
||||||
0,
|
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||||
pageSize
|
dt.setHours(h, m, s || 0);
|
||||||
);
|
} else {
|
||||||
}, [orgId]); // Re-run if orgId changes
|
const now = new Date();
|
||||||
|
dt.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
timeEnd = dt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeStart,
|
||||||
|
timeEnd,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
...filters,
|
||||||
|
clientId: filters.clientId ? Number(filters.clientId) : undefined,
|
||||||
|
siteResourceId: filters.siteResourceId
|
||||||
|
? Number(filters.siteResourceId)
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
}, [dateRange, currentPage, pageSize, filters]);
|
||||||
|
|
||||||
|
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||||
|
...logQueries.connection({
|
||||||
|
orgId: orgId as string,
|
||||||
|
filters: queryFilters
|
||||||
|
}),
|
||||||
|
enabled: isPaidUser(tierMatrix.connectionLogs) && build !== "oss"
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = isLoading
|
||||||
|
? generateSampleConnectionLogs()
|
||||||
|
: (data?.log ?? []);
|
||||||
|
const totalCount = data?.pagination?.total ?? 0;
|
||||||
|
const filterAttributes = data?.filterAttributes ?? {
|
||||||
|
protocols: [],
|
||||||
|
destAddrs: [],
|
||||||
|
clients: [],
|
||||||
|
resources: [],
|
||||||
|
users: []
|
||||||
|
};
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
// put the search params in the url for the time
|
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
queryDateTime(startDate, endDate, 0, pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page changes
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
newPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page size changes
|
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0); // Reset to first page when changing page size
|
setCurrentPage(0);
|
||||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter changes generically
|
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
// Create new filters object with updated value
|
const newFilters = { ...filters, [filterType]: value };
|
||||||
const newFilters = {
|
|
||||||
...filters,
|
|
||||||
[filterType]: value
|
|
||||||
};
|
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
|
|
||||||
// Update URL params
|
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
0,
|
|
||||||
pageSize,
|
|
||||||
newFilters
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -224,109 +205,8 @@ export default function ConnectionLogsPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryDateTime = async (
|
|
||||||
startDate: DateTimeValue,
|
|
||||||
endDate: DateTimeValue,
|
|
||||||
page: number = currentPage,
|
|
||||||
size: number = pageSize,
|
|
||||||
filtersParam?: typeof filters
|
|
||||||
) => {
|
|
||||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
|
||||||
if (!isPaidUser(tierMatrix.connectionLogs)) {
|
|
||||||
console.log(
|
|
||||||
"Access denied: subscription inactive or license locked"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the provided filters or fall back to current state
|
|
||||||
const activeFilters = filtersParam || filters;
|
|
||||||
|
|
||||||
// Convert the date/time values to API parameters
|
|
||||||
const params: any = {
|
|
||||||
limit: size,
|
|
||||||
offset: page * size,
|
|
||||||
...activeFilters
|
|
||||||
};
|
|
||||||
|
|
||||||
if (startDate?.date) {
|
|
||||||
const startDateTime = new Date(startDate.date);
|
|
||||||
if (startDate.time) {
|
|
||||||
const [hours, minutes, seconds] = startDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
}
|
|
||||||
params.timeStart = startDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate?.date) {
|
|
||||||
const endDateTime = new Date(endDate.date);
|
|
||||||
if (endDate.time) {
|
|
||||||
const [hours, minutes, seconds] = endDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
} else {
|
|
||||||
// If no time is specified, set to NOW
|
|
||||||
const now = new Date();
|
|
||||||
endDateTime.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
params.timeEnd = endDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await api.get(`/org/${orgId}/logs/connection`, {
|
|
||||||
params
|
|
||||||
});
|
|
||||||
if (res.status === 200) {
|
|
||||||
setRows(res.data.data.log || []);
|
|
||||||
setTotalCount(res.data.data.pagination?.total || 0);
|
|
||||||
setFilterAttributes(res.data.data.filterAttributes);
|
|
||||||
console.log("Fetched connection logs:", res.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: formatAxiosError(error),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshData = async () => {
|
|
||||||
console.log("Data refreshed");
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
// Refresh data with current date range and pagination
|
|
||||||
await queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
currentPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("refreshError"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
// Prepare query params for export
|
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -345,7 +225,6 @@ export default function ConnectionLogsPage() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a URL for the blob and trigger a download
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -363,7 +242,6 @@ export default function ConnectionLogsPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
// Parse the Blob as JSON
|
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -380,7 +258,7 @@ export default function ConnectionLogsPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "startedAt",
|
accessorKey: "startedAt",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -395,7 +273,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "protocol",
|
accessorKey: "protocol",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("protocol")}</span>
|
<span>{t("protocol")}</span>
|
||||||
@@ -426,7 +304,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "resourceName",
|
accessorKey: "resourceName",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("resource")}</span>
|
<span>{t("resource")}</span>
|
||||||
@@ -467,7 +345,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "clientName",
|
accessorKey: "clientName",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("client")}</span>
|
<span>{t("client")}</span>
|
||||||
@@ -510,7 +388,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "userEmail",
|
accessorKey: "userEmail",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("user")}</span>
|
<span>{t("user")}</span>
|
||||||
@@ -543,7 +421,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "sourceAddr",
|
accessorKey: "sourceAddr",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("sourceAddress");
|
return t("sourceAddress");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -556,7 +434,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "destAddr",
|
accessorKey: "destAddr",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("destinationAddress")}</span>
|
<span>{t("destinationAddress")}</span>
|
||||||
@@ -585,7 +463,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "duration",
|
accessorKey: "duration",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("duration");
|
return t("duration");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -606,9 +484,6 @@ export default function ConnectionLogsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
|
||||||
Connection Details
|
|
||||||
</div>*/}
|
|
||||||
<div>
|
<div>
|
||||||
<strong>Session ID:</strong>{" "}
|
<strong>Session ID:</strong>{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
@@ -633,18 +508,6 @@ export default function ConnectionLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
|
||||||
Resource & Site
|
|
||||||
</div>*/}
|
|
||||||
{/*<div>
|
|
||||||
<strong>Resource:</strong>{" "}
|
|
||||||
{row.resourceName ?? "-"}
|
|
||||||
{row.resourceNiceId && (
|
|
||||||
<span className="text-muted-foreground ml-1">
|
|
||||||
({row.resourceNiceId})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>*/}
|
|
||||||
<div>
|
<div>
|
||||||
<strong>Client Endpoint:</strong>{" "}
|
<strong>Client Endpoint:</strong>{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
@@ -680,30 +543,8 @@ export default function ConnectionLogsPage() {
|
|||||||
<strong>Duration:</strong>{" "}
|
<strong>Duration:</strong>{" "}
|
||||||
{formatDuration(row.startedAt, row.endedAt)}
|
{formatDuration(row.startedAt, row.endedAt)}
|
||||||
</div>
|
</div>
|
||||||
{/*<div>
|
|
||||||
<strong>Resource ID:</strong>{" "}
|
|
||||||
{row.siteResourceId ?? "-"}
|
|
||||||
</div>*/}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
|
||||||
Client & Transfer
|
|
||||||
</div>*/}
|
|
||||||
{/*<div>
|
|
||||||
<strong>Bytes Sent (TX):</strong>{" "}
|
|
||||||
{formatBytes(row.bytesTx)}
|
|
||||||
</div>*/}
|
|
||||||
{/*<div>
|
|
||||||
<strong>Bytes Received (RX):</strong>{" "}
|
|
||||||
{formatBytes(row.bytesRx)}
|
|
||||||
</div>*/}
|
|
||||||
{/*<div>
|
|
||||||
<strong>Total Transfer:</strong>{" "}
|
|
||||||
{formatBytes(
|
|
||||||
(row.bytesTx ?? 0) + (row.bytesRx ?? 0)
|
|
||||||
)}
|
|
||||||
</div>*/}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -724,8 +565,8 @@ export default function ConnectionLogsPage() {
|
|||||||
title={t("connectionLogs")}
|
title={t("connectionLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="protocol"
|
searchColumn="protocol"
|
||||||
onRefresh={refreshData}
|
onRefresh={() => refetch()}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isFetching}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
@@ -737,14 +578,12 @@ export default function ConnectionLogsPage() {
|
|||||||
id: "startedAt",
|
id: "startedAt",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
// Server-side pagination props
|
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
// Row expansion props
|
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -754,3 +593,49 @@ export default function ConnectionLogsPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateSampleConnectionLogs(): QueryConnectionAuditLogResponse["log"] {
|
||||||
|
const protocols = ["tcp", "udp", "icmp"];
|
||||||
|
const destAddrs = [
|
||||||
|
"10.0.0.1:22",
|
||||||
|
"10.0.0.2:80",
|
||||||
|
"10.0.0.3:443",
|
||||||
|
"192.168.1.10:3306"
|
||||||
|
];
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const startedAt = Math.floor(
|
||||||
|
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||||
|
);
|
||||||
|
const active = Math.random() > 0.3;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: `session-${i}`,
|
||||||
|
siteResourceId: (i % 3) + 1,
|
||||||
|
orgId: "sample-org",
|
||||||
|
siteId: 1,
|
||||||
|
clientId: (i % 4) + 1,
|
||||||
|
clientEndpoint: `10.0.0.${i + 1}:51820`,
|
||||||
|
userId: i % 2 === 0 ? `user-${i}` : null,
|
||||||
|
sourceAddr: `192.168.1.${i + 1}:${40000 + i}`,
|
||||||
|
destAddr: destAddrs[Math.floor(Math.random() * destAddrs.length)],
|
||||||
|
protocol:
|
||||||
|
protocols[Math.floor(Math.random() * protocols.length)],
|
||||||
|
startedAt,
|
||||||
|
endedAt: active ? null : startedAt + Math.floor(Math.random() * 3600),
|
||||||
|
bytesTx: active ? null : Math.floor(Math.random() * 1024 * 1024),
|
||||||
|
bytesRx: active ? null : Math.floor(Math.random() * 1024 * 1024),
|
||||||
|
resourceName: `Resource ${(i % 3) + 1}`,
|
||||||
|
resourceNiceId: `resource-${(i % 3) + 1}`,
|
||||||
|
siteName: "Sample Site",
|
||||||
|
siteNiceId: "sample-site",
|
||||||
|
clientName: `Client ${(i % 4) + 1}`,
|
||||||
|
clientNiceId: `client-${(i % 4) + 1}`,
|
||||||
|
clientType: i % 2 === 0 ? "user" : "machine",
|
||||||
|
userEmail: i % 2 === 0 ? `user${i}@example.com` : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
import { logQueries } from "@app/lib/queries";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
|
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -25,36 +28,11 @@ export default function GeneralPage() {
|
|||||||
const { orgId } = useParams();
|
const { orgId } = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [rows, setRows] = useState<any[]>([]);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Initialize page size from storage or default
|
|
||||||
const [pageSize, setPageSize] = useStoredPageSize("request-audit-logs", 20);
|
const [pageSize, setPageSize] = useStoredPageSize("request-audit-logs", 20);
|
||||||
|
|
||||||
const [filterAttributes, setFilterAttributes] = useState<{
|
|
||||||
actors: string[];
|
|
||||||
resources: {
|
|
||||||
id: number;
|
|
||||||
name: string | null;
|
|
||||||
}[];
|
|
||||||
locations: string[];
|
|
||||||
hosts: string[];
|
|
||||||
paths: string[];
|
|
||||||
}>({
|
|
||||||
actors: [],
|
|
||||||
resources: [],
|
|
||||||
locations: [],
|
|
||||||
hosts: [],
|
|
||||||
paths: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter states - unified object for all filters
|
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
@@ -75,32 +53,18 @@ export default function GeneralPage() {
|
|||||||
path: searchParams.get("path") || undefined
|
path: searchParams.get("path") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set default date range to last 24 hours
|
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
// if the time is in the url params, use that instead
|
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: new Date(startParam) },
|
||||||
date: new Date(startParam)
|
endDate: { date: new Date(endParam) }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: new Date(endParam)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const lastWeek = getSevenDaysAgo();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: getSevenDaysAgo() },
|
||||||
date: lastWeek
|
endDate: { date: new Date() }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: now
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,80 +73,97 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
// Trigger search with default values on component mount
|
const queryFilters = useMemo(() => {
|
||||||
useEffect(() => {
|
let timeStart: string | undefined;
|
||||||
if (build === "oss") {
|
let timeEnd: string | undefined;
|
||||||
return;
|
|
||||||
|
if (dateRange.startDate?.date) {
|
||||||
|
const dt = new Date(dateRange.startDate.date);
|
||||||
|
if (dateRange.startDate.time) {
|
||||||
|
const [h, m, s] = dateRange.startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
dt.setHours(h, m, s || 0);
|
||||||
|
}
|
||||||
|
timeStart = dt.toISOString();
|
||||||
}
|
}
|
||||||
const defaultRange = getDefaultDateRange();
|
|
||||||
queryDateTime(
|
if (dateRange.endDate?.date) {
|
||||||
defaultRange.startDate,
|
const dt = new Date(dateRange.endDate.date);
|
||||||
defaultRange.endDate,
|
if (dateRange.endDate.time) {
|
||||||
0,
|
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||||
pageSize
|
dt.setHours(h, m, s || 0);
|
||||||
);
|
} else {
|
||||||
}, [orgId]); // Re-run if orgId changes
|
const now = new Date();
|
||||||
|
dt.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
timeEnd = dt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeStart,
|
||||||
|
timeEnd,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
...filters,
|
||||||
|
resourceId: filters.resourceId
|
||||||
|
? Number(filters.resourceId)
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
}, [dateRange, currentPage, pageSize, filters]);
|
||||||
|
|
||||||
|
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||||
|
...logQueries.requests({
|
||||||
|
orgId: orgId as string,
|
||||||
|
filters: queryFilters
|
||||||
|
}),
|
||||||
|
enabled: build !== "oss"
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = isLoading ? generateSampleRequestLogs() : (data?.log ?? []);
|
||||||
|
const totalCount = data?.pagination?.total ?? 0;
|
||||||
|
const filterAttributes = data?.filterAttributes ?? {
|
||||||
|
actors: [],
|
||||||
|
resources: [],
|
||||||
|
locations: [],
|
||||||
|
hosts: [],
|
||||||
|
paths: []
|
||||||
|
};
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
// put the search params in the url for the time
|
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
queryDateTime(startDate, endDate, 0, pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page changes
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
newPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page size changes
|
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0); // Reset to first page when changing page size
|
setCurrentPage(0);
|
||||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter changes generically
|
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
console.log(`${filterType} filter changed:`, value);
|
const newFilters = { ...filters, [filterType]: value };
|
||||||
|
|
||||||
// Create new filters object with updated value
|
|
||||||
const newFilters = {
|
|
||||||
...filters,
|
|
||||||
[filterType]: value
|
|
||||||
};
|
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
|
|
||||||
// Update URL params
|
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
0,
|
|
||||||
pageSize,
|
|
||||||
newFilters
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -204,101 +185,6 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryDateTime = async (
|
|
||||||
startDate: DateTimeValue,
|
|
||||||
endDate: DateTimeValue,
|
|
||||||
page: number = currentPage,
|
|
||||||
size: number = pageSize,
|
|
||||||
filtersParam?: {
|
|
||||||
action?: string;
|
|
||||||
type?: string;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the provided filters or fall back to current state
|
|
||||||
const activeFilters = filtersParam || filters;
|
|
||||||
|
|
||||||
// Convert the date/time values to API parameters
|
|
||||||
const params: any = {
|
|
||||||
limit: size,
|
|
||||||
offset: page * size,
|
|
||||||
...activeFilters
|
|
||||||
};
|
|
||||||
|
|
||||||
if (startDate?.date) {
|
|
||||||
const startDateTime = new Date(startDate.date);
|
|
||||||
if (startDate.time) {
|
|
||||||
const [hours, minutes, seconds] = startDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
}
|
|
||||||
params.timeStart = startDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate?.date) {
|
|
||||||
const endDateTime = new Date(endDate.date);
|
|
||||||
if (endDate.time) {
|
|
||||||
const [hours, minutes, seconds] = endDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
} else {
|
|
||||||
// If no time is specified, set to NOW
|
|
||||||
const now = new Date();
|
|
||||||
endDateTime.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
params.timeEnd = endDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await api.get(`/org/${orgId}/logs/request`, { params });
|
|
||||||
if (res.status === 200) {
|
|
||||||
setRows(res.data.data.log || []);
|
|
||||||
setTotalCount(res.data.data.pagination?.total || 0);
|
|
||||||
setFilterAttributes(res.data.data.filterAttributes);
|
|
||||||
console.log("Fetched logs:", res.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("Failed to filter logs"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshData = async () => {
|
|
||||||
console.log("Data refreshed");
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
// Refresh data with current date range and pagination
|
|
||||||
await queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
currentPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("refreshError"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
// Prepare query params for export
|
// Prepare query params for export
|
||||||
@@ -781,8 +667,8 @@ export default function GeneralPage() {
|
|||||||
title={t("requestLogs")}
|
title={t("requestLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="host"
|
searchColumn="host"
|
||||||
onRefresh={refreshData}
|
onRefresh={() => refetch()}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isFetching}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
@@ -794,7 +680,6 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
// Server-side pagination props
|
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
@@ -808,3 +693,63 @@ export default function GeneralPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateSampleRequestLogs(): QueryRequestAuditLogResponse["log"] {
|
||||||
|
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"];
|
||||||
|
const paths = [
|
||||||
|
"/api/v1/users",
|
||||||
|
"/dashboard",
|
||||||
|
"/settings",
|
||||||
|
"/health",
|
||||||
|
"/metrics"
|
||||||
|
];
|
||||||
|
const hosts = ["app.example.com", "api.example.com", "admin.example.com"];
|
||||||
|
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
||||||
|
const allowedReasons = [100, 101, 102, 103, 104, 105, 106, 107, 108];
|
||||||
|
const deniedReasons = [201, 202, 203, 204, 205, 299];
|
||||||
|
const actors = [
|
||||||
|
"alice@example.com",
|
||||||
|
"bob@example.com",
|
||||||
|
"carol@example.com",
|
||||||
|
null
|
||||||
|
];
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const action = Math.random() > 0.3;
|
||||||
|
const reason = action
|
||||||
|
? allowedReasons[Math.floor(Math.random() * allowedReasons.length)]
|
||||||
|
: deniedReasons[Math.floor(Math.random() * deniedReasons.length)];
|
||||||
|
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: Math.floor(
|
||||||
|
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||||
|
),
|
||||||
|
action,
|
||||||
|
reason,
|
||||||
|
orgId: "sample-org",
|
||||||
|
actorType: actor ? "user" : null,
|
||||||
|
actor,
|
||||||
|
actorId: actor ? `user-${i}` : null,
|
||||||
|
resourceId: Math.floor(Math.random() * 5) + 1,
|
||||||
|
siteResourceId: null,
|
||||||
|
resourceNiceId: `resource-${(i % 3) + 1}`,
|
||||||
|
resourceName: `Resource ${(i % 3) + 1}`,
|
||||||
|
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
||||||
|
location: locations[Math.floor(Math.random() * locations.length)],
|
||||||
|
userAgent: "Mozilla/5.0",
|
||||||
|
metadata: null,
|
||||||
|
headers: null,
|
||||||
|
query: null,
|
||||||
|
originalRequestURL: null,
|
||||||
|
scheme: "https",
|
||||||
|
host: hosts[Math.floor(Math.random() * hosts.length)],
|
||||||
|
path: paths[Math.floor(Math.random() * paths.length)],
|
||||||
|
method: methods[Math.floor(Math.random() * methods.length)],
|
||||||
|
tls: true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import { build } from "@server/build";
|
|||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import SetResourcePasswordForm from "components/SetResourcePasswordForm";
|
import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm";
|
||||||
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
|
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|||||||
+3
-1
@@ -21,9 +21,11 @@ export default async function Page(props: {
|
|||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
redirect: string | undefined;
|
redirect: string | undefined;
|
||||||
t: string | undefined;
|
t: string | undefined;
|
||||||
|
orgs?: string | undefined;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const params = await props.searchParams; // this is needed to prevent static optimization
|
const params = await props.searchParams; // this is needed to prevent static optimization
|
||||||
|
const showOrgPicker = params.orgs === "1";
|
||||||
|
|
||||||
const env = pullEnv();
|
const env = pullEnv();
|
||||||
|
|
||||||
@@ -106,7 +108,7 @@ export default async function Page(props: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetOrgId) {
|
if (targetOrgId && !showOrgPicker) {
|
||||||
return <RedirectToOrg targetOrgId={targetOrgId} />;
|
return <RedirectToOrg targetOrgId={targetOrgId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,20 +27,18 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
|||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<InfoSections cols={3}>
|
<InfoSections cols={userDisplayName ? 3 : 2}>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>{client.name}</InfoSectionContent>
|
<InfoSectionContent>{client.name}</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
<InfoSection>
|
{userDisplayName ? (
|
||||||
<InfoSectionTitle>
|
<InfoSection>
|
||||||
{userDisplayName ? t("user") : t("identifier")}
|
<InfoSectionTitle>{t("user")}</InfoSectionTitle>
|
||||||
</InfoSectionTitle>
|
<InfoSectionContent>
|
||||||
<InfoSectionContent>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<span>{userDisplayName}</span>
|
||||||
<span>{userDisplayName || client.niceId}</span>
|
{(client.userType ?? "internal") !==
|
||||||
{userDisplayName &&
|
|
||||||
(client.userType ?? "internal") !==
|
|
||||||
"internal" && (
|
"internal" && (
|
||||||
<IdpTypeBadge
|
<IdpTypeBadge
|
||||||
type={client.userType ?? "oidc"}
|
type={client.userType ?? "oidc"}
|
||||||
@@ -54,9 +52,10 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
) : null}
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
Funnel,
|
Funnel,
|
||||||
MoreHorizontal,
|
MoreHorizontal
|
||||||
PlusIcon
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -40,7 +39,6 @@ import {
|
|||||||
startTransition,
|
startTransition,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useOptimistic,
|
|
||||||
useState,
|
useState,
|
||||||
useTransition
|
useTransition
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -62,8 +60,10 @@ import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertI
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { LabelBadge } from "./label-badge";
|
import { type SelectedLabel } from "./labels-selector";
|
||||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
import { TableLabelsCell } from "./TableLabelsCell";
|
||||||
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
|
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||||
|
|
||||||
export type InternalResourceSiteRow = ResourceSiteRow;
|
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||||
|
|
||||||
@@ -162,12 +162,12 @@ export default function ClientResourcesTable({
|
|||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
// const interval = setInterval(() => {
|
||||||
router.refresh();
|
// router.refresh();
|
||||||
}, 30_000);
|
// }, 30_000);
|
||||||
return () => clearInterval(interval);
|
// return () => clearInterval(interval);
|
||||||
}, [router]);
|
// }, [router]);
|
||||||
|
|
||||||
const siteIdQ = searchParams.get("siteId");
|
const siteIdQ = searchParams.get("siteId");
|
||||||
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
|
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
|
||||||
@@ -220,59 +220,6 @@ export default function ClientResourcesTable({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) {
|
|
||||||
const { siteNames, siteNiceIds, orgId } = resourceRow;
|
|
||||||
|
|
||||||
if (!siteNames || siteNames.length === 0) {
|
|
||||||
return (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("noSites", { defaultValue: "No sites" })}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (siteNames.length === 1) {
|
|
||||||
return (
|
|
||||||
<Link href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}>
|
|
||||||
<Button variant="outline">
|
|
||||||
{siteNames[0]}
|
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{siteNames.length} {t("sites")}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
{siteNames.map((siteName, idx) => (
|
|
||||||
<DropdownMenuItem key={siteNiceIds[idx]} asChild>
|
|
||||||
<Link
|
|
||||||
href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`}
|
|
||||||
className="flex items-center gap-2 cursor-pointer"
|
|
||||||
>
|
|
||||||
{siteName}
|
|
||||||
<ArrowUpRight className="h-3 w-3" />
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const internalColumns = useMemo<
|
const internalColumns = useMemo<
|
||||||
ExtendedColumnDef<InternalResourceRow>[]
|
ExtendedColumnDef<InternalResourceRow>[]
|
||||||
>(() => {
|
>(() => {
|
||||||
@@ -585,9 +532,15 @@ export default function ClientResourcesTable({
|
|||||||
id: "labels",
|
id: "labels",
|
||||||
accessorKey: "labels",
|
accessorKey: "labels",
|
||||||
header: () => (
|
header: () => (
|
||||||
<span className="p-3 text-end w-full inline-block">
|
<LabelColumnFilterButton
|
||||||
{t("labels")}
|
orgId={orgId}
|
||||||
</span>
|
selectedValues={searchParams.getAll("labels")}
|
||||||
|
onSelectedValuesChange={(value) =>
|
||||||
|
handleFilterChange("labels", value)
|
||||||
|
}
|
||||||
|
label={t("labels")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
|
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
|
||||||
<ClientResourceLabelCell
|
<ClientResourceLabelCell
|
||||||
@@ -603,13 +556,15 @@ export default function ClientResourcesTable({
|
|||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
value: string | undefined | null
|
value: string | undefined | null | string[]
|
||||||
) {
|
) {
|
||||||
searchParams.delete(column);
|
searchParams.delete(column);
|
||||||
searchParams.delete("page");
|
searchParams.delete("page");
|
||||||
|
|
||||||
if (value) {
|
if (typeof value === "string") {
|
||||||
searchParams.set(column, value);
|
searchParams.set(column, value);
|
||||||
|
} else if (value) {
|
||||||
|
value.forEach((val) => searchParams.append(column, val));
|
||||||
}
|
}
|
||||||
filter({
|
filter({
|
||||||
searchParams
|
searchParams
|
||||||
@@ -682,7 +637,7 @@ export default function ClientResourcesTable({
|
|||||||
rows={internalResources}
|
rows={internalResources}
|
||||||
tableId="internal-resources"
|
tableId="internal-resources"
|
||||||
searchPlaceholder={t("resourcesSearch")}
|
searchPlaceholder={t("resourcesSearch")}
|
||||||
searchQuery={searchParams.get("query") ?? ""}
|
searchQuery={searchParams.get("query")?.toString()}
|
||||||
onAdd={() => setIsCreateDialogOpen(true)}
|
onAdd={() => setIsCreateDialogOpen(true)}
|
||||||
addButtonText={t("resourceAdd")}
|
addButtonText={t("resourceAdd")}
|
||||||
onSearch={handleSearchChange}
|
onSearch={handleSearchChange}
|
||||||
@@ -743,27 +698,28 @@ function ClientResourceLabelCell({
|
|||||||
}: ClientResourceLabelCellProps) {
|
}: ClientResourceLabelCellProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [localLabels, setLocalLabels] = useLocalLabels(
|
||||||
const router = useRouter();
|
resource.labels,
|
||||||
|
resource.id
|
||||||
const labels = resource.labels ?? [];
|
);
|
||||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
|
||||||
|
|
||||||
function toggleResourceLabel(
|
function toggleResourceLabel(
|
||||||
label: SelectedLabel,
|
label: SelectedLabel,
|
||||||
action: "attach" | "detach"
|
action: "attach" | "detach"
|
||||||
) {
|
) {
|
||||||
startTransition(async () => {
|
const previousLabels = localLabels;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
if (action === "attach") {
|
if (action === "attach") {
|
||||||
setOptimisticLabels([...optimisticLabels, label]);
|
setLocalLabels([...previousLabels, label]);
|
||||||
await api.put(
|
await api.put(
|
||||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
`/org/${orgId}/label/${label.labelId}/attach`,
|
||||||
{ siteResourceId: resource.id }
|
{ siteResourceId: resource.id }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setOptimisticLabels(
|
setLocalLabels(
|
||||||
optimisticLabels.filter(
|
previousLabels.filter(
|
||||||
(lb) => lb.labelId !== label.labelId
|
(lb) => lb.labelId !== label.labelId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -773,59 +729,21 @@ function ClientResourceLabelCell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLocalLabels(previousLabels);
|
||||||
toast({
|
toast({
|
||||||
title: t("error"),
|
title: t("error"),
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
description: formatAxiosError(e, t("errorOccurred")),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
});
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
|
<TableLabelsCell
|
||||||
{optimisticLabels.slice(0, 3).map((label) => (
|
orgId={orgId}
|
||||||
<LabelBadge
|
localLabels={localLabels}
|
||||||
key={label.labelId}
|
toggleLabel={toggleResourceLabel}
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
/>
|
||||||
{...label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{optimisticLabels.length > 3 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex gap-1 items-center",
|
|
||||||
"rounded-full text-sm cursor-pointer",
|
|
||||||
"px-1.5 py-0 h-auto"
|
|
||||||
)}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
>
|
|
||||||
+{optimisticLabels.length - 3}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="p-1 size-auto rounded-full"
|
|
||||||
title={t("addLabels")}
|
|
||||||
>
|
|
||||||
<span className="sr-only">{t("addLabels")}</span>
|
|
||||||
<PlusIcon className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="center" className="p-0 w-full">
|
|
||||||
<LabelsSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedLabels={optimisticLabels}
|
|
||||||
toggleLabel={toggleResourceLabel}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export default function CreateBlueprintForm({
|
|||||||
if (res && res.status === 201) {
|
if (res && res.status === 201) {
|
||||||
const createdBlueprint = res.data.data;
|
const createdBlueprint = res.data.data;
|
||||||
toast({
|
toast({
|
||||||
variant: "warning",
|
variant: createdBlueprint.succeeded ? "default" : "warning",
|
||||||
title: createdBlueprint.succeeded ? "Success" : "Warning",
|
title: createdBlueprint.succeeded ? "Success" : "Warning",
|
||||||
description: createdBlueprint.message
|
description: createdBlueprint.message
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -318,12 +318,28 @@ export default function DeviceLoginForm({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<InputOTP
|
<InputOTP
|
||||||
maxLength={9}
|
maxLength={8}
|
||||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value
|
value={field.value
|
||||||
.replace(/-/g, "")
|
.replace(/-/g, "")
|
||||||
.toUpperCase()}
|
.toUpperCase()}
|
||||||
|
onPaste={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const pastedText =
|
||||||
|
event.clipboardData.getData(
|
||||||
|
"text"
|
||||||
|
);
|
||||||
|
const cleanedValue =
|
||||||
|
pastedText
|
||||||
|
.replace(
|
||||||
|
/[^a-zA-Z0-9]/g,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 8);
|
||||||
|
field.onChange(cleanedValue);
|
||||||
|
}}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
// Strip hyphens and convert to uppercase
|
// Strip hyphens and convert to uppercase
|
||||||
const cleanedValue = value
|
const cleanedValue = value
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function ExitNodesTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: t("online"),
|
friendlyName: t("status"),
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -131,7 +131,7 @@ export default function ExitNodesTable({
|
|||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t("online")}
|
{t("status")}
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ export default function HealthChecksTable({
|
|||||||
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
||||||
const [resourceFilterOpen, setResourceFilterOpen] = useState(false);
|
const [resourceFilterOpen, setResourceFilterOpen] = useState(false);
|
||||||
|
|
||||||
const pageSize = pagination.pageSize;
|
|
||||||
const query = searchParams.get("query") ?? undefined;
|
const query = searchParams.get("query") ?? undefined;
|
||||||
|
|
||||||
const siteIdQ = searchParams.get("siteId");
|
const siteIdQ = searchParams.get("siteId");
|
||||||
@@ -164,12 +163,12 @@ export default function HealthChecksTable({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
// const interval = setInterval(() => {
|
||||||
router.refresh();
|
// router.refresh();
|
||||||
}, 30_000);
|
// }, 30_000);
|
||||||
return () => clearInterval(interval);
|
// return () => clearInterval(interval);
|
||||||
}, [router]);
|
// }, [router]);
|
||||||
|
|
||||||
const handlePaginationChange = (newState: PaginationState) => {
|
const handlePaginationChange = (newState: PaginationState) => {
|
||||||
searchParams.set("page", (newState.pageIndex + 1).toString());
|
searchParams.set("page", (newState.pageIndex + 1).toString());
|
||||||
@@ -586,7 +585,9 @@ export default function HealthChecksTable({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={r.hcEnabled}
|
checked={r.hcEnabled}
|
||||||
disabled={
|
disabled={
|
||||||
!isPaid || togglingId === r.targetHealthCheckId || !!r.resourceId
|
!isPaid ||
|
||||||
|
togglingId === r.targetHealthCheckId ||
|
||||||
|
!!r.resourceId
|
||||||
}
|
}
|
||||||
onCheckedChange={(v) => handleToggleEnabled(r, v)}
|
onCheckedChange={(v) => handleToggleEnabled(r, v)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList
|
||||||
|
} from "@app/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
|
import { CheckIcon, Funnel } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { orgQueries } from "@app/lib/queries";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
import { LabelBadge } from "./label-badge";
|
||||||
|
import { LabelOverflowBadge } from "./label-overflow-badge";
|
||||||
|
import { LABEL_COLORS } from "./labels-selector";
|
||||||
|
|
||||||
|
function areSelectionsEqual(a: string[], b: string[]) {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const setB = new Set(b);
|
||||||
|
return a.every((value) => setB.has(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
type LabelColumnFilterButtonProps = {
|
||||||
|
selectedValues: string[];
|
||||||
|
onSelectedValuesChange: (values: string[]) => void;
|
||||||
|
className?: string;
|
||||||
|
label: string;
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LabelColumnFilterButton({
|
||||||
|
selectedValues,
|
||||||
|
onSelectedValuesChange,
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
orgId
|
||||||
|
}: LabelColumnFilterButtonProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [draftValues, setDraftValues] = useState<string[]>(selectedValues);
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
|
||||||
|
const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
|
||||||
|
|
||||||
|
const { data: labels = [] } = useQuery(
|
||||||
|
orgQueries.labels({
|
||||||
|
orgId,
|
||||||
|
query: debouncedQuery,
|
||||||
|
perPage: 500
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const draftSet = useMemo(() => new Set(draftValues), [draftValues]);
|
||||||
|
|
||||||
|
const selectedLabels = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedValues.map((name) => {
|
||||||
|
const foundLabel = labels.find((label) => label.name === name);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
color: foundLabel?.color ?? LABEL_COLORS.gray
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[selectedValues, labels]
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
if (selectedLabels.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedLabels.length === 1) {
|
||||||
|
const label = selectedLabels[0];
|
||||||
|
return (
|
||||||
|
<LabelBadge
|
||||||
|
displayOnly
|
||||||
|
name={label.name}
|
||||||
|
color={label.color}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LabelOverflowBadge
|
||||||
|
labels={selectedLabels}
|
||||||
|
displayOnly
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [selectedLabels]);
|
||||||
|
|
||||||
|
function toggle(value: string) {
|
||||||
|
setDraftValues((current) =>
|
||||||
|
current.includes(value)
|
||||||
|
? current.filter((v) => v !== value)
|
||||||
|
: [...current, value]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
|
if (nextOpen) {
|
||||||
|
setDraftValues(selectedValues);
|
||||||
|
setOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
if (!areSelectionsEqual(draftValues, selectedValues)) {
|
||||||
|
onSelectedValuesChange(draftValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className={cn(
|
||||||
|
"justify-between text-sm h-8 px-2",
|
||||||
|
selectedValues.length === 0 &&
|
||||||
|
"text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="shrink-0">{label}</span>
|
||||||
|
<Funnel className="size-4 flex-none shrink-0" />
|
||||||
|
{summary}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className={dataTableFilterPopoverContentClassName}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t("labelSearch")}
|
||||||
|
value={labelSearchQuery}
|
||||||
|
onValueChange={setlabelsSearchQuery}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{draftValues.length > 0 && (
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
setDraftValues([]);
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("accessLabelFilterClear")}
|
||||||
|
</CommandItem>
|
||||||
|
)}
|
||||||
|
{labels.map((label) => (
|
||||||
|
<CommandItem
|
||||||
|
key={label.name}
|
||||||
|
value={label.name}
|
||||||
|
onSelect={() => {
|
||||||
|
toggle(label.name);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
draftSet.has(label.name)
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="size-2 rounded-full bg-(--color) flex-none"
|
||||||
|
style={{
|
||||||
|
// @ts-expect-error css color
|
||||||
|
"--color": label.color
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{label.name}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+41
-38
@@ -5,6 +5,7 @@ import type { SidebarNavSection } from "@app/app/navigation";
|
|||||||
import { LayoutSidebar } from "@app/components/LayoutSidebar";
|
import { LayoutSidebar } from "@app/components/LayoutSidebar";
|
||||||
import { LayoutHeader } from "@app/components/LayoutHeader";
|
import { LayoutHeader } from "@app/components/LayoutHeader";
|
||||||
import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu";
|
import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu";
|
||||||
|
import { CommandPaletteProvider } from "@app/components/command-palette/CommandPaletteProvider";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
@@ -37,51 +38,53 @@ export async function Layout({
|
|||||||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen-safe overflow-hidden">
|
<CommandPaletteProvider orgId={orgId} orgs={orgs} navItems={navItems}>
|
||||||
{/* Desktop Sidebar */}
|
<div className="flex h-screen-safe overflow-hidden">
|
||||||
{showSidebar && (
|
{/* Desktop Sidebar */}
|
||||||
<LayoutSidebar
|
{showSidebar && (
|
||||||
orgId={orgId}
|
<LayoutSidebar
|
||||||
orgs={orgs}
|
|
||||||
navItems={navItems}
|
|
||||||
defaultSidebarCollapsed={initialSidebarCollapsed}
|
|
||||||
hasCookiePreference={hasCookiePreference}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main content area */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex-1 flex flex-col h-full min-w-0 relative",
|
|
||||||
!showSidebar && "w-full"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Mobile header */}
|
|
||||||
{showHeader && (
|
|
||||||
<LayoutMobileMenu
|
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
orgs={orgs}
|
orgs={orgs}
|
||||||
navItems={navItems}
|
navItems={navItems}
|
||||||
showSidebar={showSidebar}
|
defaultSidebarCollapsed={initialSidebarCollapsed}
|
||||||
showTopBar={showTopBar}
|
hasCookiePreference={hasCookiePreference}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop header */}
|
{/* Main content area */}
|
||||||
{showHeader && <LayoutHeader showTopBar={showTopBar} />}
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1 flex flex-col h-full min-w-0 relative",
|
||||||
|
!showSidebar && "w-full"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Mobile header */}
|
||||||
|
{showHeader && (
|
||||||
|
<LayoutMobileMenu
|
||||||
|
orgId={orgId}
|
||||||
|
orgs={orgs}
|
||||||
|
navItems={navItems}
|
||||||
|
showSidebar={showSidebar}
|
||||||
|
showTopBar={showTopBar}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Desktop header */}
|
||||||
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
{showHeader && <LayoutHeader showTopBar={showTopBar} />}
|
||||||
<div
|
|
||||||
className={cn(
|
{/* Main content */}
|
||||||
"container mx-auto max-w-12xl mb-12",
|
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
||||||
showHeader && "md:pt-14" // Add top padding only on desktop to account for fixed header
|
<div
|
||||||
)}
|
className={cn(
|
||||||
>
|
"container mx-auto max-w-12xl mb-12",
|
||||||
{children}
|
showHeader && "md:pt-14" // Add top padding only on desktop to account for fixed header
|
||||||
</div>
|
)}
|
||||||
</main>
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CommandPaletteProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ProfileIcon from "@app/components/ProfileIcon";
|
|||||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import BrandingLogo from "./BrandingLogo";
|
import BrandingLogo from "./BrandingLogo";
|
||||||
|
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
|||||||
|
|
||||||
{showTopBar && (
|
{showTopBar && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
<CommandPaletteTrigger />
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
<ProfileIcon />
|
<ProfileIcon />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useUserContext } from "@app/hooks/useUserContext";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import ProfileIcon from "@app/components/ProfileIcon";
|
import ProfileIcon from "@app/components/ProfileIcon";
|
||||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||||
|
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
|
||||||
import type { SidebarNavSection } from "@app/app/navigation";
|
import type { SidebarNavSection } from "@app/app/navigation";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -121,6 +122,7 @@ export function LayoutMobileMenu({
|
|||||||
{showTopBar && (
|
{showTopBar && (
|
||||||
<div className="ml-auto flex items-center justify-end">
|
<div className="ml-auto flex items-center justify-end">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
<CommandPaletteTrigger variant="mobile" />
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
<ProfileIcon />
|
<ProfileIcon />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
Loader,
|
Loader,
|
||||||
|
LoaderIcon,
|
||||||
RefreshCw
|
RefreshCw
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -427,7 +428,7 @@ export function LogDataTable<TData, TValue>({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="relative">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
@@ -535,6 +536,19 @@ export function LogDataTable<TData, TValue>({
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<>
|
||||||
|
<div className="backdrop-blur-[3px] z-10 absolute inset-0 top-10"></div>
|
||||||
|
<div className="absolute z-20 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 border border-border rounded-md bg-muted">
|
||||||
|
<div className="flex items-center gap-2 p-6">
|
||||||
|
<LoaderIcon className="size-4 animate-spin" />
|
||||||
|
{t("loadingEllipsis")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<DataTablePagination
|
<DataTablePagination
|
||||||
table={table}
|
table={table}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -10,20 +10,21 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
|
||||||
ArrowUpDown,
|
|
||||||
MoreHorizontal,
|
|
||||||
CircleSlash,
|
|
||||||
ArrowDown01Icon,
|
ArrowDown01Icon,
|
||||||
|
ArrowRight,
|
||||||
ArrowUp10Icon,
|
ArrowUp10Icon,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
PlusIcon
|
CircleSlash,
|
||||||
|
MoreHorizontal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -31,25 +32,18 @@ import { useRouter } from "next/navigation";
|
|||||||
import {
|
import {
|
||||||
startTransition,
|
startTransition,
|
||||||
useMemo,
|
useMemo,
|
||||||
useOptimistic,
|
|
||||||
useState,
|
useState,
|
||||||
useTransition
|
useTransition
|
||||||
} from "react";
|
} from "react";
|
||||||
import { LabelBadge } from "./label-badge";
|
|
||||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger
|
|
||||||
} from "./ui/popover";
|
|
||||||
import { Badge } from "./ui/badge";
|
|
||||||
import type { PaginationState } from "@tanstack/react-table";
|
|
||||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
|
||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
|
||||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
|
import { type SelectedLabel } from "./labels-selector";
|
||||||
|
import { TableLabelsCell } from "./TableLabelsCell";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
|
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||||
|
|
||||||
export type ClientRow = {
|
export type ClientRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -280,7 +274,7 @@ export default function MachineClientsTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: t("online"),
|
friendlyName: t("status"),
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<ColumnFilterButton
|
<ColumnFilterButton
|
||||||
@@ -302,7 +296,7 @@ export default function MachineClientsTable({
|
|||||||
}
|
}
|
||||||
searchPlaceholder={t("searchPlaceholder")}
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
emptyMessage={t("emptySearchOptions")}
|
emptyMessage={t("emptySearchOptions")}
|
||||||
label={t("online")}
|
label={t("status")}
|
||||||
className="p-3"
|
className="p-3"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -415,9 +409,15 @@ export default function MachineClientsTable({
|
|||||||
id: "labels",
|
id: "labels",
|
||||||
accessorKey: "labels",
|
accessorKey: "labels",
|
||||||
header: () => (
|
header: () => (
|
||||||
<span className="p-3 text-end w-full inline-block">
|
<LabelColumnFilterButton
|
||||||
{t("labels")}
|
orgId={orgId}
|
||||||
</span>
|
selectedValues={searchParams.getAll("labels")}
|
||||||
|
onSelectedValuesChange={(value) =>
|
||||||
|
handleFilterChange("labels", value)
|
||||||
|
}
|
||||||
|
label={t("labels")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }: { row: { original: ClientRow } }) => (
|
cell: ({ row }: { row: { original: ClientRow } }) => (
|
||||||
<MachineClientLabelCell
|
<MachineClientLabelCell
|
||||||
@@ -510,11 +510,6 @@ export default function MachineClientsTable({
|
|||||||
return baseColumns;
|
return baseColumns;
|
||||||
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]);
|
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]);
|
||||||
|
|
||||||
const booleanSearchFilterSchema = z
|
|
||||||
.enum(["true", "false"])
|
|
||||||
.optional()
|
|
||||||
.catch(undefined);
|
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
value: string | null | undefined | string[]
|
value: string | null | undefined | string[]
|
||||||
@@ -585,6 +580,7 @@ export default function MachineClientsTable({
|
|||||||
rows={machineClients}
|
rows={machineClients}
|
||||||
tableId="machine-clients"
|
tableId="machine-clients"
|
||||||
searchPlaceholder={t("machinesSearch")}
|
searchPlaceholder={t("machinesSearch")}
|
||||||
|
searchQuery={searchParams.get("query")?.toString()}
|
||||||
onAdd={() =>
|
onAdd={() =>
|
||||||
startNavigation(() =>
|
startNavigation(() =>
|
||||||
router.push(`/${orgId}/settings/clients/machine/create`)
|
router.push(`/${orgId}/settings/clients/machine/create`)
|
||||||
@@ -602,35 +598,6 @@ export default function MachineClientsTable({
|
|||||||
columnVisibility={defaultMachineColumnVisibility}
|
columnVisibility={defaultMachineColumnVisibility}
|
||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
stickyRightColumn="actions"
|
stickyRightColumn="actions"
|
||||||
filters={[
|
|
||||||
{
|
|
||||||
id: "status",
|
|
||||||
label: t("status") || "Status",
|
|
||||||
multiSelect: true,
|
|
||||||
displayMode: "calculated",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
id: "active",
|
|
||||||
label: t("active") || "Active",
|
|
||||||
value: "active"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "archived",
|
|
||||||
label: t("archived") || "Archived",
|
|
||||||
value: "archived"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "blocked",
|
|
||||||
label: t("blocked") || "Blocked",
|
|
||||||
value: "blocked"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
onValueChange(selectedValues: string[]) {
|
|
||||||
handleFilterChange("status", selectedValues);
|
|
||||||
},
|
|
||||||
values: searchParams.getAll("status")
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -641,27 +608,31 @@ type MachineClientLabelCellProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function MachineClientLabelCell({ client, orgId }: MachineClientLabelCellProps) {
|
function MachineClientLabelCell({
|
||||||
|
client,
|
||||||
|
orgId
|
||||||
|
}: MachineClientLabelCellProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [localLabels, setLocalLabels] = useLocalLabels(client.labels, client.id);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const labels = client.labels ?? [];
|
function toggleClientLabel(
|
||||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
label: SelectedLabel,
|
||||||
|
action: "attach" | "detach"
|
||||||
|
) {
|
||||||
|
const previousLabels = localLabels;
|
||||||
|
|
||||||
function toggleClientLabel(label: SelectedLabel, action: "attach" | "detach") {
|
void (async () => {
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
try {
|
||||||
if (action === "attach") {
|
if (action === "attach") {
|
||||||
setOptimisticLabels([...optimisticLabels, label]);
|
setLocalLabels([...previousLabels, label]);
|
||||||
await api.put(
|
await api.put(
|
||||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
`/org/${orgId}/label/${label.labelId}/attach`,
|
||||||
{ clientId: client.id }
|
{ clientId: client.id }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setOptimisticLabels(
|
setLocalLabels(
|
||||||
optimisticLabels.filter(
|
previousLabels.filter(
|
||||||
(lb) => lb.labelId !== label.labelId
|
(lb) => lb.labelId !== label.labelId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -671,59 +642,21 @@ function MachineClientLabelCell({ client, orgId }: MachineClientLabelCellProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLocalLabels(previousLabels);
|
||||||
toast({
|
toast({
|
||||||
title: t("error"),
|
title: t("error"),
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
description: formatAxiosError(e, t("errorOccurred")),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
});
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
|
<TableLabelsCell
|
||||||
{optimisticLabels.slice(0, 3).map((label) => (
|
orgId={orgId}
|
||||||
<LabelBadge
|
localLabels={localLabels}
|
||||||
key={label.labelId}
|
toggleLabel={toggleClientLabel}
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
/>
|
||||||
{...label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{optimisticLabels.length > 3 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex gap-1 items-center",
|
|
||||||
"rounded-full text-sm cursor-pointer",
|
|
||||||
"px-1.5 py-0 h-auto"
|
|
||||||
)}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
>
|
|
||||||
+{optimisticLabels.length - 3}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="p-1 size-auto rounded-full"
|
|
||||||
title={t("addLabels")}
|
|
||||||
>
|
|
||||||
<span className="sr-only">{t("addLabels")}</span>
|
|
||||||
<PlusIcon className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="center" className="p-0 w-full">
|
|
||||||
<LabelsSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedLabels={optimisticLabels}
|
|
||||||
toggleLabel={toggleClientLabel}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
placeholder={t("labelPlaceholder")}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -104,13 +103,16 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="size-4 rounded-full bg-(--color) flex-none"
|
className="size-2 rounded-full bg-(--color) flex-none"
|
||||||
style={{
|
style={{
|
||||||
// @ts-expect-error css color
|
// @ts-expect-error css color
|
||||||
"--color": value
|
"--color": value
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span data-name>{color}</span>
|
<span data-name>
|
||||||
|
{color.charAt(0).toUpperCase() +
|
||||||
|
color.slice(1)}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -121,11 +121,9 @@ export default function OrgLabelsTable({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actions",
|
id: "actions",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
header: () => {
|
header: () => <span className="p-3"></span>,
|
||||||
return <span className="p-3">{t("actions")}</span>;
|
|
||||||
},
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -234,6 +232,7 @@ export default function OrgLabelsTable({
|
|||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing || isFiltering}
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
rowCount={rowCount}
|
rowCount={rowCount}
|
||||||
|
stickyRightColumn="actions"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,8 +70,10 @@ import z from "zod";
|
|||||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
import UptimeMiniBar from "./UptimeMiniBar";
|
import UptimeMiniBar from "./UptimeMiniBar";
|
||||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
import { type SelectedLabel } from "./labels-selector";
|
||||||
import { LabelBadge } from "./label-badge";
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
|
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||||
|
import { TableLabelsCell } from "./TableLabelsCell";
|
||||||
|
|
||||||
export type TargetHealth = {
|
export type TargetHealth = {
|
||||||
targetId: number;
|
targetId: number;
|
||||||
@@ -169,12 +171,12 @@ export default function ProxyResourcesTable({
|
|||||||
};
|
};
|
||||||
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
// const interval = setInterval(() => {
|
||||||
router.refresh();
|
// router.refresh();
|
||||||
}, 30_000);
|
// }, 30_000);
|
||||||
return () => clearInterval(interval);
|
// return () => clearInterval(interval);
|
||||||
}, [router]);
|
// }, [router]);
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
@@ -640,9 +642,15 @@ export default function ProxyResourcesTable({
|
|||||||
id: "labels",
|
id: "labels",
|
||||||
accessorKey: "labels",
|
accessorKey: "labels",
|
||||||
header: () => (
|
header: () => (
|
||||||
<span className="p-3 text-end w-full inline-block">
|
<LabelColumnFilterButton
|
||||||
{t("labels")}
|
orgId={orgId}
|
||||||
</span>
|
selectedValues={searchParams.getAll("labels")}
|
||||||
|
onSelectedValuesChange={(value) =>
|
||||||
|
handleFilterChange("labels", value)
|
||||||
|
}
|
||||||
|
label={t("labels")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }: { row: { original: ResourceRow } }) => (
|
cell: ({ row }: { row: { original: ResourceRow } }) => (
|
||||||
<ResourceLabelCell resource={row.original} orgId={orgId} />
|
<ResourceLabelCell resource={row.original} orgId={orgId} />
|
||||||
@@ -655,13 +663,15 @@ export default function ProxyResourcesTable({
|
|||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
value: string | undefined | null
|
value: string | undefined | null | string[]
|
||||||
) {
|
) {
|
||||||
searchParams.delete(column);
|
searchParams.delete(column);
|
||||||
searchParams.delete("page");
|
searchParams.delete("page");
|
||||||
|
|
||||||
if (value) {
|
if (typeof value === "string") {
|
||||||
searchParams.set(column, value);
|
searchParams.set(column, value);
|
||||||
|
} else if (value) {
|
||||||
|
value.forEach((val) => searchParams.append(column, val));
|
||||||
}
|
}
|
||||||
filter({
|
filter({
|
||||||
searchParams
|
searchParams
|
||||||
@@ -721,6 +731,7 @@ export default function ProxyResourcesTable({
|
|||||||
searchPlaceholder={t("resourcesSearch")}
|
searchPlaceholder={t("resourcesSearch")}
|
||||||
pagination={pagination}
|
pagination={pagination}
|
||||||
rowCount={rowCount}
|
rowCount={rowCount}
|
||||||
|
searchQuery={searchParams.get("query")?.toString()}
|
||||||
onSearch={handleSearchChange}
|
onSearch={handleSearchChange}
|
||||||
onPaginationChange={handlePaginationChange}
|
onPaginationChange={handlePaginationChange}
|
||||||
onAdd={() =>
|
onAdd={() =>
|
||||||
@@ -755,29 +766,29 @@ function ResourceLabelCell({ resource, orgId }: ResourceLabelCellProps) {
|
|||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [localLabels, setLocalLabels] = useLocalLabels(
|
||||||
|
resource.labels,
|
||||||
const router = useRouter();
|
resource.id
|
||||||
|
);
|
||||||
const labels = resource.labels ?? [];
|
|
||||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
|
||||||
|
|
||||||
function toggleSiteLabel(
|
function toggleSiteLabel(
|
||||||
label: SelectedLabel,
|
label: SelectedLabel,
|
||||||
action: "attach" | "detach"
|
action: "attach" | "detach"
|
||||||
) {
|
) {
|
||||||
startTransition(async () => {
|
const previousLabels = localLabels;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
if (action === "attach") {
|
if (action === "attach") {
|
||||||
setOptimisticLabels([...optimisticLabels, label]);
|
setLocalLabels([...previousLabels, label]);
|
||||||
|
|
||||||
await api.put(
|
await api.put(
|
||||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
`/org/${orgId}/label/${label.labelId}/attach`,
|
||||||
{ resourceId: resource.id }
|
{ resourceId: resource.id }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setOptimisticLabels(
|
setLocalLabels(
|
||||||
optimisticLabels.filter(
|
previousLabels.filter(
|
||||||
(lb) => lb.labelId !== label.labelId
|
(lb) => lb.labelId !== label.labelId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -787,60 +798,22 @@ function ResourceLabelCell({ resource, orgId }: ResourceLabelCellProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLocalLabels(previousLabels);
|
||||||
toast({
|
toast({
|
||||||
title: t("error"),
|
title: t("error"),
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
description: formatAxiosError(e, t("errorOccurred")),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
});
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
|
<TableLabelsCell
|
||||||
{optimisticLabels.slice(0, 3).map((label) => (
|
orgId={orgId}
|
||||||
<LabelBadge
|
localLabels={localLabels}
|
||||||
key={label.labelId}
|
toggleLabel={toggleSiteLabel}
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
/>
|
||||||
{...label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{optimisticLabels.length > 3 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex gap-1 items-center",
|
|
||||||
"rounded-full text-sm cursor-pointer",
|
|
||||||
"px-1.5 py-0 h-auto"
|
|
||||||
)}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
>
|
|
||||||
+{optimisticLabels.length - 3}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="p-1 size-auto rounded-full"
|
|
||||||
title={t("addLabels")}
|
|
||||||
>
|
|
||||||
<span className="sr-only">{t("addLabels")}</span>
|
|
||||||
<PlusIcon className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="center" className="p-0 w-full">
|
|
||||||
<LabelsSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedLabels={optimisticLabels}
|
|
||||||
toggleLabel={toggleSiteLabel}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,15 +36,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{/* 4 cols because of the certs */}
|
{/* 4 cols because of the certs */}
|
||||||
<InfoSections cols={resource.http && build != "oss" ? 6 : 5}>
|
<InfoSections cols={resource.http && build != "oss" ? 5 : 4}>
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
<span className="inline-flex items-center">
|
|
||||||
{resource.niceId}
|
|
||||||
</span>
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
{resource.http ? (
|
{resource.http ? (
|
||||||
<>
|
<>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
|
|||||||
+50
-16
@@ -46,6 +46,20 @@ function toSshSudoMode(value: string | null | undefined): SshSudoMode {
|
|||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
|
||||||
|
if (!value?.trim()) return true;
|
||||||
|
|
||||||
|
const commands = value
|
||||||
|
.split(",")
|
||||||
|
.map((command) => command.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return commands.every((command) => {
|
||||||
|
const executable = command.split(/\s+/)[0];
|
||||||
|
return executable.startsWith("/");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type RoleFormValues = {
|
export type RoleFormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -74,19 +88,33 @@ export function RoleForm({
|
|||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z
|
||||||
name: z
|
.object({
|
||||||
.string({ message: t("nameRequired") })
|
name: z
|
||||||
.min(1)
|
.string({ message: t("nameRequired") })
|
||||||
.max(32),
|
.min(1)
|
||||||
description: z.string().max(255).optional(),
|
.max(32),
|
||||||
requireDeviceApproval: z.boolean().optional(),
|
description: z.string().max(255).optional(),
|
||||||
allowSsh: z.boolean().optional(),
|
requireDeviceApproval: z.boolean().optional(),
|
||||||
sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES),
|
allowSsh: z.boolean().optional(),
|
||||||
sshSudoCommands: z.string().optional(),
|
sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES),
|
||||||
sshCreateHomeDir: z.boolean().optional(),
|
sshSudoCommands: z.string().optional(),
|
||||||
sshUnixGroups: z.string().optional()
|
sshCreateHomeDir: z.boolean().optional(),
|
||||||
});
|
sshUnixGroups: z.string().optional()
|
||||||
|
})
|
||||||
|
.superRefine((values, ctx) => {
|
||||||
|
if (
|
||||||
|
values.sshSudoMode === "commands" &&
|
||||||
|
!hasOnlyAbsoluteSudoCommands(values.sshSudoCommands)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["sshSudoCommands"],
|
||||||
|
message:
|
||||||
|
"Each sudo command must start with an absolute path (for example, /usr/bin/systemctl)."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const defaultValues: RoleFormValues = role
|
const defaultValues: RoleFormValues = role
|
||||||
? {
|
? {
|
||||||
@@ -296,7 +324,9 @@ export function RoleForm({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="allowSsh"
|
name="allowSsh"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const allowSshOptions: OptionSelectOption<"allow" | "disallow">[] = [
|
const allowSshOptions: OptionSelectOption<
|
||||||
|
"allow" | "disallow"
|
||||||
|
>[] = [
|
||||||
{
|
{
|
||||||
value: "allow",
|
value: "allow",
|
||||||
label: t("roleAllowSshAllow")
|
label: t("roleAllowSshAllow")
|
||||||
@@ -311,7 +341,9 @@ export function RoleForm({
|
|||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("roleAllowSsh")}
|
{t("roleAllowSsh")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<OptionSelect<"allow" | "disallow">
|
<OptionSelect<
|
||||||
|
"allow" | "disallow"
|
||||||
|
>
|
||||||
options={allowSshOptions}
|
options={allowSshOptions}
|
||||||
value={
|
value={
|
||||||
sshDisabled
|
sshDisabled
|
||||||
@@ -322,7 +354,9 @@ export function RoleForm({
|
|||||||
}
|
}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
if (sshDisabled) return;
|
if (sshDisabled) return;
|
||||||
field.onChange(v === "allow");
|
field.onChange(
|
||||||
|
v === "allow"
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
cols={2}
|
cols={2}
|
||||||
disabled={sshDisabled}
|
disabled={sshDisabled}
|
||||||
|
|||||||
@@ -61,8 +61,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
|||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<InfoSections cols={site.endpoint ? 5 : 4}>
|
<InfoSections cols={site.endpoint ? 4 : 3}>
|
||||||
{identifierSection}
|
|
||||||
{statusSection}
|
{statusSection}
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
|
|||||||
+374
-399
@@ -37,7 +37,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
PlusIcon
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -46,7 +45,6 @@ import {
|
|||||||
startTransition,
|
startTransition,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useOptimistic,
|
|
||||||
useState,
|
useState,
|
||||||
useTransition
|
useTransition
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -61,9 +59,10 @@ import {
|
|||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { LabelBadge } from "./label-badge";
|
import { type SelectedLabel } from "./labels-selector";
|
||||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||||
|
import { TableLabelsCell } from "./TableLabelsCell";
|
||||||
|
|
||||||
export type SiteRow = {
|
export type SiteRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -122,12 +121,12 @@ export default function SitesTable({
|
|||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
// const interval = setInterval(() => {
|
||||||
router.refresh();
|
// router.refresh();
|
||||||
}, 30_000);
|
// }, 30_000);
|
||||||
return () => clearInterval(interval);
|
// return () => clearInterval(interval);
|
||||||
}, []);
|
// }, []);
|
||||||
|
|
||||||
const booleanSearchFilterSchema = z
|
const booleanSearchFilterSchema = z
|
||||||
.enum(["true", "false"])
|
.enum(["true", "false"])
|
||||||
@@ -136,14 +135,16 @@ export default function SitesTable({
|
|||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
value: string | undefined | null
|
value: string | undefined | null | string[]
|
||||||
) {
|
) {
|
||||||
const sp = new URLSearchParams(searchParams);
|
const sp = new URLSearchParams(searchParams);
|
||||||
sp.delete(column);
|
sp.delete(column);
|
||||||
sp.delete("page");
|
sp.delete("page");
|
||||||
|
|
||||||
if (value) {
|
if (typeof value === "string") {
|
||||||
sp.set(column, value);
|
sp.set(column, value);
|
||||||
|
} else if (value) {
|
||||||
|
value.forEach((val) => sp.append(column, val));
|
||||||
}
|
}
|
||||||
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
|
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
|
||||||
}
|
}
|
||||||
@@ -183,358 +184,373 @@ export default function SitesTable({
|
|||||||
|
|
||||||
const columns = useMemo<ExtendedColumnDef<SiteRow>[]>(() => {
|
const columns = useMemo<ExtendedColumnDef<SiteRow>[]>(() => {
|
||||||
const cols: ExtendedColumnDef<SiteRow>[] = [
|
const cols: ExtendedColumnDef<SiteRow>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
header: () => {
|
header: () => {
|
||||||
const nameOrder = getSortDirection("name", searchParams);
|
const nameOrder = getSortDirection("name", searchParams);
|
||||||
const Icon =
|
const Icon =
|
||||||
nameOrder === "asc"
|
nameOrder === "asc"
|
||||||
? ArrowDown01Icon
|
? ArrowDown01Icon
|
||||||
: nameOrder === "desc"
|
: nameOrder === "desc"
|
||||||
? ArrowUp10Icon
|
? ArrowUp10Icon
|
||||||
: ChevronsUpDownIcon;
|
: ChevronsUpDownIcon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="p-3"
|
className="p-3"
|
||||||
onClick={() => toggleSort("name")}
|
onClick={() => toggleSort("name")}
|
||||||
>
|
>
|
||||||
{t("name")}
|
{t("name")}
|
||||||
<Icon className="ml-2 h-4 w-4" />
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "niceId",
|
|
||||||
accessorKey: "nice",
|
|
||||||
friendlyName: t("identifier"),
|
|
||||||
enableHiding: true,
|
|
||||||
header: () => {
|
|
||||||
return <span className="p-3">{t("identifier")}</span>;
|
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
{
|
||||||
return <span>{row.original.nice || "-"}</span>;
|
id: "niceId",
|
||||||
}
|
accessorKey: "nice",
|
||||||
},
|
friendlyName: t("identifier"),
|
||||||
{
|
enableHiding: true,
|
||||||
accessorKey: "online",
|
header: () => {
|
||||||
friendlyName: t("online"),
|
return <span className="p-3">{t("identifier")}</span>;
|
||||||
header: () => {
|
},
|
||||||
return (
|
cell: ({ row }) => {
|
||||||
<ColumnFilterButton
|
return <span>{row.original.nice || "-"}</span>;
|
||||||
options={[
|
}
|
||||||
{ value: "true", label: t("online") },
|
},
|
||||||
{ value: "false", label: t("offline") }
|
{
|
||||||
]}
|
accessorKey: "online",
|
||||||
selectedValue={booleanSearchFilterSchema.parse(
|
friendlyName: t("status"),
|
||||||
searchParams.get("online")
|
header: () => {
|
||||||
)}
|
return (
|
||||||
onValueChange={(value) =>
|
<ColumnFilterButton
|
||||||
handleFilterChange("online", value)
|
options={[
|
||||||
|
{ value: "true", label: t("online") },
|
||||||
|
{ value: "false", label: t("offline") }
|
||||||
|
]}
|
||||||
|
selectedValue={booleanSearchFilterSchema.parse(
|
||||||
|
searchParams.get("online")
|
||||||
|
)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("online", value)
|
||||||
|
}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
label={t("status")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const originalRow = row.original;
|
||||||
|
if (
|
||||||
|
originalRow.type == "newt" ||
|
||||||
|
originalRow.type == "wireguard"
|
||||||
|
) {
|
||||||
|
if (originalRow.online) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center space-x-2">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
|
<span>{t("online")}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center space-x-2">
|
||||||
|
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||||
|
<span>{t("offline")}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
searchPlaceholder={t("searchPlaceholder")}
|
|
||||||
emptyMessage={t("emptySearchOptions")}
|
|
||||||
label={t("online")}
|
|
||||||
className="p-3"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const originalRow = row.original;
|
|
||||||
if (
|
|
||||||
originalRow.type == "newt" ||
|
|
||||||
originalRow.type == "wireguard"
|
|
||||||
) {
|
|
||||||
if (originalRow.online) {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center space-x-2">
|
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
||||||
<span>{t("online")}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "uptime",
|
||||||
|
friendlyName: "Uptime",
|
||||||
|
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const originalRow = row.original;
|
||||||
|
if (originalRow.type == "local") {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
|
return <UptimeMiniBar siteId={originalRow.id} days={30} />;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "mbIn",
|
||||||
|
friendlyName: t("dataIn"),
|
||||||
|
header: () => {
|
||||||
|
const dataInOrder = getSortDirection(
|
||||||
|
"megabytesIn",
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
const Icon =
|
||||||
|
dataInOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: dataInOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleSort("megabytesIn")}
|
||||||
|
>
|
||||||
|
{t("dataIn")}
|
||||||
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "mbOut",
|
||||||
|
friendlyName: t("dataOut"),
|
||||||
|
header: () => {
|
||||||
|
const dataOutOrder = getSortDirection(
|
||||||
|
"megabytesOut",
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const Icon =
|
||||||
|
dataOutOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: dataOutOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleSort("megabytesOut")}
|
||||||
|
>
|
||||||
|
{t("dataOut")}
|
||||||
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "type",
|
||||||
|
friendlyName: t("type"),
|
||||||
|
header: () => {
|
||||||
|
return <span className="p-3">{t("type")}</span>;
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const originalRow = row.original;
|
||||||
|
|
||||||
|
if (originalRow.type === "newt") {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center space-x-2">
|
<div className="flex items-center space-x-1">
|
||||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
<Badge variant="secondary">
|
||||||
<span>{t("offline")}</span>
|
<div className="flex items-center space-x-1">
|
||||||
</span>
|
<span>Newt</span>
|
||||||
|
{originalRow.newtVersion && (
|
||||||
|
<span>
|
||||||
|
v{originalRow.newtVersion}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Badge>
|
||||||
|
{originalRow.newtUpdateAvailable && (
|
||||||
|
<InfoPopup
|
||||||
|
info={t("newtUpdateAvailableInfo")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return <span>-</span>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "uptime",
|
|
||||||
friendlyName: "Uptime",
|
|
||||||
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const originalRow = row.original;
|
|
||||||
if (originalRow.type == "local") {
|
|
||||||
return <span>-</span>;
|
|
||||||
}
|
|
||||||
return <UptimeMiniBar siteId={originalRow.id} days={30} />;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "mbIn",
|
|
||||||
friendlyName: t("dataIn"),
|
|
||||||
header: () => {
|
|
||||||
const dataInOrder = getSortDirection(
|
|
||||||
"megabytesIn",
|
|
||||||
searchParams
|
|
||||||
);
|
|
||||||
const Icon =
|
|
||||||
dataInOrder === "asc"
|
|
||||||
? ArrowDown01Icon
|
|
||||||
: dataInOrder === "desc"
|
|
||||||
? ArrowUp10Icon
|
|
||||||
: ChevronsUpDownIcon;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => toggleSort("megabytesIn")}
|
|
||||||
>
|
|
||||||
{t("dataIn")}
|
|
||||||
<Icon className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "mbOut",
|
|
||||||
friendlyName: t("dataOut"),
|
|
||||||
header: () => {
|
|
||||||
const dataOutOrder = getSortDirection(
|
|
||||||
"megabytesOut",
|
|
||||||
searchParams
|
|
||||||
);
|
|
||||||
|
|
||||||
const Icon =
|
if (originalRow.type === "wireguard") {
|
||||||
dataOutOrder === "asc"
|
return (
|
||||||
? ArrowDown01Icon
|
<div className="flex items-center space-x-2">
|
||||||
: dataOutOrder === "desc"
|
<Badge variant="secondary">WireGuard</Badge>
|
||||||
? ArrowUp10Icon
|
</div>
|
||||||
: ChevronsUpDownIcon;
|
);
|
||||||
return (
|
}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
if (originalRow.type === "local") {
|
||||||
onClick={() => toggleSort("megabytesOut")}
|
return (
|
||||||
>
|
<div className="flex items-center space-x-2">
|
||||||
{t("dataOut")}
|
<Badge variant="secondary">Local</Badge>
|
||||||
<Icon className="ml-2 h-4 w-4" />
|
</div>
|
||||||
</Button>
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "type",
|
|
||||||
friendlyName: t("type"),
|
|
||||||
header: () => {
|
|
||||||
return <span className="p-3">{t("type")}</span>;
|
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
{
|
||||||
const originalRow = row.original;
|
id: "resources",
|
||||||
|
accessorKey: "resourceCount",
|
||||||
if (originalRow.type === "newt") {
|
friendlyName: t("resources"),
|
||||||
|
header: () => <span className="p-3">{t("resources")}</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const siteRow = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-1">
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setResourcesDialogSite(siteRow)}
|
||||||
|
className="flex h-8 items-center gap-2 px-0 font-normal"
|
||||||
|
>
|
||||||
|
<span className="text-sm tabular-nums">
|
||||||
|
{siteRow.resourceCount} {t("resources")}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "exitNode",
|
||||||
|
friendlyName: t("exitNode"),
|
||||||
|
header: () => {
|
||||||
|
return <span className="p-3">{t("exitNode")}</span>;
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const originalRow = row.original;
|
||||||
|
if (!originalRow.exitNodeName) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCloudNode =
|
||||||
|
build == "saas" &&
|
||||||
|
originalRow.exitNodeName &&
|
||||||
|
[
|
||||||
|
"mercury",
|
||||||
|
"venus",
|
||||||
|
"earth",
|
||||||
|
"mars",
|
||||||
|
"jupiter",
|
||||||
|
"saturn",
|
||||||
|
"uranus",
|
||||||
|
"neptune",
|
||||||
|
"pluto"
|
||||||
|
].includes(originalRow.exitNodeName.toLowerCase());
|
||||||
|
|
||||||
|
if (isCloudNode) {
|
||||||
|
const capitalizedName =
|
||||||
|
originalRow.exitNodeName.charAt(0).toUpperCase() +
|
||||||
|
originalRow.exitNodeName.slice(1).toLowerCase();
|
||||||
|
return (
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
<div className="flex items-center space-x-1">
|
Pangolin {capitalizedName}
|
||||||
<span>Newt</span>
|
|
||||||
{originalRow.newtVersion && (
|
|
||||||
<span>v{originalRow.newtVersion}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Badge>
|
</Badge>
|
||||||
{originalRow.newtUpdateAvailable && (
|
);
|
||||||
<InfoPopup
|
}
|
||||||
info={t("newtUpdateAvailableInfo")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (originalRow.type === "wireguard") {
|
// Self-hosted node
|
||||||
return (
|
if (originalRow.remoteExitNodeId) {
|
||||||
<div className="flex items-center space-x-2">
|
return (
|
||||||
<Badge variant="secondary">WireGuard</Badge>
|
<Link
|
||||||
</div>
|
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
|
||||||
);
|
>
|
||||||
}
|
<Button variant="outline" size="sm">
|
||||||
|
{originalRow.exitNodeName}
|
||||||
if (originalRow.type === "local") {
|
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Badge variant="secondary">Local</Badge>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "resources",
|
|
||||||
accessorKey: "resourceCount",
|
|
||||||
friendlyName: t("resources"),
|
|
||||||
header: () => <span className="p-3">{t("resources")}</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const siteRow = row.original;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setResourcesDialogSite(siteRow)}
|
|
||||||
className="flex h-8 items-center gap-2 px-2 font-normal"
|
|
||||||
>
|
|
||||||
<span className="text-sm tabular-nums">
|
|
||||||
{siteRow.resourceCount} {t("resources")}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "exitNode",
|
|
||||||
friendlyName: t("exitNode"),
|
|
||||||
header: () => {
|
|
||||||
return <span className="p-3">{t("exitNode")}</span>;
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const originalRow = row.original;
|
|
||||||
if (!originalRow.exitNodeName) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCloudNode =
|
|
||||||
build == "saas" &&
|
|
||||||
originalRow.exitNodeName &&
|
|
||||||
[
|
|
||||||
"mercury",
|
|
||||||
"venus",
|
|
||||||
"earth",
|
|
||||||
"mars",
|
|
||||||
"jupiter",
|
|
||||||
"saturn",
|
|
||||||
"uranus",
|
|
||||||
"neptune",
|
|
||||||
"pluto"
|
|
||||||
].includes(originalRow.exitNodeName.toLowerCase());
|
|
||||||
|
|
||||||
if (isCloudNode) {
|
|
||||||
const capitalizedName =
|
|
||||||
originalRow.exitNodeName.charAt(0).toUpperCase() +
|
|
||||||
originalRow.exitNodeName.slice(1).toLowerCase();
|
|
||||||
return (
|
|
||||||
<Badge variant="secondary">
|
|
||||||
Pangolin {capitalizedName}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Self-hosted node
|
|
||||||
if (originalRow.remoteExitNodeId) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
|
|
||||||
>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
{originalRow.exitNodeName}
|
|
||||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback if no remoteExitNodeId
|
|
||||||
return <span>{originalRow.exitNodeName}</span>;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "address",
|
|
||||||
header: () => {
|
|
||||||
return <span className="p-3">{t("address")}</span>;
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const originalRow = row.original;
|
|
||||||
return originalRow.address ? (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span>{originalRow.address}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
"-"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
enableHiding: false,
|
|
||||||
header: () => <span className="p-3"></span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const siteRow = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 justify-end">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
<span className="sr-only">Open menu</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</Link>
|
||||||
<DropdownMenuContent align="end">
|
);
|
||||||
<Link
|
}
|
||||||
className="block w-full"
|
|
||||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
// Fallback if no remoteExitNodeId
|
||||||
>
|
return <span>{originalRow.exitNodeName}</span>;
|
||||||
<DropdownMenuItem>
|
}
|
||||||
{t("viewSettings")}
|
},
|
||||||
</DropdownMenuItem>
|
{
|
||||||
</Link>
|
accessorKey: "address",
|
||||||
<Link
|
header: () => {
|
||||||
className="block w-full"
|
return <span className="p-3">{t("address")}</span>;
|
||||||
href={`/${siteRow.orgId}/settings/resources/proxy?siteId=${siteRow.id}`}
|
},
|
||||||
>
|
cell: ({ row }) => {
|
||||||
<DropdownMenuItem>
|
const originalRow = row.original;
|
||||||
{t("sitesTableViewPublicResources")}
|
return originalRow.address ? (
|
||||||
</DropdownMenuItem>
|
<div className="flex items-center space-x-2">
|
||||||
</Link>
|
<span>{originalRow.address}</span>
|
||||||
<Link
|
</div>
|
||||||
className="block w-full"
|
) : (
|
||||||
href={`/${siteRow.orgId}/settings/resources/client?siteId=${siteRow.id}`}
|
"-"
|
||||||
>
|
);
|
||||||
<DropdownMenuItem>
|
}
|
||||||
{t("sitesTableViewPrivateResources")}
|
},
|
||||||
</DropdownMenuItem>
|
{
|
||||||
</Link>
|
id: "actions",
|
||||||
</DropdownMenuContent>
|
enableHiding: false,
|
||||||
</DropdownMenu>
|
header: () => <span className="p-3"></span>,
|
||||||
<Link
|
cell: ({ row }) => {
|
||||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
const siteRow = row.original;
|
||||||
>
|
return (
|
||||||
<Button variant={"outline"}>
|
<div className="flex items-center gap-2 justify-end">
|
||||||
{t("edit")}
|
<DropdownMenu>
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
<DropdownMenuTrigger asChild>
|
||||||
</Button>
|
<Button
|
||||||
</Link>
|
variant="ghost"
|
||||||
</div>
|
className="h-8 w-8 p-0"
|
||||||
);
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
Open menu
|
||||||
|
</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<Link
|
||||||
|
className="block w-full"
|
||||||
|
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
{t("viewSettings")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="block w-full"
|
||||||
|
href={`/${siteRow.orgId}/settings/resources/proxy?siteId=${siteRow.id}`}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
{t("sitesTableViewPublicResources")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="block w-full"
|
||||||
|
href={`/${siteRow.orgId}/settings/resources/client?siteId=${siteRow.id}`}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
{t(
|
||||||
|
"sitesTableViewPrivateResources"
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Link
|
||||||
|
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||||
|
>
|
||||||
|
<Button variant={"outline"}>
|
||||||
|
{t("edit")}
|
||||||
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
if (isLabelFeatureEnabled) {
|
||||||
cols.splice(cols.length - 1, 0, {
|
cols.splice(cols.length - 1, 0, {
|
||||||
accessorKey: "labels",
|
accessorKey: "labels",
|
||||||
header: () => (
|
header: () => (
|
||||||
<span className="p-3 text-end w-full inline-block">
|
<LabelColumnFilterButton
|
||||||
{t("labels")}
|
orgId={orgId}
|
||||||
</span>
|
selectedValues={searchParams.getAll("labels")}
|
||||||
|
onSelectedValuesChange={(value) =>
|
||||||
|
handleFilterChange("labels", value)
|
||||||
|
}
|
||||||
|
label={t("labels")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }: { row: { original: SiteRow } }) => (
|
cell: ({ row }: { row: { original: SiteRow } }) => (
|
||||||
<SiteLabelCell site={row.original} orgId={orgId} />
|
<SiteLabelCell site={row.original} orgId={orgId} />
|
||||||
@@ -674,29 +690,26 @@ function SiteLabelCell({ site, orgId }: SiteLabelCellProps) {
|
|||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [localLabels, setLocalLabels] = useLocalLabels(site.labels, site.id);
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const labels = site.labels ?? [];
|
|
||||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
|
||||||
|
|
||||||
function toggleSiteLabel(
|
function toggleSiteLabel(
|
||||||
label: SelectedLabel,
|
label: SelectedLabel,
|
||||||
action: "attach" | "detach"
|
action: "attach" | "detach"
|
||||||
) {
|
) {
|
||||||
startTransition(async () => {
|
const previousLabels = localLabels;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
if (action === "attach") {
|
if (action === "attach") {
|
||||||
setOptimisticLabels([...optimisticLabels, label]);
|
setLocalLabels([...previousLabels, label]);
|
||||||
|
|
||||||
await api.put(
|
await api.put(
|
||||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
`/org/${orgId}/label/${label.labelId}/attach`,
|
||||||
{ siteId: site.id }
|
{ siteId: site.id }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setOptimisticLabels(
|
setLocalLabels(
|
||||||
optimisticLabels.filter(
|
previousLabels.filter(
|
||||||
(lb) => lb.labelId !== label.labelId
|
(lb) => lb.labelId !== label.labelId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -706,59 +719,21 @@ function SiteLabelCell({ site, orgId }: SiteLabelCellProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLocalLabels(previousLabels);
|
||||||
toast({
|
toast({
|
||||||
title: t("error"),
|
title: t("error"),
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
description: formatAxiosError(e, t("errorOccurred")),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
});
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
|
<TableLabelsCell
|
||||||
{optimisticLabels.slice(0, 3).map((label) => (
|
orgId={orgId}
|
||||||
<LabelBadge
|
localLabels={localLabels}
|
||||||
key={label.labelId}
|
toggleLabel={toggleSiteLabel}
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
/>
|
||||||
{...label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{optimisticLabels.length > 3 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex gap-1 items-center",
|
|
||||||
"rounded-full text-sm cursor-pointer",
|
|
||||||
"px-1.5 py-0 h-auto"
|
|
||||||
)}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
>
|
|
||||||
+{optimisticLabels.length - 3}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="p-1 size-auto rounded-full"
|
|
||||||
title={t("addLabels")}
|
|
||||||
>
|
|
||||||
<span className="sr-only">{t("addLabels")}</span>
|
|
||||||
<PlusIcon className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="center" className="p-0 w-full">
|
|
||||||
<LabelsSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedLabels={optimisticLabels}
|
|
||||||
toggleLabel={toggleSiteLabel}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
|
import type { Measurable } from "@radix-ui/rect";
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { LabelBadge } from "./label-badge";
|
||||||
|
import { LabelOverflowBadge } from "./label-overflow-badge";
|
||||||
|
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "./ui/popover";
|
||||||
|
|
||||||
|
const MAX_VISIBLE_LABELS = 4;
|
||||||
|
const MAX_VISIBLE_BEFORE_OVERFLOW = MAX_VISIBLE_LABELS - 1;
|
||||||
|
|
||||||
|
type TableLabelsCellProps = {
|
||||||
|
orgId: string;
|
||||||
|
localLabels: SelectedLabel[];
|
||||||
|
toggleLabel: (label: SelectedLabel, action: "attach" | "detach") => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TableLabelsCell({
|
||||||
|
orgId,
|
||||||
|
localLabels,
|
||||||
|
toggleLabel
|
||||||
|
}: TableLabelsCellProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const frozenAnchorRef = useRef<Measurable>({
|
||||||
|
getBoundingClientRect: () => new DOMRect()
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasOverflow = localLabels.length > MAX_VISIBLE_LABELS;
|
||||||
|
const visibleLabels = localLabels.slice(
|
||||||
|
0,
|
||||||
|
hasOverflow ? MAX_VISIBLE_BEFORE_OVERFLOW : MAX_VISIBLE_LABELS
|
||||||
|
);
|
||||||
|
const overflowLabels = hasOverflow
|
||||||
|
? localLabels.slice(MAX_VISIBLE_BEFORE_OVERFLOW)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
if (open && triggerRef.current) {
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
|
frozenAnchorRef.current = {
|
||||||
|
getBoundingClientRect: () => rect
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setIsPopoverOpen(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-1">
|
||||||
|
<Popover open={isPopoverOpen} onOpenChange={handleOpenChange}>
|
||||||
|
<PopoverAnchor virtualRef={frozenAnchorRef} />
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="size-auto shrink-0 rounded-full p-1"
|
||||||
|
title={t("addLabels")}
|
||||||
|
>
|
||||||
|
<span className="sr-only">{t("addLabels")}</span>
|
||||||
|
<PlusIcon className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
className={`${dataTableFilterPopoverContentClassName} p-0`}
|
||||||
|
updatePositionStrategy="optimized"
|
||||||
|
>
|
||||||
|
<LabelsSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedLabels={localLabels}
|
||||||
|
toggleLabel={toggleLabel}
|
||||||
|
onClose={() => handleOpenChange(false)}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<div className="flex min-w-0 flex-nowrap items-center justify-start gap-1 overflow-hidden">
|
||||||
|
{visibleLabels.map((label) => (
|
||||||
|
<LabelBadge
|
||||||
|
key={label.labelId}
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => handleOpenChange(true)}
|
||||||
|
{...label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{overflowLabels.length > 0 && (
|
||||||
|
<LabelOverflowBadge
|
||||||
|
labels={overflowLabels}
|
||||||
|
onClick={() => handleOpenChange(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -405,7 +405,7 @@ export default function UserDevicesTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: t("connected"),
|
friendlyName: t("status"),
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<ColumnFilterButton
|
<ColumnFilterButton
|
||||||
@@ -427,7 +427,7 @@ export default function UserDevicesTable({
|
|||||||
}
|
}
|
||||||
searchPlaceholder={t("searchPlaceholder")}
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
emptyMessage={t("emptySearchOptions")}
|
emptyMessage={t("emptySearchOptions")}
|
||||||
label={t("connected")}
|
label={t("status")}
|
||||||
className="p-3"
|
className="p-3"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -794,6 +794,7 @@ export default function UserDevicesTable({
|
|||||||
columnVisibility={defaultUserColumnVisibility}
|
columnVisibility={defaultUserColumnVisibility}
|
||||||
onSearch={handleSearchChange}
|
onSearch={handleSearchChange}
|
||||||
onPaginationChange={handlePaginationChange}
|
onPaginationChange={handlePaginationChange}
|
||||||
|
searchQuery={searchParams.get("query")?.toString()}
|
||||||
pagination={pagination}
|
pagination={pagination}
|
||||||
rowCount={rowCount}
|
rowCount={rowCount}
|
||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator
|
||||||
|
} from "@app/components/ui/command";
|
||||||
|
import type { SidebarNavSection } from "@app/app/navigation";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCommandPalette } from "./commandPaletteContext";
|
||||||
|
import { useCommandPaletteActions } from "./useCommandPaletteActions";
|
||||||
|
import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation";
|
||||||
|
import { useCommandPaletteOrganizations } from "./useCommandPaletteOrganizations";
|
||||||
|
import { useCommandPaletteSearch } from "./useCommandPaletteSearch";
|
||||||
|
|
||||||
|
type CommandPaletteProps = {
|
||||||
|
orgId?: string;
|
||||||
|
orgs?: ListUserOrgsResponse["orgs"];
|
||||||
|
navItems: SidebarNavSection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const router = useRouter();
|
||||||
|
const { open, setOpen } = useCommandPalette();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const navigationGroups = useCommandPaletteNavigation(navItems);
|
||||||
|
const organizations = useCommandPaletteOrganizations(orgs);
|
||||||
|
const actions = useCommandPaletteActions(orgId, orgs);
|
||||||
|
const { shouldSearch, sites, resources, users, machineClients, isLoading } =
|
||||||
|
useCommandPaletteSearch({
|
||||||
|
orgId,
|
||||||
|
query: search,
|
||||||
|
enabled: open
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(nextOpen: boolean) => {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
if (!nextOpen) {
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
const runCommand = useCallback(
|
||||||
|
(command: () => void) => {
|
||||||
|
setOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
command();
|
||||||
|
},
|
||||||
|
[setOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasEntityResults =
|
||||||
|
sites.length > 0 ||
|
||||||
|
resources.length > 0 ||
|
||||||
|
users.length > 0 ||
|
||||||
|
machineClients.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
title={t("commandPaletteTitle")}
|
||||||
|
description={t("commandPaletteDescription")}
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t("commandPaletteSearchPlaceholder")}
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
|
||||||
|
|
||||||
|
{navigationGroups.map((group) => (
|
||||||
|
<CommandGroup key={group.heading} heading={group.heading}>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<CommandItem
|
||||||
|
key={item.id}
|
||||||
|
value={`${item.title} ${group.heading}`}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() => router.push(item.href))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{organizations.length > 1 && (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup
|
||||||
|
heading={t("commandPaletteOrganizations")}
|
||||||
|
>
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<CommandItem
|
||||||
|
key={org.id}
|
||||||
|
value={`${org.name} ${org.orgId}`}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() => router.push(org.href))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">{org.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||||
|
{org.orgId}
|
||||||
|
</span>
|
||||||
|
{org.isPrimaryOrg && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="ml-auto shrink-0 text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
{t("primary")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shouldSearch && orgId && (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
{isLoading && !hasEntityResults ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
{t("commandPaletteSearching")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{sites.length > 0 && (
|
||||||
|
<CommandGroup
|
||||||
|
heading={t("commandPaletteSites")}
|
||||||
|
>
|
||||||
|
{sites.map((site) => (
|
||||||
|
<CommandItem
|
||||||
|
key={site.id}
|
||||||
|
value={`${site.name} site`}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() =>
|
||||||
|
router.push(site.href)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{site.name}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
{resources.length > 0 && (
|
||||||
|
<CommandGroup
|
||||||
|
heading={t("commandPaletteResources")}
|
||||||
|
>
|
||||||
|
{resources.map((resource) => (
|
||||||
|
<CommandItem
|
||||||
|
key={resource.id}
|
||||||
|
value={`${resource.name} resource`}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() =>
|
||||||
|
router.push(
|
||||||
|
resource.href
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{resource.name}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
{users.length > 0 && (
|
||||||
|
<CommandGroup
|
||||||
|
heading={t("commandPaletteUsers")}
|
||||||
|
>
|
||||||
|
{users.map((user) => (
|
||||||
|
<CommandItem
|
||||||
|
key={user.id}
|
||||||
|
value={`${user.name} ${user.email}`}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() =>
|
||||||
|
router.push(user.href)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate">
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
{machineClients.length > 0 && (
|
||||||
|
<CommandGroup
|
||||||
|
heading={t("commandPaletteClients")}
|
||||||
|
>
|
||||||
|
{machineClients.map((client) => (
|
||||||
|
<CommandItem
|
||||||
|
key={client.id}
|
||||||
|
value={`${client.name} client`}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() =>
|
||||||
|
router.push(client.href)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{client.name}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading={t("commandPaletteActions")}>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<CommandItem
|
||||||
|
key={action.id}
|
||||||
|
value={action.label}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() => {
|
||||||
|
if (action.onSelect) {
|
||||||
|
action.onSelect();
|
||||||
|
} else if (action.href) {
|
||||||
|
router.push(action.href);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{action.icon}
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { SidebarNavSection } from "@app/app/navigation";
|
||||||
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
CommandPaletteContextProvider,
|
||||||
|
type CommandPaletteContextValue
|
||||||
|
} from "./commandPaletteContext";
|
||||||
|
import { CommandPalette } from "./CommandPalette";
|
||||||
|
|
||||||
|
type CommandPaletteProviderProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
orgId?: string;
|
||||||
|
orgs?: ListUserOrgsResponse["orgs"];
|
||||||
|
navItems: SidebarNavSection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function isEditableTarget(target: EventTarget | null) {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
const tagName = target.tagName;
|
||||||
|
return (
|
||||||
|
tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPaletteProvider({
|
||||||
|
children,
|
||||||
|
orgId,
|
||||||
|
orgs,
|
||||||
|
navItems
|
||||||
|
}: CommandPaletteProviderProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setOpen((current) => !current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contextValue = useMemo<CommandPaletteContextValue>(
|
||||||
|
() => ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
toggle
|
||||||
|
}),
|
||||||
|
[open, toggle]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
event.key.toLowerCase() !== "k" ||
|
||||||
|
!(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open && isEditableTarget(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [open, toggle]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandPaletteContextProvider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
<CommandPalette orgId={orgId} orgs={orgs} navItems={navItems} />
|
||||||
|
</CommandPaletteContextProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { CommandShortcut } from "@app/components/ui/command";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCommandPalette } from "./commandPaletteContext";
|
||||||
|
|
||||||
|
type CommandPaletteTriggerProps = {
|
||||||
|
variant?: "header" | "mobile";
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useIsMac() {
|
||||||
|
const [isMac, setIsMac] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMac(/Mac|iPhone|iPod|iPad/.test(navigator.platform));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMac;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPaletteTrigger({
|
||||||
|
variant = "header",
|
||||||
|
className
|
||||||
|
}: CommandPaletteTriggerProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { setOpen } = useCommandPalette();
|
||||||
|
const isMac = useIsMac();
|
||||||
|
|
||||||
|
if (variant === "mobile") {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={className}
|
||||||
|
aria-label={t("commandPaletteTitle")}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Search className="size-5" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"hidden h-9 w-56 justify-start gap-2 px-3 text-muted-foreground md:flex lg:w-64",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-label={t("commandPaletteTitle")}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Search className="size-4 shrink-0 opacity-50" />
|
||||||
|
<span className="flex-1 truncate text-left text-sm font-normal">
|
||||||
|
{t("commandPaletteSearchPlaceholder")}
|
||||||
|
</span>
|
||||||
|
<CommandShortcut>
|
||||||
|
{isMac
|
||||||
|
? t("commandPaletteShortcutMac")
|
||||||
|
: t("commandPaletteShortcutWindows")}
|
||||||
|
</CommandShortcut>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export type CommandPaletteContextValue = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
export function CommandPaletteContextProvider({
|
||||||
|
value,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
value: CommandPaletteContextValue;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CommandPaletteContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</CommandPaletteContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommandPalette() {
|
||||||
|
const context = useContext(CommandPaletteContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useCommandPalette must be used within CommandPaletteProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import {
|
||||||
|
BellRing,
|
||||||
|
Building2,
|
||||||
|
Globe,
|
||||||
|
KeyRound,
|
||||||
|
MonitorUp,
|
||||||
|
Plus,
|
||||||
|
SunMoon,
|
||||||
|
UserPlus
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
|
|
||||||
|
export type CommandPaletteAction = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
href?: string;
|
||||||
|
onSelect?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCommandPaletteActions(
|
||||||
|
orgId?: string,
|
||||||
|
orgs?: ListUserOrgsResponse["orgs"]
|
||||||
|
): CommandPaletteAction[] {
|
||||||
|
const t = useTranslations();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const { user } = useUserContext();
|
||||||
|
const { setTheme, theme } = useTheme();
|
||||||
|
const isAdminPage = pathname?.startsWith("/admin");
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const actions: CommandPaletteAction[] = [];
|
||||||
|
|
||||||
|
function cycleTheme() {
|
||||||
|
const currentTheme = theme || "system";
|
||||||
|
if (currentTheme === "light") {
|
||||||
|
setTheme("dark");
|
||||||
|
} else if (currentTheme === "dark") {
|
||||||
|
setTheme("system");
|
||||||
|
} else {
|
||||||
|
setTheme("light");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdminPage) {
|
||||||
|
actions.push({
|
||||||
|
id: "create-admin-api-key",
|
||||||
|
label: t("commandPaletteCreateApiKey"),
|
||||||
|
icon: <KeyRound className="size-4" />,
|
||||||
|
href: "/admin/api-keys/create"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
build === "oss" ||
|
||||||
|
env?.app.identityProviderMode === "global" ||
|
||||||
|
env?.app.identityProviderMode === undefined
|
||||||
|
) {
|
||||||
|
actions.push({
|
||||||
|
id: "create-admin-idp",
|
||||||
|
label: t("commandPaletteCreateIdentityProvider"),
|
||||||
|
icon: <Plus className="size-4" />,
|
||||||
|
href: "/admin/idp/create"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (orgId) {
|
||||||
|
actions.push({
|
||||||
|
id: "create-site",
|
||||||
|
label: t("commandPaletteCreateSite"),
|
||||||
|
icon: <Plus className="size-4" />,
|
||||||
|
href: `/${orgId}/settings/sites/create`
|
||||||
|
});
|
||||||
|
actions.push({
|
||||||
|
id: "create-proxy-resource",
|
||||||
|
label: t("commandPaletteCreateProxyResource"),
|
||||||
|
icon: <Globe className="size-4" />,
|
||||||
|
href: `/${orgId}/settings/resources/proxy/create`
|
||||||
|
});
|
||||||
|
actions.push({
|
||||||
|
id: "create-user",
|
||||||
|
label: t("commandPaletteCreateUser"),
|
||||||
|
icon: <UserPlus className="size-4" />,
|
||||||
|
href: `/${orgId}/settings/access/users/create`
|
||||||
|
});
|
||||||
|
actions.push({
|
||||||
|
id: "create-api-key",
|
||||||
|
label: t("commandPaletteCreateApiKey"),
|
||||||
|
icon: <KeyRound className="size-4" />,
|
||||||
|
href: `/${orgId}/settings/api-keys/create`
|
||||||
|
});
|
||||||
|
actions.push({
|
||||||
|
id: "create-machine-client",
|
||||||
|
label: t("commandPaletteCreateMachineClient"),
|
||||||
|
icon: <MonitorUp className="size-4" />,
|
||||||
|
href: `/${orgId}/settings/clients/machine/create`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!env?.flags.disableEnterpriseFeatures) {
|
||||||
|
actions.push({
|
||||||
|
id: "create-alert-rule",
|
||||||
|
label: t("commandPaletteCreateAlertRule"),
|
||||||
|
icon: <BellRing className="size-4" />,
|
||||||
|
href: `/${orgId}/settings/alerting/create`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
|
||||||
|
build === "saas" ||
|
||||||
|
env?.app.identityProviderMode === "org" ||
|
||||||
|
(env?.app.identityProviderMode === undefined && build !== "oss")
|
||||||
|
) {
|
||||||
|
actions.push({
|
||||||
|
id: "create-idp",
|
||||||
|
label: t("commandPaletteCreateIdentityProvider"),
|
||||||
|
icon: <Plus className="size-4" />,
|
||||||
|
href: `/${orgId}/settings/idp/create`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canChooseOrganization = !isAdminPage && (orgs?.length ?? 0) > 1;
|
||||||
|
|
||||||
|
if (canChooseOrganization) {
|
||||||
|
actions.push({
|
||||||
|
id: "choose-org",
|
||||||
|
label: t("commandPaletteChooseOrganization"),
|
||||||
|
icon: <Building2 className="size-4" />,
|
||||||
|
href: "/?orgs=1"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push({
|
||||||
|
id: "toggle-theme",
|
||||||
|
label: t("commandPaletteToggleTheme"),
|
||||||
|
icon: <SunMoon className="size-4" />,
|
||||||
|
onSelect: cycleTheme
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user.serverAdmin && !isAdminPage) {
|
||||||
|
actions.push({
|
||||||
|
id: "go-admin",
|
||||||
|
label: t("serverAdmin"),
|
||||||
|
icon: <Building2 className="size-4" />,
|
||||||
|
href: "/admin/users"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}, [isAdminPage, orgId, orgs, env, user.serverAdmin, theme, setTheme, t]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { SidebarNavSection } from "@app/components/SidebarNav";
|
||||||
|
import { flattenNavSections } from "@app/lib/flattenNavItems";
|
||||||
|
import {
|
||||||
|
hydrateNavHref,
|
||||||
|
navHrefParamsFromRoute
|
||||||
|
} from "@app/lib/hydrateNavHref";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export type NavigationCommand = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
sectionHeading: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigationCommandGroup = {
|
||||||
|
heading: string;
|
||||||
|
items: NavigationCommand[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCommandPaletteNavigation(
|
||||||
|
navItems: SidebarNavSection[]
|
||||||
|
): NavigationCommandGroup[] {
|
||||||
|
const params = useParams();
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const hrefParams = navHrefParamsFromRoute(params);
|
||||||
|
const flat = flattenNavSections(navItems);
|
||||||
|
const groups = new Map<string, NavigationCommand[]>();
|
||||||
|
|
||||||
|
for (const item of flat) {
|
||||||
|
const href = hydrateNavHref(item.href, hrefParams);
|
||||||
|
if (!href) continue;
|
||||||
|
|
||||||
|
const groupItems = groups.get(item.sectionHeading) ?? [];
|
||||||
|
groupItems.push({
|
||||||
|
id: `nav-${item.sectionHeading}-${item.title}-${href}`,
|
||||||
|
title: t(item.title),
|
||||||
|
href,
|
||||||
|
icon: item.icon,
|
||||||
|
sectionHeading: item.sectionHeading
|
||||||
|
});
|
||||||
|
groups.set(item.sectionHeading, groupItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groups.entries()).map(([heading, items]) => ({
|
||||||
|
heading: t(heading),
|
||||||
|
items
|
||||||
|
}));
|
||||||
|
}, [navItems, params, t]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export type OrganizationCommand = {
|
||||||
|
id: string;
|
||||||
|
orgId: string;
|
||||||
|
name: string;
|
||||||
|
isPrimaryOrg?: boolean;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCommandPaletteOrganizations(
|
||||||
|
orgs: ListUserOrgsResponse["orgs"] | undefined
|
||||||
|
): OrganizationCommand[] {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!orgs?.length) return [];
|
||||||
|
|
||||||
|
const sortedOrgs = [...orgs].sort((a, b) => {
|
||||||
|
const aPrimary = Boolean(a.isPrimaryOrg);
|
||||||
|
const bPrimary = Boolean(b.isPrimaryOrg);
|
||||||
|
if (aPrimary && !bPrimary) return -1;
|
||||||
|
if (!aPrimary && bPrimary) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortedOrgs.map((org) => {
|
||||||
|
const newPath = pathname.includes("/settings/")
|
||||||
|
? pathname.replace(/^\/[^/]+/, `/${org.orgId}`)
|
||||||
|
: `/${org.orgId}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `org-${org.orgId}`,
|
||||||
|
orgId: org.orgId,
|
||||||
|
name: org.name,
|
||||||
|
isPrimaryOrg: org.isPrimaryOrg,
|
||||||
|
href: newPath
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [orgs, pathname]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { orgQueries } from "@app/lib/queries";
|
||||||
|
import { useQueries } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
|
const SEARCH_PER_PAGE = 5;
|
||||||
|
const MIN_QUERY_LENGTH = 2;
|
||||||
|
|
||||||
|
export type SiteSearchResult = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourceSearchResult = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserSearchResult = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientSearchResult = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCommandPaletteSearch({
|
||||||
|
orgId,
|
||||||
|
query,
|
||||||
|
enabled
|
||||||
|
}: {
|
||||||
|
orgId?: string;
|
||||||
|
query: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}) {
|
||||||
|
const [debouncedQuery] = useDebounce(query, 150);
|
||||||
|
const trimmedQuery = debouncedQuery.trim();
|
||||||
|
const shouldSearch =
|
||||||
|
enabled && !!orgId && trimmedQuery.length >= MIN_QUERY_LENGTH;
|
||||||
|
|
||||||
|
const [sitesQuery, resourcesQuery, usersQuery, clientsQuery] = useQueries({
|
||||||
|
queries: [
|
||||||
|
{
|
||||||
|
...orgQueries.sites({
|
||||||
|
orgId: orgId ?? "",
|
||||||
|
query: trimmedQuery,
|
||||||
|
perPage: SEARCH_PER_PAGE
|
||||||
|
}),
|
||||||
|
enabled: shouldSearch
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...orgQueries.resources({
|
||||||
|
orgId: orgId ?? "",
|
||||||
|
query: trimmedQuery,
|
||||||
|
perPage: SEARCH_PER_PAGE
|
||||||
|
}),
|
||||||
|
enabled: shouldSearch
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...orgQueries.users({
|
||||||
|
orgId: orgId ?? "",
|
||||||
|
query: trimmedQuery,
|
||||||
|
perPage: SEARCH_PER_PAGE
|
||||||
|
}),
|
||||||
|
enabled: shouldSearch
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...orgQueries.machineClients({
|
||||||
|
orgId: orgId ?? "",
|
||||||
|
query: trimmedQuery,
|
||||||
|
perPage: SEARCH_PER_PAGE
|
||||||
|
}),
|
||||||
|
enabled: shouldSearch
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const sites = useMemo((): SiteSearchResult[] => {
|
||||||
|
if (!orgId || !sitesQuery.data) return [];
|
||||||
|
return sitesQuery.data.map((site) => ({
|
||||||
|
id: `site-${site.siteId}`,
|
||||||
|
name: site.name,
|
||||||
|
href: `/${orgId}/settings/sites/${site.niceId}`
|
||||||
|
}));
|
||||||
|
}, [orgId, sitesQuery.data]);
|
||||||
|
|
||||||
|
const resources = useMemo((): ResourceSearchResult[] => {
|
||||||
|
if (!orgId || !resourcesQuery.data) return [];
|
||||||
|
return resourcesQuery.data.map((resource) => ({
|
||||||
|
id: `resource-${resource.resourceId}`,
|
||||||
|
name: resource.name,
|
||||||
|
href: `/${orgId}/settings/resources/proxy/${resource.niceId}`
|
||||||
|
}));
|
||||||
|
}, [orgId, resourcesQuery.data]);
|
||||||
|
|
||||||
|
const users = useMemo((): UserSearchResult[] => {
|
||||||
|
if (!orgId || !usersQuery.data) return [];
|
||||||
|
return usersQuery.data.map((user) => ({
|
||||||
|
id: `user-${user.id}`,
|
||||||
|
name: user.name ?? user.email ?? user.username ?? "",
|
||||||
|
email: user.email ?? user.username ?? "",
|
||||||
|
href: `/${orgId}/settings/access/users/${user.id}`
|
||||||
|
}));
|
||||||
|
}, [orgId, usersQuery.data]);
|
||||||
|
|
||||||
|
const machineClients = useMemo((): ClientSearchResult[] => {
|
||||||
|
if (!orgId || !clientsQuery.data) return [];
|
||||||
|
return clientsQuery.data
|
||||||
|
.filter((client) => !client.userId)
|
||||||
|
.map((client) => ({
|
||||||
|
id: `client-${client.clientId}`,
|
||||||
|
name: client.name,
|
||||||
|
href: `/${orgId}/settings/clients/machine/${client.niceId}`
|
||||||
|
}));
|
||||||
|
}, [orgId, clientsQuery.data]);
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
shouldSearch &&
|
||||||
|
(sitesQuery.isFetching ||
|
||||||
|
resourcesQuery.isFetching ||
|
||||||
|
usersQuery.isFetching ||
|
||||||
|
clientsQuery.isFetching);
|
||||||
|
|
||||||
|
return {
|
||||||
|
debouncedQuery: trimmedQuery,
|
||||||
|
shouldSearch,
|
||||||
|
sites,
|
||||||
|
resources,
|
||||||
|
users,
|
||||||
|
machineClients,
|
||||||
|
isLoading
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,40 +1,62 @@
|
|||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
|
|
||||||
|
const labelBadgeClassName =
|
||||||
|
"inline-flex h-auto items-center gap-1 rounded-full border border-input bg-background py-0 pl-1.5 pr-2 text-sm";
|
||||||
|
|
||||||
export type LabelBadgeProps = {
|
export type LabelBadgeProps = {
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
displayOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LabelBadge({
|
export function LabelBadge({
|
||||||
onClick,
|
onClick,
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
className
|
className,
|
||||||
|
displayOnly = false
|
||||||
}: LabelBadgeProps) {
|
}: LabelBadgeProps) {
|
||||||
return (
|
const content = (
|
||||||
<Button
|
<>
|
||||||
variant="outline"
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex gap-1 items-center",
|
|
||||||
"rounded-full text-sm cursor-pointer",
|
|
||||||
"pl-1.5 pr-2 py-0 h-auto",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="size-3 rounded-full bg-(--color) flex-none"
|
className="size-2 flex-none rounded-full bg-(--color)"
|
||||||
style={{
|
style={{
|
||||||
// @ts-expect-error css color
|
// @ts-expect-error css color
|
||||||
"--color": color
|
"--color": color
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="whitespace-nowrap text-ellipsis max-w-16 overflow-hidden relative">
|
<span className="relative max-w-24 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{displayOnly ? (
|
||||||
|
<span className={cn(labelBadgeClassName, className)}>
|
||||||
|
{content}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
labelBadgeClassName,
|
||||||
|
"cursor-pointer",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{name}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
|
|
||||||
|
export type LabelOverflowItem = {
|
||||||
|
color: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelOverflowBadgeClassName =
|
||||||
|
"inline-flex h-auto shrink-0 items-center gap-1.5 rounded-full border border-input bg-background py-0 pl-1.5 pr-2 text-sm";
|
||||||
|
|
||||||
|
export type LabelOverflowBadgeProps = {
|
||||||
|
labels: LabelOverflowItem[];
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
displayOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_OVERFLOW_COLORS = 3;
|
||||||
|
|
||||||
|
export function LabelOverflowBadge({
|
||||||
|
labels,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
displayOnly = false
|
||||||
|
}: LabelOverflowBadgeProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
if (labels.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayColors = labels
|
||||||
|
.slice(0, MAX_OVERFLOW_COLORS)
|
||||||
|
.map((label) => label.color);
|
||||||
|
|
||||||
|
const overflowNames = labels
|
||||||
|
.map((label) => label.name)
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
|
|
||||||
|
const tooltipContent =
|
||||||
|
overflowNames.length > 0
|
||||||
|
? overflowNames.join(", ")
|
||||||
|
: t("labelOverflowCount", { count: labels.length });
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-center">
|
||||||
|
{displayColors.map((color, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"size-2 flex-none rounded-full bg-(--color) ring-1 ring-background",
|
||||||
|
index > 0 && "-ml-1"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
// @ts-expect-error css color
|
||||||
|
"--color": color
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-nowrap text-muted-foreground">
|
||||||
|
{t("labelOverflowCount", { count: labels.length })}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{displayOnly ? (
|
||||||
|
<span
|
||||||
|
className={cn(labelOverflowBadgeClassName, className)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
labelOverflowBadgeClassName,
|
||||||
|
"cursor-pointer",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{tooltipContent}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export type LabelsSelectorProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
selectedLabels: SelectedLabel[];
|
selectedLabels: SelectedLabel[];
|
||||||
toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void;
|
toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void;
|
||||||
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LABEL_COLORS = {
|
export const LABEL_COLORS = {
|
||||||
@@ -51,7 +52,8 @@ export const LABEL_COLORS = {
|
|||||||
export function LabelsSelector({
|
export function LabelsSelector({
|
||||||
orgId,
|
orgId,
|
||||||
selectedLabels,
|
selectedLabels,
|
||||||
toggleLabel
|
toggleLabel,
|
||||||
|
onClose
|
||||||
}: LabelsSelectorProps) {
|
}: LabelsSelectorProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
|
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
|
||||||
@@ -200,24 +202,30 @@ export function LabelsSelector({
|
|||||||
? "detach"
|
? "detach"
|
||||||
: "attach"
|
: "attach"
|
||||||
);
|
);
|
||||||
// } else {
|
onClose?.();
|
||||||
// onSelectionChange([
|
|
||||||
// ...selectedLabels,
|
|
||||||
// label
|
|
||||||
// ]);
|
|
||||||
// }
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
className="pointer-events-none shrink-0"
|
className="shrink-0"
|
||||||
checked={selectedIds.has(label.labelId)}
|
checked={selectedIds.has(label.labelId)}
|
||||||
onCheckedChange={() => {}}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
toggleLabel(
|
||||||
|
label,
|
||||||
|
checked ? "attach" : "detach"
|
||||||
|
);
|
||||||
|
}}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="inline-block size-3 flex-none rounded-full bg-(--label-color)"
|
className="inline-block size-2 flex-none rounded-full bg-(--label-color)"
|
||||||
style={{
|
style={{
|
||||||
// @ts-expect-error CSS variable
|
// @ts-expect-error CSS variable
|
||||||
"--label-color": label.color
|
"--label-color": label.color
|
||||||
|
|||||||
+55
-18
@@ -101,16 +101,41 @@ ${colorConfig
|
|||||||
|
|
||||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
type ChartTooltipValue = number | string | Array<number | string>;
|
||||||
|
type ChartTooltipName = number | string;
|
||||||
|
type ChartTooltipPayload = RechartsPrimitive.DefaultTooltipContentProps<
|
||||||
|
ChartTooltipValue,
|
||||||
|
ChartTooltipName
|
||||||
|
>["payload"];
|
||||||
|
type ChartTooltipPayloadItem = NonNullable<ChartTooltipPayload>[number];
|
||||||
|
|
||||||
|
type ChartTooltipContentProps = React.ComponentProps<"div"> & {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: ChartTooltipPayload;
|
||||||
|
label?: RechartsPrimitive.DefaultTooltipContentProps<
|
||||||
|
ChartTooltipValue,
|
||||||
|
ChartTooltipName
|
||||||
|
>["label"];
|
||||||
|
labelFormatter?: RechartsPrimitive.DefaultTooltipContentProps<
|
||||||
|
ChartTooltipValue,
|
||||||
|
ChartTooltipName
|
||||||
|
>["labelFormatter"];
|
||||||
|
formatter?: RechartsPrimitive.DefaultTooltipContentProps<
|
||||||
|
ChartTooltipValue,
|
||||||
|
ChartTooltipName
|
||||||
|
>["formatter"];
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: "line" | "dot" | "dashed";
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
color?: string;
|
||||||
|
labelClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const ChartTooltipContent = React.forwardRef<
|
const ChartTooltipContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
ChartTooltipContentProps
|
||||||
React.ComponentProps<"div"> & {
|
|
||||||
hideLabel?: boolean;
|
|
||||||
hideIndicator?: boolean;
|
|
||||||
indicator?: "line" | "dot" | "dashed";
|
|
||||||
nameKey?: string;
|
|
||||||
labelKey?: string;
|
|
||||||
}
|
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
@@ -187,8 +212,11 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
{!nestLabel ? tooltipLabel : null}
|
{!nestLabel ? tooltipLabel : null}
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
{payload
|
{payload
|
||||||
.filter((item) => item.type !== "none")
|
.filter(
|
||||||
.map((item, index) => {
|
(item: ChartTooltipPayloadItem) =>
|
||||||
|
item.type !== "none"
|
||||||
|
)
|
||||||
|
.map((item: ChartTooltipPayloadItem, index: number) => {
|
||||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||||
|
|
||||||
const itemConfig = getPayloadConfigFromPayload(
|
const itemConfig = getPayloadConfigFromPayload(
|
||||||
@@ -201,7 +229,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.dataKey}
|
key={String(item.dataKey ?? index)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
indicator === "dot" && "items-center"
|
indicator === "dot" && "items-center"
|
||||||
@@ -301,13 +329,20 @@ ChartTooltipContent.displayName = "ChartTooltip";
|
|||||||
|
|
||||||
const ChartLegend = RechartsPrimitive.Legend;
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
type ChartLegendPayload =
|
||||||
|
RechartsPrimitive.DefaultLegendContentProps["payload"];
|
||||||
|
type ChartLegendPayloadItem = NonNullable<ChartLegendPayload>[number];
|
||||||
|
|
||||||
|
type ChartLegendContentProps = React.ComponentProps<"div"> & {
|
||||||
|
payload?: ChartLegendPayload;
|
||||||
|
verticalAlign?: RechartsPrimitive.DefaultLegendContentProps["verticalAlign"];
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const ChartLegendContent = React.forwardRef<
|
const ChartLegendContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<"div"> &
|
ChartLegendContentProps
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
|
||||||
hideIcon?: boolean;
|
|
||||||
nameKey?: string;
|
|
||||||
}
|
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
@@ -335,8 +370,10 @@ const ChartLegendContent = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{payload
|
{payload
|
||||||
.filter((item) => item.type !== "none")
|
.filter(
|
||||||
.map((item) => {
|
(item: ChartLegendPayloadItem) => item.type !== "none"
|
||||||
|
)
|
||||||
|
.map((item: ChartLegendPayloadItem) => {
|
||||||
const key = `${nameKey || item.dataKey || "value"}`;
|
const key = `${nameKey || item.dataKey || "value"}`;
|
||||||
const itemConfig = getPayloadConfigFromPayload(
|
const itemConfig = getPayloadConfigFromPayload(
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { SelectedLabel } from "@app/components/labels-selector";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useLocalLabels(
|
||||||
|
serverLabels: SelectedLabel[] | undefined,
|
||||||
|
entityId: number
|
||||||
|
) {
|
||||||
|
const labels = serverLabels ?? [];
|
||||||
|
const [localLabels, setLocalLabels] = useState(labels);
|
||||||
|
|
||||||
|
const serverLabelIds = labels
|
||||||
|
.map((label) => label.labelId)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.join(",");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalLabels(serverLabels ?? []);
|
||||||
|
}, [entityId, serverLabelIds]);
|
||||||
|
|
||||||
|
return [localLabels, setLocalLabels] as const;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type {
|
||||||
|
SidebarNavItem,
|
||||||
|
SidebarNavSection
|
||||||
|
} from "@app/components/SidebarNav";
|
||||||
|
|
||||||
|
export type FlatNavItem = {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
sectionHeading: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function flattenItems(
|
||||||
|
items: SidebarNavItem[],
|
||||||
|
sectionHeading: string,
|
||||||
|
result: FlatNavItem[]
|
||||||
|
) {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.href) {
|
||||||
|
result.push({
|
||||||
|
title: item.title,
|
||||||
|
href: item.href,
|
||||||
|
icon: item.icon,
|
||||||
|
sectionHeading
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (item.items?.length) {
|
||||||
|
flattenItems(item.items, sectionHeading, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenNavSections(
|
||||||
|
sections: SidebarNavSection[]
|
||||||
|
): FlatNavItem[] {
|
||||||
|
const result: FlatNavItem[] = [];
|
||||||
|
for (const section of sections) {
|
||||||
|
flattenItems(section.items, section.heading, result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export type NavHrefParams = {
|
||||||
|
orgId?: string;
|
||||||
|
niceId?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
userId?: string;
|
||||||
|
apiKeyId?: string;
|
||||||
|
clientId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hydrateNavHref(
|
||||||
|
val: string | undefined,
|
||||||
|
params: NavHrefParams
|
||||||
|
): string | undefined {
|
||||||
|
if (!val) return undefined;
|
||||||
|
return val
|
||||||
|
.replace("{orgId}", params.orgId ?? "")
|
||||||
|
.replace("{niceId}", params.niceId ?? "")
|
||||||
|
.replace("{resourceId}", params.resourceId ?? "")
|
||||||
|
.replace("{userId}", params.userId ?? "")
|
||||||
|
.replace("{apiKeyId}", params.apiKeyId ?? "")
|
||||||
|
.replace("{clientId}", params.clientId ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navHrefParamsFromRoute(
|
||||||
|
params: Record<string, string | string[] | undefined>
|
||||||
|
): NavHrefParams {
|
||||||
|
return {
|
||||||
|
orgId: params.orgId as string | undefined,
|
||||||
|
niceId: params.niceId as string | undefined,
|
||||||
|
resourceId: params.resourceId as string | undefined,
|
||||||
|
userId: params.userId as string | undefined,
|
||||||
|
apiKeyId: params.apiKeyId as string | undefined,
|
||||||
|
clientId: params.clientId as string | undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
+241
-7
@@ -1,17 +1,25 @@
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
||||||
|
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
||||||
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
||||||
|
import type {
|
||||||
|
QueryAccessAuditLogResponse,
|
||||||
|
QueryActionAuditLogResponse,
|
||||||
|
QueryConnectionAuditLogResponse,
|
||||||
|
QueryRequestAuditLogResponse
|
||||||
|
} from "@server/routers/auditLogs/types";
|
||||||
import type { ListClientsResponse } from "@server/routers/client";
|
import type { ListClientsResponse } from "@server/routers/client";
|
||||||
import type {
|
import type {
|
||||||
ListDomainsResponse,
|
GetDNSRecordsResponse,
|
||||||
GetDNSRecordsResponse
|
ListDomainsResponse
|
||||||
} from "@server/routers/domain";
|
} from "@server/routers/domain";
|
||||||
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
|
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
|
||||||
|
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||||
import type {
|
import type {
|
||||||
GetResourceWhitelistResponse,
|
GetResourceWhitelistResponse,
|
||||||
ListResourceNamesResponse,
|
ListResourceNamesResponse,
|
||||||
ListResourcesResponse
|
ListResourcesResponse
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
|
||||||
import type { ListRolesResponse } from "@server/routers/role";
|
import type { ListRolesResponse } from "@server/routers/role";
|
||||||
import type { ListSitesResponse } from "@server/routers/site";
|
import type { ListSitesResponse } from "@server/routers/site";
|
||||||
import type {
|
import type {
|
||||||
@@ -31,8 +39,6 @@ import type { AxiosResponse } from "axios";
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { remote } from "./api";
|
import { remote } from "./api";
|
||||||
import { durationToMs } from "./durationToMs";
|
import { durationToMs } from "./durationToMs";
|
||||||
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
|
||||||
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
|
||||||
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||||
|
|
||||||
export type ProductUpdate = {
|
export type ProductUpdate = {
|
||||||
@@ -589,7 +595,111 @@ export const logAnalyticsFiltersSchema = z.object({
|
|||||||
resourceId: z.coerce.number().optional().catch(undefined)
|
resourceId: z.coerce.number().optional().catch(undefined)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LogAnalyticsFilters = z.TypeOf<typeof logAnalyticsFiltersSchema>;
|
export type LogAnalyticsFilters = z.output<typeof logAnalyticsFiltersSchema>;
|
||||||
|
|
||||||
|
export const httpLogsFiltersSchema = z.object({
|
||||||
|
timeStart: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeStart must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
timeEnd: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeEnd must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
page: z.coerce.number().optional().catch(0).default(0),
|
||||||
|
pageSize: z.coerce.number().optional().catch(20).default(20),
|
||||||
|
resourceId: z.coerce.number().optional().catch(undefined),
|
||||||
|
action: z.string().optional().catch(undefined),
|
||||||
|
host: z.string().optional().catch(undefined),
|
||||||
|
location: z.string().optional().catch(undefined),
|
||||||
|
actor: z.string().optional().catch(undefined),
|
||||||
|
method: z.string().optional().catch(undefined),
|
||||||
|
reason: z.string().optional().catch(undefined),
|
||||||
|
path: z.string().optional().catch(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type HttpLogFilters = z.output<typeof httpLogsFiltersSchema>;
|
||||||
|
|
||||||
|
export const accessLogsFiltersSchema = z.object({
|
||||||
|
timeStart: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeStart must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
timeEnd: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeEnd must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
page: z.coerce.number().optional().catch(0).default(0),
|
||||||
|
pageSize: z.coerce.number().optional().catch(20).default(20),
|
||||||
|
resourceId: z.coerce.number().optional().catch(undefined),
|
||||||
|
action: z.string().optional().catch(undefined),
|
||||||
|
location: z.string().optional().catch(undefined),
|
||||||
|
actor: z.string().optional().catch(undefined),
|
||||||
|
type: z.string().optional().catch(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AccessLogFilters = z.output<typeof accessLogsFiltersSchema>;
|
||||||
|
|
||||||
|
export const actionLogsFiltersSchema = z.object({
|
||||||
|
timeStart: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeStart must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
timeEnd: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeEnd must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
page: z.coerce.number().optional().catch(0).default(0),
|
||||||
|
pageSize: z.coerce.number().optional().catch(20).default(20),
|
||||||
|
action: z.string().optional().catch(undefined),
|
||||||
|
actor: z.string().optional().catch(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ActionLogFilters = z.output<typeof actionLogsFiltersSchema>;
|
||||||
|
|
||||||
|
export const connectionLogsFiltersSchema = z.object({
|
||||||
|
timeStart: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeStart must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
timeEnd: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeEnd must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
page: z.coerce.number().optional().catch(0).default(0),
|
||||||
|
pageSize: z.coerce.number().optional().catch(20).default(20),
|
||||||
|
protocol: z.string().optional().catch(undefined),
|
||||||
|
destAddr: z.string().optional().catch(undefined),
|
||||||
|
clientId: z.coerce.number().optional().catch(undefined),
|
||||||
|
siteResourceId: z.coerce.number().optional().catch(undefined),
|
||||||
|
userId: z.string().optional().catch(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ConnectionLogFilters = z.output<typeof connectionLogsFiltersSchema>;
|
||||||
|
|
||||||
export const logQueries = {
|
export const logQueries = {
|
||||||
requestAnalytics: ({
|
requestAnalytics: ({
|
||||||
@@ -600,7 +710,7 @@ export const logQueries = {
|
|||||||
filters: LogAnalyticsFilters;
|
filters: LogAnalyticsFilters;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["REQUEST_LOG_ANALYTICS", orgId, filters] as const,
|
queryKey: ["REQUEST_LOGS", orgId, "ANALYTICS", filters] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<QueryRequestAnalyticsResponse>
|
AxiosResponse<QueryRequestAnalyticsResponse>
|
||||||
@@ -616,6 +726,130 @@ export const logQueries = {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
requests: ({
|
||||||
|
orgId,
|
||||||
|
filters
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
filters: HttpLogFilters;
|
||||||
|
}) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["REQUEST_LOGS", orgId, "ALL", filters] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const { page, pageSize, ...rest } = filters;
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<QueryRequestAuditLogResponse>
|
||||||
|
>(`/org/${orgId}/logs/request`, {
|
||||||
|
params: {
|
||||||
|
...rest,
|
||||||
|
limit: pageSize,
|
||||||
|
offset: page * pageSize
|
||||||
|
},
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
if (query.state.data) {
|
||||||
|
return durationToMs(30, "seconds");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
access: ({
|
||||||
|
orgId,
|
||||||
|
filters
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
filters: AccessLogFilters;
|
||||||
|
}) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ACCESS_LOGS", orgId, "ALL", filters] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const { page, pageSize, ...rest } = filters;
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<QueryAccessAuditLogResponse>
|
||||||
|
>(`/org/${orgId}/logs/access`, {
|
||||||
|
params: {
|
||||||
|
...rest,
|
||||||
|
limit: pageSize,
|
||||||
|
offset: page * pageSize
|
||||||
|
},
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
if (query.state.data) {
|
||||||
|
return durationToMs(30, "seconds");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
action: ({
|
||||||
|
orgId,
|
||||||
|
filters
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
filters: ActionLogFilters;
|
||||||
|
}) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ACTION_LOGS", orgId, "ALL", filters] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const { page, pageSize, ...rest } = filters;
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<QueryActionAuditLogResponse>
|
||||||
|
>(`/org/${orgId}/logs/action`, {
|
||||||
|
params: {
|
||||||
|
...rest,
|
||||||
|
limit: pageSize,
|
||||||
|
offset: page * pageSize
|
||||||
|
},
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
if (query.state.data) {
|
||||||
|
return durationToMs(30, "seconds");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
connection: ({
|
||||||
|
orgId,
|
||||||
|
filters
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
filters: ConnectionLogFilters;
|
||||||
|
}) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["CONNECTION_LOGS", orgId, "ALL", filters] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const { page, pageSize, ...rest } = filters;
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<QueryConnectionAuditLogResponse>
|
||||||
|
>(`/org/${orgId}/logs/connection`, {
|
||||||
|
params: {
|
||||||
|
...rest,
|
||||||
|
limit: pageSize,
|
||||||
|
offset: page * pageSize
|
||||||
|
},
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
if (query.state.data) {
|
||||||
|
return durationToMs(30, "seconds");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare module "*.css";
|
||||||
+45
-15
@@ -1,28 +1,49 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"baseUrl": "src",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@server/*": ["../server/*"],
|
"@server/*": [
|
||||||
"@test/*": ["../test/*"],
|
"./server/*"
|
||||||
"@app/*": ["*"],
|
],
|
||||||
"@cli/*": ["../cli/*"],
|
"@test/*": [
|
||||||
"@/*": ["./*"],
|
"./test/*"
|
||||||
"#private/*": ["../server/private/*"],
|
],
|
||||||
"#open/*": ["../server/*"],
|
"@app/*": [
|
||||||
"#closed/*": ["../server/private/*"],
|
"./src/*"
|
||||||
"#dynamic/*": ["../server/private/*"]
|
],
|
||||||
|
"@cli/*": [
|
||||||
|
"./cli/*"
|
||||||
|
],
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
],
|
||||||
|
"#private/*": [
|
||||||
|
"./server/private/*"
|
||||||
|
],
|
||||||
|
"#open/*": [
|
||||||
|
"./server/*"
|
||||||
|
],
|
||||||
|
"#closed/*": [
|
||||||
|
"./server/private/*"
|
||||||
|
],
|
||||||
|
"#dynamic/*": [
|
||||||
|
"./server/private/*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -31,6 +52,15 @@
|
|||||||
],
|
],
|
||||||
"target": "ES2024"
|
"target": "ES2024"
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
}
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"**/*.d.ts",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
+46
-16
@@ -1,28 +1,49 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"baseUrl": "src",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@server/*": ["../server/*"],
|
"@server/*": [
|
||||||
"@test/*": ["../test/*"],
|
"./server/*"
|
||||||
"@app/*": ["*"],
|
],
|
||||||
"@cli/*": ["../cli/*"],
|
"@test/*": [
|
||||||
"@/*": ["./*"],
|
"./test/*"
|
||||||
"#private/*": ["../server/private/*"],
|
],
|
||||||
"#open/*": ["../server/*"],
|
"@app/*": [
|
||||||
"#closed/*": ["../server/private/*"],
|
"./src/*"
|
||||||
"#dynamic/*": ["../server/*"]
|
],
|
||||||
|
"@cli/*": [
|
||||||
|
"./cli/*"
|
||||||
|
],
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
],
|
||||||
|
"#private/*": [
|
||||||
|
"./server/private/*"
|
||||||
|
],
|
||||||
|
"#open/*": [
|
||||||
|
"./server/*"
|
||||||
|
],
|
||||||
|
"#closed/*": [
|
||||||
|
"./server/private/*"
|
||||||
|
],
|
||||||
|
"#dynamic/*": [
|
||||||
|
"./server/*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -31,6 +52,15 @@
|
|||||||
],
|
],
|
||||||
"target": "ES2024"
|
"target": "ES2024"
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
}
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"**/*.d.ts",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
+46
-16
@@ -1,28 +1,49 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"baseUrl": "src",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@server/*": ["../server/*"],
|
"@server/*": [
|
||||||
"@test/*": ["../test/*"],
|
"./server/*"
|
||||||
"@app/*": ["*"],
|
],
|
||||||
"@cli/*": ["../cli/*"],
|
"@test/*": [
|
||||||
"@/*": ["./*"],
|
"./test/*"
|
||||||
"#private/*": ["../server/private/*"],
|
],
|
||||||
"#open/*": ["../server/*"],
|
"@app/*": [
|
||||||
"#closed/*": ["../server/private/*"],
|
"./src/*"
|
||||||
"#dynamic/*": ["../server/private/*"]
|
],
|
||||||
|
"@cli/*": [
|
||||||
|
"./cli/*"
|
||||||
|
],
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
],
|
||||||
|
"#private/*": [
|
||||||
|
"./server/private/*"
|
||||||
|
],
|
||||||
|
"#open/*": [
|
||||||
|
"./server/*"
|
||||||
|
],
|
||||||
|
"#closed/*": [
|
||||||
|
"./server/private/*"
|
||||||
|
],
|
||||||
|
"#dynamic/*": [
|
||||||
|
"./server/private/*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -31,6 +52,15 @@
|
|||||||
],
|
],
|
||||||
"target": "ES2024"
|
"target": "ES2024"
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
}
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"**/*.d.ts",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user