Merge branch 'dev' of https://github.com/fosrl/pangolin into dev

This commit is contained in:
miloschwartz
2026-04-26 10:23:36 -07:00
31 changed files with 946 additions and 77 deletions

View File

@@ -0,0 +1,264 @@
"use client";
import { UsersDataTable } from "@app/components/AdminUsersDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuContent,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
export type GlobalUserRow = {
id: string;
name: string | null;
username: string;
email: string | null;
type: string;
idpId: number | null;
idpName: string;
dateCreated: string;
twoFactorEnabled: boolean | null;
twoFactorSetupRequested: boolean | null;
};
type Props = {
users: GlobalUserRow[];
};
export default function UsersTable({ users }: Props) {
const router = useRouter();
const t = useTranslations();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selected, setSelected] = useState<GlobalUserRow | null>(null);
const [rows, setRows] = useState<GlobalUserRow[]>(users);
const api = createApiClient(useEnvContext());
const deleteUser = (id: string) => {
api.delete(`/user/${id}`)
.catch((e) => {
console.error(t("userErrorDelete"), e);
toast({
variant: "destructive",
title: t("userErrorDelete"),
description: formatAxiosError(e, t("userErrorDelete"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== id);
setRows(newRows);
});
};
const columns: ExtendedColumnDef<GlobalUserRow>[] = [
{
accessorKey: "id",
friendlyName: "ID",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
ID
</Button>
);
}
},
{
accessorKey: "username",
friendlyName: t("username"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("username")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "email",
friendlyName: t("email"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("email")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "name",
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "idpName",
friendlyName: t("identityProvider"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identityProvider")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "twoFactorEnabled",
friendlyName: t("twoFactor"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("twoFactor")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex flex-row items-center gap-2">
<span>
{userRow.twoFactorEnabled ||
userRow.twoFactorSetupRequested ? (
<span className="text-green-500">
{t("enabled")}
</span>
) : (
<span>{t("disabled")}</span>
)}
</span>
</div>
);
}
},
{
id: "actions",
header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => {
const r = row.original;
return (
<>
<div className="flex items-center gap-2">
<Button
variant={"outline"}
onClick={() => {
router.push(`/admin/users/${r.id}`);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
<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">
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
);
}
}
];
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelected(null);
}}
dialog={
<div className="space-y-2">
<p>{t("userQuestionRemove")}</p>
<p>{t("userMessageRemove")}</p>
</div>
}
buttonText={t("userDeleteConfirm")}
onConfirm={async () => deleteUser(selected!.id)}
string={
selected.email || selected.name || selected.username
}
title={t("userDeleteServer")}
/>
)}
<UsersDataTable columns={columns} data={rows} />
</>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function UsersDataTable<TData, TValue>({
columns,
data,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="userServer-table"
title={t("userServer")}
searchPlaceholder={t("userSearch")}
searchColumn="email"
onRefresh={onRefresh}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
stickyLeftColumn="username"
stickyRightColumn="actions"
/>
);
}

View File

@@ -118,6 +118,8 @@ function triggerLabel(rule: AlertRuleRow, t: (k: string) => string) {
return t("alertingTriggerResourceHealthy");
case "resource_unhealthy":
return t("alertingTriggerResourceUnhealthy");
case "resource_degraded":
return t("alertingTriggerResourceDegraded");
case "resource_toggle":
return t("alertingTriggerResourceToggle");
default:

View File

@@ -47,15 +47,7 @@ import {
PopoverTrigger
} from "@app/components/ui/popover";
import { CaretSortIcon } from "@radix-ui/react-icons";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { ChevronsUpDown } from "lucide-react";
import { Checkbox } from "@app/components/ui/checkbox";
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
import { constructShareLink } from "@app/lib/shareLinks";
@@ -275,10 +267,11 @@ export default function CreateShareLinkForm({
</PopoverTrigger>
<PopoverContent className="p-0">
<ResourceSelector
orgId={
org.org
.orgId
}
excludeWildcard
orgId={
org.org
.orgId
}
selectedResource={
selectedResource
}

View File

@@ -32,7 +32,6 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { UpdateResourceResponse } from "@server/routers/resource";
import type { PaginationState } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
import { AxiosResponse } from "axios";
import {
ArrowDown01Icon,

View File

@@ -0,0 +1,41 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
createRole?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function RolesDataTable<TData, TValue>({
columns,
data,
createRole,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="roles-table"
title={t("roles")}
searchPlaceholder={t("accessRolesSearch")}
searchColumn="name"
onAdd={createRole}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t("accessRolesAdd")}
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -42,7 +42,8 @@ const barColorClass: Record<string, string> = {
good: "bg-green-500",
degraded: "bg-yellow-500",
bad: "bg-red-500",
no_data: "bg-neutral-200 dark:bg-neutral-700"
no_data: "bg-neutral-200 dark:bg-neutral-700",
unknown: "bg-neutral-200 dark:bg-neutral-700"
};
type UptimeBarProps = {
@@ -188,7 +189,7 @@ export default function UptimeBar({
<div className="font-semibold text-xs">
{formatDate(day.date)}
</div>
{day.status !== "no_data" && (
{day.status !== "no_data" && day.status !== "unknown" && (
<div className="text-xs text-primary-foreground/80">
{t("uptimeTooltipUptimeLabel")}:{" "}
<span className="font-medium text-primary-foreground">
@@ -224,7 +225,7 @@ export default function UptimeBar({
))}
</div>
)}
{day.status === "no_data" && (
{(day.status === "no_data" || day.status === "unknown") && (
<div className="text-xs text-primary-foreground/60">
{t("uptimeNoMonitoringData")}
</div>

View File

@@ -34,7 +34,8 @@ const barColorClass: Record<string, string> = {
good: "bg-green-500",
degraded: "bg-yellow-500",
bad: "bg-red-500",
no_data: "bg-neutral-200 dark:bg-neutral-700"
no_data: "bg-neutral-200 dark:bg-neutral-700",
unknown: "bg-neutral-200 dark:bg-neutral-700"
};
type UptimeMiniBarProps = {
@@ -137,7 +138,7 @@ export default function UptimeMiniBar({
{formatDate(day.date)}
</div>
<div className="text-xs text-primary-foreground/80">
{day.status === "no_data"
{day.status === "no_data" || day.status === "unknown"
? t("uptimeNoData")
: `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
</div>

View File

@@ -0,0 +1,41 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
inviteUser?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function UsersDataTable<TData, TValue>({
columns,
data,
inviteUser,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="users-table"
title={t("users")}
searchPlaceholder={t("accessUsersSearch")}
searchColumn="email"
onAdd={inviteUser}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t("accessUserCreate")}
enableColumnVisibility={true}
stickyLeftColumn="displayUsername"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1147,6 +1147,7 @@ export function AlertRuleSourceFields({
if (
curTrigger !== "resource_healthy" &&
curTrigger !== "resource_unhealthy" &&
curTrigger !== "resource_degraded" &&
curTrigger !== "resource_toggle"
) {
setValue("trigger", "resource_toggle", {
@@ -1367,6 +1368,9 @@ export function AlertRuleTriggerFields({
<SelectItem value="resource_unhealthy">
{t("alertingTriggerResourceUnhealthy")}
</SelectItem>
<SelectItem value="resource_degraded">
{t("alertingTriggerResourceDegraded")}
</SelectItem>
</>
) : (
<>

View File

@@ -17,19 +17,21 @@ import { useDebounce } from "use-debounce";
export type SelectedResource = Pick<
ListResourcesResponse["resources"][number],
"name" | "resourceId" | "fullDomain" | "niceId" | "ssl"
"name" | "resourceId" | "fullDomain" | "niceId" | "ssl" | "wildcard"
>;
export type ResourceSelectorProps = {
orgId: string;
selectedResource?: SelectedResource | null;
onSelectResource: (resource: SelectedResource) => void;
excludeWildcard?: boolean;
};
export function ResourceSelector({
orgId,
selectedResource,
onSelectResource
onSelectResource,
excludeWildcard = false
}: ResourceSelectorProps) {
const t = useTranslations();
const [resourceSearchQuery, setResourceSearchQuery] = useState("");
@@ -46,10 +48,13 @@ export function ResourceSelector({
// always include the selected resource in the list of resources shown
const resourcesShown = useMemo(() => {
const allResources: Array<SelectedResource> = [...resources];
const allResources: Array<SelectedResource> = excludeWildcard
? resources.filter((r) => !r.wildcard)
: [...resources];
if (
debouncedSearchQuery.trim().length === 0 &&
selectedResource &&
!(excludeWildcard && selectedResource.wildcard) &&
!allResources.find(
(resource) =>
resource.resourceId === selectedResource?.resourceId
@@ -58,7 +63,7 @@ export function ResourceSelector({
allResources.unshift(selectedResource);
}
return allResources;
}, [debouncedSearchQuery, resources, selectedResource]);
}, [debouncedSearchQuery, resources, selectedResource, excludeWildcard]);
return (
<Command shouldFilter={false}>

View File

@@ -25,6 +25,7 @@ export type AlertTrigger =
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_degraded"
| "resource_toggle";
export type AlertRuleFormAction =
@@ -77,6 +78,7 @@ export type AlertRuleApiPayload = {
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_degraded"
| "resource_toggle";
enabled: boolean;
allSites: boolean;
@@ -160,6 +162,7 @@ export function buildFormSchema(t: (k: string) => string) {
"health_check_toggle",
"resource_healthy",
"resource_unhealthy",
"resource_degraded",
"resource_toggle"
]),
actions: z.array(
@@ -243,6 +246,7 @@ export function buildFormSchema(t: (k: string) => string) {
const resourceTriggers: AlertTrigger[] = [
"resource_healthy",
"resource_unhealthy",
"resource_degraded",
"resource_toggle"
];
if (
@@ -344,7 +348,9 @@ export function alertRuleAllResourcesSelected(
eventType: string,
resourceIds: number[] | undefined
): boolean {
return eventType.startsWith("resource_") && (resourceIds?.length ?? 0) === 0;
return (
eventType.startsWith("resource_") && (resourceIds?.length ?? 0) === 0
);
}
export function alertRuleAllHealthChecksSelected(

View File

@@ -0,0 +1,129 @@
import { z } from "zod";
const STORAGE_PREFIX = "pangolin:alert-rules:";
export const webhookHeaderEntrySchema = z.object({
key: z.string(),
value: z.string()
});
export const alertActionSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("notify"),
userIds: z.array(z.string()),
roleIds: z.array(z.number()),
emails: z.array(z.string())
}),
z.object({
type: z.literal("webhook"),
url: z.string().url(),
method: z.string().min(1),
headers: z.array(webhookHeaderEntrySchema),
secret: z.string().optional()
})
]);
export const alertSourceSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("site"),
siteIds: z.array(z.number())
}),
z.object({
type: z.literal("health_check"),
targetIds: z.array(z.number())
})
]);
export const alertTriggerSchema = z.enum([
"site_online",
"site_offline",
"health_check_healthy",
"health_check_unhealthy"
]);
export const alertRuleSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(255),
enabled: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
source: alertSourceSchema,
trigger: alertTriggerSchema,
actions: z.array(alertActionSchema).min(1)
});
export type AlertRule = z.infer<typeof alertRuleSchema>;
export type AlertAction = z.infer<typeof alertActionSchema>;
export type AlertTrigger = z.infer<typeof alertTriggerSchema>;
function storageKey(orgId: string) {
return `${STORAGE_PREFIX}${orgId}`;
}
export function getRule(orgId: string, ruleId: string): AlertRule | undefined {
return loadRules(orgId).find((r) => r.id === ruleId);
}
export function loadRules(orgId: string): AlertRule[] {
if (typeof window === "undefined") {
return [];
}
try {
const raw = localStorage.getItem(storageKey(orgId));
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
const out: AlertRule[] = [];
for (const item of parsed) {
const r = alertRuleSchema.safeParse(item);
if (r.success) {
out.push(r.data);
}
}
return out;
} catch {
return [];
}
}
export function saveRules(orgId: string, rules: AlertRule[]) {
if (typeof window === "undefined") {
return;
}
localStorage.setItem(storageKey(orgId), JSON.stringify(rules));
}
export function upsertRule(orgId: string, rule: AlertRule) {
const rules = loadRules(orgId);
const i = rules.findIndex((r) => r.id === rule.id);
if (i >= 0) {
rules[i] = rule;
} else {
rules.push(rule);
}
saveRules(orgId, rules);
}
export function deleteRule(orgId: string, ruleId: string) {
const rules = loadRules(orgId).filter((r) => r.id !== ruleId);
saveRules(orgId, rules);
}
export function newRuleId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export function isoNow() {
return new Date().toISOString();
}