diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts
index de2158c6..e612d5ec 100644
--- a/server/routers/resource/listResources.ts
+++ b/server/routers/resource/listResources.ts
@@ -8,6 +8,7 @@ import {
resourcePassword,
resourcePincode,
targets,
+ targetHealthCheck,
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -63,6 +64,9 @@ type JoinedRow = {
targetIp: string | null;
targetPort: number | null;
targetEnabled: boolean | null;
+
+ hcHealth: string | null;
+ hcEnabled: boolean | null;
};
// grouped by resource with targets[])
@@ -87,6 +91,7 @@ export type ResourceWithTargets = {
ip: string;
port: number;
enabled: boolean;
+ healthStatus?: 'healthy' | 'unhealthy' | 'unknown';
}>;
};
@@ -114,6 +119,8 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
targetPort: targets.port,
targetEnabled: targets.enabled,
+ hcHealth: targetHealthCheck.hcHealth,
+ hcEnabled: targetHealthCheck.hcEnabled,
})
.from(resources)
.leftJoin(
@@ -129,6 +136,10 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
eq(resourceHeaderAuth.resourceId, resources.resourceId)
)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
+ .leftJoin(
+ targetHealthCheck,
+ eq(targetHealthCheck.targetId, targets.targetId)
+ )
.where(
and(
inArray(resources.resourceId, accessibleResourceIds),
@@ -269,18 +280,19 @@ export async function listResources(
map.set(row.resourceId, entry);
}
- // Push target if present
- if (
- row.targetId != null &&
- row.targetIp &&
- row.targetPort != null &&
- row.targetEnabled != null
- ) {
+ if (row.targetId != null && row.targetIp && row.targetPort != null && row.targetEnabled != null) {
+ let healthStatus: 'healthy' | 'unhealthy' | 'unknown' = 'unknown';
+
+ if (row.hcEnabled && row.hcHealth) {
+ healthStatus = row.hcHealth as 'healthy' | 'unhealthy' | 'unknown';
+ }
+
entry.targets.push({
targetId: row.targetId,
ip: row.targetIp,
port: row.targetPort,
enabled: row.targetEnabled,
+ healthStatus: healthStatus,
});
}
}
diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx
index 0f8ee262..5b18c3c5 100644
--- a/src/app/[orgId]/settings/resources/page.tsx
+++ b/src/app/[orgId]/settings/resources/page.tsx
@@ -92,7 +92,14 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
: "not_protected",
enabled: resource.enabled,
domainId: resource.domainId || undefined,
- ssl: resource.ssl
+ ssl: resource.ssl,
+ targets: resource.targets?.map(target => ({
+ targetId: target.targetId,
+ ip: target.ip,
+ port: target.port,
+ enabled: target.enabled,
+ healthStatus: target.healthStatus
+ }))
};
});
diff --git a/src/components/ResourcesTable.tsx b/src/components/ResourcesTable.tsx
index a717ae1b..9faee629 100644
--- a/src/components/ResourcesTable.tsx
+++ b/src/components/ResourcesTable.tsx
@@ -32,6 +32,9 @@ import {
Plus,
Search,
ChevronDown,
+ Clock,
+ Wifi,
+ WifiOff,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -70,6 +73,15 @@ import EditInternalResourceDialog from "@app/components/EditInternalResourceDial
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import { Alert, AlertDescription } from "@app/components/ui/alert";
+
+export type TargetHealth = {
+ targetId: number;
+ ip: string;
+ port: number;
+ enabled: boolean;
+ healthStatus?: 'healthy' | 'unhealthy' | 'unknown';
+};
+
export type ResourceRow = {
id: number;
nice: string | null;
@@ -85,8 +97,52 @@ export type ResourceRow = {
ssl: boolean;
targetHost?: string;
targetPort?: number;
+ targets?: TargetHealth[];
};
+
+function getOverallHealthStatus(targets?: TargetHealth[]): 'online' | 'degraded' | 'offline' | 'unknown' {
+ if (!targets || targets.length === 0) {
+ return 'unknown';
+ }
+
+ const monitoredTargets = targets.filter(t => t.enabled && t.healthStatus && t.healthStatus !== 'unknown');
+
+ if (monitoredTargets.length === 0) {
+ return 'unknown';
+ }
+
+ const healthyCount = monitoredTargets.filter(t => t.healthStatus === 'healthy').length;
+ const unhealthyCount = monitoredTargets.filter(t => t.healthStatus === 'unhealthy').length;
+
+ if (healthyCount === monitoredTargets.length) {
+ return 'online';
+ } else if (unhealthyCount === monitoredTargets.length) {
+ return 'offline';
+ } else {
+ return 'degraded';
+ }
+}
+
+function StatusIcon({ status, className = "" }: {
+ status: 'online' | 'degraded' | 'offline' | 'unknown';
+ className?: string;
+}) {
+ const iconClass = `h-4 w-4 ${className}`;
+
+ switch (status) {
+ case 'online':
+ return