mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-30 20:52:40 +00:00
Support unknown and degraded status
This commit is contained in:
@@ -20,4 +20,15 @@ export async function fireHealthCheckUnhealthyAlert(
|
|||||||
trx?: unknown
|
trx?: unknown
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fireHealthCheckUnknownAlert(
|
||||||
|
orgId: string,
|
||||||
|
healthCheckId: number,
|
||||||
|
healthCheckName?: string | null,
|
||||||
|
healthCheckTargetId?: number | null,
|
||||||
|
extra?: Record<string, unknown>,
|
||||||
|
trx?: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,7 @@ import { hashPassword } from "@server/auth/password";
|
|||||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||||
import { isValidRegionId } from "@server/db/regions";
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts";
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
|
|
||||||
export type ProxyResourcesResults = {
|
export type ProxyResourcesResults = {
|
||||||
@@ -169,6 +170,18 @@ export async function updateProxyResources(
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
healthchecksToUpdate.push(newHealthcheck);
|
healthchecksToUpdate.push(newHealthcheck);
|
||||||
|
|
||||||
|
// Insert unknown status history when HC is created in disabled state
|
||||||
|
if (!healthcheckData?.enabled) {
|
||||||
|
await fireHealthCheckUnknownAlert(
|
||||||
|
orgId,
|
||||||
|
newHealthcheck.targetHealthCheckId,
|
||||||
|
newHealthcheck.name,
|
||||||
|
newHealthcheck.targetId,
|
||||||
|
undefined,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find existing resource by niceId and orgId
|
// Find existing resource by niceId and orgId
|
||||||
@@ -557,6 +570,20 @@ export async function updateProxyResources(
|
|||||||
targetsToUpdate.push(updatedTarget);
|
targetsToUpdate.push(updatedTarget);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert unknown status history when HC is disabled
|
||||||
|
const isDisablingHc =
|
||||||
|
!healthcheckData?.enabled && oldHealthcheck?.hcEnabled;
|
||||||
|
if (isDisablingHc) {
|
||||||
|
await fireHealthCheckUnknownAlert(
|
||||||
|
orgId,
|
||||||
|
newHealthcheck.targetHealthCheckId,
|
||||||
|
newHealthcheck.name,
|
||||||
|
newHealthcheck.targetId,
|
||||||
|
undefined,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await createTarget(existingResource.resourceId, targetData);
|
await createTarget(existingResource.resourceId, targetData);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface StatusHistoryDayBucket {
|
|||||||
uptimePercent: number; // 0-100
|
uptimePercent: number; // 0-100
|
||||||
totalDowntimeSeconds: number;
|
totalDowntimeSeconds: number;
|
||||||
downtimeWindows: { start: number; end: number | null; status: string }[];
|
downtimeWindows: { start: number; end: number | null; status: string }[];
|
||||||
status: "good" | "degraded" | "bad" | "no_data";
|
status: "good" | "degraded" | "bad" | "no_data" | "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusHistoryResponse {
|
export interface StatusHistoryResponse {
|
||||||
@@ -54,6 +54,7 @@ export function computeBuckets(
|
|||||||
|
|
||||||
const windows: { start: number; end: number | null; status: string }[] = [];
|
const windows: { start: number; end: number | null; status: string }[] = [];
|
||||||
let dayDowntime = 0;
|
let dayDowntime = 0;
|
||||||
|
let dayDegradedTime = 0;
|
||||||
|
|
||||||
let windowStart = dayStartSec;
|
let windowStart = dayStartSec;
|
||||||
let windowStatus = currentStatus;
|
let windowStatus = currentStatus;
|
||||||
@@ -63,8 +64,8 @@ export function computeBuckets(
|
|||||||
const windowEnd = evt.timestamp;
|
const windowEnd = evt.timestamp;
|
||||||
const isDown =
|
const isDown =
|
||||||
windowStatus === "offline" ||
|
windowStatus === "offline" ||
|
||||||
windowStatus === "unhealthy" ||
|
windowStatus === "unhealthy";
|
||||||
windowStatus === "unknown";
|
const isDegraded = windowStatus === "degraded";
|
||||||
if (isDown) {
|
if (isDown) {
|
||||||
dayDowntime += windowEnd - windowStart;
|
dayDowntime += windowEnd - windowStart;
|
||||||
windows.push({
|
windows.push({
|
||||||
@@ -72,6 +73,13 @@ export function computeBuckets(
|
|||||||
end: windowEnd,
|
end: windowEnd,
|
||||||
status: windowStatus,
|
status: windowStatus,
|
||||||
});
|
});
|
||||||
|
} else if (isDegraded) {
|
||||||
|
dayDegradedTime += windowEnd - windowStart;
|
||||||
|
windows.push({
|
||||||
|
start: windowStart,
|
||||||
|
end: windowEnd,
|
||||||
|
status: windowStatus,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
windowStart = evt.timestamp;
|
windowStart = evt.timestamp;
|
||||||
@@ -83,8 +91,8 @@ export function computeBuckets(
|
|||||||
const finalEnd = Math.min(dayEndSec, nowSec);
|
const finalEnd = Math.min(dayEndSec, nowSec);
|
||||||
const isDown =
|
const isDown =
|
||||||
windowStatus === "offline" ||
|
windowStatus === "offline" ||
|
||||||
windowStatus === "unhealthy" ||
|
windowStatus === "unhealthy";
|
||||||
windowStatus === "unknown";
|
const isDegraded = windowStatus === "degraded";
|
||||||
if (isDown && finalEnd > windowStart) {
|
if (isDown && finalEnd > windowStart) {
|
||||||
dayDowntime += finalEnd - windowStart;
|
dayDowntime += finalEnd - windowStart;
|
||||||
windows.push({
|
windows.push({
|
||||||
@@ -92,6 +100,13 @@ export function computeBuckets(
|
|||||||
end: finalEnd,
|
end: finalEnd,
|
||||||
status: windowStatus,
|
status: windowStatus,
|
||||||
});
|
});
|
||||||
|
} else if (isDegraded && finalEnd > windowStart) {
|
||||||
|
dayDegradedTime += finalEnd - windowStart;
|
||||||
|
windows.push({
|
||||||
|
start: windowStart,
|
||||||
|
end: finalEnd,
|
||||||
|
status: windowStatus,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +120,7 @@ export function computeBuckets(
|
|||||||
effectiveDayLength > 0
|
effectiveDayLength > 0
|
||||||
? Math.max(
|
? Math.max(
|
||||||
0,
|
0,
|
||||||
((effectiveDayLength - dayDowntime) /
|
((effectiveDayLength - dayDowntime - dayDegradedTime) /
|
||||||
effectiveDayLength) *
|
effectiveDayLength) *
|
||||||
100
|
100
|
||||||
)
|
)
|
||||||
@@ -113,11 +128,27 @@ export function computeBuckets(
|
|||||||
|
|
||||||
const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10);
|
const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const hasAnyData = currentStatus !== null || dayEvents.length > 0;
|
||||||
|
|
||||||
|
// The whole observable window is "unknown" if every status we have seen is unknown
|
||||||
|
const allStatuses = [
|
||||||
|
...(currentStatus !== null ? [currentStatus] : []),
|
||||||
|
...dayEvents.map((e) => e.status)
|
||||||
|
];
|
||||||
|
const onlyUnknownData =
|
||||||
|
hasAnyData && allStatuses.every((s) => s === "unknown");
|
||||||
|
|
||||||
let status: StatusHistoryDayBucket["status"] = "no_data";
|
let status: StatusHistoryDayBucket["status"] = "no_data";
|
||||||
if (currentStatus !== null || dayEvents.length > 0) {
|
if (hasAnyData) {
|
||||||
if (uptimePct >= 99) status = "good";
|
if (onlyUnknownData) {
|
||||||
else if (uptimePct >= 50) status = "degraded";
|
status = "unknown";
|
||||||
else status = "bad";
|
} else if (dayDowntime > 0 && uptimePct < 50) {
|
||||||
|
status = "bad";
|
||||||
|
} else if (dayDowntime > 0 || dayDegradedTime > 0) {
|
||||||
|
status = "degraded";
|
||||||
|
} else {
|
||||||
|
status = "good";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buckets.push({
|
buckets.push({
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import { eq } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
fireResourceDegradedAlert,
|
fireResourceDegradedAlert,
|
||||||
fireResourceHealthyAlert,
|
fireResourceHealthyAlert,
|
||||||
fireResourceUnhealthyAlert
|
fireResourceUnhealthyAlert,
|
||||||
|
fireResourceUnknownAlert
|
||||||
} from "./resourceEvents";
|
} from "./resourceEvents";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -148,6 +149,32 @@ export async function fireHealthCheckUnhealthyAlert(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fireHealthCheckUnknownAlert(
|
||||||
|
orgId: string,
|
||||||
|
healthCheckId: number,
|
||||||
|
healthCheckName?: string | null,
|
||||||
|
healthCheckTargetId?: number | null,
|
||||||
|
extra?: Record<string, unknown>,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await trx.insert(statusHistory).values({
|
||||||
|
entityType: "health_check",
|
||||||
|
entityId: healthCheckId,
|
||||||
|
orgId: orgId,
|
||||||
|
status: "unknown",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleResource(orgId, healthCheckTargetId, trx);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleResource(orgId: string, healthCheckTargetId?: number | null, trx: Transaction | typeof db = db) {
|
async function handleResource(orgId: string, healthCheckTargetId?: number | null, trx: Transaction | typeof db = db) {
|
||||||
if (!healthCheckTargetId) {
|
if (!healthCheckTargetId) {
|
||||||
return;
|
return;
|
||||||
@@ -178,10 +205,16 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
|
|||||||
.where(eq(targets.resourceId, resource.resourceId));
|
.where(eq(targets.resourceId, resource.resourceId));
|
||||||
|
|
||||||
let health = "healthy";
|
let health = "healthy";
|
||||||
|
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
|
||||||
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
|
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
|
||||||
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
|
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
|
||||||
|
|
||||||
if (allHealthy) {
|
if (allUnknown) {
|
||||||
|
logger.debug(
|
||||||
|
`Marking resource ${resource.resourceId} as unknown because all health checks are disabled`
|
||||||
|
);
|
||||||
|
health = "unknown";
|
||||||
|
} else if (allHealthy) {
|
||||||
health = "healthy";
|
health = "healthy";
|
||||||
} else if (allUnhealthy) {
|
} else if (allUnhealthy) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -202,7 +235,15 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
|
|||||||
.set({ health })
|
.set({ health })
|
||||||
.where(eq(resources.resourceId, resource.resourceId));
|
.where(eq(resources.resourceId, resource.resourceId));
|
||||||
|
|
||||||
if (health === "unhealthy") {
|
if (health === "unknown") {
|
||||||
|
await fireResourceUnknownAlert(
|
||||||
|
orgId,
|
||||||
|
resource.resourceId,
|
||||||
|
resource.name,
|
||||||
|
undefined,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
} else if (health === "unhealthy") {
|
||||||
await fireResourceUnhealthyAlert(
|
await fireResourceUnhealthyAlert(
|
||||||
orgId,
|
orgId,
|
||||||
resource.resourceId,
|
resource.resourceId,
|
||||||
|
|||||||
@@ -183,3 +183,49 @@ export async function fireResourceDegradedAlert(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a `resource_unknown` alert for the given resource.
|
||||||
|
*
|
||||||
|
* Call this when all health checks on a resource are disabled so that the
|
||||||
|
* resource status transitions to unknown.
|
||||||
|
*
|
||||||
|
* @param orgId - Organisation that owns the resource.
|
||||||
|
* @param resourceId - Numeric primary key of the resource.
|
||||||
|
* @param resourceName - Human-readable name shown in notifications (optional).
|
||||||
|
* @param extra - Any additional key/value pairs to include in the payload.
|
||||||
|
*/
|
||||||
|
export async function fireResourceUnknownAlert(
|
||||||
|
orgId: string,
|
||||||
|
resourceId: number,
|
||||||
|
resourceName?: string | null,
|
||||||
|
extra?: Record<string, unknown>,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await trx.insert(statusHistory).values({
|
||||||
|
entityType: "resource",
|
||||||
|
entityId: resourceId,
|
||||||
|
orgId: orgId,
|
||||||
|
status: "unknown",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "resource_toggle",
|
||||||
|
orgId,
|
||||||
|
resourceId,
|
||||||
|
data: {
|
||||||
|
resourceId,
|
||||||
|
status: "unknown",
|
||||||
|
...(resourceName != null ? { resourceName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
63
server/private/lib/alerts/types.ts
Normal file
63
server/private/lib/alerts/types.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Alert event types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type AlertEventType =
|
||||||
|
| "site_online"
|
||||||
|
| "site_offline"
|
||||||
|
| "health_check_healthy"
|
||||||
|
| "health_check_not_healthy";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Webhook authentication config (stored as encrypted JSON in the DB)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type WebhookAuthType = "none" | "bearer" | "basic" | "custom";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored as an encrypted JSON blob in `alertWebhookActions.config`.
|
||||||
|
*/
|
||||||
|
export interface WebhookAlertConfig {
|
||||||
|
/** Authentication strategy for the webhook endpoint */
|
||||||
|
authType: WebhookAuthType;
|
||||||
|
/** Bearer token – used when authType === "bearer" */
|
||||||
|
bearerToken?: string;
|
||||||
|
/** Basic credentials – "username:password" – used when authType === "basic" */
|
||||||
|
basicCredentials?: string;
|
||||||
|
/** Custom header name – used when authType === "custom" */
|
||||||
|
customHeaderName?: string;
|
||||||
|
/** Custom header value – used when authType === "custom" */
|
||||||
|
customHeaderValue?: string;
|
||||||
|
/** Extra headers to send with every webhook request */
|
||||||
|
headers?: Array<{ key: string; value: string }>;
|
||||||
|
/** HTTP method (default POST) */
|
||||||
|
method?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal alert event passed through the processing pipeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface AlertContext {
|
||||||
|
eventType: AlertEventType;
|
||||||
|
orgId: string;
|
||||||
|
/** Set for site_online / site_offline events */
|
||||||
|
siteId?: number;
|
||||||
|
/** Set for health_check_* events */
|
||||||
|
healthCheckId?: number;
|
||||||
|
/** Human-readable context data included in emails and webhook payloads */
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { addPeer } from "../gerbil/peers";
|
import { addPeer } from "../gerbil/peers";
|
||||||
import { addTargets } from "../newt/targets";
|
import { addTargets } from "../newt/targets";
|
||||||
|
import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts";
|
||||||
import { pickPort } from "./helpers";
|
import { pickPort } from "./helpers";
|
||||||
import { isTargetValid } from "@server/lib/validators";
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -225,6 +226,11 @@ export async function updateTarget(
|
|||||||
hcHealthValue = undefined;
|
hcHealthValue = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDisablingHc =
|
||||||
|
(parsedBody.data.hcEnabled === false ||
|
||||||
|
parsedBody.data.hcEnabled === null) &&
|
||||||
|
existingHc.hcEnabled === true;
|
||||||
|
|
||||||
const [updatedHc] = await db
|
const [updatedHc] = await db
|
||||||
.update(targetHealthCheck)
|
.update(targetHealthCheck)
|
||||||
.set({
|
.set({
|
||||||
@@ -250,6 +256,15 @@ export async function updateTarget(
|
|||||||
.where(eq(targetHealthCheck.targetId, targetId))
|
.where(eq(targetHealthCheck.targetId, targetId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
if (isDisablingHc) {
|
||||||
|
await fireHealthCheckUnknownAlert(
|
||||||
|
resource.orgId,
|
||||||
|
existingHc.targetHealthCheckId,
|
||||||
|
existingHc.name,
|
||||||
|
updatedHc.targetId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (site.pubKey) {
|
if (site.pubKey) {
|
||||||
if (site.type == "wireguard") {
|
if (site.type == "wireguard") {
|
||||||
await addPeer(site.exitNodeId!, {
|
await addPeer(site.exitNodeId!, {
|
||||||
|
|||||||
264
src/app/admin/users/AdminUsersTable.tsx
Normal file
264
src/app/admin/users/AdminUsersTable.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/AdminUsersDataTable.tsx
Normal file
37
src/components/AdminUsersDataTable.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/RolesDataTable.tsx
Normal file
41
src/components/RolesDataTable.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,7 +42,8 @@ const barColorClass: Record<string, string> = {
|
|||||||
good: "bg-green-500",
|
good: "bg-green-500",
|
||||||
degraded: "bg-yellow-500",
|
degraded: "bg-yellow-500",
|
||||||
bad: "bg-red-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 = {
|
type UptimeBarProps = {
|
||||||
@@ -188,7 +189,7 @@ export default function UptimeBar({
|
|||||||
<div className="font-semibold text-xs">
|
<div className="font-semibold text-xs">
|
||||||
{formatDate(day.date)}
|
{formatDate(day.date)}
|
||||||
</div>
|
</div>
|
||||||
{day.status !== "no_data" && (
|
{day.status !== "no_data" && day.status !== "unknown" && (
|
||||||
<div className="text-xs text-primary-foreground/80">
|
<div className="text-xs text-primary-foreground/80">
|
||||||
{t("uptimeTooltipUptimeLabel")}:{" "}
|
{t("uptimeTooltipUptimeLabel")}:{" "}
|
||||||
<span className="font-medium text-primary-foreground">
|
<span className="font-medium text-primary-foreground">
|
||||||
@@ -224,7 +225,7 @@ export default function UptimeBar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{day.status === "no_data" && (
|
{(day.status === "no_data" || day.status === "unknown") && (
|
||||||
<div className="text-xs text-primary-foreground/60">
|
<div className="text-xs text-primary-foreground/60">
|
||||||
{t("uptimeNoMonitoringData")}
|
{t("uptimeNoMonitoringData")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ const barColorClass: Record<string, string> = {
|
|||||||
good: "bg-green-500",
|
good: "bg-green-500",
|
||||||
degraded: "bg-yellow-500",
|
degraded: "bg-yellow-500",
|
||||||
bad: "bg-red-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 = {
|
type UptimeMiniBarProps = {
|
||||||
@@ -137,7 +138,7 @@ export default function UptimeMiniBar({
|
|||||||
{formatDate(day.date)}
|
{formatDate(day.date)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-primary-foreground/80">
|
<div className="text-xs text-primary-foreground/80">
|
||||||
{day.status === "no_data"
|
{day.status === "no_data" || day.status === "unknown"
|
||||||
? t("uptimeNoData")
|
? t("uptimeNoData")
|
||||||
: `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
|
: `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
41
src/components/UsersDataTable.tsx
Normal file
41
src/components/UsersDataTable.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/lib/alertRulesLocalStorage.ts
Normal file
129
src/lib/alertRulesLocalStorage.ts
Normal 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user