Compare commits

..

1 Commits

Author SHA1 Message Date
Owen
6337cf4359 Fix #3104 2026-05-20 16:14:47 -07:00
117 changed files with 6050 additions and 9773 deletions

View File

@@ -14,13 +14,12 @@ body:
label: Environment
description: Please fill out the relevant details below for your environment.
value: |
- OS Type & Version:
- OS Type & Version: (e.g., Ubuntu 22.04)
- Pangolin Version:
- Edition (Community or Enterprise):
- Gerbil Version:
- Traefik Version:
- Newt Version:
- Client Version:
- Olm Version: (if applicable)
validations:
required: true

View File

@@ -1,4 +1,4 @@
import { APP_PATH } from "./server/lib/consts";
import { APP_PATH } from "@server/lib/consts";
import { defineConfig } from "drizzle-kit";
import path from "path";

View File

@@ -5,7 +5,7 @@ go 1.25.0
require (
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/term v0.43.0
golang.org/x/term v0.42.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -33,6 +33,6 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.23.0 // indirect
)

View File

@@ -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/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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -255,23 +255,6 @@
"resourceGoTo": "Go to Resource",
"resourceDelete": "Delete Resource",
"resourceDeleteConfirm": "Confirm Delete Resource",
"labelDelete": "Delete Label",
"labelAdd": "Add Label",
"labelCreateSuccessMessage": "Label Created Successfully",
"labelEditSuccessMessage": "Label Modified Successfully",
"labelNameField": "Label Name",
"labelColorField": "Label Color",
"labelPlaceholder": "Ex: homelab",
"labelCreate": "Create Label",
"createLabelDialogTitle": "Create Label",
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
"labelEdit": "Edit Label",
"editLabelDialogTitle": "Update Label",
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
"labelDeleteConfirm": "Confirm Delete Label",
"labelErrorDelete": "Failed to delete label",
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
"visibility": "Visibility",
"enabled": "Enabled",
"disabled": "Disabled",
@@ -1157,18 +1140,6 @@
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
"idpErrorNotFound": "IdP not found",
"inviteInvalid": "Invalid Invite",
"labels": "Labels",
"orgLabelsDescription": "Manage labels in this organization.",
"addLabels": "Add labels",
"siteLabelsTab": "Labels",
"siteLabelsDescription": "Manage labels associated with this site.",
"labelsNotFound": "Labels not found",
"labelSearch": "Search labels",
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
"accessLabelFilterClear": "Clear label filters",
"selectColor": "Select color",
"createNewLabel": "Create new org label \"{label}\"",
"inviteInvalidDescription": "The invite link is invalid.",
"inviteErrorWrongUser": "Invite is not for this user",
"inviteErrorUserNotExists": "User does not exist. Please create an account first.",
@@ -1354,29 +1325,6 @@
"otpAuthBack": "Back to Password",
"navbar": "Navigation Menu",
"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",
"otpErrorEnable": "Unable to enable 2FA",
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
@@ -1672,7 +1620,6 @@
"certificateStatus": "Certificate",
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
"loading": "Loading",
"loadingEllipsis": "Loading...",
"loadingAnalytics": "Loading Analytics",
"restart": "Restart",
"domains": "Domains",

View File

@@ -5,7 +5,12 @@ const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
reactStrictMode: false,
reactCompiler: true,
eslint: {
ignoreDuringBuilds: true
},
experimental: {
reactCompiler: true
},
output: "standalone"
};

5604
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,10 +32,10 @@
"format": "prettier --write ."
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.5.0",
"@aws-sdk/client-s3": "3.1047.0",
"@faker-js/faker": "10.4.0",
"@headlessui/react": "2.2.10",
"@asteasolutions/zod-to-openapi": "8.4.1",
"@aws-sdk/client-s3": "3.1011.0",
"@faker-js/faker": "10.3.0",
"@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2",
"@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2",
@@ -59,17 +59,16 @@
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "1.2.8",
"@react-email/body": "0.3.0",
"@react-email/components": "1.0.12",
"@react-email/render": "2.0.8",
"@react-email/tailwind": "2.0.7",
"@react-email/components": "1.0.8",
"@react-email/render": "2.0.4",
"@react-email/tailwind": "2.0.5",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.0",
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.100.10",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3",
"arctic": "3.7.0",
"axios": "1.16.1",
"axios": "1.15.0",
"better-sqlite3": "11.9.1",
"canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1",
@@ -81,76 +80,76 @@
"d3": "7.9.0",
"drizzle-orm": "0.45.2",
"express": "5.2.1",
"express-rate-limit": "8.5.2",
"express-rate-limit": "8.3.0",
"glob": "13.0.6",
"helmet": "8.1.0",
"http-errors": "2.0.1",
"input-otp": "1.4.2",
"ioredis": "5.10.1",
"ioredis": "5.10.0",
"jmespath": "0.16.0",
"js-yaml": "4.1.1",
"jsonwebtoken": "9.0.3",
"lucide-react": "0.577.0",
"maxmind": "5.0.6",
"maxmind": "5.0.5",
"moment": "2.30.1",
"next": "16.2.6",
"next-intl": "4.12.0",
"next": "15.5.15",
"next-intl": "4.8.3",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
"node-cache": "5.1.2",
"nodemailer": "8.0.7",
"nodemailer": "8.0.5",
"oslo": "1.2.1",
"pg": "8.20.0",
"posthog-node": "5.34.1",
"posthog-node": "5.28.0",
"qrcode.react": "4.2.0",
"react": "19.2.6",
"react": "19.2.4",
"react-day-picker": "9.14.0",
"react-dom": "19.2.6",
"react-dom": "19.2.4",
"react-easy-sort": "1.8.0",
"react-hook-form": "7.75.0",
"react-hook-form": "7.71.2",
"react-icons": "5.6.0",
"recharts": "3.8.1",
"recharts": "2.15.4",
"reodotdev": "1.1.0",
"resend": "6.12.3",
"semver": "7.8.0",
"resend": "6.9.2",
"semver": "7.7.4",
"sshpk": "1.18.0",
"stripe": "20.4.1",
"swagger-ui-express": "5.0.1",
"tailwind-merge": "3.6.0",
"tailwind-merge": "3.5.0",
"topojson-client": "3.1.0",
"tw-animate-css": "1.4.0",
"use-debounce": "10.1.1",
"uuid": "14.0.0",
"use-debounce": "10.1.0",
"uuid": "13.0.0",
"vaul": "1.1.2",
"visionscarto-world-atlas": "1.0.0",
"winston": "3.19.0",
"winston-daily-rotate-file": "5.0.0",
"ws": "8.20.1",
"yaml": "2.9.0",
"ws": "8.19.0",
"yaml": "2.8.3",
"yargs": "18.0.0",
"zod": "4.4.3",
"zod": "4.3.6",
"zod-validation-error": "5.0.0"
},
"devDependencies": {
"@dotenvx/dotenvx": "1.66.0",
"@dotenvx/dotenvx": "1.54.1",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/ui": "^6.1.4",
"@tailwindcss/postcss": "4.3.0",
"@tanstack/react-query-devtools": "5.100.10",
"@react-email/preview-server": "5.2.10",
"@tailwindcss/postcss": "4.2.2",
"@tanstack/react-query-devtools": "5.91.3",
"@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19",
"@types/crypto-js": "4.2.2",
"@types/d3": "7.4.3",
"@types/express": "5.0.6",
"@types/express-session": "1.19.0",
"@types/express-session": "1.18.2",
"@types/jmespath": "0.15.2",
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "25.8.0",
"@types/nodemailer": "8.0.0",
"@types/node": "25.3.5",
"@types/nodemailer": "7.0.11",
"@types/nprogress": "0.2.3",
"@types/pg": "8.20.0",
"@types/pg": "8.18.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/semver": "7.7.1",
@@ -161,22 +160,21 @@
"@types/yargs": "17.0.35",
"babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "0.31.10",
"esbuild": "0.28.0",
"esbuild-node-externals": "1.22.0",
"eslint": "10.3.0",
"eslint-config-next": "16.2.6",
"postcss": "8.5.14",
"prettier": "3.8.3",
"react-email": "6.1.4",
"tailwindcss": "4.3.0",
"tsc-alias": "1.8.17",
"tsx": "4.22.0",
"typescript": "6.0.3",
"typescript-eslint": "8.59.3"
"esbuild": "0.27.4",
"esbuild-node-externals": "1.20.1",
"eslint": "10.0.3",
"eslint-config-next": "16.1.7",
"postcss": "8.5.8",
"prettier": "3.8.1",
"react-email": "5.2.10",
"tailwindcss": "4.2.2",
"tsc-alias": "1.8.16",
"tsx": "4.21.0",
"typescript": "5.9.3",
"typescript-eslint": "8.56.1"
},
"overrides": {
"esbuild": "0.28.0",
"dompurify": "3.4.0",
"postcss": "8.5.14"
"esbuild": "0.27.4",
"dompurify": "3.3.2"
}
}

View File

@@ -148,12 +148,6 @@ export enum ActionsEnum {
updateAlertRule = "updateAlertRule",
deleteAlertRule = "deleteAlertRule",
listAlertRules = "listAlertRules",
listOrgLabels = "listOrgLabels",
createOrgLabel = "createOrgLabel",
updateOrgLabel = "updateOrgLabel",
deleteOrgLabel = "deleteOrgLabel",
attachLabelToItem = "attachLabelToItem",
detachLabelFromItem = "detachLabelFromItem",
getAlertRule = "getAlertRule",
createHealthCheck = "createHealthCheck",
updateHealthCheck = "updateHealthCheck",

View File

@@ -162,89 +162,6 @@ export const resources = pgTable("resources", {
wildcard: boolean("wildcard").notNull().default(false)
});
export const labels = pgTable("labels", {
labelId: serial("labelId").primaryKey(),
name: varchar("name").notNull(),
color: varchar("color").notNull(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const siteLabels = pgTable(
"siteLabels",
{
siteLabelId: serial("siteLabelId").primaryKey(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
);
export const resourceLabels = pgTable(
"resourceLabels",
{
resourceLabelId: serial("resourceLabelId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
);
export const siteResourceLabels = pgTable(
"siteResourceLabels",
{
siteResourceLabelId: serial("siteResourceLabelId").primaryKey(),
siteResourceId: integer("siteResourceId")
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
);
export const clientLabels = pgTable(
"clientLabels",
{
clientLabelId: serial("clientLabelId").primaryKey(),
clientId: integer("clientId")
.references(() => clients.clientId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
);
export const targets = pgTable("targets", {
targetId: serial("targetId").primaryKey(),
resourceId: integer("resourceId")
@@ -279,11 +196,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
name: varchar("name"),
hcEnabled: boolean("hcEnabled").notNull().default(false),
hcPath: varchar("hcPath"),
@@ -1182,30 +1097,19 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
complete: boolean("complete").notNull().default(false)
});
export const statusHistory = pgTable(
"statusHistory",
{
id: serial("id").primaryKey(),
entityType: varchar("entityType").notNull(),
entityId: integer("entityId").notNull(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: varchar("status").notNull(),
timestamp: integer("timestamp").notNull()
},
(table) => [
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export const statusHistory = pgTable("statusHistory", {
id: serial("id").primaryKey(),
entityType: varchar("entityType").notNull(),
entityId: integer("entityId").notNull(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: varchar("status").notNull(),
timestamp: integer("timestamp").notNull(),
}, (table) => [
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
@@ -1275,4 +1179,3 @@ export type RoundTripMessageTracker = InferSelectModel<
>;
export type Network = InferSelectModel<typeof networks>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;

View File

@@ -183,95 +183,6 @@ export const resources = sqliteTable("resources", {
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
});
export const labels = sqliteTable("labels", {
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
color: text("color").notNull(),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const siteLabels = sqliteTable(
"siteLabels",
{
siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
);
export const resourceLabels = sqliteTable(
"resourceLabels",
{
resourceLabelId: integer("resourceLabelId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
);
export const siteResourceLabels = sqliteTable(
"siteResourceLabels",
{
siteResourceLabelId: integer("siteResourceLabelId").primaryKey({
autoIncrement: true
}),
siteResourceId: integer("siteResourceId")
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
);
export const clientLabels = sqliteTable(
"clientLabels",
{
clientLabelId: integer("clientLabelId").primaryKey({
autoIncrement: true
}),
clientId: integer("clientId")
.references(() => clients.clientId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
);
export const targets = sqliteTable("targets", {
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId")
@@ -308,11 +219,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
name: text("name"),
hcEnabled: integer("hcEnabled", { mode: "boolean" })
.notNull()
@@ -1287,30 +1196,19 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
});
export const statusHistory = sqliteTable(
"statusHistory",
{
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull() // unix epoch seconds
},
(table) => [
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export const statusHistory = sqliteTable("statusHistory", {
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull(), // unix epoch seconds
}, (table) => [
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
@@ -1380,4 +1278,3 @@ export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker
>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;

View File

@@ -1,5 +1,5 @@
#! /usr/bin/env node
import "./extendZod";
import "./extendZod.ts";
import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer";

View File

@@ -24,12 +24,10 @@ export enum TierFeature {
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain",
Labels = "labels"
WildcardSubdomain = "wildcardSubdomain"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.Labels]: ["tier2", "tier3", "enterprise"],
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],

View File

@@ -82,7 +82,7 @@ export const RuleSchema = z
.object({
action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
value: z.string(),
value: z.coerce.string(),
priority: z.int().optional()
})
.refine(
@@ -340,7 +340,8 @@ export const ResourceSchema = z
if (parts.includes("*", 1)) return false; // no further wildcards
if (parts.length < 3) return false; // need at least *.label.tld
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
const labelRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
return parts.slice(1).every((label) => labelRegex.test(label));
},
{

View File

@@ -154,19 +154,8 @@ class AdaptiveCache {
keys(): string[] {
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 const cache = new AdaptiveCache();
export const regionalCache = cache; // Alias for compatability with the private version
export default cache;

View File

@@ -1,11 +0,0 @@
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;
}

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import { db, logsDb, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm";
import { regionalCache as cache } from "#dynamic/lib/cache";
import cache from "@server/lib/cache";
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
@@ -66,7 +66,7 @@ export async function invalidateStatusHistoryCache(
entityId: number
): Promise<void> {
const prefix = `statusHistory:${entityType}:${entityId}:`;
const keys = await cache.keysWithPrefix(prefix);
const keys = cache.keys().filter((k) => k.startsWith(prefix));
if (keys.length > 0) {
await cache.del(keys);
}

View File

@@ -4,7 +4,6 @@ import { resourceAccessToken, resources, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyAccessTokenAccess(
req: Request,
@@ -13,7 +12,7 @@ export async function verifyApiKeyAccessTokenAccess(
) {
try {
const apiKey = req.apiKey;
const accessTokenId = getFirstString(req.params.accessTokenId);
const accessTokenId = req.params.accessTokenId;
if (!apiKey) {
return next(
@@ -21,12 +20,6 @@ export async function verifyApiKeyAccessTokenAccess(
);
}
if (!accessTokenId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
);
}
const [accessToken] = await db
.select()
.from(resourceAccessToken)

View File

@@ -4,7 +4,6 @@ import { apiKeys, apiKeyOrg } from "@server/db";
import { and, eq, or } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyApiKeyAccess(
req: Request,
@@ -15,10 +14,8 @@ export async function verifyApiKeyApiKeyAccess(
const { apiKey: callerApiKey } = req;
const apiKeyId =
getFirstString(req.params.apiKeyId) ||
getFirstString(req.body.apiKeyId) ||
getFirstString(req.query.apiKeyId);
const orgId = getFirstString(req.params.orgId);
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
const orgId = req.params.orgId;
if (!callerApiKey) {
return next(

View File

@@ -3,7 +3,6 @@ import { db, domains, orgDomains, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyDomainAccess(
req: Request,
@@ -13,10 +12,8 @@ export async function verifyApiKeyDomainAccess(
try {
const apiKey = req.apiKey;
const domainId =
getFirstString(req.params.domainId) ||
getFirstString(req.body.domainId) ||
getFirstString(req.query.domainId);
const orgId = getFirstString(req.params.orgId);
req.params.domainId || req.body.domainId || req.query.domainId;
const orgId = req.params.orgId;
if (!apiKey) {
return next(
@@ -30,12 +27,6 @@ export async function verifyApiKeyDomainAccess(
);
}
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
if (apiKey.isRoot) {
// Root keys can access any domain in any org
return next();

View File

@@ -4,7 +4,6 @@ import { idp, idpOrg, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyIdpAccess(
req: Request,
@@ -13,12 +12,8 @@ export async function verifyApiKeyIdpAccess(
) {
try {
const apiKey = req.apiKey;
const idpIdRaw =
getFirstString(req.params.idpId) ||
getFirstString(req.body.idpId) ||
getFirstString(req.query.idpId);
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
const orgId = getFirstString(req.params.orgId);
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
const orgId = req.params.orgId;
if (!apiKey) {
return next(
@@ -32,7 +27,7 @@ export async function verifyApiKeyIdpAccess(
);
}
if (Number.isNaN(idpId)) {
if (!idpId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID")
);

View File

@@ -4,7 +4,6 @@ import { apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyOrgAccess(
req: Request,
@@ -13,7 +12,7 @@ export async function verifyApiKeyOrgAccess(
) {
try {
const apiKeyId = req.apiKey?.apiKeyId;
const orgId = getFirstString(req.params.orgId);
const orgId = req.params.orgId;
if (!apiKeyId) {
return next(
@@ -46,7 +45,7 @@ export async function verifyApiKeyOrgAccess(
}
if (!req.apiKeyOrg) {
return next(
next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"

View File

@@ -4,7 +4,6 @@ import { siteResources, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeySiteResourceAccess(
req: Request,
@@ -13,8 +12,7 @@ export async function verifyApiKeySiteResourceAccess(
) {
try {
const apiKey = req.apiKey;
const siteResourceIdRaw = getFirstString(req.params.siteResourceId);
const siteResourceId = Number.parseInt(siteResourceIdRaw ?? "", 10);
const siteResourceId = parseInt(req.params.siteResourceId);
if (!apiKey) {
return next(
@@ -22,7 +20,7 @@ export async function verifyApiKeySiteResourceAccess(
);
}
if (Number.isNaN(siteResourceId)) {
if (!siteResourceId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,

View File

@@ -4,7 +4,6 @@ import { resources, targets, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyTargetAccess(
req: Request,
@@ -13,8 +12,7 @@ export async function verifyApiKeyTargetAccess(
) {
try {
const apiKey = req.apiKey;
const targetIdRaw = getFirstString(req.params.targetId);
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
const targetId = parseInt(req.params.targetId);
if (!apiKey) {
return next(
@@ -22,7 +20,7 @@ export async function verifyApiKeyTargetAccess(
);
}
if (Number.isNaN(targetId)) {
if (isNaN(targetId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
);

View File

@@ -7,7 +7,6 @@ import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyAccessTokenAccess(
req: Request,
@@ -15,7 +14,7 @@ export async function verifyAccessTokenAccess(
next: NextFunction
) {
const userId = req.user!.userId;
const accessTokenId = getFirstString(req.params.accessTokenId);
const accessTokenId = req.params.accessTokenId;
if (!userId) {
return next(
@@ -23,12 +22,6 @@ export async function verifyAccessTokenAccess(
);
}
if (!accessTokenId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
);
}
const [accessToken] = await db
.select()
.from(resourceAccessToken)
@@ -94,7 +87,7 @@ export async function verifyAccessTokenAccess(
}
if (!req.userOrg) {
return next(
next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"

View File

@@ -6,7 +6,6 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyAccess(
req: Request,
@@ -15,24 +14,9 @@ export async function verifyApiKeyAccess(
) {
try {
const userId = req.user!.userId;
const apiKeyIdFromParams = getFirstString(req.params?.apiKeyId);
const apiKeyIdFromBody = getFirstString(req.body?.apiKeyId);
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);
const apiKeyId =
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
const orgId = req.params.orgId;
if (!userId) {
return next(
@@ -120,7 +104,10 @@ export async function verifyApiKeyAccess(
}
}
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrg.userId,
orgId
);
return next();
} catch (error) {

View File

@@ -6,7 +6,6 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyDomainAccess(
req: Request,
@@ -15,8 +14,9 @@ export async function verifyDomainAccess(
) {
try {
const userId = req.user!.userId;
const domainId = getFirstString(req.params.domainId);
const orgId = getFirstString(req.params.orgId);
const domainId =
req.params.domainId;
const orgId = req.params.orgId;
if (!userId) {
return next(
@@ -62,7 +62,10 @@ export async function verifyDomainAccess(
.select()
.from(userOrgs)
.where(
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
req.userOrg = userOrgRole[0];

View File

@@ -3,7 +3,6 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { usageService } from "@server/lib/billing/usageService";
import { build } from "@server/build";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyLimits(
req: Request,
@@ -14,10 +13,7 @@ export async function verifyLimits(
return next();
}
const orgId =
req.userOrgId ||
req.apiKeyOrg?.orgId ||
getFirstString(req.params.orgId);
const orgId = req.userOrgId || req.apiKeyOrg?.orgId || req.params.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

View File

@@ -6,7 +6,6 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyOrgAccess(
req: Request,
@@ -14,7 +13,7 @@ export async function verifyOrgAccess(
next: NextFunction
) {
const userId = req.user!.userId;
const orgId = getFirstString(req.params.orgId);
const orgId = req.params.orgId;
if (!userId) {
return next(

View File

@@ -1,16 +1,10 @@
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 createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifySiteProvisioningKeyAccess(
req: Request,
@@ -19,10 +13,8 @@ export async function verifySiteProvisioningKeyAccess(
) {
try {
const userId = req.user!.userId;
const siteProvisioningKeyId = getFirstString(
req.params.siteProvisioningKeyId
);
const orgId = getFirstString(req.params.orgId);
const siteProvisioningKeyId = req.params.siteProvisioningKeyId;
const orgId = req.params.orgId;
if (!userId) {
return next(
@@ -88,7 +80,10 @@ export async function verifySiteProvisioningKeyAccess(
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, row.siteProvisioningKeyOrg.orgId)
eq(
userOrgs.orgId,
row.siteProvisioningKeyOrg.orgId
)
)
)
.limit(1);

View File

@@ -7,7 +7,6 @@ import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "../auth/canUserAccessResource";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyTargetAccess(
req: Request,
@@ -15,8 +14,7 @@ export async function verifyTargetAccess(
next: NextFunction
) {
const userId = req.user!.userId;
const targetIdRaw = getFirstString(req.params.targetId);
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
const targetId = parseInt(req.params.targetId);
if (!userId) {
return next(

View File

@@ -4,7 +4,6 @@ import { userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyUserIsOrgOwner(
req: Request,
@@ -12,7 +11,7 @@ export async function verifyUserIsOrgOwner(
next: NextFunction
) {
const userId = req.user!.userId;
const orgId = getFirstString(req.params.orgId);
const orgId = req.params.orgId;
if (!userId) {
return next(

View File

@@ -13,7 +13,7 @@
import NodeCache from "node-cache";
import logger from "@server/logger";
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
import { redisManager } from "@server/private/lib/redis";
// Create local cache with maxKeys limit to prevent memory leaks
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
@@ -298,147 +298,3 @@ class AdaptiveCache {
// Export singleton instance
export const cache = new AdaptiveCache();
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();

View File

@@ -24,8 +24,7 @@ import { LogStreamingManager } from "./LogStreamingManager";
*/
export const logStreamingManager = new LogStreamingManager();
if (build !== "saas") {
// this is handled separately in the saas build, so we don't want to start it here
if (build != "saas") { // this is handled separately in the saas build, so we don't want to start it here
logStreamingManager.start();
}

View File

@@ -73,25 +73,6 @@ export const privateConfigSchema = z
.object({
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(),

View File

@@ -109,14 +109,14 @@ class RedisManager {
password: redisConfig.password,
db: redisConfig.db
};
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
if (redisConfig.tls) {
opts.tls = {
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
};
}
return opts;
}
@@ -135,14 +135,14 @@ class RedisManager {
password: replica.password,
db: replica.db || redisConfig.db
};
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
if (redisConfig.tls) {
opts.tls = {
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
};
}
return opts;
}
@@ -855,163 +855,3 @@ class RedisManager {
export const redisManager = new RedisManager();
export const redis = redisManager.getClient();
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();

View File

@@ -19,7 +19,6 @@ import { eq, and } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyCertificateAccess(
req: Request,
@@ -28,43 +27,11 @@ export async function verifyCertificateAccess(
) {
try {
// Assume user/org access is already verified
const orgId = getFirstString(req.params.orgId);
const certIdFromParams = getFirstString(req.params?.certId);
const certIdFromBody = getFirstString(req.body?.certId);
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;
const orgId = req.params.orgId;
const certId =
req.params.certId || req.body?.certId || req.query?.certId;
let domainId =
req.params.domainId || req.body?.domainId || req.query?.domainId;
if (!orgId) {
return next(
@@ -98,7 +65,7 @@ export async function verifyCertificateAccess(
);
}
domainId = cert.domainId ?? undefined;
domainId = cert.domainId;
if (!domainId) {
return next(
createHttpError(

View File

@@ -17,7 +17,6 @@ import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyIdpAccess(
req: Request,
@@ -26,12 +25,8 @@ export async function verifyIdpAccess(
) {
try {
const userId = req.user!.userId;
const idpIdRaw =
getFirstString(req.params.idpId) ||
getFirstString(req.body?.idpId) ||
getFirstString(req.query?.idpId);
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
const orgId = getFirstString(req.params.orgId);
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
const orgId = req.params.orgId;
if (!userId) {
return next(
@@ -45,7 +40,7 @@ export async function verifyIdpAccess(
);
}
if (Number.isNaN(idpId)) {
if (!idpId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
);

View File

@@ -18,7 +18,6 @@ import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyRemoteExitNodeAccess(
req: Request,
@@ -26,11 +25,11 @@ export async function verifyRemoteExitNodeAccess(
next: NextFunction
) {
const userId = req.user!.userId; // Assuming you have user information in the request
const orgId = getFirstString(req.params.orgId);
const orgId = req.params.orgId;
const remoteExitNodeId =
getFirstString(req.params.remoteExitNodeId) ||
getFirstString(req.body?.remoteExitNodeId) ||
getFirstString(req.query?.remoteExitNodeId);
req.params.remoteExitNodeId ||
req.body.remoteExitNodeId ||
req.query.remoteExitNodeId;
if (!userId) {
return next(
@@ -38,15 +37,6 @@ export async function verifyRemoteExitNodeAccess(
);
}
if (!orgId || !remoteExitNodeId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid organization or remote exit node ID"
)
);
}
try {
const [remoteExitNode] = await db
.select()

View File

@@ -25,7 +25,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
next: NextFunction
): Promise<any> {
try {
if (build !== "saas") {
if (build != "saas") {
return next();
}

View File

@@ -31,7 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks";
import * as labels from "#private/routers/labels";
import * as client from "@server/routers/client";
import {
@@ -734,59 +733,6 @@ authenticated.get(
alertRule.getAlertRule
);
authenticated.get(
"/org/:orgId/labels",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.listOrgLabels),
labels.listOrgLabels
);
authenticated.post(
"/org/:orgId/labels",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.createOrgLabel),
labels.createOrgLabel
);
authenticated.patch(
"/org/:orgId/label/:labelId",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.updateOrgLabel),
labels.updateOrgLabel
);
authenticated.delete(
"/org/:orgId/label/:labelId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
labels.deleteOrgLabel
);
authenticated.put(
"/org/:orgId/label/:labelId/attach",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.attachLabelToItem),
labels.attachLabelToItem
);
authenticated.put(
"/org/:orgId/label/:labelId/detach",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
labels.detachLabelFromItem
);
authenticated.get(
"/org/:orgId/health-checks",
verifyValidLicense,

View File

@@ -16,44 +16,40 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import { getFirstString } from "@server/lib/requestParams";
import privateConfig from "#private/lib/config";
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
export interface CreateNewLicenseResponse {
data: Data;
success: boolean;
error: boolean;
message: string;
status: number;
data: Data
success: boolean
error: boolean
message: string
status: number
}
export interface Data {
licenseKey: LicenseKey;
licenseKey: LicenseKey
}
export interface LicenseKey {
id: number;
instanceName: any;
instanceId: string;
licenseKey: string;
tier: string;
type: string;
quantity: number;
quantity_2: number;
isValid: boolean;
updatedAt: string;
createdAt: string;
expiresAt: string;
paidFor: boolean;
orgId: string;
metadata: string;
id: number
instanceName: any
instanceId: string
licenseKey: string
tier: string
type: string
quantity: number
quantity_2: number
isValid: boolean
updatedAt: string
createdAt: string
expiresAt: string
paidFor: boolean
orgId: string
metadata: string
}
export async function createNewLicense(
orgId: string,
licenseData: any
): Promise<CreateNewLicenseResponse> {
export async function createNewLicense(orgId: string, licenseData: any): Promise<CreateNewLicenseResponse> {
try {
const response = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
@@ -84,7 +80,7 @@ export async function generateNewLicense(
next: NextFunction
): Promise<any> {
try {
const orgId = getFirstString(req.params.orgId);
const { orgId } = req.params;
if (!orgId) {
return next(

View File

@@ -16,7 +16,6 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import { getFirstString } from "@server/lib/requestParams";
import privateConfig from "#private/lib/config";
import {
GeneratedLicenseKey,
@@ -56,7 +55,7 @@ export async function listSaasLicenseKeys(
next: NextFunction
): Promise<any> {
try {
const orgId = getFirstString(req.params.orgId);
const { orgId } = req.params;
if (!orgId) {
return next(

View File

@@ -1,224 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
clients,
clientLabels,
db,
labels,
resourceLabels,
resources,
siteLabels,
siteResourceLabels,
siteResources,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const attachLabelBodySchema = z.strictObject({
siteId: z.number().int().optional(),
resourceId: z.number().int().optional(),
siteResourceId: z.number().int().optional(),
clientId: z.number().int().optional()
});
export async function attachLabelToItem(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = attachLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, resourceId, siteResourceId, clientId } =
parsedBody.data;
if (!siteId && !resourceId && !siteResourceId && !clientId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Label with Id ${labelId} not found`
)
);
}
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with Id ${siteId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(siteLabels)
.values({
labelId,
siteId
})
.onConflictDoNothing();
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(resourceLabels)
.values({
labelId,
resourceId
})
.onConflictDoNothing();
}
if (siteResourceId) {
const resourceCount = await db.$count(
siteResources,
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`SiteResource with Id ${siteResourceId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(siteResourceLabels)
.values({
labelId,
siteResourceId
})
.onConflictDoNothing();
}
if (clientId) {
const clientCount = await db.$count(
clients,
and(
eq(clients.clientId, clientId),
eq(clients.orgId, orgId),
isNull(clients.userId)
)
);
if (clientCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with Id ${clientId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(clientLabels)
.values({
labelId,
clientId
})
.onConflictDoNothing();
}
return response(res, {
data: {},
success: true,
error: false,
message: "Label attached successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,149 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
db,
labels,
resourceLabels,
resources,
siteLabels,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const bodySchema = z.strictObject({
name: z.string().nonempty(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty(),
siteId: z.number().int().optional(),
resourceId: z.number().int().optional()
});
export async function createOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { name, color, siteId, resourceId } = parsedBody.data;
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Site with Id ${siteId} doesn't exist.`
)
);
}
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
}
const label = await db.transaction(async (tx) => {
const [label] = await tx
.insert(labels)
.values({
name,
color,
orgId
})
.returning();
if (siteId) {
await tx.insert(siteLabels).values({
siteId,
labelId: label.labelId
});
}
if (resourceId) {
await tx.insert(resourceLabels).values({
resourceId,
labelId: label.labelId
});
}
return label;
});
return response<CreateOrEditLabelResponse>(res, {
data: { label },
success: true,
error: false,
message: "Org Label created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,72 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
export async function deleteOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
}
await db
.delete(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
return response(res, {
data: null,
success: true,
error: false,
message: "Label deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,224 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
clients,
clientLabels,
db,
labels,
resourceLabels,
resources,
siteLabels,
siteResourceLabels,
siteResources,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const detachLabelBodySchema = z.strictObject({
siteId: z.number().int().optional(),
resourceId: z.number().int().optional(),
siteResourceId: z.number().int().optional(),
clientId: z.number().int().optional()
});
export async function detachLabelFromItem(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = detachLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, resourceId, siteResourceId, clientId } =
parsedBody.data;
if (!siteId && !resourceId && !siteResourceId && !clientId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Label with Id ${labelId} not found`
)
);
}
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with Id ${siteId} doesn't exist.`
)
);
}
await db
.delete(siteLabels)
.where(
and(
eq(siteLabels.labelId, labelId),
eq(siteLabels.siteId, siteId)
)
);
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
await db
.delete(resourceLabels)
.where(
and(
eq(resourceLabels.labelId, labelId),
eq(resourceLabels.resourceId, resourceId)
)
);
}
if (siteResourceId) {
const resourceCount = await db.$count(
siteResources,
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`SiteResource with Id ${siteResourceId} doesn't exist.`
)
);
}
await db
.delete(siteResourceLabels)
.where(
and(
eq(siteResourceLabels.labelId, labelId),
eq(siteResourceLabels.siteResourceId, siteResourceId)
)
);
}
if (clientId) {
const clientCount = await db.$count(
clients,
and(
eq(clients.clientId, clientId),
eq(clients.orgId, orgId),
isNull(clients.userId)
)
);
if (clientCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with Id ${clientId} doesn't exist.`
)
);
}
await db
.delete(clientLabels)
.where(
and(
eq(clientLabels.labelId, labelId),
eq(clientLabels.clientId, clientId)
)
);
}
return response(res, {
data: {},
success: true,
error: false,
message: "Label detached successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,19 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./listOrgLabels";
export * from "./createOrgLabel";
export * from "./updateOrgLabel";
export * from "./attachLabelToItem";
export * from "./detachLabelFromItem";
export * from "./deleteOrgLabel";

View File

@@ -1,155 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, asc, eq, like, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const listLabelsSchema = z.object({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
});
function queryLabelsBase() {
return db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color
})
.from(labels);
}
export async function listOrgLabels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listLabelsSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const { pageSize, page, query } = parsedQuery.data;
const conditions = [and(eq(labels.orgId, orgId))];
if (query) {
conditions.push(
like(
sql`LOWER(${labels.name})`,
"%" + query.toLowerCase() + "%"
)
);
}
const baseQuery = queryLabelsBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
queryLabelsBase()
.where(and(...conditions))
.as("filtered_labels")
);
const labelListQuery = baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(labels.name));
const [totalCount, rows] = await Promise.all([
countQuery,
labelListQuery
]);
return response<ListOrgLabelsResponse>(res, {
data: {
labels: rows,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "Labels retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,101 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const updateLabelBodySchema = z.strictObject({
name: z.string().min(1).max(255).optional(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty()
});
export async function updateOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = updateLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
}
const { name, color } = parsedBody.data;
const [label] = await db
.update(labels)
.set({
name,
color
})
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)))
.returning();
return response<CreateOrEditLabelResponse>(res, {
data: {
label
},
success: true,
error: false,
message: "Label updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -25,7 +25,6 @@ import { UserType } from "@server/types/UserTypes";
import { verifyPassword } from "@server/auth/password";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { verifyTotpCode } from "@server/auth/totp";
import { getFirstString } from "@server/lib/requestParams";
// The RP ID is the domain name of your application
const rpID = (() => {
@@ -407,12 +406,7 @@ export async function deleteSecurityKey(
res: Response,
next: NextFunction
): Promise<any> {
const encodedCredentialId = getFirstString(req.params.credentialId);
if (!encodedCredentialId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid credential ID")
);
}
const { credentialId: encodedCredentialId } = req.params;
const credentialId = decodeURIComponent(encodedCredentialId);
const user = req.user as User;

View File

@@ -1,20 +1,15 @@
import {
clientLabels,
clients,
clientSitesAssociationsCache,
currentFingerprint,
db,
labels,
olms,
orgs,
roleClients,
sites,
userClients,
users,
type Label
users
} from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -118,27 +113,7 @@ const listClientsSchema = z.object({
description:
"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() {
@@ -194,7 +169,6 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
siteNiceId: string | null;
}>;
olmUpdateAvailable?: boolean;
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
};
type OlmWithUpdateAvailable = ClientWithSites;
@@ -230,16 +204,8 @@ export async function listClients(
)
);
}
const {
page,
pageSize,
online,
query,
status,
sort_by,
order,
labels: labelFilter
} = parsedQuery.data;
const { page, pageSize, online, query, status, sort_by, order } =
parsedQuery.data;
const parsedParams = listClientsParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -289,11 +255,6 @@ export async function listClients(
(client) => client.clientId
);
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
// Get client count with filter
const conditions = [
and(
@@ -326,48 +287,21 @@ export async function listClients(
conditions.push(or(...filterAggregates));
}
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
if (query) {
conditions.push(
inArray(
clients.clientId,
db
.select({ id: clientLabels.clientId })
.from(clientLabels)
.innerJoin(
labels,
eq(labels.labelId, clientLabels.labelId)
)
.where(inArray(labels.name, labelFilter))
or(
like(
sql`LOWER(${clients.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${clients.niceId})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${clients.name})`, q),
like(sql`LOWER(${clients.niceId})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
clients.clientId,
db
.select({ id: clientLabels.clientId })
.from(clientLabels)
.innerJoin(
labels,
eq(labels.labelId, clientLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
}
const baseQuery = queryClientsBase().where(and(...conditions));
const countQuery = db.$count(baseQuery.as("filtered_clients"));
@@ -392,30 +326,6 @@ export async function listClients(
const clientIds = clientsList.map((client) => client.clientId);
const siteAssociations = await getSiteAssociations(clientIds);
let labelsForClients: Array<{
labelId: number;
name: string;
color: string;
clientId: number;
}> = [];
if (isLabelFeatureEnabled && clientIds.length > 0) {
labelsForClients = await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
clientId: clientLabels.clientId
})
.from(labels)
.innerJoin(
clientLabels,
eq(clientLabels.labelId, labels.labelId)
)
.where(inArray(clientLabels.clientId, clientIds))
.orderBy(asc(clientLabels.clientLabelId));
}
// Group site associations by client ID
const sitesByClient = siteAssociations.reduce(
(acc, association) => {
@@ -443,10 +353,7 @@ export async function listClients(
const clientsWithSites = clientsList.map((client) => {
return {
...client,
sites: sitesByClient[client.clientId] || [],
labels: labelsForClients.filter(
(l) => l.clientId === client.clientId
)
sites: sitesByClient[client.clientId] || []
};
});

View File

@@ -8,6 +8,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { domain } from "zod/v4/core/regexes";
const getDomainSchema = z.strictObject({
domainId: z.string().optional(),

View File

@@ -1,10 +0,0 @@
import type { Label } from "@server/db";
import type { PaginatedResponse } from "@server/types/Pagination";
export type ListOrgLabelsResponse = PaginatedResponse<{
labels: Omit<Label, "orgId">[];
}>;
export type CreateOrEditLabelResponse = {
label: Label;
};

View File

@@ -19,7 +19,6 @@ import {
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib/response";
import { getFirstString } from "@server/lib/requestParams";
export async function getUserResources(
req: Request,
@@ -27,7 +26,7 @@ export async function getUserResources(
next: NextFunction
): Promise<any> {
try {
const orgId = getFirstString(req.params.orgId);
const { orgId } = req.params;
const userId = req.user?.userId;
if (!userId) {
@@ -36,12 +35,6 @@ 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
const [userOrg] = await db
.select()

View File

@@ -1,9 +1,7 @@
import {
db,
labels,
resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility,
resourceLabels,
resourcePassword,
resourcePincode,
resources,
@@ -11,11 +9,8 @@ import {
sites,
targetHealthCheck,
targets,
userResources,
type Label
userResources
} from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -71,7 +66,7 @@ const listResourcesSchema = z.object({
}),
query: z.string().optional(),
sort_by: z
.literal("name")
.enum(["name"])
.optional()
.catch(undefined)
.openapi({
@@ -123,27 +118,7 @@ const listResourcesSchema = z.object({
type: "integer",
description:
"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[])
@@ -179,7 +154,6 @@ export type ResourceWithTargets = {
siteNiceId: string;
online?: boolean; // undefined for local sites
}>;
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
};
function queryResourcesBase() {
@@ -281,8 +255,7 @@ export async function listResources(
healthStatus,
sort_by,
order,
siteId,
labels: labelFilter
siteId
} = parsedQuery.data;
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
@@ -315,11 +288,6 @@ export async function listResources(
);
}
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
let accessibleResources: Array<{ resourceId: number }>;
if (req.user) {
accessibleResources = await db
@@ -357,6 +325,24 @@ export async function listResources(
)
];
if (query) {
conditions.push(
or(
like(
sql`LOWER(${resources.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${resources.niceId})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${resources.fullDomain})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (typeof enabled !== "undefined") {
conditions.push(eq(resources.enabled, enabled));
}
@@ -401,49 +387,6 @@ export async function listResources(
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) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${resources.name})`, q),
like(sql`LOWER(${resources.niceId})`, q),
like(sql`LOWER(${resources.fullDomain})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
resources.resourceId,
db
.select({ id: resourceLabels.resourceId })
.from(resourceLabels)
.innerJoin(
labels,
eq(labels.labelId, resourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
}
const baseQuery = queryResourcesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
@@ -464,36 +407,6 @@ export async function listResources(
]);
const resourceIdList = rows.map((row) => row.resourceId);
let labelsForResources: Array<{
labelId: number;
name: string;
color: string;
resourceId: number;
}> = [];
if (isLabelFeatureEnabled) {
labelsForResources =
resourceIdList.length === 0
? []
: await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
resourceId: resourceLabels.resourceId
})
.from(labels)
.innerJoin(
resourceLabels,
eq(resourceLabels.labelId, labels.labelId)
)
.where(
inArray(resourceLabels.resourceId, resourceIdList)
)
.orderBy(asc(resourceLabels.resourceLabelId));
}
const allResourceTargets =
resourceIdList.length === 0
? []
@@ -545,10 +458,7 @@ export async function listResources(
headerAuthId: row.headerAuthId,
health: row.health ?? null,
targets: [],
sites: [],
labels: labelsForResources.filter(
(l) => l.resourceId === row.resourceId
)
sites: []
};
map.set(row.resourceId, entry);
}

View File

@@ -9,10 +9,7 @@ import {
siteResources,
targets,
sites,
userSites,
labels,
siteLabels,
type Label
userSites
} from "@server/db";
import cache from "#dynamic/lib/cache";
import response from "@server/lib/response";
@@ -26,8 +23,6 @@ import createHttpError from "http-errors";
import semver from "semver";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
// Stale-while-revalidate: keeps the last successfully fetched version so that
// a transient network failure / timeout does not flip every site back to
@@ -187,32 +182,12 @@ const listSitesSchema = z.object({
type: "string",
enum: ["pending", "approved"],
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"
})
});
function querySitesBase() {
return db
.selectDistinct({
.select({
siteId: sites.siteId,
niceId: sites.niceId,
name: sites.name,
@@ -258,7 +233,6 @@ type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
online?: SiteRowBase["online"]; // undefined for local sites
newtUpdateAvailable?: boolean;
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
};
export type ListSitesResponse = PaginatedResponse<{
@@ -334,21 +308,8 @@ export async function listSites(
.where(eq(sites.orgId, orgId));
}
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const {
pageSize,
page,
query,
sort_by,
order,
online,
status,
labels: labelFilter
} = parsedQuery.data;
const { pageSize, page, query, sort_by, order, online, status } =
parsedQuery.data;
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
@@ -358,55 +319,26 @@ export async function listSites(
eq(sites.orgId, orgId)
)
];
if (query) {
conditions.push(
or(
like(
sql`LOWER(${sites.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${sites.niceId})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (typeof online !== "undefined") {
conditions.push(eq(sites.online, online));
}
if (typeof status !== "undefined") {
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) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${sites.name})`, q),
like(sql`LOWER(${sites.niceId})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
sites.siteId,
db
.select({ id: siteLabels.siteId })
.from(siteLabels)
.innerJoin(
labels,
eq(labels.labelId, siteLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
}
const baseQuery = querySitesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
@@ -435,46 +367,11 @@ export async function listSites(
// Get latest version asynchronously without blocking the response
const latestNewtVersionPromise = getLatestNewtVersion();
const siteIds = rows.map((site) => site.siteId);
let labelsForSites: Array<{
labelId: number;
name: string;
color: string;
siteId: number;
}> = [];
if (isLabelFeatureEnabled) {
labelsForSites =
siteIds.length === 0
? []
: await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
siteId: siteLabels.siteId
})
.from(labels)
.innerJoin(
siteLabels,
eq(siteLabels.labelId, labels.labelId)
)
.where(inArray(siteLabels.siteId, siteIds))
.orderBy(asc(siteLabels.siteLabelId));
}
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
// Initially set to false, will be updated if version check succeeds
siteWithUpdate.newtUpdateAvailable = false;
// associate labels
const labelsForSite = labelsForSites.filter(
(label) => label.siteId === site.siteId
);
return { ...siteWithUpdate, labels: labelsForSite };
return siteWithUpdate;
});
// Try to get the latest version, but don't block if it fails

View File

@@ -1,14 +1,4 @@
import {
db,
DB_TYPE,
Label,
SiteResource,
siteNetworks,
siteResourceLabels,
siteResources,
sites,
labels
} from "@server/db";
import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -19,8 +9,6 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
orgId: z.string()
@@ -81,30 +69,15 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
default: "asc",
description: "Sort order"
}),
siteId: z.coerce.number<string>().int().positive().optional().openapi({
type: "integer",
description:
"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()))
siteId: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch([])
.openapi({
type: "array",
description: "Filter by resource labels"
type: "integer",
description:
"When set, only site resources associated with this site (via network) are returned"
})
});
@@ -115,7 +88,6 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
siteNames: string[];
siteNiceIds: string[];
siteAddresses: (string | null)[];
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
})[];
}>;
@@ -259,21 +231,8 @@ export async function listAllSiteResourcesByOrg(
}
const { orgId } = parsedParams.data;
const {
page,
pageSize,
query,
mode,
sort_by,
order,
siteId,
labels: labelFilter
} = parsedQuery.data;
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const { page, pageSize, query, mode, sort_by, order, siteId } =
parsedQuery.data;
const conditions = [and(eq(siteResources.orgId, orgId))];
@@ -299,55 +258,39 @@ export async function listAllSiteResourcesByOrg(
inArray(siteResources.siteResourceId, resourcesForSite)
);
}
if (mode) {
conditions.push(eq(siteResources.mode, mode));
}
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
if (query) {
conditions.push(
inArray(
siteResources.siteResourceId,
db
.select({ id: siteResourceLabels.siteResourceId })
.from(siteResourceLabels)
.innerJoin(
labels,
eq(labels.labelId, siteResourceLabels.labelId)
)
.where(inArray(labels.name, labelFilter))
or(
like(
sql`LOWER(${siteResources.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.niceId})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.destination})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.alias})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.aliasAddress})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${sites.name})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${siteResources.name})`, q),
like(sql`LOWER(${siteResources.niceId})`, q),
like(sql`LOWER(${siteResources.destination})`, q),
like(sql`LOWER(${siteResources.alias})`, q),
like(sql`LOWER(${siteResources.aliasAddress})`, q),
like(sql`LOWER(${sites.name})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
siteResources.siteResourceId,
db
.select({ id: siteResourceLabels.siteResourceId })
.from(siteResourceLabels)
.innerJoin(
labels,
eq(labels.labelId, siteResourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
if (mode) {
conditions.push(eq(siteResources.mode, mode));
}
const baseQuery = querySiteResourcesBase().where(and(...conditions));
@@ -372,51 +315,11 @@ export async function listAllSiteResourcesByOrg(
countQuery
]);
const siteResourcesList = siteResourcesRaw.map(
transformSiteResourceRow
);
const siteResourceIdList = siteResourcesList.map(
(r) => r.siteResourceId
);
let labelsForSiteResources: Array<{
labelId: number;
name: string;
color: string;
siteResourceId: number;
}> = [];
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
labelsForSiteResources = await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
siteResourceId: siteResourceLabels.siteResourceId
})
.from(labels)
.innerJoin(
siteResourceLabels,
eq(siteResourceLabels.labelId, labels.labelId)
)
.where(
inArray(
siteResourceLabels.siteResourceId,
siteResourceIdList
)
)
.orderBy(asc(siteResourceLabels.siteResourceLabelId));
}
const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow);
return response<ListAllSiteResourcesByOrgResponse>(res, {
data: {
siteResources: siteResourcesList.map((r) => ({
...r,
labels: labelsForSiteResources.filter(
(l) => l.siteResourceId === r.siteResourceId
)
})),
siteResources: siteResourcesList,
pagination: {
total: totalCount,
pageSize,
@@ -437,4 +340,4 @@ export async function listAllSiteResourcesByOrg(
)
);
}
}
}

View File

@@ -1,63 +0,0 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
import { AxiosResponse } from "axios";
import OrgLabelsTable from "@app/components/OrgLabelsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Labels"
};
type Props = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
};
export const dynamic = "force-dynamic";
export default async function LabelsPage({ params, searchParams }: Props) {
const { orgId } = await params;
const searchParamsObj = new URLSearchParams(await searchParams);
let labels: ListOrgLabelsResponse["labels"] = [];
let pagination: ListOrgLabelsResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try {
const res = await internal.get<AxiosResponse<ListOrgLabelsResponse>>(
`/org/${orgId}/labels?${searchParamsObj.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
labels = responseData.labels;
pagination = responseData.pagination;
} catch (e) {}
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("labels")}
description={t("orgLabelsDescription")}
/>
<OrgLabelsTable
labels={labels}
orgId={orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}

View File

@@ -76,8 +76,7 @@ export default async function ClientsPage(props: ClientsPageProps) {
agent: client.agent,
archived: client.archived || false,
blocked: client.blocked || false,
approvalState: client.approvalState ?? "approved",
labels: client.labels ?? []
approvalState: client.approvalState ?? "approved"
};
};

View File

@@ -1,7 +1,7 @@
"use client";
import { Button } from "@app/components/ui/button";
import { toast } from "@app/hooks/useToast";
import { useState, useTransition, useMemo } from "react";
import { useState, useRef, useEffect, useTransition } from "react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useParams, useRouter, useSearchParams } from "next/navigation";
@@ -20,9 +20,6 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
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() {
const router = useRouter();
@@ -33,8 +30,23 @@ export default function GeneralPage() {
const { isPaidUser } = usePaidStatus();
const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
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<{
action?: string;
type?: string;
@@ -49,21 +61,40 @@ export default function GeneralPage() {
actor: searchParams.get("actor") || undefined
});
// Pagination state
const [totalCount, setTotalCount] = 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);
// Set default date range to last 24 hours
const getDefaultDateRange = () => {
// if the time is in the url params, use that instead
const startParam = searchParams.get("start");
const endParam = searchParams.get("end");
if (startParam && endParam) {
return {
startDate: { date: new Date(startParam) },
endDate: { date: new Date(endParam) }
startDate: {
date: new Date(startParam)
},
endDate: {
date: new Date(endParam)
}
};
}
const now = new Date();
const lastWeek = getSevenDaysAgo();
return {
startDate: { date: getSevenDaysAgo() },
endDate: { date: new Date() }
startDate: {
date: lastWeek
},
endDate: {
date: now
}
};
};
@@ -72,95 +103,75 @@ export default function GeneralPage() {
endDate: DateTimeValue;
}>(getDefaultDateRange());
const queryFilters = useMemo(() => {
let timeStart: string | undefined;
let timeEnd: string | undefined;
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();
}
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: []
};
// Trigger search with default values on component mount
useEffect(() => {
const defaultRange = getDefaultDateRange();
queryDateTime(
defaultRange.startDate,
defaultRange.endDate,
0,
pageSize
);
}, [orgId]); // Re-run if orgId changes
const handleDateRangeChange = (
startDate: DateTimeValue,
endDate: DateTimeValue
) => {
setDateRange({ startDate, endDate });
setCurrentPage(0);
setCurrentPage(0); // Reset to first page when filtering
// put the search params in the url for the time
updateUrlParamsForAllFilters({
start: startDate.date?.toISOString() || "",
end: endDate.date?.toISOString() || ""
});
queryDateTime(startDate, endDate, 0, pageSize);
};
// Handle page changes
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
queryDateTime(
dateRange.startDate,
dateRange.endDate,
newPage,
pageSize
);
};
// Handle page size changes
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(0);
setCurrentPage(0); // Reset to first page when changing page size
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
};
// Handle filter changes generically
const handleFilterChange = (
filterType: keyof typeof filters,
value: string | undefined
) => {
const newFilters = { ...filters, [filterType]: value };
// Create new filters object with updated value
const newFilters = {
...filters,
[filterType]: value
};
setFilters(newFilters);
setCurrentPage(0);
setCurrentPage(0); // Reset to first page when filtering
// Update URL params
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 = (
@@ -182,8 +193,114 @@ export default function GeneralPage() {
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 () => {
try {
// Prepare query params for export
const params: any = {
timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString()
@@ -199,6 +316,7 @@ export default function GeneralPage() {
params
});
// Create a URL for the blob and trigger a download
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
@@ -216,6 +334,7 @@ export default function GeneralPage() {
const data = error.response.data;
if (data instanceof Blob && data.type === "application/json") {
// Parse the Blob as JSON
const text = await data.text();
const errorData = JSON.parse(text);
apiErrorMessage = errorData.message;
@@ -232,7 +351,7 @@ export default function GeneralPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "timestamp",
header: () => {
header: ({ column }) => {
return t("timestamp");
},
cell: ({ row }) => {
@@ -247,7 +366,7 @@ export default function GeneralPage() {
},
{
accessorKey: "action",
header: () => {
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("action")}</span>
@@ -260,6 +379,7 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("action", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
@@ -276,11 +396,13 @@ export default function GeneralPage() {
},
{
accessorKey: "ip",
header: () => t("ip")
header: ({ column }) => {
return t("ip");
}
},
{
accessorKey: "location",
header: () => {
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("location")}</span>
@@ -295,6 +417,7 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("location", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
@@ -319,7 +442,7 @@ export default function GeneralPage() {
},
{
accessorKey: "resourceName",
header: () => {
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("resource")}</span>
@@ -332,6 +455,7 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("resourceId", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
@@ -357,7 +481,7 @@ export default function GeneralPage() {
},
{
accessorKey: "type",
header: () => {
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("type")}</span>
@@ -376,6 +500,7 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("type", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
@@ -393,7 +518,7 @@ export default function GeneralPage() {
},
{
accessorKey: "actor",
header: () => {
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("actor")}</span>
@@ -406,6 +531,7 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("actor", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
@@ -433,12 +559,16 @@ export default function GeneralPage() {
},
{
accessorKey: "actorId",
header: () => t("actorId"),
cell: ({ row }) => (
<span className="flex items-center gap-1">
{row.original.actorId || "-"}
</span>
)
header: ({ column }) => {
return t("actorId");
},
cell: ({ row }) => {
return (
<span className="flex items-center gap-1">
{row.original.actorId || "-"}
</span>
);
}
}
];
@@ -484,10 +614,13 @@ export default function GeneralPage() {
columns={columns}
data={rows}
title={t("accessLogs")}
onRefresh={() => refetch()}
isRefreshing={isFetching}
onRefresh={refreshData}
isRefreshing={isRefreshing}
onExport={() => startTransition(exportData)}
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}
dateRange={{
start: dateRange.startDate,
@@ -497,12 +630,14 @@ export default function GeneralPage() {
id: "timestamp",
desc: true
}}
// Server-side pagination props
totalCount={totalCount}
currentPage={currentPage}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
isLoading={isLoading}
// Row expansion props
expandable={true}
renderExpandedRow={renderExpandedRow}
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
@@ -510,41 +645,3 @@ 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)]
};
});
}

View File

@@ -10,17 +10,14 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { logQueries } from "@app/lib/queries";
import { build } from "@server/build";
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 axios from "axios";
import { Key, User } from "lucide-react";
import { useTranslations } from "next-intl";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { useEffect, useState, useTransition } from "react";
export default function GeneralPage() {
const router = useRouter();
@@ -31,8 +28,18 @@ export default function GeneralPage() {
const { isPaidUser } = usePaidStatus();
const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
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<{
action?: string;
actor?: string;
@@ -41,21 +48,40 @@ export default function GeneralPage() {
actor: searchParams.get("actor") || undefined
});
// Pagination state
const [totalCount, setTotalCount] = 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);
// Set default date range to last 24 hours
const getDefaultDateRange = () => {
// if the time is in the url params, use that instead
const startParam = searchParams.get("start");
const endParam = searchParams.get("end");
if (startParam && endParam) {
return {
startDate: { date: new Date(startParam) },
endDate: { date: new Date(endParam) }
startDate: {
date: new Date(startParam)
},
endDate: {
date: new Date(endParam)
}
};
}
const now = new Date();
const lastWeek = getSevenDaysAgo();
return {
startDate: { date: getSevenDaysAgo() },
endDate: { date: new Date() }
startDate: {
date: lastWeek
},
endDate: {
date: now
}
};
};
@@ -64,90 +90,78 @@ export default function GeneralPage() {
endDate: DateTimeValue;
}>(getDefaultDateRange());
const queryFilters = useMemo(() => {
let timeStart: string | undefined;
let timeEnd: string | undefined;
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();
// Trigger search with default values on component mount
useEffect(() => {
if (build === "oss") {
return;
}
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
};
}, [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 defaultRange = getDefaultDateRange();
queryDateTime(
defaultRange.startDate,
defaultRange.endDate,
0,
pageSize
);
}, [orgId]); // Re-run if orgId changes
const handleDateRangeChange = (
startDate: DateTimeValue,
endDate: DateTimeValue
) => {
setDateRange({ startDate, endDate });
setCurrentPage(0);
setCurrentPage(0); // Reset to first page when filtering
// put the search params in the url for the time
updateUrlParamsForAllFilters({
start: startDate.date?.toISOString() || "",
end: endDate.date?.toISOString() || ""
});
queryDateTime(startDate, endDate, 0, pageSize);
};
// Handle page changes
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
queryDateTime(
dateRange.startDate,
dateRange.endDate,
newPage,
pageSize
);
};
// Handle page size changes
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(0);
setCurrentPage(0); // Reset to first page when changing page size
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
};
// Handle filter changes generically
const handleFilterChange = (
filterType: keyof typeof filters,
value: string | undefined
) => {
const newFilters = { ...filters, [filterType]: value };
// Create new filters object with updated value
const newFilters = {
...filters,
[filterType]: value
};
setFilters(newFilters);
setCurrentPage(0);
setCurrentPage(0); // Reset to first page when filtering
// Update URL params
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 = (
@@ -169,8 +183,110 @@ export default function GeneralPage() {
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 () => {
try {
// Prepare query params for export
const params: any = {
timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString()
@@ -186,6 +302,7 @@ export default function GeneralPage() {
params
});
// Create a URL for the blob and trigger a download
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
@@ -203,6 +320,7 @@ export default function GeneralPage() {
const data = error.response.data;
if (data instanceof Blob && data.type === "application/json") {
// Parse the Blob as JSON
const text = await data.text();
const errorData = JSON.parse(text);
apiErrorMessage = errorData.message;
@@ -219,7 +337,7 @@ export default function GeneralPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "timestamp",
header: () => {
header: ({ column }) => {
return t("timestamp");
},
cell: ({ row }) => {
@@ -234,16 +352,22 @@ export default function GeneralPage() {
},
{
accessorKey: "action",
header: () => {
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("action")}</span>
<ColumnFilter
options={[]}
options={filterAttributes.actions.map((action) => ({
label:
action.charAt(0).toUpperCase() +
action.slice(1),
value: action
}))}
selectedValue={filters.action}
onValueChange={(value) =>
handleFilterChange("action", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
@@ -261,7 +385,7 @@ export default function GeneralPage() {
},
{
accessorKey: "actor",
header: () => {
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("actor")}</span>
@@ -274,6 +398,7 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("actor", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
@@ -295,7 +420,7 @@ export default function GeneralPage() {
},
{
accessorKey: "actorId",
header: () => {
header: ({ column }) => {
return t("actorId");
},
cell: ({ row }) => {
@@ -344,9 +469,12 @@ export default function GeneralPage() {
title={t("actionLogs")}
searchPlaceholder={t("searchLogs")}
searchColumn="action"
onRefresh={() => refetch()}
isRefreshing={isFetching}
onRefresh={refreshData}
isRefreshing={isRefreshing}
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}
onDateRangeChange={handleDateRangeChange}
dateRange={{
@@ -357,12 +485,14 @@ export default function GeneralPage() {
id: "timestamp",
desc: true
}}
// Server-side pagination props
totalCount={totalCount}
currentPage={currentPage}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
isLoading={isLoading}
// Row expansion props
expandable={true}
renderExpandedRow={renderExpandedRow}
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
@@ -370,39 +500,3 @@ 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
};
});
}

View File

@@ -11,18 +11,24 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { logQueries } from "@app/lib/queries";
import { build } from "@server/build";
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 axios from "axios";
import { ArrowUpRight, Laptop, User } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { useEffect, 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 {
if (endedAt === null || endedAt === undefined) return "Active";
@@ -48,8 +54,24 @@ export default function ConnectionLogsPage() {
const { isPaidUser } = usePaidStatus();
const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
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<{
protocol?: string;
destAddr?: string;
@@ -64,24 +86,43 @@ export default function ConnectionLogsPage() {
userId: searchParams.get("userId") || undefined
});
// Pagination state
const [totalCount, setTotalCount] = 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(
"connection-audit-logs",
20
);
// Set default date range to last 7 days
const getDefaultDateRange = () => {
// if the time is in the url params, use that instead
const startParam = searchParams.get("start");
const endParam = searchParams.get("end");
if (startParam && endParam) {
return {
startDate: { date: new Date(startParam) },
endDate: { date: new Date(endParam) }
startDate: {
date: new Date(startParam)
},
endDate: {
date: new Date(endParam)
}
};
}
const now = new Date();
const lastWeek = getSevenDaysAgo();
return {
startDate: { date: getSevenDaysAgo() },
endDate: { date: new Date() }
startDate: {
date: lastWeek
},
endDate: {
date: now
}
};
};
@@ -90,100 +131,78 @@ export default function ConnectionLogsPage() {
endDate: DateTimeValue;
}>(getDefaultDateRange());
const queryFilters = useMemo(() => {
let timeStart: string | undefined;
let timeEnd: string | undefined;
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();
// Trigger search with default values on component mount
useEffect(() => {
if (build === "oss") {
return;
}
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,
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 defaultRange = getDefaultDateRange();
queryDateTime(
defaultRange.startDate,
defaultRange.endDate,
0,
pageSize
);
}, [orgId]); // Re-run if orgId changes
const handleDateRangeChange = (
startDate: DateTimeValue,
endDate: DateTimeValue
) => {
setDateRange({ startDate, endDate });
setCurrentPage(0);
setCurrentPage(0); // Reset to first page when filtering
// put the search params in the url for the time
updateUrlParamsForAllFilters({
start: startDate.date?.toISOString() || "",
end: endDate.date?.toISOString() || ""
});
queryDateTime(startDate, endDate, 0, pageSize);
};
// Handle page changes
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
queryDateTime(
dateRange.startDate,
dateRange.endDate,
newPage,
pageSize
);
};
// Handle page size changes
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(0);
setCurrentPage(0); // Reset to first page when changing page size
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
};
// Handle filter changes generically
const handleFilterChange = (
filterType: keyof typeof filters,
value: string | undefined
) => {
const newFilters = { ...filters, [filterType]: value };
// Create new filters object with updated value
const newFilters = {
...filters,
[filterType]: value
};
setFilters(newFilters);
setCurrentPage(0);
setCurrentPage(0); // Reset to first page when filtering
// Update URL params
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 = (
@@ -205,8 +224,109 @@ export default function ConnectionLogsPage() {
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: 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 () => {
try {
// Prepare query params for export
const params: any = {
timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString()
@@ -225,6 +345,7 @@ export default function ConnectionLogsPage() {
}
);
// Create a URL for the blob and trigger a download
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
@@ -242,6 +363,7 @@ export default function ConnectionLogsPage() {
const data = error.response.data;
if (data instanceof Blob && data.type === "application/json") {
// Parse the Blob as JSON
const text = await data.text();
const errorData = JSON.parse(text);
apiErrorMessage = errorData.message;
@@ -258,7 +380,7 @@ export default function ConnectionLogsPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "startedAt",
header: () => {
header: ({ column }) => {
return t("timestamp");
},
cell: ({ row }) => {
@@ -273,7 +395,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "protocol",
header: () => {
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("protocol")}</span>
@@ -304,7 +426,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "resourceName",
header: () => {
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("resource")}</span>
@@ -345,7 +467,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "clientName",
header: () => {
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("client")}</span>
@@ -388,7 +510,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "userEmail",
header: () => {
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("user")}</span>
@@ -421,7 +543,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "sourceAddr",
header: () => {
header: ({ column }) => {
return t("sourceAddress");
},
cell: ({ row }) => {
@@ -434,7 +556,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "destAddr",
header: () => {
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("destinationAddress")}</span>
@@ -463,7 +585,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "duration",
header: () => {
header: ({ column }) => {
return t("duration");
},
cell: ({ row }) => {
@@ -484,6 +606,9 @@ export default function ConnectionLogsPage() {
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
<div className="space-y-2">
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
Connection Details
</div>*/}
<div>
<strong>Session ID:</strong>{" "}
<span className="font-mono">
@@ -508,6 +633,18 @@ export default function ConnectionLogsPage() {
</div>
</div>
<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>
<strong>Client Endpoint:</strong>{" "}
<span className="font-mono">
@@ -543,8 +680,30 @@ export default function ConnectionLogsPage() {
<strong>Duration:</strong>{" "}
{formatDuration(row.startedAt, row.endedAt)}
</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 className="space-y-2" />
</div>
</div>
);
@@ -565,8 +724,8 @@ export default function ConnectionLogsPage() {
title={t("connectionLogs")}
searchPlaceholder={t("searchLogs")}
searchColumn="protocol"
onRefresh={() => refetch()}
isRefreshing={isFetching}
onRefresh={refreshData}
isRefreshing={isRefreshing}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
onDateRangeChange={handleDateRangeChange}
@@ -578,12 +737,14 @@ export default function ConnectionLogsPage() {
id: "startedAt",
desc: true
}}
// Server-side pagination props
totalCount={totalCount}
currentPage={currentPage}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
isLoading={isLoading}
// Row expansion props
expandable={true}
renderExpandedRow={renderExpandedRow}
disabled={
@@ -593,49 +754,3 @@ 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
};
});
}

View File

@@ -9,17 +9,14 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useTranslations } from "next-intl";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { logQueries } from "@app/lib/queries";
import { ColumnDef } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { useEffect, useState, useTransition } from "react";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { build } from "@server/build";
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
export default function GeneralPage() {
const router = useRouter();
@@ -28,11 +25,36 @@ export default function GeneralPage() {
const { orgId } = useParams();
const searchParams = useSearchParams();
const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isExporting, startTransition] = useTransition();
// Pagination state
const [totalCount, setTotalCount] = 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 [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<{
action?: string;
resourceId?: string;
@@ -53,18 +75,32 @@ export default function GeneralPage() {
path: searchParams.get("path") || undefined
});
// Set default date range to last 24 hours
const getDefaultDateRange = () => {
// if the time is in the url params, use that instead
const startParam = searchParams.get("start");
const endParam = searchParams.get("end");
if (startParam && endParam) {
return {
startDate: { date: new Date(startParam) },
endDate: { date: new Date(endParam) }
startDate: {
date: new Date(startParam)
},
endDate: {
date: new Date(endParam)
}
};
}
const now = new Date();
const lastWeek = getSevenDaysAgo();
return {
startDate: { date: getSevenDaysAgo() },
endDate: { date: new Date() }
startDate: {
date: lastWeek
},
endDate: {
date: now
}
};
};
@@ -73,97 +109,80 @@ export default function GeneralPage() {
endDate: DateTimeValue;
}>(getDefaultDateRange());
const queryFilters = useMemo(() => {
let timeStart: string | undefined;
let timeEnd: string | undefined;
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();
// Trigger search with default values on component mount
useEffect(() => {
if (build === "oss") {
return;
}
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.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 defaultRange = getDefaultDateRange();
queryDateTime(
defaultRange.startDate,
defaultRange.endDate,
0,
pageSize
);
}, [orgId]); // Re-run if orgId changes
const handleDateRangeChange = (
startDate: DateTimeValue,
endDate: DateTimeValue
) => {
setDateRange({ startDate, endDate });
setCurrentPage(0);
setCurrentPage(0); // Reset to first page when filtering
// put the search params in the url for the time
updateUrlParamsForAllFilters({
start: startDate.date?.toISOString() || "",
end: endDate.date?.toISOString() || ""
});
queryDateTime(startDate, endDate, 0, pageSize);
};
// Handle page changes
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
queryDateTime(
dateRange.startDate,
dateRange.endDate,
newPage,
pageSize
);
};
// Handle page size changes
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(0);
setCurrentPage(0); // Reset to first page when changing page size
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
};
// Handle filter changes generically
const handleFilterChange = (
filterType: keyof typeof filters,
value: string | undefined
) => {
const newFilters = { ...filters, [filterType]: value };
console.log(`${filterType} filter changed:`, value);
// Create new filters object with updated value
const newFilters = {
...filters,
[filterType]: value
};
setFilters(newFilters);
setCurrentPage(0);
setCurrentPage(0); // Reset to first page when filtering
// Update URL params
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 = (
@@ -185,6 +204,101 @@ export default function GeneralPage() {
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 () => {
try {
// Prepare query params for export
@@ -667,8 +781,8 @@ export default function GeneralPage() {
title={t("requestLogs")}
searchPlaceholder={t("searchLogs")}
searchColumn="host"
onRefresh={() => refetch()}
isRefreshing={isFetching}
onRefresh={refreshData}
isRefreshing={isRefreshing}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
onDateRangeChange={handleDateRangeChange}
@@ -680,6 +794,7 @@ export default function GeneralPage() {
id: "timestamp",
desc: true
}}
// Server-side pagination props
totalCount={totalCount}
currentPage={currentPage}
onPageChange={handlePageChange}
@@ -693,63 +808,3 @@ 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
};
});
}

View File

@@ -127,8 +127,7 @@ export default async function ClientResourcesPage(
authDaemonPort: siteResource.authDaemonPort ?? null,
subdomain: siteResource.subdomain ?? null,
domainId: siteResource.domainId ?? null,
fullDomain: siteResource.fullDomain ?? null,
labels: siteResource.labels ?? []
fullDomain: siteResource.fullDomain ?? null
};
}
);

View File

@@ -49,7 +49,7 @@ import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm";
import SetResourcePasswordForm from "components/SetResourcePasswordForm";
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";

View File

@@ -111,7 +111,6 @@ export default async function ProxyResourcesPage(
protocol: resource.protocol,
proxyPort: resource.proxyPort,
http: resource.http,
labels: resource.labels,
authState: !resource.http
? "none"
: resource.sso ||

View File

@@ -60,7 +60,6 @@ export default async function SitesPage(props: SitesPageProps) {
return {
name: site.name,
id: site.siteId,
labels: site.labels,
nice: site.niceId.toString(),
address: site.address?.split("/")[0],
mbIn: formatSize(site.megabytesIn || 0, site.type),

View File

@@ -23,7 +23,6 @@ import {
Server,
Settings,
SquareMousePointer,
TagIcon,
TicketCheck,
Unplug,
User,
@@ -100,7 +99,7 @@ export const orgNavSections = (
href: "/{orgId}/settings/domains",
icon: <Globe className="size-4 flex-none" />
},
...(build === "saas"
...(build == "saas"
? [
{
title: "sidebarRemoteExitNodes",
@@ -238,19 +237,10 @@ export const orgNavSections = (
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
},
...(build !== "oss"
? [
{
title: "labels",
href: "/{orgId}/settings/labels",
icon: <TagIcon className="size-4 flex-none" />
}
]
: [])
}
]
},
...(build === "saas" && options?.isPrimaryOrg
...(build == "saas" && options?.isPrimaryOrg
? [
{
title: "sidebarBillingAndLicenses",

View File

@@ -21,11 +21,9 @@ export default async function Page(props: {
searchParams: Promise<{
redirect: string | undefined;
t: string | undefined;
orgs?: string | undefined;
}>;
}) {
const params = await props.searchParams; // this is needed to prevent static optimization
const showOrgPicker = params.orgs === "1";
const env = pullEnv();
@@ -108,7 +106,7 @@ export default async function Page(props: {
}
}
if (targetOrgId && !showOrgPicker) {
if (targetOrgId) {
return <RedirectToOrg targetOrgId={targetOrgId} />;
}

View File

@@ -27,18 +27,20 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
return (
<Alert>
<AlertDescription>
<InfoSections cols={userDisplayName ? 3 : 2}>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
<InfoSectionContent>{client.name}</InfoSectionContent>
</InfoSection>
{userDisplayName ? (
<InfoSection>
<InfoSectionTitle>{t("user")}</InfoSectionTitle>
<InfoSectionContent>
<div className="flex flex-wrap items-center gap-2">
<span>{userDisplayName}</span>
{(client.userType ?? "internal") !==
<InfoSection>
<InfoSectionTitle>
{userDisplayName ? t("user") : t("identifier")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="flex flex-wrap items-center gap-2">
<span>{userDisplayName || client.niceId}</span>
{userDisplayName &&
(client.userType ?? "internal") !==
"internal" && (
<IdpTypeBadge
type={client.userType ?? "oidc"}
@@ -52,10 +54,9 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
}
/>
)}
</div>
</InfoSectionContent>
</InfoSection>
) : null}
</div>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>

View File

@@ -2,6 +2,7 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
@@ -35,13 +36,7 @@ import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import {
startTransition,
useEffect,
useMemo,
useState,
useTransition
} from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
import type { PaginationState } from "@tanstack/react-table";
@@ -58,12 +53,6 @@ import {
} from "@app/components/ResourceSitesStatusCell";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { type SelectedLabel } from "./labels-selector";
import { TableLabelsCell } from "./TableLabelsCell";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { useLocalLabels } from "@app/hooks/useLocalLabels";
export type InternalResourceSiteRow = ResourceSiteRow;
@@ -95,11 +84,6 @@ export type InternalResourceRow = {
subdomain?: string | null;
domainId?: string | null;
fullDomain?: string | null;
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
};
function formatDestinationDisplay(row: InternalResourceRow): string {
@@ -157,17 +141,14 @@ export default function ClientResourcesTable({
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [isRefreshing, startRefreshTransition] = useTransition();
const [isRefreshing, startTransition] = useTransition();
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
// useEffect(() => {
// const interval = setInterval(() => {
// router.refresh();
// }, 30_000);
// return () => clearInterval(interval);
// }, [router]);
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 30_000);
return () => clearInterval(interval);
}, [router]);
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
@@ -186,7 +167,7 @@ export default function ClientResourcesTable({
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
const refreshData = () => {
startRefreshTransition(() => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
@@ -204,8 +185,8 @@ export default function ClientResourcesTable({
siteId: number
) => {
try {
startTransition(async () => {
await api.delete(`/site-resource/${resourceId}`).then(() => {
await api.delete(`/site-resource/${resourceId}`).then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
@@ -220,351 +201,359 @@ export default function ClientResourcesTable({
}
};
const internalColumns = useMemo<
ExtendedColumnDef<InternalResourceRow>[]
>(() => {
const cols: ExtendedColumnDef<InternalResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) {
const { siteNames, siteNiceIds, orgId } = resourceRow;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{
id: "sites",
accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => (
<Popover
open={siteFilterOpen}
onOpenChange={setSiteFilterOpen}
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"
>
<PopoverTrigger asChild>
<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: ExtendedColumnDef<InternalResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{
id: "sites",
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => (
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
{t("standaloneHcFilterAnySite")}
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
{t("standaloneHcFilterAnySite")}
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<ResourceSitesStatusCell
orgId={resourceRow.orgId}
resourceSites={resourceRow.sites}
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
);
}
},
{
accessorKey: "mode",
friendlyName: t("editInternalResourceDialogMode"),
header: () => (
<ColumnFilterButton
options={[
{
value: "host",
label: t("editInternalResourceDialogModeHost")
},
{
value: "cidr",
label: t("editInternalResourceDialogModeCidr")
},
{
value: "http",
label: t("editInternalResourceDialogModeHttp")
}
]}
selectedValue={searchParams.get("mode") ?? undefined}
onValueChange={(value) =>
handleFilterChange("mode", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("editInternalResourceDialogMode")}
className="p-3"
</PopoverContent>
</Popover>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<ResourceSitesStatusCell
orgId={resourceRow.orgId}
resourceSites={resourceRow.sites}
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
const modeLabels: Record<
"host" | "cidr" | "port" | "http",
string
> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"),
http: t("editInternalResourceDialogModeHttp")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
},
{
accessorKey: "destination",
friendlyName: t("resourcesTableDestination"),
header: () => (
<span className="p-3">
{t("resourcesTableDestination")}
</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
const display = formatDestinationDisplay(resourceRow);
);
}
},
{
accessorKey: "mode",
friendlyName: t("editInternalResourceDialogMode"),
header: () => (
<ColumnFilterButton
options={[
{
value: "host",
label: t("editInternalResourceDialogModeHost")
},
{
value: "cidr",
label: t("editInternalResourceDialogModeCidr")
},
{
value: "http",
label: t("editInternalResourceDialogModeHttp")
}
]}
selectedValue={searchParams.get("mode") ?? undefined}
onValueChange={(value) => handleFilterChange("mode", value)}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("editInternalResourceDialogMode")}
className="p-3"
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
const modeLabels: Record<
"host" | "cidr" | "port" | "http",
string
> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"),
http: t("editInternalResourceDialogModeHttp")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
},
{
accessorKey: "destination",
friendlyName: t("resourcesTableDestination"),
header: () => (
<span className="p-3">{t("resourcesTableDestination")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
const display = formatDestinationDisplay(resourceRow);
return (
<CopyToClipboard
text={display}
isLink={false}
displayText={display}
/>
);
}
},
{
accessorKey: "alias",
friendlyName: t("resourcesTableAlias"),
header: () => (
<span className="p-3">{t("resourcesTableAlias")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
if (resourceRow.mode === "host" && resourceRow.alias) {
return (
<CopyToClipboard
text={display}
text={resourceRow.alias}
isLink={false}
displayText={display}
displayText={resourceRow.alias}
/>
);
}
},
{
accessorKey: "alias",
friendlyName: t("resourcesTableAlias"),
header: () => (
<span className="p-3">{t("resourcesTableAlias")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
if (resourceRow.mode === "host" && resourceRow.alias) {
return (
<CopyToClipboard
text={resourceRow.alias}
isLink={false}
displayText={resourceRow.alias}
/>
);
}
if (resourceRow.mode === "http") {
const domainId = resourceRow.domainId;
const fullDomain = resourceRow.fullDomain;
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
const did =
build !== "oss" &&
resourceRow.ssl &&
domainId != null &&
domainId !== "" &&
fullDomain != null &&
fullDomain !== "";
if (resourceRow.mode === "http") {
const domainId = resourceRow.domainId;
const fullDomain = resourceRow.fullDomain;
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
const did =
build !== "oss" &&
resourceRow.ssl &&
domainId != null &&
domainId !== "" &&
fullDomain != null &&
fullDomain !== "";
return (
<div className="flex items-center gap-2 min-w-0">
{did ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={fullDomain}
/>
) : null}
<div className="">
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
</div>
</div>
);
}
return <span>-</span>;
}
},
{
accessorKey: "aliasAddress",
friendlyName: t("resourcesTableAliasAddress"),
enableHiding: true,
header: () => (
<div className="flex items-center gap-2 p-3">
<span>{t("resourcesTableAliasAddress")}</span>
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
</div>
),
cell: ({ row }) => {
const resourceRow = row.original;
return resourceRow.aliasAddress ? (
<CopyToClipboard
text={resourceRow.aliasAddress}
isLink={false}
displayText={resourceRow.aliasAddress}
/>
) : (
<span>-</span>
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = 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">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
<div className="flex items-center gap-2 min-w-0">
{did ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={fullDomain}
/>
) : null}
<div className="">
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
</div>
</div>
);
}
return <span>-</span>;
}
];
if (isLabelFeatureEnabled) {
cols.splice(cols.length - 1, 0, {
id: "labels",
accessorKey: "labels",
header: () => (
<LabelColumnFilterButton
orgId={orgId}
selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
},
{
accessorKey: "aliasAddress",
friendlyName: t("resourcesTableAliasAddress"),
enableHiding: true,
header: () => (
<div className="flex items-center gap-2 p-3">
<span>{t("resourcesTableAliasAddress")}</span>
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
</div>
),
cell: ({ row }) => {
const resourceRow = row.original;
return resourceRow.aliasAddress ? (
<CopyToClipboard
text={resourceRow.aliasAddress}
isLink={false}
displayText={resourceRow.aliasAddress}
/>
),
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
<ClientResourceLabelCell
resource={row.original}
orgId={orgId}
/>
)
});
) : (
<span>-</span>
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = 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">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
}
return cols;
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
];
function handleFilterChange(
column: string,
value: string | undefined | null | string[]
value: string | undefined | null
) {
searchParams.delete(column);
searchParams.delete("page");
if (typeof value === "string") {
if (value) {
searchParams.set(column, value);
} else if (value) {
value.forEach((val) => searchParams.append(column, val));
}
filter({
searchParams
@@ -637,7 +626,7 @@ export default function ClientResourcesTable({
rows={internalResources}
tableId="internal-resources"
searchPlaceholder={t("resourcesSearch")}
searchQuery={searchParams.get("query")?.toString()}
searchQuery={searchParams.get("query") ?? ""}
onAdd={() => setIsCreateDialogOpen(true)}
addButtonText={t("resourceAdd")}
onSearch={handleSearchChange}
@@ -649,8 +638,7 @@ export default function ClientResourcesTable({
enableColumnVisibility
columnVisibility={{
niceId: false,
aliasAddress: false,
labels: false
aliasAddress: false
}}
stickyLeftColumn="name"
stickyRightColumn="actions"
@@ -686,64 +674,3 @@ export default function ClientResourcesTable({
</>
);
}
type ClientResourceLabelCellProps = {
resource: InternalResourceRow;
orgId: string;
};
function ClientResourceLabelCell({
resource,
orgId
}: ClientResourceLabelCellProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [localLabels, setLocalLabels] = useLocalLabels(
resource.labels,
resource.id
);
function toggleResourceLabel(
label: SelectedLabel,
action: "attach" | "detach"
) {
const previousLabels = localLabels;
void (async () => {
try {
if (action === "attach") {
setLocalLabels([...previousLabels, label]);
await api.put(
`/org/${orgId}/label/${label.labelId}/attach`,
{ siteResourceId: resource.id }
);
} else {
setLocalLabels(
previousLabels.filter(
(lb) => lb.labelId !== label.labelId
)
);
await api.put(
`/org/${orgId}/label/${label.labelId}/detach`,
{ siteResourceId: resource.id }
);
}
} catch (e) {
setLocalLabels(previousLabels);
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
})();
}
return (
<TableLabelsCell
orgId={orgId}
localLabels={localLabels}
toggleLabel={toggleResourceLabel}
/>
);
}

View File

@@ -109,7 +109,7 @@ export default function CreateBlueprintForm({
if (res && res.status === 201) {
const createdBlueprint = res.data.data;
toast({
variant: createdBlueprint.succeeded ? "default" : "warning",
variant: "warning",
title: createdBlueprint.succeeded ? "Success" : "Warning",
description: createdBlueprint.message
});

View File

@@ -16,7 +16,7 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useState, useTransition } from "react";
import { useState } from "react";
import {
cleanForFQDN,
InternalResourceForm,
@@ -39,30 +39,30 @@ export default function CreateInternalResourceDialog({
}: CreateInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, setIsSubmitting] = useState(false);
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
const [isSubmitting, startTransition] = useTransition();
function handleSubmit(values: InternalResourceFormValues) {
startTransition(async () => {
try {
let data = { ...values };
if (
(data.mode === "host" || data.mode === "http") &&
isHostname(data.destination)
) {
const currentAlias = data.alias?.trim() || "";
if (!currentAlias) {
let aliasValue = data.destination;
if (data.destination.toLowerCase() === "localhost") {
aliasValue = `${cleanForFQDN(data.name)}.internal`;
}
data = { ...data, alias: aliasValue };
async function handleSubmit(values: InternalResourceFormValues) {
setIsSubmitting(true);
try {
let data = { ...values };
if (
(data.mode === "host" || data.mode === "http") &&
isHostname(data.destination)
) {
const currentAlias = data.alias?.trim() || "";
if (!currentAlias) {
let aliasValue = data.destination;
if (data.destination.toLowerCase() === "localhost") {
aliasValue = `${cleanForFQDN(data.name)}.internal`;
}
data = { ...data, alias: aliasValue };
}
}
await api.put<
AxiosResponse<{ data: { siteResourceId: number } }>
>(`/org/${orgId}/site-resource`, {
await api.put<AxiosResponse<{ data: { siteResourceId: number } }>>(
`/org/${orgId}/site-resource`,
{
name: data.name,
siteIds: data.siteIds,
mode: data.mode,
@@ -106,30 +106,32 @@ export default function CreateInternalResourceDialog({
clientIds: data.clients
? data.clients.map((c) => parseInt(c.id))
: []
});
}
);
toast({
title: t("createInternalResourceDialogSuccess"),
description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
),
variant: "default"
});
setOpen(false);
onSuccess?.();
} catch (error) {
toast({
title: t("createInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"createInternalResourceDialogFailedToCreateInternalResource"
)
),
variant: "destructive"
});
}
});
toast({
title: t("createInternalResourceDialogSuccess"),
description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
),
variant: "default"
});
setOpen(false);
onSuccess?.();
} catch (error) {
toast({
title: t("createInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"createInternalResourceDialogFailedToCreateInternalResource"
)
),
variant: "destructive"
});
} finally {
setIsSubmitting(false);
}
}
return (

View File

@@ -1,102 +0,0 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm";
import { Button } from "./ui/button";
export type CreateOrgLabelDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
onSuccess?: () => void;
};
export function CreateOrgLabelDialog({
open,
setOpen,
orgId,
onSuccess
}: CreateOrgLabelDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, startTransition] = useTransition();
async function createOrgLabel(data: { name: string; color: string }) {
try {
const res = await api.post<
AxiosResponse<CreateOrEditLabelResponse>
>(`/org/${orgId}/labels`, data);
if (res.status === 201) {
setOpen(false);
onSuccess?.();
toast({
title: t("success"),
description: t("labelCreateSuccessMessage")
});
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="md:max-w-md">
<CredenzaHeader>
<CredenzaTitle>{t("createLabelDialogTitle")}</CredenzaTitle>
<CredenzaDescription>
{t("createLabelDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<OrgLabelForm
onSubmit={(data) => {
startTransition(async () => createOrgLabel(data));
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="org-label-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("labelCreate")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -318,28 +318,12 @@ export default function DeviceLoginForm({
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={8}
maxLength={9}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
{...field}
value={field.value
.replace(/-/g, "")
.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) => {
// Strip hyphens and convert to uppercase
const cleanedValue = value

View File

@@ -1,109 +0,0 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm";
import { Button } from "./ui/button";
export type EditOrgLabelDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
onSuccess?: () => void;
label: {
name: string;
color: string;
labelId: number;
};
};
export function EditOrgLabelDialog({
open,
setOpen,
orgId,
onSuccess,
label
}: EditOrgLabelDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, startTransition] = useTransition();
async function editOrgLabel(data: { name: string; color: string }) {
try {
const res = await api.patch<
AxiosResponse<CreateOrEditLabelResponse>
>(`/org/${orgId}/label/${label.labelId}`, data);
if (res.status === 200) {
setOpen(false);
onSuccess?.();
toast({
title: t("success"),
description: t("labelEditSuccessMessage")
});
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="md:max-w-md">
<CredenzaHeader>
<CredenzaTitle>{t("editLabelDialogTitle")}</CredenzaTitle>
<CredenzaDescription>
{t("editLabelDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<OrgLabelForm
defaultValue={label}
onSubmit={(data) => {
startTransition(async () => editOrgLabel(data));
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="org-label-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("labelEdit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -122,7 +122,7 @@ export default function ExitNodesTable({
},
{
accessorKey: "online",
friendlyName: t("status"),
friendlyName: t("online"),
header: ({ column }) => {
return (
<Button
@@ -131,7 +131,7 @@ export default function ExitNodesTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
{t("online")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);

View File

@@ -109,6 +109,7 @@ export default function HealthChecksTable({
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [resourceFilterOpen, setResourceFilterOpen] = useState(false);
const pageSize = pagination.pageSize;
const query = searchParams.get("query") ?? undefined;
const siteIdQ = searchParams.get("siteId");
@@ -163,12 +164,12 @@ export default function HealthChecksTable({
});
}
// useEffect(() => {
// const interval = setInterval(() => {
// router.refresh();
// }, 30_000);
// return () => clearInterval(interval);
// }, [router]);
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 30_000);
return () => clearInterval(interval);
}, [router]);
const handlePaginationChange = (newState: PaginationState) => {
searchParams.set("page", (newState.pageIndex + 1).toString());
@@ -585,9 +586,7 @@ export default function HealthChecksTable({
<Switch
checked={r.hcEnabled}
disabled={
!isPaid ||
togglingId === r.targetHealthCheckId ||
!!r.resourceId
!isPaid || togglingId === r.targetHealthCheckId || !!r.resourceId
}
onCheckedChange={(v) => handleToggleEnabled(r, v)}
/>

View File

@@ -1,207 +0,0 @@
"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>
);
}

View File

@@ -5,7 +5,6 @@ import type { SidebarNavSection } from "@app/app/navigation";
import { LayoutSidebar } from "@app/components/LayoutSidebar";
import { LayoutHeader } from "@app/components/LayoutHeader";
import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu";
import { CommandPaletteProvider } from "@app/components/command-palette/CommandPaletteProvider";
import { cookies } from "next/headers";
interface LayoutProps {
@@ -38,53 +37,51 @@ export async function Layout({
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
return (
<CommandPaletteProvider orgId={orgId} orgs={orgs} navItems={navItems}>
<div className="flex h-screen-safe overflow-hidden">
{/* Desktop Sidebar */}
{showSidebar && (
<LayoutSidebar
<div className="flex h-screen-safe overflow-hidden">
{/* Desktop Sidebar */}
{showSidebar && (
<LayoutSidebar
orgId={orgId}
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}
orgs={orgs}
navItems={navItems}
defaultSidebarCollapsed={initialSidebarCollapsed}
hasCookiePreference={hasCookiePreference}
showSidebar={showSidebar}
showTopBar={showTopBar}
/>
)}
{/* 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}
orgs={orgs}
navItems={navItems}
showSidebar={showSidebar}
showTopBar={showTopBar}
/>
)}
{/* Desktop header */}
{showHeader && <LayoutHeader showTopBar={showTopBar} />}
{/* Desktop header */}
{showHeader && <LayoutHeader showTopBar={showTopBar} />}
{/* Main content */}
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div
className={cn(
"container mx-auto max-w-12xl mb-12",
showHeader && "md:pt-14" // Add top padding only on desktop to account for fixed header
)}
>
{children}
</div>
</main>
</div>
{/* Main content */}
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div
className={cn(
"container mx-auto max-w-12xl mb-12",
showHeader && "md:pt-14" // Add top padding only on desktop to account for fixed header
)}
>
{children}
</div>
</main>
</div>
</CommandPaletteProvider>
</div>
);
}

View File

@@ -6,7 +6,6 @@ import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { useTheme } from "next-themes";
import BrandingLogo from "./BrandingLogo";
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
@@ -68,7 +67,6 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
{showTopBar && (
<div className="flex items-center space-x-2">
<CommandPaletteTrigger />
<ThemeSwitcher />
<ProfileIcon />
</div>

View File

@@ -13,7 +13,6 @@ import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
import type { SidebarNavSection } from "@app/app/navigation";
import {
Sheet,
@@ -122,7 +121,6 @@ export function LayoutMobileMenu({
{showTopBar && (
<div className="ml-auto flex items-center justify-end">
<div className="flex items-center space-x-2">
<CommandPaletteTrigger variant="mobile" />
<ThemeSwitcher />
<ProfileIcon />
</div>

View File

@@ -28,7 +28,6 @@ import {
ChevronRight,
Download,
Loader,
LoaderIcon,
RefreshCw
} from "lucide-react";
import { useTranslations } from "next-intl";
@@ -428,7 +427,7 @@ export function LogDataTable<TData, TValue>({
)}
</div>
</CardHeader>
<CardContent className="relative">
<CardContent>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -536,19 +535,6 @@ export function LogDataTable<TData, TValue>({
)}
</TableBody>
</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">
<DataTablePagination
table={table}

View File

@@ -2,7 +2,7 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,40 +10,29 @@ import {
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon,
ChevronsUpDownIcon,
CircleSlash,
ArrowUpDown,
MoreHorizontal,
CircleSlash,
ArrowDown01Icon,
ArrowUp10Icon,
ChevronsUpDownIcon
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
startTransition,
useMemo,
useState,
useTransition
} from "react";
import { useMemo, useState, useTransition } from "react";
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 z from "zod";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
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 = {
id: number;
@@ -64,11 +53,6 @@ export type ClientRow = {
archived?: boolean;
blocked?: boolean;
approvalState: "approved" | "pending" | "denied";
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
};
type ClientTableProps = {
@@ -100,21 +84,17 @@ export default function MachineClientsTable({
);
const api = createApiClient(useEnvContext());
const [isRefreshing, startRefreshTransition] = useTransition();
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
const defaultMachineColumnVisibility = {
subnet: false,
userId: false,
niceId: false,
labels: false
niceId: false
};
const refreshData = () => {
startRefreshTransition(() => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
@@ -274,7 +254,7 @@ export default function MachineClientsTable({
},
{
accessorKey: "online",
friendlyName: t("status"),
friendlyName: t("online"),
header: () => {
return (
<ColumnFilterButton
@@ -296,7 +276,7 @@ export default function MachineClientsTable({
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("status")}
label={t("online")}
className="p-3"
/>
);
@@ -404,30 +384,6 @@ export default function MachineClientsTable({
}
];
if (isLabelFeatureEnabled) {
baseColumns.push({
id: "labels",
accessorKey: "labels",
header: () => (
<LabelColumnFilterButton
orgId={orgId}
selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
),
cell: ({ row }: { row: { original: ClientRow } }) => (
<MachineClientLabelCell
client={row.original}
orgId={orgId}
/>
)
});
}
// Only include actions column if there are rows without userIds
if (hasRowsWithoutUserId) {
baseColumns.push({
@@ -508,7 +464,12 @@ export default function MachineClientsTable({
}
return baseColumns;
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]);
}, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
function handleFilterChange(
column: string,
@@ -580,7 +541,6 @@ export default function MachineClientsTable({
rows={machineClients}
tableId="machine-clients"
searchPlaceholder={t("machinesSearch")}
searchQuery={searchParams.get("query")?.toString()}
onAdd={() =>
startNavigation(() =>
router.push(`/${orgId}/settings/clients/machine/create`)
@@ -598,65 +558,36 @@ export default function MachineClientsTable({
columnVisibility={defaultMachineColumnVisibility}
stickyLeftColumn="name"
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")
}
]}
/>
</>
);
}
type MachineClientLabelCellProps = {
client: ClientRow;
orgId: string;
};
function MachineClientLabelCell({
client,
orgId
}: MachineClientLabelCellProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [localLabels, setLocalLabels] = useLocalLabels(client.labels, client.id);
function toggleClientLabel(
label: SelectedLabel,
action: "attach" | "detach"
) {
const previousLabels = localLabels;
void (async () => {
try {
if (action === "attach") {
setLocalLabels([...previousLabels, label]);
await api.put(
`/org/${orgId}/label/${label.labelId}/attach`,
{ clientId: client.id }
);
} else {
setLocalLabels(
previousLabels.filter(
(lb) => lb.labelId !== label.labelId
)
);
await api.put(
`/org/${orgId}/label/${label.labelId}/detach`,
{ clientId: client.id }
);
}
} catch (e) {
setLocalLabels(previousLabels);
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
})();
}
return (
<TableLabelsCell
orgId={orgId}
localLabels={localLabels}
toggleLabel={toggleClientLabel}
/>
);
}

View File

@@ -1,128 +0,0 @@
"use client";
import z from "zod";
import { Input } from "./ui/input";
import { useTranslations } from "use-intl";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "./ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "./ui/select";
import { LABEL_COLORS } from "./labels-selector";
const labelFormSchema = z.object({
name: z.string().nonempty(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty()
});
export type LabelFormData = z.infer<typeof labelFormSchema>;
export type OrgLabelFormProps = {
onSubmit: (data: LabelFormData) => void;
defaultValue?: LabelFormData;
};
export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
const t = useTranslations();
const colorValues = Object.values(LABEL_COLORS);
const randomColor =
colorValues[Math.floor(Math.random() * colorValues.length)];
const form = useForm({
resolver: zodResolver(labelFormSchema),
defaultValues: {
name: defaultValue?.name ?? "",
color: defaultValue?.color ?? randomColor
}
});
return (
<Form {...form}>
<form
id="org-label-form"
className="flex flex-col gap-4 px-0.5"
action={async () => {
if (await form.trigger()) {
onSubmit(form.getValues());
}
}}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("labelNameField")}</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel>{t("labelColorField")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("selectColor")}
/>
</SelectTrigger>
<SelectContent>
{Object.entries(LABEL_COLORS).map(
([color, value]) => (
<SelectItem
value={value}
key={color}
className="flex items-center gap-2"
>
<div
className="size-2 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": value
}}
/>
<span data-name>
{color.charAt(0).toUpperCase() +
color.slice(1)}
</span>
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}

View File

@@ -1,239 +0,0 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { type PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ChevronsUpDownIcon,
MoreHorizontal,
PencilIcon,
PencilLineIcon
} from "lucide-react";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "next/navigation";
import { useActionState, useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "./ui/controlled-data-table";
import { LabelBadge } from "./label-badge";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { cn } from "@app/lib/cn";
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog";
import { EditOrgLabelDialog } from "./EditOrgLabelDialog";
export type LabelRow = {
labelId: number;
name: string;
color: string;
};
type OrgLabelsTableProps = {
labels: LabelRow[];
pagination: PaginationState;
orgId: string;
rowCount: number;
};
export default function OrgLabelsTable({
labels,
orgId,
pagination,
rowCount
}: OrgLabelsTableProps) {
const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [selectedLabel, setSelectedLabel] = useState<LabelRow | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition();
const api = createApiClient(useEnvContext());
const t = useTranslations();
function refreshData() {
startTransition(async () => {
try {
router.refresh();
} catch {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({ searchParams });
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({ searchParams });
}, 300);
const columns = useMemo<ExtendedColumnDef<LabelRow>[]>(
() => [
{
accessorKey: "name",
enableHiding: false,
header: () => {
return <span className="p-3">{t("name")}</span>;
},
cell: ({ row }) => (
<div className="flex items-center gap-1.5 group">
<div
className="size-2.5 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": row.original.color
}}
/>
{row.original.name}
</div>
)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t("openMenu")}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedLabel(row.original);
setIsEditModalOpen(true);
}}
>
{t("edit")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedLabel(row.original);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
],
[searchParams, t]
);
function deleteLabel(label: LabelRow) {
startTransition(async () => {
await api
.delete(`/org/${orgId}/label/${label.labelId}`)
.catch((e) => {
toast({
variant: "destructive",
title: t("labelErrorDelete"),
description: formatAxiosError(e, t("labelErrorDelete"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
}
return (
<>
{selectedLabel && (
<>
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedLabel(null);
}}
dialog={
<div className="space-y-2">
<p>{t("labelQuestionRemove")}</p>
<p>{t("labelMessageRemove")}</p>
</div>
}
buttonText={t("labelDeleteConfirm")}
onConfirm={async () => deleteLabel(selectedLabel)}
string={selectedLabel.name}
title={t("labelDelete")}
/>
<EditOrgLabelDialog
open={isEditModalOpen}
setOpen={setIsEditModalOpen}
orgId={orgId}
onSuccess={() =>
startTransition(() => router.refresh())
}
label={selectedLabel}
/>
</>
)}
<CreateOrgLabelDialog
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
orgId={orgId}
onSuccess={() => startTransition(() => router.refresh())}
/>
<ControlledDataTable
columns={columns}
rows={labels}
addButtonText={t("labelAdd")}
onAdd={() => setIsCreateModalOpen(true)}
tableId="org-labels-table"
searchPlaceholder={t("labelSearch")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
rowCount={rowCount}
stickyRightColumn="actions"
/>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -36,7 +36,15 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<Alert>
<AlertDescription>
{/* 4 cols because of the certs */}
<InfoSections cols={resource.http && build != "oss" ? 5 : 4}>
<InfoSections cols={resource.http && build != "oss" ? 6 : 5}>
<InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent>
<span className="inline-flex items-center">
{resource.niceId}
</span>
</InfoSectionContent>
</InfoSection>
{resource.http ? (
<>
<InfoSection>

View File

@@ -46,20 +46,6 @@ function toSshSudoMode(value: string | null | undefined): SshSudoMode {
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 = {
name: string;
description?: string;
@@ -88,33 +74,19 @@ export function RoleForm({
const { isPaidUser } = usePaidStatus();
const { env } = useEnvContext();
const formSchema = z
.object({
name: z
.string({ message: t("nameRequired") })
.min(1)
.max(32),
description: z.string().max(255).optional(),
requireDeviceApproval: z.boolean().optional(),
allowSsh: z.boolean().optional(),
sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES),
sshSudoCommands: 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 formSchema = z.object({
name: z
.string({ message: t("nameRequired") })
.min(1)
.max(32),
description: z.string().max(255).optional(),
requireDeviceApproval: z.boolean().optional(),
allowSsh: z.boolean().optional(),
sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES),
sshSudoCommands: z.string().optional(),
sshCreateHomeDir: z.boolean().optional(),
sshUnixGroups: z.string().optional()
});
const defaultValues: RoleFormValues = role
? {
@@ -324,9 +296,7 @@ export function RoleForm({
control={form.control}
name="allowSsh"
render={({ field }) => {
const allowSshOptions: OptionSelectOption<
"allow" | "disallow"
>[] = [
const allowSshOptions: OptionSelectOption<"allow" | "disallow">[] = [
{
value: "allow",
label: t("roleAllowSshAllow")
@@ -341,9 +311,7 @@ export function RoleForm({
<FormLabel>
{t("roleAllowSsh")}
</FormLabel>
<OptionSelect<
"allow" | "disallow"
>
<OptionSelect<"allow" | "disallow">
options={allowSshOptions}
value={
sshDisabled
@@ -354,9 +322,7 @@ export function RoleForm({
}
onChange={(v) => {
if (sshDisabled) return;
field.onChange(
v === "allow"
);
field.onChange(v === "allow");
}}
cols={2}
disabled={sshDisabled}

View File

@@ -61,7 +61,8 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
return (
<Alert>
<AlertDescription>
<InfoSections cols={site.endpoint ? 4 : 3}>
<InfoSections cols={site.endpoint ? 5 : 4}>
{identifierSection}
{statusSection}
<InfoSection>
<InfoSectionTitle>

View File

@@ -3,16 +3,6 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import UptimeMiniBar from "@app/components/UptimeMiniBar";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import {
@@ -24,9 +14,9 @@ import {
import { InfoPopup } from "@app/components/ui/info-popup";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build";
import { type PaginationState } from "@tanstack/react-table";
import {
@@ -36,34 +26,30 @@ import {
ArrowUpRight,
ChevronDown,
ChevronsUpDownIcon,
MoreHorizontal,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
startTransition,
useEffect,
useMemo,
useState,
useTransition
} from "react";
import { useState, useTransition, useEffect } from "react";
import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "./ui/controlled-data-table";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { cn } from "@app/lib/cn";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { type SelectedLabel } from "./labels-selector";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { useLocalLabels } from "@app/hooks/useLocalLabels";
import { TableLabelsCell } from "./TableLabelsCell";
export type SiteRow = {
id: number;
nice: string;
@@ -80,11 +66,6 @@ export type SiteRow = {
exitNodeEndpoint?: string;
remoteExitNodeId?: string;
resourceCount: number;
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
};
type SitesTableProps = {
@@ -115,18 +96,15 @@ export default function SitesTable({
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
const api = createApiClient(useEnvContext());
const t = useTranslations();
// useEffect(() => {
// const interval = setInterval(() => {
// router.refresh();
// }, 30_000);
// return () => clearInterval(interval);
// }, []);
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 30_000);
return () => clearInterval(interval);
}, []);
const booleanSearchFilterSchema = z
.enum(["true", "false"])
@@ -135,16 +113,14 @@ export default function SitesTable({
function handleFilterChange(
column: string,
value: string | undefined | null | string[]
value: string | undefined | null
) {
const sp = new URLSearchParams(searchParams);
sp.delete(column);
sp.delete("page");
if (typeof value === "string") {
if (value) {
sp.set(column, value);
} else if (value) {
value.forEach((val) => sp.append(column, val));
}
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
}
@@ -182,384 +158,361 @@ export default function SitesTable({
});
}
const columns = useMemo<ExtendedColumnDef<SiteRow>[]>(() => {
const cols: ExtendedColumnDef<SiteRow>[] = [
{
accessorKey: "name",
enableHiding: false,
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
const columns: ExtendedColumnDef<SiteRow>[] = [
{
accessorKey: "name",
enableHiding: false,
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "nice",
friendlyName: t("identifier"),
enableHiding: true,
header: () => {
return <span className="p-3">{t("identifier")}</span>;
},
{
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>;
}
},
{
accessorKey: "online",
friendlyName: t("status"),
header: () => {
return (
<ColumnFilterButton
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>
);
cell: ({ row }) => {
return <span>{row.original.nice || "-"}</span>;
}
},
{
accessorKey: "online",
friendlyName: t("online"),
header: () => {
return (
<ColumnFilterButton
options={[
{ value: "true", label: t("online") },
{ value: "false", label: t("offline") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("online")
)}
onValueChange={(value) =>
handleFilterChange("online", value)
}
} else {
return <span>-</span>;
}
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("online")}
className="p-3"
/>
);
},
{
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") {
cell: ({ row }) => {
const originalRow = row.original;
if (
originalRow.type == "newt" ||
originalRow.type == "wireguard"
) {
if (originalRow.online) {
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-1">
<span>Newt</span>
{originalRow.newtVersion && (
<span>
v{originalRow.newtVersion}
</span>
)}
</div>
</Badge>
{originalRow.newtUpdateAvailable && (
<InfoPopup
info={t("newtUpdateAvailableInfo")}
/>
)}
</div>
);
}
if (originalRow.type === "wireguard") {
return (
<div className="flex items-center space-x-2">
<Badge variant="secondary">WireGuard</Badge>
</div>
);
}
if (originalRow.type === "local") {
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-0 font-normal"
>
<span className="text-sm tabular-nums">
{siteRow.resourceCount} {t("resources")}
<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>
<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();
);
} else {
return (
<Badge variant="secondary">
Pangolin {capitalizedName}
</Badge>
<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>
);
}
// 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>;
} else {
return <span>-</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: "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>;
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const siteRow = row.original;
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.type === "newt") {
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>
</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 className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-1">
<span>Newt</span>
{originalRow.newtVersion && (
<span>v{originalRow.newtVersion}</span>
)}
</div>
</Badge>
{originalRow.newtUpdateAvailable && (
<InfoPopup
info={t("newtUpdateAvailableInfo")}
/>
)}
</div>
);
}
if (originalRow.type === "wireguard") {
return (
<div className="flex items-center space-x-2">
<Badge variant="secondary">WireGuard</Badge>
</div>
);
}
if (originalRow.type === "local") {
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-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 "-";
}
if (isLabelFeatureEnabled) {
cols.splice(cols.length - 1, 0, {
accessorKey: "labels",
header: () => (
<LabelColumnFilterButton
orgId={orgId}
selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
),
cell: ({ row }: { row: { original: SiteRow } }) => (
<SiteLabelCell site={row.original} orgId={orgId} />
)
});
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 }: { row: any }) => {
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>
</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>
<DropdownMenuItem
onClick={() => {
setSelectedSite(siteRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</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>
);
}
}
return cols;
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
];
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
@@ -669,8 +622,7 @@ export default function SitesTable({
niceId: false,
nice: false,
exitNode: false,
address: false,
labels: false
address: false
}}
enableColumnVisibility
stickyLeftColumn="name"
@@ -679,61 +631,3 @@ export default function SitesTable({
</>
);
}
type SiteLabelCellProps = {
site: SiteRow;
orgId: string;
};
function SiteLabelCell({ site, orgId }: SiteLabelCellProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [localLabels, setLocalLabels] = useLocalLabels(site.labels, site.id);
function toggleSiteLabel(
label: SelectedLabel,
action: "attach" | "detach"
) {
const previousLabels = localLabels;
void (async () => {
try {
if (action === "attach") {
setLocalLabels([...previousLabels, label]);
await api.put(
`/org/${orgId}/label/${label.labelId}/attach`,
{ siteId: site.id }
);
} else {
setLocalLabels(
previousLabels.filter(
(lb) => lb.labelId !== label.labelId
)
);
await api.put(
`/org/${orgId}/label/${label.labelId}/detach`,
{ siteId: site.id }
);
}
} catch (e) {
setLocalLabels(previousLabels);
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
})();
}
return (
<TableLabelsCell
orgId={orgId}
localLabels={localLabels}
toggleLabel={toggleSiteLabel}
/>
);
}

View File

@@ -1,107 +0,0 @@
"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>
);
}

View File

@@ -405,7 +405,7 @@ export default function UserDevicesTable({
},
{
accessorKey: "online",
friendlyName: t("status"),
friendlyName: t("connected"),
header: () => {
return (
<ColumnFilterButton
@@ -427,7 +427,7 @@ export default function UserDevicesTable({
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("status")}
label={t("connected")}
className="p-3"
/>
);
@@ -794,7 +794,6 @@ export default function UserDevicesTable({
columnVisibility={defaultUserColumnVisibility}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
pagination={pagination}
rowCount={rowCount}
stickyLeftColumn="name"

View File

@@ -1,270 +0,0 @@
"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>
);
}

View File

@@ -1,76 +0,0 @@
"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>
);
}

View File

@@ -1,69 +0,0 @@
"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>
);
}

View File

@@ -1,37 +0,0 @@
"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;
}

View File

@@ -1,161 +0,0 @@
"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]);
}

View File

@@ -1,57 +0,0 @@
"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]);
}

Some files were not shown because too many files have changed in this diff Show More