Compare commits

..

1 Commits

Author SHA1 Message Date
Fred KISSIE d2936cd10b 🚧 server admin 2026-09-02 19:19:03 +02:00
16 changed files with 487 additions and 287 deletions
+9 -1
View File
@@ -1424,6 +1424,15 @@
"logoutError": "Error logging out", "logoutError": "Error logging out",
"signingAs": "Signed in as", "signingAs": "Signed in as",
"serverAdmin": "Server Admin", "serverAdmin": "Server Admin",
"promoteServerAdmin": "Promote to Server admin",
"promoteServerAdminTitle": "Promote to Server Admin",
"promoteServerAdminQuestion": "Are you sure you want to promote {selectedUser} to server admin?",
"promoteServerAdminMessage": "Server admins have full access to every organization, user, and setting on this instance.",
"promoteServerAdminWarning": "You cannot demote a server admin from this page.",
"promoteServerAdminConfirm": "Promote to server admin",
"promoteServerAdminSuccess": "User promoted",
"promoteServerAdminSuccessDescription": "{selectedUser} is now a server admin.",
"promoteServerAdminError": "Failed to promote user",
"managedSelfhosted": "Managed Self-Hosted", "managedSelfhosted": "Managed Self-Hosted",
"otpEnable": "Enable Two-factor", "otpEnable": "Enable Two-factor",
"otpDisable": "Disable Two-factor", "otpDisable": "Disable Two-factor",
@@ -2717,7 +2726,6 @@
"healthScheme": "Method", "healthScheme": "Method",
"healthSelectScheme": "Select Method", "healthSelectScheme": "Select Method",
"healthCheckPortInvalid": "Port must be between 1 and 65535", "healthCheckPortInvalid": "Port must be between 1 and 65535",
"healthCheckHostnameInvalid": "Hostname must not contain whitespace",
"healthCheckPath": "Path", "healthCheckPath": "Path",
"healthHostname": "IP / Host", "healthHostname": "IP / Host",
"healthPort": "Port", "healthPort": "Port",
+1 -85
View File
@@ -26,9 +26,7 @@ import {
sites, sites,
clients, clients,
sessions, sessions,
labels, labels
aiProviders,
virtualApiKeys
} from "./schema"; } from "./schema";
export const dnsChallenge = pgTable("dnsChallenges", { export const dnsChallenge = pgTable("dnsChallenges", {
@@ -616,87 +614,6 @@ export const trialNotifications = pgTable("trialNotifications", {
sentAt: bigint("sentAt", { mode: "number" }).notNull() sentAt: bigint("sentAt", { mode: "number" }).notNull()
}); });
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = pgTable(
"aiSessionLog",
{
id: serial("id").primaryKey(),
sessionId: varchar("sessionId").notNull(),
orgId: varchar("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
capability: varchar("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: varchar("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: varchar("requestedModel"),
isStream: boolean("isStream").notNull().default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: boolean("truncated").notNull().default(false),
statusCode: integer("statusCode"),
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch seconds
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -743,4 +660,3 @@ export type AlertEmailRecipients = InferSelectModel<
>; >;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>; export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>; export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
+81 -1
View File
@@ -1,4 +1,3 @@
import { aiSessionLog } from "@server/db/sqlite";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { InferSelectModel, sql } from "drizzle-orm"; import { InferSelectModel, sql } from "drizzle-orm";
import { import {
@@ -1959,6 +1958,87 @@ export const aiBudgetBreachEvents = pgTable(
] ]
); );
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = pgTable(
"aiSessionLog",
{
id: serial("id").primaryKey(),
sessionId: varchar("sessionId").notNull(),
orgId: varchar("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
capability: varchar("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: varchar("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: varchar("requestedModel"),
isStream: boolean("isStream").notNull().default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: boolean("truncated").notNull().default(false),
statusCode: integer("statusCode"),
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch seconds
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
export const certificates = pgTable("certificates", { export const certificates = pgTable("certificates", {
certId: serial("certId").primaryKey(), certId: serial("certId").primaryKey(),
domain: varchar("domain", { length: 255 }).notNull().unique(), domain: varchar("domain", { length: 255 }).notNull().unique(),
+1 -89
View File
@@ -9,7 +9,6 @@ import {
uniqueIndex uniqueIndex
} from "drizzle-orm/sqlite-core"; } from "drizzle-orm/sqlite-core";
import { import {
aiProviders,
clients, clients,
domains, domains,
exitNodes, exitNodes,
@@ -21,8 +20,7 @@ import {
siteResources, siteResources,
sites, sites,
targetHealthCheck, targetHealthCheck,
users, users
virtualApiKeys
} from "./schema"; } from "./schema";
export const dnsChallenge = sqliteTable("dnsChallenges", { export const dnsChallenge = sqliteTable("dnsChallenges", {
@@ -611,91 +609,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
sentAt: integer("sentAt").notNull() sentAt: integer("sentAt").notNull()
}); });
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = sqliteTable(
"aiSessionLog",
{
id: integer("id").primaryKey({ autoIncrement: true }),
sessionId: text("sessionId").notNull(),
orgId: text("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
capability: text("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: text("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: text("requestedModel"),
isStream: integer("isStream", { mode: "boolean" })
.notNull()
.default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: integer("truncated", { mode: "boolean" })
.notNull()
.default(false),
statusCode: integer("statusCode"),
createdAt: integer("createdAt").notNull() // epoch seconds
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -734,4 +647,3 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>; export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>; export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>; export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
+118 -34
View File
@@ -147,7 +147,9 @@ export const sites = sqliteTable(
.$type<"pending" | "approved">() .$type<"pending" | "approved">()
.default("approved") .default("approved")
}, },
(table) => [index("idx_sites_orgId").on(table.orgId)] (table) => [
index("idx_sites_orgId").on(table.orgId)
]
); );
export const resources = sqliteTable( export const resources = sqliteTable(
@@ -190,9 +192,7 @@ export const resources = sqliteTable(
mode: "boolean" mode: "boolean"
}), }),
applyRules: integer("applyRules", { mode: "boolean" }), applyRules: integer("applyRules", { mode: "boolean" }),
enabled: integer("enabled", { mode: "boolean" }) enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
.notNull()
.default(true),
stickySession: integer("stickySession", { mode: "boolean" }) stickySession: integer("stickySession", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
@@ -220,14 +220,10 @@ export const resources = sqliteTable(
maintenanceEstimatedTime: text("maintenanceEstimatedTime"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }) wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
.notNull()
.default(false),
mode: text("mode") mode: text("mode")
.default("http") .default("http")
.$type< .$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"
>()
.notNull(), // rdp, ssh, http, vnc, inference .notNull(), // rdp, ssh, http, vnc, inference
pamMode: text("pamMode") pamMode: text("pamMode")
.$type<"passthrough" | "push">() .$type<"passthrough" | "push">()
@@ -240,7 +236,9 @@ export const resources = sqliteTable(
.$type<"pending" | "approved">() .$type<"pending" | "approved">()
.default("approved") .default("approved")
}, },
(table) => [index("idx_resources_orgId").on(table.orgId)] (table) => [
index("idx_resources_orgId").on(table.orgId)
]
); );
export const resourceAiProviders = sqliteTable( export const resourceAiProviders = sqliteTable(
@@ -290,7 +288,9 @@ export const labels = sqliteTable(
}) })
.notNull() .notNull()
}, },
(table) => [index("idx_labels_orgId").on(table.orgId)] (table) => [
index("idx_labels_orgId").on(table.orgId)
]
); );
export const launcherViews = sqliteTable("launcherViews", { export const launcherViews = sqliteTable("launcherViews", {
@@ -409,9 +409,7 @@ export const targets = sqliteTable(
method: text("method"), method: text("method"),
port: integer("port").notNull(), port: integer("port").notNull(),
internalPort: integer("internalPort"), internalPort: integer("internalPort"),
enabled: integer("enabled", { mode: "boolean" }) enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
.notNull()
.default(true),
path: text("path"), path: text("path"),
pathMatchType: text("pathMatchType"), // exact, prefix, regex pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
@@ -707,7 +705,9 @@ export const newts = sqliteTable(
onDelete: "cascade" onDelete: "cascade"
}) })
}, },
(table) => [index("idx_newts_siteId").on(table.siteId)] (table) => [
index("idx_newts_siteId").on(table.siteId)
]
); );
export const clients = sqliteTable( export const clients = sqliteTable(
@@ -740,12 +740,8 @@ export const clients = sqliteTable(
online: integer("online", { mode: "boolean" }).notNull().default(false), online: integer("online", { mode: "boolean" }).notNull().default(false),
// endpoint: text("endpoint"), // endpoint: text("endpoint"),
lastHolePunch: integer("lastHolePunch"), lastHolePunch: integer("lastHolePunch"),
archived: integer("archived", { mode: "boolean" }) archived: integer("archived", { mode: "boolean" }).notNull().default(false),
.notNull() blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
.default(false),
blocked: integer("blocked", { mode: "boolean" })
.notNull()
.default(false),
approvalState: text("approvalState").$type< approvalState: text("approvalState").$type<
"pending" | "approved" | "denied" "pending" | "approved" | "denied"
>() >()
@@ -799,11 +795,11 @@ export const olms = sqliteTable(
// optionally tied to a user and in this case delete when the user deletes // optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade" onDelete: "cascade"
}), }),
archived: integer("archived", { mode: "boolean" }) archived: integer("archived", { mode: "boolean" }).notNull().default(false)
.notNull()
.default(false)
}, },
(table) => [index("idx_olms_userId").on(table.userId)] (table) => [
index("idx_olms_userId").on(table.userId)
]
); );
export const currentFingerprint = sqliteTable("currentFingerprint", { export const currentFingerprint = sqliteTable("currentFingerprint", {
@@ -979,7 +975,9 @@ export const sessions = sqliteTable(
.notNull() .notNull()
.default(false) .default(false)
}, },
(table) => [index("idx_sessions_userId").on(table.userId)] (table) => [
index("idx_sessions_userId").on(table.userId)
]
); );
export const newtSessions = sqliteTable("newtSession", { export const newtSessions = sqliteTable("newtSession", {
@@ -1009,9 +1007,7 @@ export const userOrgs = sqliteTable(
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
isOwner: integer("isOwner", { mode: "boolean" }) isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
.notNull()
.default(false),
autoProvisioned: integer("autoProvisioned", { autoProvisioned: integer("autoProvisioned", {
mode: "boolean" mode: "boolean"
}).default(false), }).default(false),
@@ -1066,12 +1062,14 @@ export const roles = sqliteTable(
}).default(false), }).default(false),
sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands" sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"), sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: integer("sshCreateHomeDir", { sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
mode: "boolean" true
}).default(true), ),
sshUnixGroups: text("sshUnixGroups").default("[]") sshUnixGroups: text("sshUnixGroups").default("[]")
}, },
(table) => [index("idx_roles_orgId").on(table.orgId)] (table) => [
index("idx_roles_orgId").on(table.orgId)
]
); );
export const userOrgRoles = sqliteTable( export const userOrgRoles = sqliteTable(
@@ -1999,6 +1997,91 @@ export const aiBudgetBreachEvents = sqliteTable(
] ]
); );
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = sqliteTable(
"aiSessionLog",
{
id: integer("id").primaryKey({ autoIncrement: true }),
sessionId: text("sessionId").notNull(),
orgId: text("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
capability: text("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: text("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: text("requestedModel"),
isStream: integer("isStream", { mode: "boolean" })
.notNull()
.default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: integer("truncated", { mode: "boolean" })
.notNull()
.default(false),
statusCode: integer("statusCode"),
createdAt: integer("createdAt").notNull() // epoch seconds
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
export const certificates = sqliteTable("certificates", { export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }), certId: integer("certId").primaryKey({ autoIncrement: true }),
domain: text("domain").notNull().unique(), domain: text("domain").notNull().unique(),
@@ -2109,6 +2192,7 @@ export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>; export type AiBudget = InferSelectModel<typeof aiBudgets>;
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>; export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>; export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>; export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
export type SiteResourceAiProvider = InferSelectModel< export type SiteResourceAiProvider = InferSelectModel<
typeof siteResourceAiProviders typeof siteResourceAiProviders
-12
View File
@@ -121,13 +121,6 @@ export async function applyBlueprint({
(hc) => hc.targetId === target.targetId (hc) => hc.targetId === target.targetId
); );
// The DB writes for all resources have already committed
// by this point, so a push failure for one target (e.g.
// a newt rejecting a malformed health check) must not
// abort pushing the rest, and must not mark the whole
// blueprint as failed when the config was actually
// persisted successfully.
try {
if (["http", "tcp", "udp"].includes(target.mode)) { if (["http", "tcp", "udp"].includes(target.mode)) {
await addProxyTargets( await addProxyTargets(
site.newt.newtId, site.newt.newtId,
@@ -149,11 +142,6 @@ export async function applyBlueprint({
site.newt.version site.newt.version
); );
} }
} catch (e) {
logger.error(
`Failed to push target ${target.targetId} to newt on site ${site.sites.siteId}. Error: ${e}`
);
}
} }
} }
} }
+1 -27
View File
@@ -29,34 +29,8 @@ export const SiteSchema = z.object({
"docker-socket-enabled": z.boolean().optional().default(true) "docker-socket-enabled": z.boolean().optional().default(true)
}); });
// A malformed hostname (e.g. stray whitespace) is silently accepted here but
// fails to parse as a URL when newt builds the health check request, which
// takes the target out of the routing pool and breaks the resource entirely
// (see #3677). Validate eagerly so blueprints reject it up front instead.
const healthCheckHostnameSchema = z
.string()
.trim()
.min(1)
.refine((val) => !/\s/.test(val), {
message: "Hostname must not contain whitespace"
})
.refine(
(val) => {
if (z.union([z.ipv4(), z.ipv6()]).safeParse(val).success) {
return true;
}
const hostnameRegex =
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
return hostnameRegex.test(val);
},
{
message:
"Hostname must be a valid IP address or hostname (no spaces or invalid characters)"
}
);
export const TargetHealthCheckSchema = z.object({ export const TargetHealthCheckSchema = z.object({
hostname: healthCheckHostnameSchema, hostname: z.string(),
port: z.int().min(1).max(65535), port: z.int().min(1).max(65535),
enabled: z.boolean().optional().default(true), enabled: z.boolean().optional().default(true),
path: z.string().optional().default("/"), path: z.string().optional().default("/"),
+6 -8
View File
@@ -158,7 +158,7 @@ class RedisManager {
this.writeClient = new Redis({ this.writeClient = new Redis({
...masterConfig, ...masterConfig,
enableReadyCheck: false, enableReadyCheck: false,
maxRetriesPerRequest: 50, maxRetriesPerRequest: 3,
keepAlive: 30000, keepAlive: 30000,
connectTimeout: this.connectionTimeout, connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout commandTimeout: this.commandTimeout
@@ -169,7 +169,7 @@ class RedisManager {
this.readClient = new Redis({ this.readClient = new Redis({
...replicaConfig!, ...replicaConfig!,
enableReadyCheck: false, enableReadyCheck: false,
maxRetriesPerRequest: 50, maxRetriesPerRequest: 3,
keepAlive: 30000, keepAlive: 30000,
connectTimeout: this.connectionTimeout, connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout commandTimeout: this.commandTimeout
@@ -186,7 +186,7 @@ class RedisManager {
this.publisher = new Redis({ this.publisher = new Redis({
...masterConfig, ...masterConfig,
enableReadyCheck: false, enableReadyCheck: false,
maxRetriesPerRequest: 50, maxRetriesPerRequest: 3,
keepAlive: 30000, keepAlive: 30000,
connectTimeout: this.connectionTimeout, connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout commandTimeout: this.commandTimeout
@@ -196,7 +196,7 @@ class RedisManager {
this.subscriber = new Redis({ this.subscriber = new Redis({
...(this.hasReplicas ? replicaConfig! : masterConfig), ...(this.hasReplicas ? replicaConfig! : masterConfig),
enableReadyCheck: false, enableReadyCheck: false,
maxRetriesPerRequest: 50, maxRetriesPerRequest: 3,
keepAlive: 30000, keepAlive: 30000,
connectTimeout: this.connectionTimeout, connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout commandTimeout: this.commandTimeout
@@ -901,9 +901,7 @@ class RegionalRedisManager {
// if the configured host doesn't match that pattern (e.g. local dev), // if the configured host doesn't match that pattern (e.g. local dev),
// in which case callers should fall back to the primary for reads. // in which case callers should fall back to the primary for reads.
private getReplicaHost(primaryHost: string): string | null { private getReplicaHost(primaryHost: string): string | null {
const match = primaryHost.match( const match = primaryHost.match(/^redis\.([^.]+)\.svc\.cluster\.local$/);
/^redis\.([^.]+)\.svc\.cluster\.local$/
);
if (!match) return null; if (!match) return null;
const namespace = match[1]; const namespace = match[1];
return `redis-1.redis-headless.${namespace}.svc.cluster.local`; return `redis-1.redis-headless.${namespace}.svc.cluster.local`;
@@ -914,7 +912,7 @@ class RegionalRedisManager {
const baseOpts = { const baseOpts = {
...cfg, ...cfg,
enableReadyCheck: false, enableReadyCheck: false,
maxRetriesPerRequest: 50, maxRetriesPerRequest: 3,
keepAlive: 10000, keepAlive: 10000,
connectTimeout: this.connectionTimeout, connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout commandTimeout: this.commandTimeout
+6
View File
@@ -1378,6 +1378,12 @@ if (build !== "saas") {
user.adminGeneratePasswordResetCode user.adminGeneratePasswordResetCode
); );
authenticated.post(
"/user/:userId/promote-server-admin",
verifyUserIsServerAdmin,
user.adminPromoteServerAdmin
);
authenticated.delete( authenticated.delete(
"/user/:userId", "/user/:userId",
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
+2 -2
View File
@@ -4,7 +4,7 @@ import { db, idp, users } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -196,7 +196,7 @@ export async function adminListUsers(
} }
} }
const conditions = [eq(users.serverAdmin, false)]; const conditions: Array<SQL<unknown> | undefined> = [];
if (query) { if (query) {
const q = "%" + query.toLowerCase() + "%"; const q = "%" + query.toLowerCase() + "%";
@@ -0,0 +1,116 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
const promoteServerAdminParamsSchema = z.strictObject({
userId: z.string()
});
export type AdminPromoteServerAdminResponse = {
userId: string;
serverAdmin: boolean;
};
const AdminPromoteServerAdminResponseDataSchema = z.object({
userId: z.string(),
serverAdmin: z.boolean()
});
registry.registerPath({
method: "post",
path: "/user/{userId}/promote-server-admin",
description: "Promote a user to server admin (server admin).",
tags: [OpenAPITags.User],
request: {
params: promoteServerAdminParamsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
AdminPromoteServerAdminResponseDataSchema
)
}
}
}
}
});
export async function adminPromoteServerAdmin(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = promoteServerAdminParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { userId } = parsedParams.data;
const [existingUser] = await db
.select({
userId: users.userId,
serverAdmin: users.serverAdmin
})
.from(users)
.where(eq(users.userId, userId))
.limit(1);
if (!existingUser) {
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
}
if (existingUser.serverAdmin) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User is already a server admin"
)
);
}
logger.info(
`Promoting user ${userId} to server admin (by ${req.user?.userId})`
);
await db
.update(users)
.set({ serverAdmin: true })
.where(eq(users.userId, userId));
return response<AdminPromoteServerAdminResponse>(res, {
data: {
userId: existingUser.userId,
serverAdmin: true
},
success: true,
error: false,
message: "User promoted to server admin successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+1
View File
@@ -11,6 +11,7 @@ export * from "./adminListUsers";
export * from "./adminRemoveUser"; export * from "./adminRemoveUser";
export * from "./adminGetUser"; export * from "./adminGetUser";
export * from "./adminGeneratePasswordResetCode"; export * from "./adminGeneratePasswordResetCode";
export * from "./adminPromoteServerAdmin";
export * from "./listInvitations"; export * from "./listInvitations";
export * from "./removeInvitation"; export * from "./removeInvitation";
export * from "./createOrgUser"; export * from "./createOrgUser";
+3
View File
@@ -81,6 +81,9 @@ export default async function UsersPage(props: AdminUsersPageProps) {
}; };
}); });
console.log({
userRows
});
return ( return (
<> <>
<SettingsSectionTitle <SettingsSectionTitle
+120 -2
View File
@@ -19,7 +19,8 @@ import {
ArrowRight, ArrowRight,
ArrowUp10Icon, ArrowUp10Icon,
ChevronsUpDownIcon, ChevronsUpDownIcon,
MoreHorizontal MoreHorizontal,
ShieldUserIcon
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -43,6 +44,14 @@ import {
CredenzaClose CredenzaClose
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
import { Badge } from "./ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "./ui/tooltip";
import { useUserContext } from "@app/hooks/useUserContext";
export type GlobalUserRow = { export type GlobalUserRow = {
id: string; id: string;
@@ -90,6 +99,9 @@ export default function UsersTable({
const [passwordResetCodeData, setPasswordResetCodeData] = const [passwordResetCodeData, setPasswordResetCodeData] =
useState<AdminGeneratePasswordResetCodeResponse | null>(null); useState<AdminGeneratePasswordResetCodeResponse | null>(null);
const [isGeneratingCode, setIsGeneratingCode] = useState(false); const [isGeneratingCode, setIsGeneratingCode] = useState(false);
const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false);
const [promoting, setPromoting] = useState<GlobalUserRow | null>(null);
const user = useUserContext();
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const { const {
@@ -184,6 +196,37 @@ export default function UsersTable({
} }
}; };
const promoteToServerAdmin = async (user: GlobalUserRow) => {
try {
await api.post(`/user/${user.id}/promote-server-admin`);
toast({
title: t("promoteServerAdminSuccess"),
description: t("promoteServerAdminSuccessDescription", {
selectedUser: getUserDisplayName({
email: user.email,
name: user.name,
username: user.username
})
})
});
startTransition(() => {
router.refresh();
});
} catch (e) {
console.error(t("promoteServerAdminError"), e);
toast({
variant: "destructive",
title: t("promoteServerAdminError"),
description: formatAxiosError(e, t("promoteServerAdminError"))
});
} finally {
setIsPromoteModalOpen(false);
setPromoting(null);
}
};
function toggleSort(column: string) { function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams); const newSearch = getNextSortOrder(column, searchParams);
filter({ filter({
@@ -235,7 +278,35 @@ export default function UsersTable({
<Icon className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
); );
} },
cell: ({ row }) => (
<span className="inline-flex gap-1 items-center">
{row.original.username}{" "}
{row.original.id === user.user.userId && (
<>
<span className="text-muted-foreground">
&middot;
</span>{" "}
<span className="text-primary">you</span>
</>
)}
{row.original.serverAdmin && (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<ShieldUserIcon className="text-primary size-5 flex-none" />
</TooltipTrigger>
<TooltipContent>
{t("serverAdmin")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* <Badge>{t("serverAdmin")}</Badge> */}
</>
)}
</span>
)
}, },
{ {
accessorKey: "email", accessorKey: "email",
@@ -369,11 +440,22 @@ export default function UsersTable({
{t("generatePasswordResetCode")} {t("generatePasswordResetCode")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{!r.serverAdmin && (
<DropdownMenuItem
onClick={() => {
setPromoting(r);
setIsPromoteModalOpen(true);
}}
>
{t("promoteServerAdmin")}
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setSelected(r); setSelected(r);
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
}} }}
className="text-red-400"
> >
{t("delete")} {t("delete")}
</DropdownMenuItem> </DropdownMenuItem>
@@ -435,6 +517,42 @@ export default function UsersTable({
/> />
)} )}
{promoting && (
<ConfirmDeleteDialog
open={isPromoteModalOpen}
setOpen={(val) => {
setIsPromoteModalOpen(val);
if (!val) {
setPromoting(null);
}
}}
dialog={
<div className="space-y-2">
<p>
{t("promoteServerAdminQuestion", {
selectedUser: getUserDisplayName({
email: promoting.email,
name: promoting.name,
username: promoting.username
})
})}
</p>
<p>{t("promoteServerAdminMessage")}</p>
</div>
}
buttonText={t("promoteServerAdminConfirm")}
onConfirm={async () => promoteToServerAdmin(promoting)}
string={getUserDisplayName({
email: promoting.email,
name: promoting.name,
username: promoting.username
})}
warningText={t("promoteServerAdminWarning")}
title={t("promoteServerAdminTitle")}
/>
)}
<ControlledDataTable <ControlledDataTable
columns={columns} columns={columns}
rows={users} rows={users}
+1 -5
View File
@@ -172,6 +172,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
.nullable() .nullable()
.optional(), .optional(),
hcScheme: z.string().optional(), hcScheme: z.string().optional(),
hcHostname: z.string(),
hcPort: z hcPort: z
.string() .string()
.min(1, { message: t("healthCheckPortInvalid") }) .min(1, { message: t("healthCheckPortInvalid") })
@@ -183,11 +184,6 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
{ message: t("healthCheckPortInvalid") } { message: t("healthCheckPortInvalid") }
), ),
hcFollowRedirects: z.boolean(), hcFollowRedirects: z.boolean(),
hcHostname: z
.string()
.refine((val) => !/\s/.test(val), {
message: t("healthCheckHostnameInvalid")
}),
hcMode: z.string(), hcMode: z.string(),
hcUnhealthyInterval: z.int().positive().min(5), hcUnhealthyInterval: z.int().positive().min(5),
hcTlsServerName: z.string(), hcTlsServerName: z.string(),
@@ -155,13 +155,13 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
} }
/> />
<CommandList className="max-h-118 min-h-0 h-(--cmdk-list-height) scroll-pb-4 scroll-pt-2 transition-[height] duration-250 ease-in-out"> <CommandList className="max-h-118 min-h-0 h-(--cmdk-list-height) scroll-pb-4 scroll-pt-2 transition-[height] duration-250 ease-in-out">
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
<CommandGroup <CommandGroup
heading={t("commandActionModeInfo")} heading={t("commandActionModeInfo")}
className="[&_[cmdk-group-heading]]:text-sm" className="[&_[cmdk-group-heading]]:text-sm"
/> />
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
{!isActionMode && {!isActionMode &&
navigationGroups.map((group, groupIndex) => ( navigationGroups.map((group, groupIndex) => (
<React.Fragment key={group.heading}> <React.Fragment key={group.heading}>