Skip to content

Commit e97fd18

Browse files
authored
Merge pull request #84 from biersoeckli/fix/prevent-ui-from-crashing-when-external-data-cannot-be-fetched
fix: implement error handling for external data fetching in backups and monitoring page
2 parents e25856c + b6b1349 commit e97fd18

8 files changed

Lines changed: 124 additions & 44 deletions

File tree

src/app/backups/page.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getAuthUserSession, isAuthorizedForBackups } from "@/server/utils/actio
44
import PageTitle from "@/components/custom/page-title";
55
import backupService from "@/server/services/standalone-services/backup.service";
66
import BackupsTable from "./backups-table";
7+
import { CatchUtils } from "@/shared/utils/catch.utils";
78
import { AlertCircle, AlertTriangleIcon } from "lucide-react"
89
import {
910
Alert,
@@ -15,10 +16,11 @@ import {
1516
export default async function BackupsPage() {
1617

1718
await isAuthorizedForBackups();
18-
const {
19-
backupInfoModels,
20-
backupsVolumesWithoutActualBackups
21-
} = await backupService.getBackupsForAllS3Targets();
19+
const backupData = await CatchUtils.resultOrUndefined(() => backupService.getBackupsForAllS3Targets());
20+
21+
const backupInfoModels = backupData?.backupInfoModels ?? [];
22+
const backupsVolumesWithoutActualBackups = backupData?.backupsVolumesWithoutActualBackups ?? [];
23+
const failedS3Targets = backupData?.failedS3Targets ?? [];
2224

2325
const hasMissedBackups = backupInfoModels.some(x => x.missedBackup === true);
2426

@@ -29,6 +31,20 @@ export default async function BackupsPage() {
2931
subtitle={`View all backups wich are stored in all S3 Target destinations. If a backup exists from an app wich doesnt exist anymore, it will be shown as orphaned.`}>
3032
</PageTitle>
3133
<div className="space-y-4">
34+
{!backupData && <Alert variant="destructive">
35+
<AlertCircle className="h-4 w-4" />
36+
<AlertTitle>Backup data could not be loaded</AlertTitle>
37+
<AlertDescription>
38+
The configured backup storage could not be reached. Please verify your S3/B2 target settings and credentials.
39+
</AlertDescription>
40+
</Alert>}
41+
{failedS3Targets.length > 0 && <Alert variant="destructive">
42+
<AlertCircle className="h-4 w-4" />
43+
<AlertTitle>Some S3 targets could not be loaded</AlertTitle>
44+
<AlertDescription>
45+
Backups from the following locations could not be fetched: {failedS3Targets.map((target) => `${target.name} (${target.endpoint}/${target.bucketName})`).join(', ')}
46+
</AlertDescription>
47+
</Alert>}
3248
{backupsVolumesWithoutActualBackups.length > 0 && <Alert variant="destructive">
3349
<AlertCircle className="h-4 w-4" />
3450
<AlertTitle>Apps without Backup</AlertTitle>

src/app/monitoring/disk-chart.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,17 @@ export default function ChartDiskRessources({
1717
nodeRessource: NodeResourceModel;
1818
}) {
1919

20+
const diskUsed = nodeRessource.diskUsageAbsolut ?? 0;
21+
const diskReserved = nodeRessource.diskUsageReserved ?? 0;
22+
const diskCapacity = nodeRessource.diskUsageCapacity ?? 0;
23+
const diskSchedulable = nodeRessource.diskSpaceSchedulable ?? Math.max(0, diskCapacity - diskUsed - diskReserved);
24+
const diskUsedAndReserved = diskUsed + diskReserved;
25+
const diskUsagePercent = diskCapacity > 0 ? (diskUsedAndReserved / diskCapacity) * 100 : 0;
26+
2027
const chartData = [{
21-
diskUsed: nodeRessource.diskUsageAbsolut,
22-
diskReserved: nodeRessource.diskUsageReserved,
23-
diskSchedulable: nodeRessource.diskSpaceSchedulable
28+
diskUsed,
29+
diskReserved,
30+
diskSchedulable
2431
}];
2532

2633
const chartConfig = {
@@ -81,7 +88,7 @@ export default function ChartDiskRessources({
8188
y={(viewBox.cy || 0) - 10}
8289
className="fill-foreground text-4xl font-bold"
8390
>
84-
{((nodeRessource.diskUsageAbsolut + nodeRessource.diskUsageReserved) / nodeRessource.diskUsageCapacity * 100).toFixed(0)}%
91+
{diskUsagePercent.toFixed(0)}%
8592
</tspan>
8693
<tspan
8794
x={viewBox.cx}
@@ -94,7 +101,7 @@ export default function ChartDiskRessources({
94101
x={viewBox.cx}
95102
y={(viewBox.cy || 0) + 30}
96103
className="fill-muted-foreground">
97-
{KubeSizeConverter.convertBytesToReadableSize(nodeRessource.diskUsageAbsolut + nodeRessource.diskUsageReserved, 1, true)} / {KubeSizeConverter.convertBytesToReadableSize(nodeRessource.diskUsageCapacity, 1)}
104+
{KubeSizeConverter.convertBytesToReadableSize(diskUsedAndReserved, 1, true)} / {KubeSizeConverter.convertBytesToReadableSize(diskCapacity, 1)}
98105
</tspan>
99106
</text>
100107
);

src/app/monitoring/monitoring-nodes.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ export default function ResourcesNodes({
4040
resourcesNodes?: NodeResourceModel[];
4141
}) {
4242

43+
const getDiskUsageAbsolut = (node: NodeResourceModel) => node.diskUsageAbsolut ?? 0;
44+
const getDiskUsageReserved = (node: NodeResourceModel) => node.diskUsageReserved ?? 0;
45+
const getDiskUsageCapacity = (node: NodeResourceModel) => node.diskUsageCapacity ?? 0;
46+
const toPercent = (used: number, capacity: number) => (capacity > 0 ? (used / capacity) * 100 : 0);
47+
4348
const [updatedNodeRessources, setUpdatedResourcesNodes] = useState<NodeResourceModel[] | undefined>(resourcesNodes);
4449

4550
const fetchResourcesNodes = async () => {
@@ -76,9 +81,9 @@ export default function ResourcesNodes({
7681
cpuCapacity: acc.cpuCapacity + node.cpuCapacity,
7782
ramUsage: acc.ramUsage + node.ramUsage,
7883
ramCapacity: acc.ramCapacity + node.ramCapacity,
79-
diskUsageAbsolut: acc.diskUsageAbsolut + node.diskUsageAbsolut,
80-
diskUsageReserved: acc.diskUsageReserved + node.diskUsageReserved,
81-
diskCapacity: acc.diskCapacity + node.diskUsageCapacity,
84+
diskUsageAbsolut: acc.diskUsageAbsolut + getDiskUsageAbsolut(node),
85+
diskUsageReserved: acc.diskUsageReserved + getDiskUsageReserved(node),
86+
diskCapacity: acc.diskCapacity + getDiskUsageCapacity(node),
8287
}), {
8388
cpuUsage: 0, cpuCapacity: 0,
8489
ramUsage: 0, ramCapacity: 0,
@@ -227,7 +232,7 @@ export default function ResourcesNodes({
227232
return (
228233
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
229234
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-3xl font-bold">
230-
{(((clusterStats.diskUsageAbsolut + clusterStats.diskUsageReserved) / clusterStats.diskCapacity) * 100).toFixed(0)}%
235+
{toPercent(clusterStats.diskUsageAbsolut + clusterStats.diskUsageReserved, clusterStats.diskCapacity).toFixed(0)}%
231236
</tspan>
232237
<tspan x={viewBox.cx} y={(viewBox.cy || 0) + 24} className="fill-muted-foreground">
233238
Used
@@ -283,11 +288,22 @@ export default function ResourcesNodes({
283288
</TableCell>
284289
<TableCell className="w-[25%]">
285290
<div className="space-y-1">
286-
<div className="flex justify-between text-xs text-muted-foreground">
287-
<span>{(((node.diskUsageAbsolut + node.diskUsageReserved) / node.diskUsageCapacity) * 100).toFixed(0)}%</span>
288-
<span>{KubeSizeConverter.convertBytesToReadableSize(node.diskUsageAbsolut + node.diskUsageReserved)} / {KubeSizeConverter.convertBytesToReadableSize(node.diskUsageCapacity)}</span>
289-
</div>
290-
<Progress value={((node.diskUsageAbsolut + node.diskUsageReserved) / node.diskUsageCapacity) * 100} className="h-2" />
291+
{(() => {
292+
const diskUsed = getDiskUsageAbsolut(node);
293+
const diskReserved = getDiskUsageReserved(node);
294+
const diskCapacity = getDiskUsageCapacity(node);
295+
const diskUsedAndReserved = diskUsed + diskReserved;
296+
const diskPercent = toPercent(diskUsedAndReserved, diskCapacity);
297+
return (
298+
<>
299+
<div className="flex justify-between text-xs text-muted-foreground">
300+
<span>{diskPercent.toFixed(0)}%</span>
301+
<span>{KubeSizeConverter.convertBytesToReadableSize(diskUsedAndReserved)} / {KubeSizeConverter.convertBytesToReadableSize(diskCapacity)}</span>
302+
</div>
303+
<Progress value={diskPercent} className="h-2" />
304+
</>
305+
);
306+
})()}
291307
</div>
292308
</TableCell>
293309
<TableCell className="text-right">

src/app/monitoring/page.tsx

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,17 @@ import AppRessourceMonitoring from "./app-monitoring";
1111
import AppVolumeMonitoring from "./app-volumes-monitoring";
1212
import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model";
1313
import { UserGroupUtils } from "@/shared/utils/role.utils";
14+
import { CatchUtils } from "@/shared/utils/catch.utils";
1415

1516
export default async function ResourceNodesInfoPage() {
1617

1718
const session = await getAuthUserSession();
18-
let resourcesNode: NodeResourceModel[] | undefined;
19-
let volumesUsage: AppVolumeMonitoringUsageModel[] | undefined;
20-
let updatedNodeRessources: AppMonitoringUsageModel[] | undefined;
21-
try {
22-
[resourcesNode, volumesUsage, updatedNodeRessources] = await Promise.all([
23-
clusterService.getNodeResourceUsage(),
24-
monitoringService.getAllAppVolumesUsage(),
25-
await monitoringService.getMonitoringForAllApps()
26-
]);
27-
} catch (ex) {
28-
// do nothing --> if an error occurs, the ResourceNodes will show a loading spinner and error message
29-
}
19+
20+
let [resourcesNode, volumesUsage, updatedNodeRessources] = await Promise.all([
21+
CatchUtils.resultOrUndefined(() => clusterService.getNodeResourceUsage()),
22+
CatchUtils.resultOrUndefined(() => monitoringService.getAllAppVolumesUsage()),
23+
CatchUtils.resultOrUndefined(() => monitoringService.getMonitoringForAllApps())
24+
]);
3025

3126
// filter by role
3227
volumesUsage = volumesUsage?.filter((volume) => UserGroupUtils.sessionHasReadAccessForApp(session, volume.appId));

src/server/services/cluster.service.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Tags } from "../utils/cache-tag-generator.utils";
66
import { revalidateTag, unstable_cache } from "next/cache";
77
import longhornApiAdapter from "../adapter/longhorn-api.adapter";
88
import { KubeSizeConverter } from "../../shared/utils/kubernetes-size-converter.utils";
9+
import { CatchUtils } from "@/shared/utils/catch.utils";
910

1011
class ClusterService {
1112

@@ -88,18 +89,18 @@ class ClusterService {
8889
nodeMetrics.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
8990
const latestUsageItem = nodeMetrics[0];
9091

91-
const diskInfo = await longhornApiAdapter.getNodeStorageInfo(node.Node.metadata?.name!);
92+
const diskInfo = await CatchUtils.resultOrUndefined(() => longhornApiAdapter.getNodeStorageInfo(node.Node.metadata?.name!));
9293

9394
return {
9495
name: node.Node.metadata?.name!,
9596
cpuUsage: latestUsageItem.cpuUsage,
9697
cpuCapacity: Number(node.CPU?.Capacity!),
9798
ramUsage: latestUsageItem.ramUsage,
9899
ramCapacity: Number(node.Memory?.Capacity!),
99-
diskUsageAbsolut: diskInfo.totalStorageMaximum - diskInfo.totalStorageAvailable,
100-
diskUsageReserved: diskInfo.totalStorageReserved,
101-
diskUsageCapacity: diskInfo.totalStorageMaximum,
102-
diskSpaceSchedulable: diskInfo.totalSchedulableStorage
100+
diskUsageAbsolut: diskInfo ? diskInfo.totalStorageMaximum - diskInfo.totalStorageAvailable : undefined,
101+
diskUsageReserved: diskInfo?.totalStorageReserved,
102+
diskUsageCapacity: diskInfo?.totalStorageMaximum,
103+
diskSpaceSchedulable: diskInfo?.totalSchedulableStorage
103104
}
104105
}));
105106
}

src/server/services/standalone-services/backup.service.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,21 +100,51 @@ class BackupService {
100100

101101
async getBackupsForAllS3Targets() {
102102
const s3Targets = await dataAccess.client.s3Target.findMany();
103-
const returnValFromAllS3Targets = await Promise.all(s3Targets.map(s3Target =>
104-
this.getBackupsFromS3Target(s3Target)));
103+
const returnValFromAllS3Targets = await Promise.all(s3Targets.map(async (s3Target) => {
104+
try {
105+
const data = await this.getBackupsFromS3Target(s3Target);
106+
return {
107+
s3Target,
108+
data,
109+
error: undefined
110+
};
111+
} catch (error) {
112+
const errorMessage = error instanceof Error
113+
? `${error.name}: ${error.message}`
114+
: String(error);
115+
console.error(`Failed to fetch backups for S3 target '${s3Target.name}' (${s3Target.endpoint}/${s3Target.bucketName})`, error);
116+
return {
117+
s3Target,
118+
data: undefined,
119+
error: errorMessage
120+
};
121+
}
122+
}));
123+
124+
const successfulResults = returnValFromAllS3Targets.filter((result) => !!result.data);
125+
const failedS3Targets = returnValFromAllS3Targets
126+
.filter((result) => !!result.error)
127+
.map((result) => ({
128+
id: result.s3Target.id,
129+
name: result.s3Target.name,
130+
endpoint: result.s3Target.endpoint,
131+
bucketName: result.s3Target.bucketName,
132+
error: result.error ?? 'Unknown error'
133+
}));
105134

106-
const backupInfoModels = returnValFromAllS3Targets.map(x => x.backupInfoModels).flat();
135+
const backupInfoModels = successfulResults.map(x => x.data!.backupInfoModels).flat();
107136
backupInfoModels.sort((a, b) => {
108137
if (a.projectName === b.projectName) {
109138
return a.appName.localeCompare(b.appName);
110139
}
111140
return a.projectName.localeCompare(b.projectName);
112141
});
113142

114-
const backupsVolumesWithoutActualBackups = returnValFromAllS3Targets.map(x => x.backupsVolumesWithoutActualBackups).flat();
143+
const backupsVolumesWithoutActualBackups = successfulResults.map(x => x.data!.backupsVolumesWithoutActualBackups).flat();
115144
return {
116145
backupInfoModels,
117-
backupsVolumesWithoutActualBackups
146+
backupsVolumesWithoutActualBackups,
147+
failedS3Targets
118148
};
119149
}
120150

src/shared/model/node-resource.model.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ export const nodeResourceZodModel = z.object({
88
cpuCapacity: z.number(),
99
ramUsage: z.number(),
1010
ramCapacity: z.number(),
11-
diskUsageAbsolut: z.number(),
12-
diskUsageCapacity: z.number(),
13-
diskUsageReserved: z.number(),
14-
diskSpaceSchedulable: z.number(),
11+
diskUsageAbsolut: z.number().optional(),
12+
diskUsageCapacity: z.number().optional(),
13+
diskUsageReserved: z.number().optional(),
14+
diskSpaceSchedulable: z.number().optional(),
1515
})
1616

1717
export type NodeResourceModel = z.infer<typeof nodeResourceZodModel>;

src/shared/utils/catch.utils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export class CatchUtils {
2+
static async resultOrUndefined<T>(promiseFunc: () => Promise<T>, context?: string): Promise<T | undefined> {
3+
const promiseName = promiseFunc.name || 'anonymous-promise';
4+
const contextInfo = context ? ` [${context}]` : '';
5+
try {
6+
return await promiseFunc();
7+
} catch (error) {
8+
const errorInfo = error instanceof Error
9+
? `${error.name}: ${error.message}`
10+
: String(error);
11+
console.error(`Error in resultOrUndefined${contextInfo} (${promiseName}): ${errorInfo}`, error);
12+
return undefined;
13+
}
14+
}
15+
}

0 commit comments

Comments
 (0)