mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-31 16:31:37 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a281ec002 | |||
| 4c000c1d49 | |||
| ea4ff75552 | |||
| c78b866087 | |||
| 48b6e98bbc | |||
| 3d5260b13e | |||
| d0b0d95b9a | |||
| c2c8b7a631 | |||
| 9bc11b8717 | |||
| 1d53211fe0 |
+31
-1
@@ -3142,5 +3142,35 @@
|
|||||||
"idpDeleteAllOrgsMenu": "Delete",
|
"idpDeleteAllOrgsMenu": "Delete",
|
||||||
"publicIpEndpoint": "Endpoint",
|
"publicIpEndpoint": "Endpoint",
|
||||||
"lastTriggeredAt": "Last Trigger",
|
"lastTriggeredAt": "Last Trigger",
|
||||||
"reject": "Reject"
|
"reject": "Reject",
|
||||||
|
"uptimeDaysAgo": "{count} days ago",
|
||||||
|
"uptimeToday": "Today",
|
||||||
|
"uptimeNoDataAvailable": "No data available",
|
||||||
|
"uptimeSuffix": "uptime",
|
||||||
|
"uptimeDowntimeSuffix": "downtime",
|
||||||
|
"uptimeTooltipUptimeLabel": "Uptime",
|
||||||
|
"uptimeTooltipDowntimeLabel": "Downtime",
|
||||||
|
"uptimeOngoing": "ongoing",
|
||||||
|
"uptimeNoMonitoringData": "No monitoring data",
|
||||||
|
"uptimeNoData": "No data",
|
||||||
|
"uptimeMiniBarDown": "Down",
|
||||||
|
"uptimeSectionTitle": "Uptime",
|
||||||
|
"uptimeSectionDescription": "Site availability over the last {days} days.",
|
||||||
|
"uptimeAddAlert": "Add Alert",
|
||||||
|
"uptimeViewAlerts": "View Alerts",
|
||||||
|
"uptimeCreateEmailAlert": "Create Email Alert",
|
||||||
|
"uptimeAlertDescriptionSite": "Get notified by email when this site goes offline or comes back online.",
|
||||||
|
"uptimeAlertDescriptionResource": "Get notified by email when this resource goes offline or comes back online.",
|
||||||
|
"uptimeAlertNamePlaceholder": "Alert name",
|
||||||
|
"uptimeAdditionalEmails": "Additional Emails",
|
||||||
|
"uptimeCreateAlert": "Create Alert",
|
||||||
|
"uptimeAlertNoRecipients": "No recipients",
|
||||||
|
"uptimeAlertNoRecipientsDescription": "Please add at least one user, role, or email to notify.",
|
||||||
|
"uptimeAlertCreated": "Alert created",
|
||||||
|
"uptimeAlertCreatedDescription": "You will be notified when this changes status.",
|
||||||
|
"uptimeAlertCreateFailed": "Failed to create alert",
|
||||||
|
"webhookUrlLabel": "URL",
|
||||||
|
"webhookHeaderKeyPlaceholder": "Key",
|
||||||
|
"webhookHeaderValuePlaceholder": "Value",
|
||||||
|
"alertLabel": "Alert"
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-3
@@ -2,7 +2,7 @@ import { PostHog } from "posthog-node";
|
|||||||
import config from "./config";
|
import config from "./config";
|
||||||
import { getHostMeta } from "./hostMeta";
|
import { getHostMeta } from "./hostMeta";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { apiKeys, db, roles, siteResources } from "@server/db";
|
import { alertRules, apiKeys, blueprints, db, roles, siteResources } from "@server/db";
|
||||||
import { sites, users, orgs, resources, clients, idp } from "@server/db";
|
import { sites, users, orgs, resources, clients, idp } from "@server/db";
|
||||||
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
|
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
|
||||||
import { APP_VERSION } from "./consts";
|
import { APP_VERSION } from "./consts";
|
||||||
@@ -15,6 +15,7 @@ class TelemetryClient {
|
|||||||
private client: PostHog | null = null;
|
private client: PostHog | null = null;
|
||||||
private enabled: boolean;
|
private enabled: boolean;
|
||||||
private intervalId: NodeJS.Timeout | null = null;
|
private intervalId: NodeJS.Timeout | null = null;
|
||||||
|
private collectionIntervalDays = 14;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const enabled = config.getRawConfig().app.telemetry.anonymous_usage;
|
const enabled = config.getRawConfig().app.telemetry.anonymous_usage;
|
||||||
@@ -33,7 +34,7 @@ class TelemetryClient {
|
|||||||
this.client = new PostHog(
|
this.client = new PostHog(
|
||||||
"phc_QYuATSSZt6onzssWcYJbXLzQwnunIpdGGDTYhzK3VjX",
|
"phc_QYuATSSZt6onzssWcYJbXLzQwnunIpdGGDTYhzK3VjX",
|
||||||
{
|
{
|
||||||
host: "https://pangolin.net/relay-O7yI"
|
host: "https://telemetry.fossorial.io/relay-O7yI"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ class TelemetryClient {
|
|||||||
logger.debug("Successfully sent analytics data");
|
logger.debug("Successfully sent analytics data");
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
336 * 60 * 60 * 1000
|
this.collectionIntervalDays * 24 * 60 * 60 * 1000 // Convert days to milliseconds
|
||||||
);
|
);
|
||||||
|
|
||||||
this.collectAndSendAnalytics().catch((err) => {
|
this.collectAndSendAnalytics().catch((err) => {
|
||||||
@@ -157,6 +158,14 @@ class TelemetryClient {
|
|||||||
})
|
})
|
||||||
.from(sites);
|
.from(sites);
|
||||||
|
|
||||||
|
const [numAlertRules] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(alertRules);
|
||||||
|
|
||||||
|
const [blueprintsCount] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(blueprints);
|
||||||
|
|
||||||
const supporterKey = config.getSupporterData();
|
const supporterKey = config.getSupporterData();
|
||||||
|
|
||||||
const allPrivateResources = await db.select().from(siteResources);
|
const allPrivateResources = await db.select().from(siteResources);
|
||||||
@@ -165,11 +174,14 @@ class TelemetryClient {
|
|||||||
let numPrivResourceAliases = 0;
|
let numPrivResourceAliases = 0;
|
||||||
let numPrivResourceHosts = 0;
|
let numPrivResourceHosts = 0;
|
||||||
let numPrivResourceCidr = 0;
|
let numPrivResourceCidr = 0;
|
||||||
|
let numPrivResourceHttp = 0;
|
||||||
for (const res of allPrivateResources) {
|
for (const res of allPrivateResources) {
|
||||||
if (res.mode === "host") {
|
if (res.mode === "host") {
|
||||||
numPrivResourceHosts += 1;
|
numPrivResourceHosts += 1;
|
||||||
} else if (res.mode === "cidr") {
|
} else if (res.mode === "cidr") {
|
||||||
numPrivResourceCidr += 1;
|
numPrivResourceCidr += 1;
|
||||||
|
} else if (res.mode === "http") {
|
||||||
|
numPrivResourceHttp += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.alias) {
|
if (res.alias) {
|
||||||
@@ -187,6 +199,9 @@ class TelemetryClient {
|
|||||||
numPrivateResources: numPrivResources,
|
numPrivateResources: numPrivResources,
|
||||||
numPrivateResourceAliases: numPrivResourceAliases,
|
numPrivateResourceAliases: numPrivResourceAliases,
|
||||||
numPrivateResourceHosts: numPrivResourceHosts,
|
numPrivateResourceHosts: numPrivResourceHosts,
|
||||||
|
numPrivateResourceCidr: numPrivResourceCidr,
|
||||||
|
numPrivateResourceHttp: numPrivResourceHttp,
|
||||||
|
numAlertRules: numAlertRules.count,
|
||||||
numUserDevices: userDevicesCount.count,
|
numUserDevices: userDevicesCount.count,
|
||||||
numMachineClients: machineClients.count,
|
numMachineClients: machineClients.count,
|
||||||
numIdentityProviders: idpCount.count,
|
numIdentityProviders: idpCount.count,
|
||||||
@@ -197,6 +212,7 @@ class TelemetryClient {
|
|||||||
appVersion: APP_VERSION,
|
appVersion: APP_VERSION,
|
||||||
numApiKeys: numApiKeys.count,
|
numApiKeys: numApiKeys.count,
|
||||||
numCustomRoles: customRoles.count,
|
numCustomRoles: customRoles.count,
|
||||||
|
numBlueprints: blueprintsCount.count,
|
||||||
supporterStatus: {
|
supporterStatus: {
|
||||||
valid: supporterKey?.valid || false,
|
valid: supporterKey?.valid || false,
|
||||||
tier: supporterKey?.tier || "None",
|
tier: supporterKey?.tier || "None",
|
||||||
@@ -285,10 +301,12 @@ class TelemetryClient {
|
|||||||
num_private_resource_aliases:
|
num_private_resource_aliases:
|
||||||
stats.numPrivateResourceAliases,
|
stats.numPrivateResourceAliases,
|
||||||
num_private_resource_hosts: stats.numPrivateResourceHosts,
|
num_private_resource_hosts: stats.numPrivateResourceHosts,
|
||||||
|
num_private_resource_cidr: stats.numPrivateResourceCidr,
|
||||||
num_user_devices: stats.numUserDevices,
|
num_user_devices: stats.numUserDevices,
|
||||||
num_machine_clients: stats.numMachineClients,
|
num_machine_clients: stats.numMachineClients,
|
||||||
num_identity_providers: stats.numIdentityProviders,
|
num_identity_providers: stats.numIdentityProviders,
|
||||||
num_sites_online: stats.numSitesOnline,
|
num_sites_online: stats.numSitesOnline,
|
||||||
|
num_blueprint_runs: stats.numBlueprints,
|
||||||
num_resources_sso_enabled: stats.resources.filter(
|
num_resources_sso_enabled: stats.resources.filter(
|
||||||
(r) => r.sso
|
(r) => r.sso
|
||||||
).length,
|
).length,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import logger from "@server/logger";
|
|||||||
import { AlertContext, WebhookAlertConfig } from "@server/routers/alertRule/types";
|
import { AlertContext, WebhookAlertConfig } from "@server/routers/alertRule/types";
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15_000;
|
const REQUEST_TIMEOUT_MS = 15_000;
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const RETRY_BASE_DELAY_MS = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a single webhook POST for an alert event.
|
* Sends a single webhook POST for an alert event.
|
||||||
@@ -49,45 +51,70 @@ export async function sendAlertWebhook(
|
|||||||
const body = JSON.stringify(payload);
|
const body = JSON.stringify(payload);
|
||||||
const headers = buildHeaders(webhookConfig);
|
const headers = buildHeaders(webhookConfig);
|
||||||
|
|
||||||
const controller = new AbortController();
|
let lastError: Error | undefined;
|
||||||
const timeoutHandle = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
||||||
|
|
||||||
let response: Response;
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
try {
|
const controller = new AbortController();
|
||||||
response = await fetch(url, {
|
const timeoutHandle = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
method: webhookConfig.method ?? "POST",
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
signal: controller.signal
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const isAbort = err instanceof Error && err.name === "AbortError";
|
|
||||||
if (isAbort) {
|
|
||||||
throw new Error(
|
|
||||||
`Alert webhook: request to "${url}" timed out after ${REQUEST_TIMEOUT_MS} ms`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
throw new Error(`Alert webhook: request to "${url}" failed – ${msg}`);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
let response: Response;
|
||||||
let snippet = "";
|
|
||||||
try {
|
try {
|
||||||
const text = await response.text();
|
response = await fetch(url, {
|
||||||
snippet = text.slice(0, 300);
|
method: webhookConfig.method ?? "POST",
|
||||||
} catch {
|
headers,
|
||||||
// best-effort
|
body,
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
const isAbort = err instanceof Error && err.name === "AbortError";
|
||||||
|
if (isAbort) {
|
||||||
|
lastError = new Error(
|
||||||
|
`Alert webhook: request to "${url}" timed out after ${REQUEST_TIMEOUT_MS} ms`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
lastError = new Error(`Alert webhook: request to "${url}" failed – ${msg}`);
|
||||||
|
}
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
|
||||||
|
logger.warn(
|
||||||
|
`Alert webhook: attempt ${attempt}/${MAX_RETRIES} failed – retrying in ${delay} ms. ${lastError.message}`
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
}
|
}
|
||||||
throw new Error(
|
|
||||||
`Alert webhook: server at "${url}" returned HTTP ${response.status} ${response.statusText}` +
|
if (!response.ok) {
|
||||||
(snippet ? ` – ${snippet}` : "")
|
let snippet = "";
|
||||||
);
|
try {
|
||||||
|
const text = await response.text();
|
||||||
|
snippet = text.slice(0, 300);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
lastError = new Error(
|
||||||
|
`Alert webhook: server at "${url}" returned HTTP ${response.status} ${response.statusText}` +
|
||||||
|
(snippet ? ` – ${snippet}` : "")
|
||||||
|
);
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
|
||||||
|
logger.warn(
|
||||||
|
`Alert webhook: attempt ${attempt}/${MAX_RETRIES} failed – retrying in ${delay} ms. ${lastError.message}`
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Alert webhook sent successfully to "${url}" for event "${context.eventType}" (attempt ${attempt}/${MAX_RETRIES})`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Alert webhook sent successfully to "${url}" for event "${context.eventType}"`);
|
throw lastError ?? new Error(`Alert webhook: all ${MAX_RETRIES} attempts failed for "${url}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ function MaintenanceSectionForm({
|
|||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
|
<PaidFeaturesAlert tiers={tierMatrix.maintencePage} />
|
||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...maintenanceForm}>
|
<Form {...maintenanceForm}>
|
||||||
<form
|
<form
|
||||||
@@ -168,9 +169,6 @@ function MaintenanceSectionForm({
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="maintenance-settings-form"
|
id="maintenance-settings-form"
|
||||||
>
|
>
|
||||||
<PaidFeaturesAlert
|
|
||||||
tiers={tierMatrix.maintencePage}
|
|
||||||
/>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={maintenanceForm.control}
|
control={maintenanceForm.control}
|
||||||
name="maintenanceModeEnabled"
|
name="maintenanceModeEnabled"
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
{site?.siteId && site?.orgId && (
|
{site?.siteId && site?.orgId && site.type != "local" && (
|
||||||
<UptimeAlertSection
|
<UptimeAlertSection
|
||||||
orgId={site.orgId}
|
orgId={site.orgId}
|
||||||
siteId={site.siteId}
|
siteId={site.siteId}
|
||||||
|
|||||||
+5
-5
@@ -41,11 +41,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: #0d0d0f;
|
--background: #141415;
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: #0d0d0f;
|
--card: #141415;
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: #0d0d0f;
|
--popover: #141415;
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.6717 0.1946 41.93);
|
--primary: oklch(0.6717 0.1946 41.93);
|
||||||
--primary-foreground: oklch(0.98 0.016 73.684);
|
--primary-foreground: oklch(0.98 0.016 73.684);
|
||||||
@@ -65,11 +65,11 @@
|
|||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: #040404;
|
--sidebar: #0C0C0D;
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.646 0.222 41.116);
|
--sidebar-primary: oklch(0.646 0.222 41.116);
|
||||||
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
|
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
|
||||||
--sidebar-accent: #131317;
|
--sidebar-accent: #171717;
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.646 0.222 41.116);
|
--sidebar-ring: oklch(0.646 0.222 41.116);
|
||||||
|
|||||||
@@ -57,11 +57,6 @@ export default function BrandingLogo(props: BrandingLogoProps) {
|
|||||||
alt="Logo"
|
alt="Logo"
|
||||||
width={props.width}
|
width={props.width}
|
||||||
height={props.height}
|
height={props.height}
|
||||||
style={
|
|
||||||
isNextImage
|
|
||||||
? { width: "auto", height: "auto" }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
return (
|
return (
|
||||||
<CredenzaContent
|
<CredenzaContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-y-auto max-h-[100dvh] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:top-[clamp(1.5rem,12vh,200px)] md:translate-y-0",
|
"flex min-h-0 max-h-[100dvh] flex-col overflow-hidden md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -122,7 +122,10 @@ const CredenzaHeader = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
const CredenzaHeader = isDesktop ? DialogHeader : SheetHeader;
|
const CredenzaHeader = isDesktop ? DialogHeader : SheetHeader;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CredenzaHeader className={cn("-mx-6 px-6", className)} {...props}>
|
<CredenzaHeader
|
||||||
|
className={cn("shrink-0 -mx-6 px-6", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
);
|
);
|
||||||
@@ -151,7 +154,7 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-0 mb-4 space-y-4 overflow-x-hidden min-w-0",
|
"min-h-0 min-w-0 flex-1 space-y-4 overflow-y-auto overflow-x-hidden px-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -169,7 +172,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
return (
|
return (
|
||||||
<CredenzaFooter
|
<CredenzaFooter
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-8 md:mt-0 -mx-6 md:-mb-4 px-6 py-4 border-t border-border gap-2 md:gap-0",
|
"mt-8 shrink-0 border-t border-border py-4 -mx-6 gap-2 px-6 bg-card md:mt-0 md:-mb-4 md:gap-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export function LayoutSidebar({
|
|||||||
<Link
|
<Link
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 rounded-md",
|
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md",
|
||||||
isSidebarCollapsed
|
isSidebarCollapsed
|
||||||
? "px-2 py-2 justify-center"
|
? "px-2 py-2 justify-center"
|
||||||
: "px-3 py-1.5"
|
: "px-3 py-1.5"
|
||||||
@@ -216,7 +216,7 @@ export function LayoutSidebar({
|
|||||||
setHasManualToggle(true);
|
setHasManualToggle(true);
|
||||||
setSidebarStateCookie(false);
|
setSidebarStateCookie(false);
|
||||||
}}
|
}}
|
||||||
className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 transition-colors"
|
className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 transition-colors"
|
||||||
aria-label={t("sidebarExpand")}
|
aria-label={t("sidebarExpand")}
|
||||||
>
|
>
|
||||||
<PanelRightOpen className="h-4 w-4" />
|
<PanelRightOpen className="h-4 w-4" />
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ export function OrgSelector({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer transition-colors",
|
"cursor-pointer transition-colors",
|
||||||
isCollapsed
|
isCollapsed
|
||||||
? "w-full h-16 flex items-center justify-center hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50"
|
? "w-full h-16 flex items-center justify-center hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50"
|
||||||
: "w-full px-5 py-4 hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50"
|
: "w-full px-5 py-4 hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Input } from "./ui/input";
|
|||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import {
|
import {
|
||||||
Credenza,
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
CredenzaContent,
|
CredenzaContent,
|
||||||
CredenzaDescription,
|
CredenzaDescription,
|
||||||
CredenzaFooter,
|
CredenzaFooter,
|
||||||
@@ -88,7 +89,7 @@ export function PathMatchModal({
|
|||||||
{t("pathMatchModalDescription")}
|
{t("pathMatchModalDescription")}
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<div className="grid gap-4">
|
<CredenzaBody className="grid gap-4 space-y-0">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="match-type">{t("pathMatchType")}</Label>
|
<Label htmlFor="match-type">{t("pathMatchType")}</Label>
|
||||||
<Select value={matchType} onValueChange={setMatchType}>
|
<Select value={matchType} onValueChange={setMatchType}>
|
||||||
@@ -122,7 +123,7 @@ export function PathMatchModal({
|
|||||||
{getHelpText()}
|
{getHelpText()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CredenzaBody>
|
||||||
<CredenzaFooter className="gap-2">
|
<CredenzaFooter className="gap-2">
|
||||||
{/* {value?.path && (
|
{/* {value?.path && (
|
||||||
)} */}
|
)} */}
|
||||||
@@ -215,7 +216,7 @@ export function PathRewriteModal({
|
|||||||
{t("pathRewriteModalDescription")}
|
{t("pathRewriteModalDescription")}
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<div className="grid gap-4">
|
<CredenzaBody className="grid gap-4 space-y-0">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="rewrite-type">
|
<Label htmlFor="rewrite-type">
|
||||||
{t("pathRewriteType")}
|
{t("pathRewriteType")}
|
||||||
@@ -257,7 +258,7 @@ export function PathRewriteModal({
|
|||||||
{getHelpText()}
|
{getHelpText()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CredenzaBody>
|
||||||
<CredenzaFooter className="gap-2">
|
<CredenzaFooter className="gap-2">
|
||||||
{value?.rewritePath && (
|
{value?.rewritePath && (
|
||||||
<Button variant="outline" onClick={handleClear}>
|
<Button variant="outline" onClick={handleClear}>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ function CollapsibleNavItem({
|
|||||||
"px-3 py-1.5",
|
"px-3 py-1.5",
|
||||||
isActive
|
isActive
|
||||||
? "bg-sidebar-accent font-medium"
|
? "bg-sidebar-accent font-medium"
|
||||||
: "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground",
|
: "text-muted-foreground hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 hover:text-foreground",
|
||||||
isDisabled && "cursor-not-allowed opacity-60"
|
isDisabled && "cursor-not-allowed opacity-60"
|
||||||
)}
|
)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
@@ -257,7 +257,7 @@ function CollapsedNavItemWithPopover({
|
|||||||
"flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full",
|
"flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full",
|
||||||
isActive || isChildActive
|
isActive || isChildActive
|
||||||
? "bg-sidebar-accent font-medium"
|
? "bg-sidebar-accent font-medium"
|
||||||
: "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground",
|
: "text-muted-foreground hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 hover:text-foreground",
|
||||||
isDisabled &&
|
isDisabled &&
|
||||||
"cursor-not-allowed opacity-60"
|
"cursor-not-allowed opacity-60"
|
||||||
)}
|
)}
|
||||||
@@ -451,7 +451,7 @@ export function SidebarNav({
|
|||||||
isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5",
|
isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5",
|
||||||
isActive
|
isActive
|
||||||
? "bg-sidebar-accent font-medium"
|
? "bg-sidebar-accent font-medium"
|
||||||
: "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground",
|
: "text-muted-foreground hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 hover:text-foreground",
|
||||||
isDisabled && "cursor-not-allowed opacity-60"
|
isDisabled && "cursor-not-allowed opacity-60"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export type SiteRow = {
|
|||||||
mbIn: string;
|
mbIn: string;
|
||||||
mbOut: string;
|
mbOut: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
type: "newt" | "wireguard";
|
type: "newt" | "wireguard" | "local";
|
||||||
newtVersion?: string;
|
newtVersion?: string;
|
||||||
newtUpdateAvailable?: boolean;
|
newtUpdateAvailable?: boolean;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
@@ -236,6 +236,9 @@ export default function SitesTable({
|
|||||||
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
|
if (originalRow.type == "local") {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<UptimeMiniBar siteId={originalRow.id} days={30} />
|
<UptimeMiniBar siteId={originalRow.id} days={30} />
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export default function SupporterStatus({
|
|||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
<strong>Business & Enterprise Users:</strong> For larger organizations or teams requiring advanced features, consider our self-serve enterprise license and Enterprise Edition.{" "}
|
<strong>Business & Enterprise Users:</strong> For larger organizations or teams requiring advanced features, consider our self-serve enterprise license and Enterprise Edition.{" "}
|
||||||
<Link
|
<Link
|
||||||
href="https://pangolin.net/pricing?hosting=self-host"
|
href="https://pangolin.net/pricing#Self-Hosted"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="underline inline-flex items-center gap-1"
|
className="underline inline-flex items-center gap-1"
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export function SwitchInput({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
|
{label && <Label htmlFor={id}>{label}</Label>}
|
||||||
<Switch
|
<Switch
|
||||||
id={id}
|
id={id}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -52,7 +53,6 @@ export function SwitchInput({
|
|||||||
onCheckedChange={onCheckedChange}
|
onCheckedChange={onCheckedChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
{label && <Label htmlFor={id}>{label}</Label>}
|
|
||||||
{info && (
|
{info && (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { orgQueries } from "@app/lib/queries";
|
|||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
interface UptimeAlertSectionProps {
|
interface UptimeAlertSectionProps {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -50,6 +51,7 @@ export default function UptimeAlertSection({
|
|||||||
resourceId,
|
resourceId,
|
||||||
days = 90
|
days = 90
|
||||||
}: UptimeAlertSectionProps) {
|
}: UptimeAlertSectionProps) {
|
||||||
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
@@ -57,7 +59,7 @@ export default function UptimeAlertSection({
|
|||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [name, setName] = useState(
|
const [name, setName] = useState(
|
||||||
`${siteId ? "Site" : "Resource"} ${startingName} Alert`
|
`${siteId ? t("site") : t("resource")} ${startingName} ${t("alertLabel")}`
|
||||||
);
|
);
|
||||||
const [userTags, setUserTags] = useState<Tag[]>([]);
|
const [userTags, setUserTags] = useState<Tag[]>([]);
|
||||||
const [roleTags, setRoleTags] = useState<Tag[]>([]);
|
const [roleTags, setRoleTags] = useState<Tag[]>([]);
|
||||||
@@ -73,9 +75,10 @@ export default function UptimeAlertSection({
|
|||||||
>(null);
|
>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const { data: alertRules, isLoading: alertRulesLoading } = useQuery(
|
const { data: alertRules, isLoading: alertRulesLoading } = useQuery({
|
||||||
orgQueries.alertRulesForSource({ orgId, siteId, resourceId })
|
...orgQueries.alertRulesForSource({ orgId, siteId, resourceId }),
|
||||||
);
|
enabled: isPaid
|
||||||
|
});
|
||||||
|
|
||||||
const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId }));
|
const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId }));
|
||||||
const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId }));
|
const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId }));
|
||||||
@@ -111,9 +114,8 @@ export default function UptimeAlertSection({
|
|||||||
) {
|
) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "No recipients",
|
title: t("uptimeAlertNoRecipients"),
|
||||||
description:
|
description: t("uptimeAlertNoRecipientsDescription")
|
||||||
"Please add at least one user, role, or email to notify."
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -135,12 +137,12 @@ export default function UptimeAlertSection({
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Alert created",
|
title: t("uptimeAlertCreated"),
|
||||||
description: "You will be notified when this changes status."
|
description: t("uptimeAlertCreatedDescription")
|
||||||
});
|
});
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setName("Uptime Alert");
|
setName(t("uptimeSectionTitle"));
|
||||||
setUserTags([]);
|
setUserTags([]);
|
||||||
setRoleTags([]);
|
setRoleTags([]);
|
||||||
setEmailTags([]);
|
setEmailTags([]);
|
||||||
@@ -155,8 +157,8 @@ export default function UptimeAlertSection({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to create alert",
|
title: t("uptimeAlertCreateFailed"),
|
||||||
description: formatAxiosError(e, "An error occurred.")
|
description: formatAxiosError(e, t("errorOccurred"))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -173,19 +175,19 @@ export default function UptimeAlertSection({
|
|||||||
const alertButton = alertRulesLoading ? (
|
const alertButton = alertRulesLoading ? (
|
||||||
<Button variant="outline" type="button" loading aria-busy="true">
|
<Button variant="outline" type="button" loading aria-busy="true">
|
||||||
<BellPlus className="size-4 mr-2" />
|
<BellPlus className="size-4 mr-2" />
|
||||||
Add Alert
|
{t("uptimeAddAlert")}
|
||||||
</Button>
|
</Button>
|
||||||
) : hasRules ? (
|
) : hasRules ? (
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link href={rulesListHref}>
|
<Link href={rulesListHref}>
|
||||||
<BellRing className="size-4 mr-2" />
|
<BellRing className="size-4 mr-2" />
|
||||||
View Alerts
|
{t("uptimeViewAlerts")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" onClick={() => setOpen(true)}>
|
<Button variant="outline" onClick={() => setOpen(true)}>
|
||||||
<BellPlus className="size-4 mr-2" />
|
<BellPlus className="size-4 mr-2" />
|
||||||
Add Alert
|
{t("uptimeAddAlert")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -195,9 +197,11 @@ export default function UptimeAlertSection({
|
|||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<SettingsSectionTitle>Uptime</SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
|
{t("uptimeSectionTitle")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
Site availability over the last {days} days.
|
{t("uptimeSectionDescription", { days })}
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</div>
|
</div>
|
||||||
{alertButton}
|
{alertButton}
|
||||||
@@ -215,11 +219,13 @@ export default function UptimeAlertSection({
|
|||||||
<Credenza open={open} onOpenChange={setOpen}>
|
<Credenza open={open} onOpenChange={setOpen}>
|
||||||
<CredenzaContent>
|
<CredenzaContent>
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>Create Email Alert</CredenzaTitle>
|
<CredenzaTitle>
|
||||||
|
{t("uptimeCreateEmailAlert")}
|
||||||
|
</CredenzaTitle>
|
||||||
<CredenzaDescription>
|
<CredenzaDescription>
|
||||||
Get notified by email when this{" "}
|
{siteId
|
||||||
{siteId ? "site" : "resource"} goes offline or comes
|
? t("uptimeAlertDescriptionSite")
|
||||||
back online.
|
: t("uptimeAlertDescriptionResource")}
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
@@ -231,20 +237,22 @@ export default function UptimeAlertSection({
|
|||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="alert-name">Name</Label>
|
<Label htmlFor="alert-name">
|
||||||
|
{t("name")}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="alert-name"
|
id="alert-name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Alert name"
|
placeholder={t("uptimeAlertNamePlaceholder")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Notify Users</Label>
|
<Label>{t("alertingNotifyUsers")}</Label>
|
||||||
<TagInput
|
<TagInput
|
||||||
activeTagIndex={activeUserTagIndex}
|
activeTagIndex={activeUserTagIndex}
|
||||||
setActiveTagIndex={setActiveUserTagIndex}
|
setActiveTagIndex={setActiveUserTagIndex}
|
||||||
placeholder="Select users..."
|
placeholder={t("alertingSelectUsers")}
|
||||||
size="sm"
|
size="sm"
|
||||||
tags={userTags}
|
tags={userTags}
|
||||||
setTags={(newTags) => {
|
setTags={(newTags) => {
|
||||||
@@ -262,11 +270,11 @@ export default function UptimeAlertSection({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Notify Roles</Label>
|
<Label>{t("alertingNotifyRoles")}</Label>
|
||||||
<TagInput
|
<TagInput
|
||||||
activeTagIndex={activeRoleTagIndex}
|
activeTagIndex={activeRoleTagIndex}
|
||||||
setActiveTagIndex={setActiveRoleTagIndex}
|
setActiveTagIndex={setActiveRoleTagIndex}
|
||||||
placeholder="Select roles..."
|
placeholder={t("alertingSelectRoles")}
|
||||||
size="sm"
|
size="sm"
|
||||||
tags={roleTags}
|
tags={roleTags}
|
||||||
setTags={(newTags) => {
|
setTags={(newTags) => {
|
||||||
@@ -284,11 +292,11 @@ export default function UptimeAlertSection({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Additional Emails</Label>
|
<Label>{t("uptimeAdditionalEmails")}</Label>
|
||||||
<TagInput
|
<TagInput
|
||||||
activeTagIndex={activeEmailTagIndex}
|
activeTagIndex={activeEmailTagIndex}
|
||||||
setActiveTagIndex={setActiveEmailTagIndex}
|
setActiveTagIndex={setActiveEmailTagIndex}
|
||||||
placeholder="Enter email addresses..."
|
placeholder={t("alertingEmailPlaceholder")}
|
||||||
size="sm"
|
size="sm"
|
||||||
tags={emailTags}
|
tags={emailTags}
|
||||||
setTags={(newTags) => {
|
setTags={(newTags) => {
|
||||||
@@ -312,14 +320,14 @@ export default function UptimeAlertSection({
|
|||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
<CredenzaClose asChild>
|
<CredenzaClose asChild>
|
||||||
<Button variant="outline">Cancel</Button>
|
<Button variant="outline">{t("cancel")}</Button>
|
||||||
</CredenzaClose>
|
</CredenzaClose>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading || !isPaid}
|
disabled={loading || !isPaid}
|
||||||
>
|
>
|
||||||
Create Alert
|
{t("uptimeCreateAlert")}
|
||||||
</Button>
|
</Button>
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
if (seconds === 0) return "0s";
|
if (seconds === 0) return "0s";
|
||||||
@@ -63,6 +64,7 @@ export default function UptimeBar({
|
|||||||
title,
|
title,
|
||||||
className
|
className
|
||||||
}: UptimeBarProps) {
|
}: UptimeBarProps) {
|
||||||
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const siteQuery = useQuery({
|
const siteQuery = useQuery({
|
||||||
@@ -104,7 +106,7 @@ export default function UptimeBar({
|
|||||||
<div
|
<div
|
||||||
className="flex items-center gap-4 text-sm ml-auto"
|
className="flex items-center gap-4 text-sm ml-auto"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
aria-label="Loading uptime summary"
|
aria-label={t("loading")}
|
||||||
>
|
>
|
||||||
<span className="h-4 w-[4.5rem] shrink-0 rounded-md bg-muted animate-pulse" />
|
<span className="h-4 w-[4.5rem] shrink-0 rounded-md bg-muted animate-pulse" />
|
||||||
<span className="h-4 w-[7rem] shrink-0 rounded-md bg-muted animate-pulse" />
|
<span className="h-4 w-[7rem] shrink-0 rounded-md bg-muted animate-pulse" />
|
||||||
@@ -122,8 +124,8 @@ export default function UptimeBar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
<span>{days} days ago</span>
|
<span>{t("uptimeDaysAgo", { count: days })}</span>
|
||||||
<span>Today</span>
|
<span>{t("uptimeToday")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -145,7 +147,7 @@ export default function UptimeBar({
|
|||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{data.overallUptimePercent.toFixed(2)}%
|
{data.overallUptimePercent.toFixed(2)}%
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
uptime
|
{t("uptimeSuffix")}
|
||||||
</span>
|
</span>
|
||||||
{data.totalDowntimeSeconds > 0 && (
|
{data.totalDowntimeSeconds > 0 && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
@@ -154,14 +156,14 @@ export default function UptimeBar({
|
|||||||
data.totalDowntimeSeconds
|
data.totalDowntimeSeconds
|
||||||
)}
|
)}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
downtime
|
{t("uptimeDowntimeSuffix")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{allNoData && (
|
{allNoData && (
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
No data available
|
{t("uptimeNoDataAvailable")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +190,7 @@ export default function UptimeBar({
|
|||||||
</div>
|
</div>
|
||||||
{day.status !== "no_data" && (
|
{day.status !== "no_data" && (
|
||||||
<div className="text-xs text-primary-foreground/80">
|
<div className="text-xs text-primary-foreground/80">
|
||||||
Uptime:{" "}
|
{t("uptimeTooltipUptimeLabel")}:{" "}
|
||||||
<span className="font-medium text-primary-foreground">
|
<span className="font-medium text-primary-foreground">
|
||||||
{day.uptimePercent.toFixed(1)}%
|
{day.uptimePercent.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
@@ -196,7 +198,7 @@ export default function UptimeBar({
|
|||||||
)}
|
)}
|
||||||
{day.totalDowntimeSeconds > 0 && (
|
{day.totalDowntimeSeconds > 0 && (
|
||||||
<div className="text-xs text-primary-foreground/80">
|
<div className="text-xs text-primary-foreground/80">
|
||||||
Downtime:{" "}
|
{t("uptimeTooltipDowntimeLabel")}:{" "}
|
||||||
<span className="font-medium text-primary-foreground">
|
<span className="font-medium text-primary-foreground">
|
||||||
{formatDuration(
|
{formatDuration(
|
||||||
day.totalDowntimeSeconds
|
day.totalDowntimeSeconds
|
||||||
@@ -214,7 +216,7 @@ export default function UptimeBar({
|
|||||||
{formatTime(w.start)}
|
{formatTime(w.start)}
|
||||||
{w.end
|
{w.end
|
||||||
? ` – ${formatTime(w.end)}`
|
? ` – ${formatTime(w.end)}`
|
||||||
: " – ongoing"}{" "}
|
: ` – ${t("uptimeOngoing")}`}{" "}
|
||||||
<span className="capitalize">
|
<span className="capitalize">
|
||||||
({w.status})
|
({w.status})
|
||||||
</span>
|
</span>
|
||||||
@@ -224,7 +226,7 @@ export default function UptimeBar({
|
|||||||
)}
|
)}
|
||||||
{day.status === "no_data" && (
|
{day.status === "no_data" && (
|
||||||
<div className="text-xs text-primary-foreground/60">
|
<div className="text-xs text-primary-foreground/60">
|
||||||
No monitoring data
|
{t("uptimeNoMonitoringData")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -234,8 +236,8 @@ export default function UptimeBar({
|
|||||||
|
|
||||||
{/* Date labels */}
|
{/* Date labels */}
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
<span>{days} days ago</span>
|
<span>{t("uptimeDaysAgo", { count: days })}</span>
|
||||||
<span>Today</span>
|
<span>{t("uptimeToday")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
if (seconds === 0) return "0s";
|
if (seconds === 0) return "0s";
|
||||||
@@ -51,6 +52,7 @@ export default function UptimeMiniBar({
|
|||||||
healthCheckId,
|
healthCheckId,
|
||||||
days = 30
|
days = 30
|
||||||
}: UptimeMiniBarProps) {
|
}: UptimeMiniBarProps) {
|
||||||
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const siteQuery = useQuery({
|
const siteQuery = useQuery({
|
||||||
@@ -105,7 +107,7 @@ export default function UptimeMiniBar({
|
|||||||
<span
|
<span
|
||||||
className="inline-flex min-w-[7ch] items-center justify-end text-xs text-muted-foreground whitespace-nowrap"
|
className="inline-flex min-w-[7ch] items-center justify-end text-xs text-muted-foreground whitespace-nowrap"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
aria-label="Loading uptime"
|
aria-label={t("loading")}
|
||||||
>
|
>
|
||||||
<span className="h-4 w-[5.5ch] max-w-full rounded bg-muted animate-pulse" />
|
<span className="h-4 w-[5.5ch] max-w-full rounded bg-muted animate-pulse" />
|
||||||
</span>
|
</span>
|
||||||
@@ -136,12 +138,12 @@ export default function UptimeMiniBar({
|
|||||||
</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"
|
||||||
? "No data"
|
? t("uptimeNoData")
|
||||||
: `${day.uptimePercent.toFixed(1)}% uptime`}
|
: `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
|
||||||
</div>
|
</div>
|
||||||
{day.totalDowntimeSeconds > 0 && (
|
{day.totalDowntimeSeconds > 0 && (
|
||||||
<div className="text-xs text-primary-foreground/70">
|
<div className="text-xs text-primary-foreground/70">
|
||||||
Down:{" "}
|
{t("uptimeMiniBarDown")}:{" "}
|
||||||
{formatDuration(day.totalDowntimeSeconds)}
|
{formatDuration(day.totalDowntimeSeconds)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -151,7 +153,7 @@ export default function UptimeMiniBar({
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{allNoData
|
{allNoData
|
||||||
? "No data"
|
? t("uptimeNoData")
|
||||||
: `${data.overallUptimePercent.toFixed(1)}%`}
|
: `${data.overallUptimePercent.toFixed(1)}%`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -714,7 +714,7 @@ function WebhookActionFields({
|
|||||||
name={`actions.${index}.url`}
|
name={`actions.${index}.url`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>URL</FormLabel>
|
<FormLabel>{t("webhookUrlLabel")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
@@ -961,7 +961,7 @@ function WebhookHeadersField({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="Key" />
|
<Input {...field} placeholder={t("webhookHeaderKeyPlaceholder")} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -972,7 +972,7 @@ function WebhookHeadersField({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="Value" />
|
<Input {...field} placeholder={t("webhookHeaderValuePlaceholder")} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -174,11 +174,7 @@ export default function AlertRuleGraphEditor({
|
|||||||
<CardContent className="p-4 sm:p-5 space-y-4">
|
<CardContent className="p-4 sm:p-5 space-y-4">
|
||||||
<fieldset
|
<fieldset
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={
|
className={`space-y-4${disabled ? " opacity-50 pointer-events-none" : ""}`}
|
||||||
disabled
|
|
||||||
? "opacity-50 pointer-events-none"
|
|
||||||
: "space-y-4"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{isNew && (
|
{isNew && (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { Checkbox } from "./ui/checkbox";
|
import { Checkbox } from "./ui/checkbox";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import type { Selectedsite } from "./site-selector";
|
import { SiteOnlineStatus, type Selectedsite } from "./site-selector";
|
||||||
|
|
||||||
export type MultiSitesSelectorProps = {
|
export type MultiSitesSelectorProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -107,7 +107,16 @@ export function MultiSitesSelector({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{site.name}</span>
|
<div className="min-w-0 flex-1 flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{site.name}
|
||||||
|
</span>
|
||||||
|
<SiteOnlineStatus
|
||||||
|
type={site.type}
|
||||||
|
online={site.online}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|||||||
@@ -18,7 +18,41 @@ import { useDebounce } from "use-debounce";
|
|||||||
export type Selectedsite = Pick<
|
export type Selectedsite = Pick<
|
||||||
ListSitesResponse["sites"][number],
|
ListSitesResponse["sites"][number],
|
||||||
"name" | "siteId" | "type"
|
"name" | "siteId" | "type"
|
||||||
>;
|
> & {
|
||||||
|
/** When omitted, no online/offline indicator is shown. */
|
||||||
|
online?: ListSitesResponse["sites"][number]["online"];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SiteOnlineStatusProps = {
|
||||||
|
type: Selectedsite["type"];
|
||||||
|
online: Selectedsite["online"];
|
||||||
|
t: (key: "online" | "offline") => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dot-only indicator matching `SitesTable` colors (newt/wireguard only; nothing for local or missing status). */
|
||||||
|
export function SiteOnlineStatus({ type, online, t }: SiteOnlineStatusProps) {
|
||||||
|
if (type !== "newt" && type !== "wireguard") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof online !== "boolean") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="shrink-0 flex items-center"
|
||||||
|
role="img"
|
||||||
|
aria-label={online ? t("online") : t("offline")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
online
|
||||||
|
? "w-2 h-2 bg-green-500 rounded-full"
|
||||||
|
: "w-2 h-2 bg-neutral-500 rounded-full"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type SitesSelectorProps = {
|
export type SitesSelectorProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -86,7 +120,16 @@ export function SitesSelector({
|
|||||||
: "opacity-0"
|
: "opacity-0"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{site.name}
|
<div className="min-w-0 flex-1 flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{site.name}
|
||||||
|
</span>
|
||||||
|
<SiteOnlineStatus
|
||||||
|
type={site.type}
|
||||||
|
online={site.online}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ export function ControlledDataTable<TData, TValue>({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto overflow-y-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
|||||||
@@ -695,7 +695,7 @@ export function DataTable<TData, TValue>({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto overflow-y-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const Table = React.forwardRef<
|
|||||||
>(({ className, sticky, ...props }, ref) => (
|
>(({ className, sticky, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
className={cn("relative w-full", {
|
className={cn("relative w-full", {
|
||||||
"overflow-auto": !sticky
|
"overflow-x-auto overflow-y-hidden": !sticky
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<table
|
<table
|
||||||
|
|||||||
Reference in New Issue
Block a user