Compare commits

...

17 Commits

Author SHA1 Message Date
Milo Schwartz
280cbb6e22 Merge pull request #2553 from LaurenceJJones/explore/static-org-dropdown
enhance(sidebar): make mobile org selector sticky
2026-03-01 11:14:16 -08:00
miloschwartz
c20babcb53 fix org selector spacing on mobile 2026-03-01 11:13:49 -08:00
Owen Schwartz
768eebe2cd Merge pull request #2432 from ChanningHe/feat-integration-api-domain-crud
feat(integration): add domain CRUD endpoints to integration API
2026-03-01 11:12:05 -08:00
Owen
66c377a5c9 Merge branch 'main' into dev 2026-02-28 12:14:41 -08:00
Owen
50c2aa0111 Add default memory limits 2026-02-28 12:14:27 -08:00
Owen
fdeb891137 Fix pagination effecting drop downs 2026-02-28 12:07:42 -08:00
Owen Schwartz
6a6e3a43b1 Merge pull request #2562 from LaurenceJJones/fix/zod-openapi-catch-error
fix(zod): Add openapi call after catch
2026-02-28 11:04:10 -08:00
Laurence
b0a34fa21b fix(openapi): Add openapi call after catch
fix: #2561
without making an explicit call to openapi a runtime error happens because it cannot infer the type, the call to openapi is the same across the codebase
2026-02-28 11:27:19 +00:00
Owen
72bf6f3c41 Comma seperated 2026-02-27 17:53:44 -08:00
miloschwartz
ad9289e0c1 sort by name by default 2026-02-27 15:53:27 -08:00
Owen Schwartz
b0cb0e5a99 Merge pull request #2559 from fosrl/dev
1.16.1
2026-02-27 12:40:23 -08:00
miloschwartz
8347203bbe add sort to name col 2026-02-27 12:39:26 -08:00
miloschwartz
4aa1186aed fix machine client pagination 2026-02-27 11:59:55 -08:00
Owen
eed87af61d Use ecr base to build 2026-02-26 21:43:14 -08:00
Owen
daeea8e7ea Add alises to quieries
Fixes #2556
2026-02-26 21:37:47 -08:00
Laurence
81c1a1da9c enhance(sidebar): make mobile org selector sticky
Make org selector sticky on mobile sidebar

  Move OrgSelector outside the scrollable container so it stays fixed
  at the top while menu items scroll, matching the desktop sidebar
  behavior introduced in 9b2c0d0b.
2026-02-26 15:45:41 +00:00
ChanningHe
52f26396ac feat(integration): add domain CRUD endpoints to integration API 2026-02-26 08:44:55 +09:00
22 changed files with 632 additions and 249 deletions

View File

@@ -1,4 +1,5 @@
FROM node:24-slim AS base # FROM node:24-slim AS base
FROM public.ecr.aws/docker/library/node:24-slim AS base
WORKDIR /app WORKDIR /app
@@ -31,7 +32,8 @@ FROM base AS builder
RUN npm ci --omit=dev RUN npm ci --omit=dev
FROM node:24-slim AS runner # FROM node:24-slim AS runner
FROM public.ecr.aws/docker/library/node:24-slim AS runner
WORKDIR /app WORKDIR /app

View File

@@ -4,6 +4,12 @@ services:
image: fosrl/pangolin:latest image: fosrl/pangolin:latest
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 1g
reservations:
memory: 256m
volumes: volumes:
- ./config:/app/config - ./config:/app/config
healthcheck: healthcheck:

View File

@@ -4,6 +4,12 @@ services:
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}} image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 1g
reservations:
memory: 256m
volumes: volumes:
- ./config:/app/config - ./config:/app/config
healthcheck: healthcheck:

View File

@@ -1102,6 +1102,12 @@
"actionGetUser": "Get User", "actionGetUser": "Get User",
"actionGetOrgUser": "Get Organization User", "actionGetOrgUser": "Get Organization User",
"actionListOrgDomains": "List Organization Domains", "actionListOrgDomains": "List Organization Domains",
"actionGetDomain": "Get Domain",
"actionCreateOrgDomain": "Create Domain",
"actionUpdateOrgDomain": "Update Domain",
"actionDeleteOrgDomain": "Delete Domain",
"actionGetDNSRecords": "Get DNS Records",
"actionRestartOrgDomain": "Restart Domain",
"actionCreateSite": "Create Site", "actionCreateSite": "Create Site",
"actionDeleteSite": "Delete Site", "actionDeleteSite": "Delete Site",
"actionGetSite": "Get Site", "actionGetSite": "Get Site",
@@ -1670,10 +1676,10 @@
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.", "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo", "sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands", "sshSudoCommands": "Sudo Commands",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo.", "sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo.",
"sshCreateHomeDir": "Create Home Directory", "sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups", "sshUnixGroups": "Unix Groups",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host.", "sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",
"retryAttempts": "Retry Attempts", "retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes", "expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",

View File

@@ -14,3 +14,4 @@ export * from "./verifyApiKeyApiKeyAccess";
export * from "./verifyApiKeyClientAccess"; export * from "./verifyApiKeyClientAccess";
export * from "./verifyApiKeySiteResourceAccess"; export * from "./verifyApiKeySiteResourceAccess";
export * from "./verifyApiKeyIdpAccess"; export * from "./verifyApiKeyIdpAccess";
export * from "./verifyApiKeyDomainAccess";

View File

@@ -0,0 +1,90 @@
import { Request, Response, NextFunction } from "express";
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";
export async function verifyApiKeyDomainAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const domainId =
req.params.domainId || req.body.domainId || req.query.domainId;
const orgId = req.params.orgId;
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (!domainId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid domain ID")
);
}
if (apiKey.isRoot) {
// Root keys can access any domain in any org
return next();
}
// Verify domain exists and belongs to the organization
const [domain] = await db
.select()
.from(domains)
.innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId))
.where(
and(
eq(orgDomains.domainId, domainId),
eq(orgDomains.orgId, orgId)
)
)
.limit(1);
if (!domain) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Domain with ID ${domainId} not found in organization ${orgId}`
)
);
}
// Verify the API key has access to this organization
if (!req.apiKeyOrg) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
req.apiKeyOrg = apiKeyOrgRes[0];
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying domain access"
)
);
}
}

View File

@@ -119,12 +119,12 @@ const listClientsSchema = z.object({
}), }),
query: z.string().optional(), query: z.string().optional(),
sort_by: z sort_by: z
.enum(["megabytesIn", "megabytesOut"]) .enum(["name", "megabytesIn", "megabytesOut"])
.optional() .optional()
.catch(undefined) .catch(undefined)
.openapi({ .openapi({
type: "string", type: "string",
enum: ["megabytesIn", "megabytesOut"], enum: ["name", "megabytesIn", "megabytesOut"],
description: "Field to sort by" description: "Field to sort by"
}), }),
order: z order: z
@@ -363,14 +363,14 @@ export async function listClients(
const countQuery = db.$count(baseQuery.as("filtered_clients")); const countQuery = db.$count(baseQuery.as("filtered_clients"));
const listMachinesQuery = baseQuery const listMachinesQuery = baseQuery
.limit(page) .limit(pageSize)
.offset(pageSize * (page - 1)) .offset(pageSize * (page - 1))
.orderBy( .orderBy(
sort_by sort_by
? order === "asc" ? order === "asc"
? asc(clients[sort_by]) ? asc(clients[sort_by])
: desc(clients[sort_by]) : desc(clients[sort_by])
: asc(clients.clientId) : asc(clients.name)
); );
const [clientsList, totalCount] = await Promise.all([ const [clientsList, totalCount] = await Promise.all([

View File

@@ -27,7 +27,8 @@ import {
verifyApiKeyClientAccess, verifyApiKeyClientAccess,
verifyApiKeySiteResourceAccess, verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceClients, verifyApiKeySetResourceClients,
verifyLimits verifyLimits,
verifyApiKeyDomainAccess
} from "@server/middlewares"; } from "@server/middlewares";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { Router } from "express"; import { Router } from "express";
@@ -347,6 +348,56 @@ authenticated.get(
domain.listDomains domain.listDomains
); );
authenticated.get(
"/org/:orgId/domain/:domainId",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.getDomain),
domain.getDomain
);
authenticated.put(
"/org/:orgId/domain",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createOrgDomain),
logActionAudit(ActionsEnum.createOrgDomain),
domain.createOrgDomain
);
authenticated.patch(
"/org/:orgId/domain/:domainId",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.updateOrgDomain),
domain.updateOrgDomain
);
authenticated.delete(
"/org/:orgId/domain/:domainId",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.deleteOrgDomain),
logActionAudit(ActionsEnum.deleteOrgDomain),
domain.deleteAccountDomain
);
authenticated.get(
"/org/:orgId/domain/:domainId/dns-records",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.getDNSRecords),
domain.getDNSRecords
);
authenticated.post(
"/org/:orgId/domain/:domainId/restart",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.restartOrgDomain),
logActionAudit(ActionsEnum.restartOrgDomain),
domain.restartOrgDomain
);
authenticated.get( authenticated.get(
"/org/:orgId/invitations", "/org/:orgId/invitations",
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,

View File

@@ -19,6 +19,7 @@ import {
and, and,
asc, asc,
count, count,
desc,
eq, eq,
inArray, inArray,
isNull, isNull,
@@ -63,6 +64,26 @@ const listResourcesSchema = z.object({
description: "Page number to retrieve" description: "Page number to retrieve"
}), }),
query: z.string().optional(), query: z.string().optional(),
sort_by: z
.enum(["name"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
}),
enabled: z enabled: z
.enum(["true", "false"]) .enum(["true", "false"])
.transform((v) => v === "true") .transform((v) => v === "true")
@@ -229,8 +250,16 @@ export async function listResources(
) )
); );
} }
const { page, pageSize, authState, enabled, query, healthStatus } = const {
parsedQuery.data; page,
pageSize,
authState,
enabled,
query,
healthStatus,
sort_by,
order
} = parsedQuery.data;
const parsedParams = listResourcesParamsSchema.safeParse(req.params); const parsedParams = listResourcesParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -395,7 +424,13 @@ export async function listResources(
baseQuery baseQuery
.limit(pageSize) .limit(pageSize)
.offset(pageSize * (page - 1)) .offset(pageSize * (page - 1))
.orderBy(asc(resources.resourceId)), .orderBy(
sort_by
? order === "asc"
? asc(resources[sort_by])
: desc(resources[sort_by])
: asc(resources.name)
),
countQuery countQuery
]); ]);

View File

@@ -108,12 +108,12 @@ const listSitesSchema = z.object({
}), }),
query: z.string().optional(), query: z.string().optional(),
sort_by: z sort_by: z
.enum(["megabytesIn", "megabytesOut"]) .enum(["name", "megabytesIn", "megabytesOut"])
.optional() .optional()
.catch(undefined) .catch(undefined)
.openapi({ .openapi({
type: "string", type: "string",
enum: ["megabytesIn", "megabytesOut"], enum: ["name", "megabytesIn", "megabytesOut"],
description: "Field to sort by" description: "Field to sort by"
}), }),
order: z order: z
@@ -278,7 +278,7 @@ export async function listSites(
// we need to add `as` so that drizzle filters the result as a subquery // we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count( const countQuery = db.$count(
querySitesBase().where(and(...conditions)) querySitesBase().where(and(...conditions)).as("filtered_sites")
); );
const siteListQuery = baseQuery const siteListQuery = baseQuery
@@ -289,7 +289,7 @@ export async function listSites(
? order === "asc" ? order === "asc"
? asc(sites[sort_by]) ? asc(sites[sort_by])
: desc(sites[sort_by]) : desc(sites[sort_by])
: asc(sites.siteId) : asc(sites.name)
); );
const [totalCount, rows] = await Promise.all([ const [totalCount, rows] = await Promise.all([

View File

@@ -4,7 +4,7 @@ import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import type { PaginatedResponse } from "@server/types/Pagination"; import type { PaginatedResponse } from "@server/types/Pagination";
import { and, asc, eq, like, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
@@ -48,6 +48,26 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
type: "string", type: "string",
enum: ["host", "cidr"], enum: ["host", "cidr"],
description: "Filter site resources by mode" description: "Filter site resources by mode"
}),
sort_by: z
.enum(["name"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
}) })
}); });
@@ -131,7 +151,8 @@ export async function listAllSiteResourcesByOrg(
} }
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const { page, pageSize, query, mode } = parsedQuery.data; const { page, pageSize, query, mode, sort_by, order } =
parsedQuery.data;
const conditions = [and(eq(siteResources.orgId, orgId))]; const conditions = [and(eq(siteResources.orgId, orgId))];
if (query) { if (query) {
@@ -172,14 +193,20 @@ export async function listAllSiteResourcesByOrg(
const baseQuery = querySiteResourcesBase().where(and(...conditions)); const baseQuery = querySiteResourcesBase().where(and(...conditions));
const countQuery = db.$count( const countQuery = db.$count(
querySiteResourcesBase().where(and(...conditions)) querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources")
); );
const [siteResourcesList, totalCount] = await Promise.all([ const [siteResourcesList, totalCount] = await Promise.all([
baseQuery baseQuery
.limit(pageSize) .limit(pageSize)
.offset(pageSize * (page - 1)) .offset(pageSize * (page - 1))
.orderBy(asc(siteResources.siteResourceId)), .orderBy(
sort_by
? order === "asc"
? asc(siteResources[sort_by])
: desc(siteResources[sort_by])
: asc(siteResources.name)
),
countQuery countQuery
]); ]);

View File

@@ -5,7 +5,7 @@ import { siteResources, sites, SiteResource } 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 { eq, and } from "drizzle-orm"; import { and, asc, desc, eq } from "drizzle-orm";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -27,7 +27,27 @@ const listSiteResourcesQuerySchema = z.object({
.optional() .optional()
.default("0") .default("0")
.transform(Number) .transform(Number)
.pipe(z.int().nonnegative()) .pipe(z.int().nonnegative()),
sort_by: z
.enum(["name"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
})
}); });
export type ListSiteResourcesResponse = { export type ListSiteResourcesResponse = {
@@ -75,7 +95,7 @@ export async function listSiteResources(
} }
const { siteId, orgId } = parsedParams.data; const { siteId, orgId } = parsedParams.data;
const { limit, offset } = parsedQuery.data; const { limit, offset, sort_by, order } = parsedQuery.data;
// Verify the site exists and belongs to the org // Verify the site exists and belongs to the org
const site = await db const site = await db
@@ -98,6 +118,13 @@ export async function listSiteResources(
eq(siteResources.orgId, orgId) eq(siteResources.orgId, orgId)
) )
) )
.orderBy(
sort_by
? order === "asc"
? asc(siteResources[sort_by])
: desc(siteResources[sort_by])
: asc(siteResources.name)
)
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);

View File

@@ -89,7 +89,14 @@ import {
} 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";
import { use, useActionState, useCallback, useEffect, useMemo, useState } from "react"; import {
use,
useActionState,
useCallback,
useEffect,
useMemo,
useState
} from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -184,7 +191,8 @@ function ProxyResourceTargetsForm({
setDockerStates((prev) => new Map(prev.set(siteId, dockerState))); setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
}; };
const refreshContainersForSite = useCallback(async (siteId: number) => { const refreshContainersForSite = useCallback(
async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId); const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers(); const containers = await dockerManager.fetchContainers();
@@ -196,9 +204,12 @@ function ProxyResourceTargetsForm({
} }
return newMap; return newMap;
}); });
}, [api]); },
[api]
);
const getDockerStateForSite = useCallback((siteId: number): DockerState => { const getDockerStateForSite = useCallback(
(siteId: number): DockerState => {
return ( return (
dockerStates.get(siteId) || { dockerStates.get(siteId) || {
isEnabled: false, isEnabled: false,
@@ -206,7 +217,9 @@ function ProxyResourceTargetsForm({
containers: [] containers: []
} }
); );
}, [dockerStates]); },
[dockerStates]
);
const [isAdvancedMode, setIsAdvancedMode] = useState(() => { const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -220,7 +233,9 @@ function ProxyResourceTargetsForm({
const removeTarget = useCallback((targetId: number) => { const removeTarget = useCallback((targetId: number) => {
setTargets((prevTargets) => { setTargets((prevTargets) => {
const targetToRemove = prevTargets.find((target) => target.targetId === targetId); const targetToRemove = prevTargets.find(
(target) => target.targetId === targetId
);
if (targetToRemove && !targetToRemove.new) { if (targetToRemove && !targetToRemove.new) {
setTargetsToRemove((prev) => [...prev, targetId]); setTargetsToRemove((prev) => [...prev, targetId]);
} }
@@ -228,7 +243,8 @@ function ProxyResourceTargetsForm({
}); });
}, []); }, []);
const updateTarget = useCallback((targetId: number, data: Partial<LocalTarget>) => { const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => { setTargets((prevTargets) => {
const site = sites.find((site) => site.siteId === data.siteId); const site = sites.find((site) => site.siteId === data.siteId);
return prevTargets.map((target) => return prevTargets.map((target) =>
@@ -242,7 +258,9 @@ function ProxyResourceTargetsForm({
: target : target
); );
}); });
}, [sites]); },
[sites]
);
const openHealthCheckDialog = useCallback((target: LocalTarget) => { const openHealthCheckDialog = useCallback((target: LocalTarget) => {
setSelectedTargetForHealthCheck(target); setSelectedTargetForHealthCheck(target);
@@ -250,7 +268,6 @@ function ProxyResourceTargetsForm({
}, []); }, []);
const columns = useMemo((): ColumnDef<LocalTarget>[] => { const columns = useMemo((): ColumnDef<LocalTarget>[] => {
const priorityColumn: ColumnDef<LocalTarget> = { const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority", id: "priority",
header: () => ( header: () => (
@@ -581,7 +598,17 @@ function ProxyResourceTargetsForm({
actionsColumn actionsColumn
]; ];
} }
}, [isAdvancedMode, isHttp, sites, updateTarget, getDockerStateForSite, refreshContainersForSite, openHealthCheckDialog, removeTarget, t]); }, [
isAdvancedMode,
isHttp,
sites,
updateTarget,
getDockerStateForSite,
refreshContainersForSite,
openHealthCheckDialog,
removeTarget,
t
]);
function addNewTarget() { function addNewTarget() {
const isHttp = resource.http; const isHttp = resource.http;

View File

@@ -15,7 +15,15 @@ import { InfoPopup } from "@app/components/ui/info-popup";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ArrowUpDown,
ArrowUpRight,
ChevronsUpDownIcon,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -133,7 +141,26 @@ export default function ClientResourcesTable({
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
friendlyName: t("name"), friendlyName: t("name"),
header: () => <span className="p-3">{t("name")}</span> 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", id: "niceId",
@@ -329,6 +356,14 @@ export default function ClientResourcesTable({
}); });
} }
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => { const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString()); searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString()); searchParams.set("pageSize", newPage.pageSize.toString());

View File

@@ -20,7 +20,7 @@ import {
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useEffect, useState } from "react"; import { useState, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
@@ -39,7 +39,8 @@ import { formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { ListResourcesResponse } from "@server/routers/resource"; import { useQuery } from "@tanstack/react-query";
import { orgQueries } from "@app/lib/queries";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -94,14 +95,22 @@ export default function CreateShareLinkForm({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const t = useTranslations(); const t = useTranslations();
const [resources, setResources] = useState< const { data: allResources = [] } = useQuery(
{ orgQueries.resources({ orgId: org?.org.orgId ?? "" })
resourceId: number; );
name: string;
niceId: string; const resources = useMemo(
resourceUrl: string; () =>
}[] allResources
>([]); .filter((r) => r.http)
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
niceId: r.niceId,
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
})),
[allResources]
);
const formSchema = z.object({ const formSchema = z.object({
resourceId: z.number({ message: t("shareErrorSelectResource") }), resourceId: z.number({ message: t("shareErrorSelectResource") }),
@@ -130,47 +139,6 @@ export default function CreateShareLinkForm({
} }
}); });
useEffect(() => {
if (!open) {
return;
}
async function fetchResources() {
const res = await api
.get<
AxiosResponse<ListResourcesResponse>
>(`/org/${org?.org.orgId}/resources`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("shareErrorFetchResource"),
description: formatAxiosError(
e,
t("shareErrorFetchResourceDescription")
)
});
});
if (res?.status === 200) {
setResources(
res.data.data.resources
.filter((r) => {
return r.http;
})
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
niceId: r.niceId,
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
}))
);
}
}
fetchResources();
}, [open]);
async function onSubmit(values: z.infer<typeof formSchema>) { async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true); setLoading(true);

View File

@@ -1230,8 +1230,12 @@ export function InternalResourceForm({
)} )}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<StrategySelect<"site" | "remote"> <StrategySelect<
value={field.value ?? undefined} "site" | "remote"
>
value={
field.value ?? undefined
}
options={[ options={[
{ {
id: "site", id: "site",
@@ -1241,7 +1245,8 @@ export function InternalResourceForm({
description: t( description: t(
"internalResourceAuthDaemonSiteDescription" "internalResourceAuthDaemonSiteDescription"
), ),
disabled: sshSectionDisabled disabled:
sshSectionDisabled
}, },
{ {
id: "remote", id: "remote",
@@ -1251,11 +1256,13 @@ export function InternalResourceForm({
description: t( description: t(
"internalResourceAuthDaemonRemoteDescription" "internalResourceAuthDaemonRemoteDescription"
), ),
disabled: sshSectionDisabled disabled:
sshSectionDisabled
} }
]} ]}
onChange={(v) => { onChange={(v) => {
if (sshSectionDisabled) return; if (sshSectionDisabled)
return;
field.onChange(v); field.onChange(v);
if (v === "site") { if (v === "site") {
form.setValue( form.setValue(
@@ -1289,10 +1296,17 @@ export function InternalResourceForm({
max={65535} max={65535}
placeholder="22123" placeholder="22123"
{...field} {...field}
disabled={sshSectionDisabled} disabled={
value={field.value ?? ""} sshSectionDisabled
}
value={
field.value ?? ""
}
onChange={(e) => { onChange={(e) => {
if (sshSectionDisabled) return; if (
sshSectionDisabled
)
return;
const v = const v =
e.target.value; e.target.value;
if (v === "") { if (v === "") {
@@ -1301,12 +1315,12 @@ export function InternalResourceForm({
); );
return; return;
} }
const num = parseInt( const num =
v, parseInt(v, 10);
10
);
field.onChange( field.onChange(
Number.isNaN(num) Number.isNaN(
num
)
? null ? null
: num : num
); );

View File

@@ -69,15 +69,16 @@ export function LayoutMobileMenu({
<SheetDescription className="sr-only"> <SheetDescription className="sr-only">
{t("navbarDescription")} {t("navbarDescription")}
</SheetDescription> </SheetDescription>
<div className="flex-1 overflow-y-auto relative"> <div className="w-full border-b border-border">
<div className="px-1"> <div className="px-1 shrink-0">
<OrgSelector <OrgSelector
orgId={orgId} orgId={orgId}
orgs={orgs} orgs={orgs}
/> />
</div> </div>
<div className="w-full border-b border-border" /> </div>
<div className="px-3 pt-3"> <div className="flex-1 overflow-y-auto relative">
<div className="px-3">
{!isAdminPage && {!isAdminPage &&
user.serverAdmin && ( user.serverAdmin && (
<div className="mb-1"> <div className="mb-1">

View File

@@ -204,7 +204,26 @@ export default function MachineClientsTable({
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
friendlyName: t("name"), friendlyName: t("name"),
header: () => <span className="px-3">{t("name")}</span>, header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
onClick={() => toggleSort("name")}
className="px-3"
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return ( return (

View File

@@ -31,7 +31,6 @@ function getActionsCategories(root: boolean) {
[t("actionListInvitations")]: "listInvitations", [t("actionListInvitations")]: "listInvitations",
[t("actionRemoveUser")]: "removeUser", [t("actionRemoveUser")]: "removeUser",
[t("actionListUsers")]: "listUsers", [t("actionListUsers")]: "listUsers",
[t("actionListOrgDomains")]: "listOrgDomains",
[t("updateOrgUser")]: "updateOrgUser", [t("updateOrgUser")]: "updateOrgUser",
[t("createOrgUser")]: "createOrgUser", [t("createOrgUser")]: "createOrgUser",
[t("actionApplyBlueprint")]: "applyBlueprint", [t("actionApplyBlueprint")]: "applyBlueprint",
@@ -39,6 +38,16 @@ function getActionsCategories(root: boolean) {
[t("actionGetBlueprint")]: "getBlueprint" [t("actionGetBlueprint")]: "getBlueprint"
}, },
Domain: {
[t("actionListOrgDomains")]: "listOrgDomains",
[t("actionGetDomain")]: "getDomain",
[t("actionCreateOrgDomain")]: "createOrgDomain",
[t("actionUpdateOrgDomain")]: "updateOrgDomain",
[t("actionDeleteOrgDomain")]: "deleteOrgDomain",
[t("actionGetDNSRecords")]: "getDNSRecords",
[t("actionRestartOrgDomain")]: "restartOrgDomain"
},
Site: { Site: {
[t("actionCreateSite")]: "createSite", [t("actionCreateSite")]: "createSite",
[t("actionDeleteSite")]: "deleteSite", [t("actionDeleteSite")]: "deleteSite",

View File

@@ -14,15 +14,19 @@ import { InfoPopup } from "@app/components/ui/info-popup";
import { Switch } from "@app/components/ui/switch"; import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { UpdateResourceResponse } from "@server/routers/resource"; import { UpdateResourceResponse } from "@server/routers/resource";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { import {
ArrowDown01Icon,
ArrowRight, ArrowRight,
ArrowUp10Icon,
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
ChevronsUpDownIcon,
Clock, Clock,
MoreHorizontal, MoreHorizontal,
ShieldCheck, ShieldCheck,
@@ -318,7 +322,26 @@ export default function ProxyResourcesTable({
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
friendlyName: t("name"), friendlyName: t("name"),
header: () => <span className="p-3">{t("name")}</span> 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", id: "niceId",
@@ -563,6 +586,14 @@ export default function ProxyResourcesTable({
}); });
} }
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => { const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString()); searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString()); searchParams.set("pageSize", newPage.pageSize.toString());

View File

@@ -141,7 +141,24 @@ export default function SitesTable({
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
header: () => { header: () => {
return <span className="p-3">{t("name")}</span>; 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>
);
} }
}, },
{ {

View File

@@ -4,7 +4,8 @@ import type { ListClientsResponse } from "@server/routers/client";
import type { ListDomainsResponse } from "@server/routers/domain"; import type { ListDomainsResponse } from "@server/routers/domain";
import type { import type {
GetResourceWhitelistResponse, GetResourceWhitelistResponse,
ListResourceNamesResponse ListResourceNamesResponse,
ListResourcesResponse
} from "@server/routers/resource"; } from "@server/routers/resource";
import type { ListRolesResponse } from "@server/routers/role"; import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site"; import type { ListSitesResponse } from "@server/routers/site";
@@ -90,23 +91,13 @@ export const productUpdatesQueries = {
}) })
}; };
export const clientFilterSchema = z.object({
pageSize: z.int().prefault(1000).optional()
});
export const orgQueries = { export const orgQueries = {
clients: ({ clients: ({ orgId }: { orgId: string }) =>
orgId,
filters
}: {
orgId: string;
filters?: z.infer<typeof clientFilterSchema>;
}) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "CLIENTS", filters] as const, queryKey: ["ORG", orgId, "CLIENTS"] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({ const sp = new URLSearchParams({
pageSize: (filters?.pageSize ?? 1000).toString() pageSize: "10000"
}); });
const res = await meta!.api.get< const res = await meta!.api.get<
@@ -143,9 +134,13 @@ export const orgQueries = {
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "SITES"] as const, queryKey: ["ORG", orgId, "SITES"] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: "10000"
});
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListSitesResponse> AxiosResponse<ListSitesResponse>
>(`/org/${orgId}/sites`, { signal }); >(`/org/${orgId}/sites?${sp.toString()}`, { signal });
return res.data.data.sites; return res.data.data.sites;
} }
}), }),
@@ -182,6 +177,22 @@ export const orgQueries = {
); );
return res.data.data.idps; return res.data.data.idps;
} }
}),
resources: ({ orgId }: { orgId: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "RESOURCES"] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: "10000"
});
const res = await meta!.api.get<
AxiosResponse<ListResourcesResponse>
>(`/org/${orgId}/resources?${sp.toString()}`, { signal });
return res.data.data.resources;
}
}) })
}; };