Openstatus www.openstatus.dev

fix(stpg): duration and manual type (#1488)

* fix: duration card type

* fix: manual type

* fix: duration uptime

* chore: include tests

authored by

Maximilian Kaske and committed by
GitHub
dd2776f2 24c9fbbe

+970 -26
+1 -1
apps/status-page/src/app/(status-page)/[domain]/(public)/page.tsx
··· 35 35 const { cardType, barType, showUptime } = useStatusPage(); 36 36 const trpc = useTRPC(); 37 37 const { data: page } = useQuery( 38 - trpc.statusPage.get.queryOptions({ slug: domain }), 38 + trpc.statusPage.get.queryOptions({ slug: domain, cardType, barType }), 39 39 ); 40 40 41 41 // NOTE: we can prefetch that to avoid loading state
+40 -22
packages/api/src/router/statusPage.ts
··· 40 40 41 41 export const statusPageRouter = createTRPCRouter({ 42 42 get: publicProcedure 43 - .input(z.object({ slug: z.string().toLowerCase() })) 43 + .input( 44 + z.object({ 45 + slug: z.string().toLowerCase(), 46 + cardType: z 47 + .enum(["requests", "duration", "dominant", "manual"]) 48 + .default("requests"), 49 + barType: z.enum(["absolute", "dominant", "manual"]).default("dominant"), 50 + }), 51 + ) 44 52 .output(selectPublicPageSchemaWithRelation.nullish()) 45 53 .query(async (opts) => { 46 54 if (!opts.input.slug) return null; ··· 90 98 reports: _page.statusReports, 91 99 monitorId: m.monitor.id, 92 100 }); 93 - const status = events.some((e) => e.type === "incident" && !e.to) 94 - ? "error" 95 - : events.some((e) => e.type === "report" && !e.to) 96 - ? "degraded" 97 - : events.some( 98 - (e) => 99 - e.type === "maintenance" && 100 - e.to && 101 - e.from.getTime() <= new Date().getTime() && 102 - e.to.getTime() >= new Date().getTime(), 103 - ) 104 - ? "info" 105 - : "success"; 101 + const status = 102 + events.some((e) => e.type === "incident" && !e.to) && 103 + opts.input.barType !== "manual" 104 + ? "error" 105 + : events.some((e) => e.type === "report" && !e.to) 106 + ? "degraded" 107 + : events.some( 108 + (e) => 109 + e.type === "maintenance" && 110 + e.to && 111 + e.from.getTime() <= new Date().getTime() && 112 + e.to.getTime() >= new Date().getTime(), 113 + ) 114 + ? "info" 115 + : "success"; 106 116 return { ...m.monitor, status, events }; 107 117 }); 108 118 109 - const status = monitors.some((m) => m.status === "error") 110 - ? "error" 111 - : monitors.some((m) => m.status === "degraded") 112 - ? "degraded" 113 - : monitors.some((m) => m.status === "info") 114 - ? "info" 115 - : "success"; 119 + const status = 120 + monitors.some((m) => m.status === "error") && 121 + opts.input.barType !== "manual" 122 + ? "error" 123 + : monitors.some((m) => m.status === "degraded") 124 + ? "degraded" 125 + : monitors.some((m) => m.status === "info") 126 + ? "info" 127 + : "success"; 116 128 117 129 // Get page-wide events (not tied to specific monitors) 118 130 const pageEvents = getEvents({ ··· 134 146 .sort((a, b) => a.from.getTime() - b.from.getTime()); 135 147 136 148 const openEvents = pageEvents.filter((event) => { 137 - if (event.type === "incident" || event.type === "report") { 149 + if (event.type === "incident" && opts.input.barType !== "manual") { 150 + if (!event.to) return true; 151 + if (event.to < new Date()) return false; 152 + return false; 153 + } 154 + if (event.type === "report") { 138 155 if (!event.to) return true; 139 156 if (event.to < new Date()) return false; 140 157 return false; ··· 325 342 data: filledData, 326 343 events, 327 344 barType: opts.input.barType, 345 + cardType: opts.input.cardType, 328 346 }); 329 347 330 348 return {
+898
packages/api/src/router/statusPage.utils.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import { 3 + fillStatusDataFor45Days, 4 + getUptime, 5 + setDataByType, 6 + } from "./statusPage.utils"; 7 + 8 + type StatusData = { 9 + day: string; 10 + count: number; 11 + ok: number; 12 + degraded: number; 13 + error: number; 14 + monitorId: string; 15 + }; 16 + 17 + type Event = { 18 + id: number; 19 + name: string; 20 + from: Date; 21 + to: Date | null; 22 + type: "maintenance" | "incident" | "report"; 23 + status: "success" | "degraded" | "error" | "info"; 24 + }; 25 + 26 + // Helper functions to create test data 27 + function createStatusData( 28 + daysAgo: number, 29 + ok = 0, 30 + degraded = 0, 31 + error = 0, 32 + ): StatusData { 33 + const date = new Date(); 34 + date.setDate(date.getDate() - daysAgo); 35 + date.setUTCHours(0, 0, 0, 0); 36 + 37 + return { 38 + day: date.toISOString(), 39 + count: ok + degraded + error, 40 + ok, 41 + degraded, 42 + error, 43 + monitorId: "1", 44 + }; 45 + } 46 + 47 + function createIncident(id: number, daysAgo: number, durationHours = 1): Event { 48 + const from = new Date(); 49 + from.setDate(from.getDate() - daysAgo); 50 + from.setHours(from.getHours() - durationHours); 51 + 52 + const to = new Date(from); 53 + to.setHours(to.getHours() + durationHours); 54 + 55 + return { 56 + id, 57 + name: "Downtime", 58 + from, 59 + to, 60 + type: "incident", 61 + status: "error", 62 + }; 63 + } 64 + 65 + function createReport(id: number, daysAgo: number, durationHours = 2): Event { 66 + const from = new Date(); 67 + from.setDate(from.getDate() - daysAgo); 68 + from.setHours(from.getHours() - durationHours); 69 + 70 + const to = new Date(from); 71 + to.setHours(to.getHours() + durationHours); 72 + 73 + return { 74 + id, 75 + name: "Performance Issues", 76 + from, 77 + to, 78 + type: "report", 79 + status: "degraded", 80 + }; 81 + } 82 + 83 + function createMaintenance( 84 + id: number, 85 + daysAgo: number, 86 + durationHours = 1, 87 + ): Event { 88 + const from = new Date(); 89 + from.setDate(from.getDate() - daysAgo); 90 + from.setHours(from.getHours() - durationHours); 91 + 92 + const to = new Date(from); 93 + to.setHours(to.getHours() + durationHours); 94 + 95 + return { 96 + id, 97 + name: "Scheduled Maintenance", 98 + from, 99 + to, 100 + type: "maintenance", 101 + status: "info", 102 + }; 103 + } 104 + 105 + describe("setDataByType", () => { 106 + describe("barType: absolute", () => { 107 + it("should show proportional bar segments with error-only incident", () => { 108 + const data = [createStatusData(0, 100, 0, 0)]; 109 + const events = [createIncident(1, 0, 2)]; 110 + 111 + const result = setDataByType({ 112 + events, 113 + data, 114 + cardType: "requests", 115 + barType: "absolute", 116 + }); 117 + 118 + expect(result).toHaveLength(1); 119 + expect(result[0].bar).toHaveLength(2); 120 + expect(result[0].bar[0].status).toBe("success"); 121 + expect(result[0].bar[1].status).toBe("error"); 122 + // Should have uptime and downtime segments 123 + expect(result[0].bar[0].height).toBeGreaterThan(0); 124 + expect(result[0].bar[1].height).toBeGreaterThan(0); 125 + }); 126 + 127 + it("should show proportional segments with multiple event types", () => { 128 + const data = [createStatusData(0, 100, 0, 0)]; 129 + const events = [ 130 + createIncident(1, 0, 1), 131 + createReport(2, 0, 2), 132 + createMaintenance(3, 0, 1), 133 + ]; 134 + 135 + const result = setDataByType({ 136 + events, 137 + data, 138 + cardType: "requests", 139 + barType: "absolute", 140 + }); 141 + 142 + expect(result[0].bar.length).toBeGreaterThan(1); 143 + // Should include info, degraded, and error segments 144 + const statuses = result[0].bar.map((b) => b.status); 145 + expect(statuses).toContain("error"); 146 + expect(statuses).toContain("degraded"); 147 + expect(statuses).toContain("info"); 148 + }); 149 + 150 + it("should show empty bar when no data available", () => { 151 + const data = [createStatusData(0, 0, 0, 0)]; 152 + const events: Event[] = []; 153 + 154 + const result = setDataByType({ 155 + events, 156 + data, 157 + cardType: "requests", 158 + barType: "absolute", 159 + }); 160 + 161 + expect(result[0].bar).toHaveLength(1); 162 + expect(result[0].bar[0].status).toBe("empty"); 163 + expect(result[0].bar[0].height).toBe(100); 164 + }); 165 + 166 + it("should show operational bar with duration cardType and no events", () => { 167 + const data = [createStatusData(0, 100, 0, 0)]; 168 + const events: Event[] = []; 169 + 170 + const result = setDataByType({ 171 + events, 172 + data, 173 + cardType: "duration", 174 + barType: "absolute", 175 + }); 176 + 177 + expect(result[0].bar).toHaveLength(1); 178 + expect(result[0].bar[0].status).toBe("success"); 179 + expect(result[0].bar[0].height).toBe(100); 180 + }); 181 + 182 + it("should show proportional status segments with mixed data and no events", () => { 183 + const data = [createStatusData(0, 80, 15, 5)]; 184 + const events: Event[] = []; 185 + 186 + const result = setDataByType({ 187 + events, 188 + data, 189 + cardType: "requests", 190 + barType: "absolute", 191 + }); 192 + 193 + expect(result[0].bar.length).toBeGreaterThan(1); 194 + const statuses = result[0].bar.map((b) => b.status); 195 + expect(statuses).toContain("success"); 196 + expect(statuses).toContain("degraded"); 197 + expect(statuses).toContain("error"); 198 + }); 199 + }); 200 + 201 + describe("barType: dominant", () => { 202 + it("should show error as dominant status when incident exists", () => { 203 + const data = [createStatusData(0, 100, 0, 0)]; 204 + const events = [createIncident(1, 0)]; 205 + 206 + const result = setDataByType({ 207 + events, 208 + data, 209 + cardType: "requests", 210 + barType: "dominant", 211 + }); 212 + 213 + expect(result[0].bar).toHaveLength(1); 214 + expect(result[0].bar[0].status).toBe("error"); 215 + expect(result[0].bar[0].height).toBe(100); 216 + }); 217 + 218 + it("should show degraded when only reports exist", () => { 219 + const data = [createStatusData(0, 100, 0, 0)]; 220 + const events = [createReport(1, 0)]; 221 + 222 + const result = setDataByType({ 223 + events, 224 + data, 225 + cardType: "requests", 226 + barType: "dominant", 227 + }); 228 + 229 + expect(result[0].bar).toHaveLength(1); 230 + expect(result[0].bar[0].status).toBe("degraded"); 231 + expect(result[0].bar[0].height).toBe(100); 232 + }); 233 + 234 + it("should show info when only maintenance exists", () => { 235 + const data = [createStatusData(0, 100, 0, 0)]; 236 + const events = [createMaintenance(1, 0)]; 237 + 238 + const result = setDataByType({ 239 + events, 240 + data, 241 + cardType: "requests", 242 + barType: "dominant", 243 + }); 244 + 245 + expect(result[0].bar).toHaveLength(1); 246 + expect(result[0].bar[0].status).toBe("info"); 247 + expect(result[0].bar[0].height).toBe(100); 248 + }); 249 + 250 + it("should prioritize error over other statuses", () => { 251 + const data = [createStatusData(0, 100, 0, 0)]; 252 + const events = [ 253 + createIncident(1, 0), 254 + createReport(2, 0), 255 + createMaintenance(3, 0), 256 + ]; 257 + 258 + const result = setDataByType({ 259 + events, 260 + data, 261 + cardType: "requests", 262 + barType: "dominant", 263 + }); 264 + 265 + expect(result[0].bar[0].status).toBe("error"); 266 + }); 267 + 268 + it("should show data status when no events", () => { 269 + const data = [createStatusData(0, 0, 100, 0)]; 270 + const events: Event[] = []; 271 + 272 + const result = setDataByType({ 273 + events, 274 + data, 275 + cardType: "requests", 276 + barType: "dominant", 277 + }); 278 + 279 + expect(result[0].bar[0].status).toBe("degraded"); 280 + }); 281 + }); 282 + 283 + describe("barType: manual", () => { 284 + it("should show degraded when reports exist", () => { 285 + const data = [createStatusData(0, 100, 0, 0)]; 286 + const events = [createReport(1, 0)]; 287 + 288 + const result = setDataByType({ 289 + events, 290 + data, 291 + cardType: "manual", 292 + barType: "manual", 293 + }); 294 + 295 + expect(result[0].bar).toHaveLength(1); 296 + expect(result[0].bar[0].status).toBe("degraded"); 297 + expect(result[0].bar[0].height).toBe(100); 298 + }); 299 + 300 + it("should show info when only maintenance exists", () => { 301 + const data = [createStatusData(0, 100, 0, 0)]; 302 + const events = [createMaintenance(1, 0)]; 303 + 304 + const result = setDataByType({ 305 + events, 306 + data, 307 + cardType: "manual", 308 + barType: "manual", 309 + }); 310 + 311 + expect(result[0].bar).toHaveLength(1); 312 + expect(result[0].bar[0].status).toBe("info"); 313 + expect(result[0].bar[0].height).toBe(100); 314 + }); 315 + 316 + it("should ignore incidents and show success", () => { 317 + const data = [createStatusData(0, 100, 0, 0)]; 318 + const events = [createIncident(1, 0)]; 319 + 320 + const result = setDataByType({ 321 + events, 322 + data, 323 + cardType: "manual", 324 + barType: "manual", 325 + }); 326 + 327 + expect(result[0].bar).toHaveLength(1); 328 + expect(result[0].bar[0].status).toBe("success"); 329 + }); 330 + 331 + it("should prioritize reports over maintenance", () => { 332 + const data = [createStatusData(0, 100, 0, 0)]; 333 + const events = [createReport(1, 0), createMaintenance(2, 0)]; 334 + 335 + const result = setDataByType({ 336 + events, 337 + data, 338 + cardType: "manual", 339 + barType: "manual", 340 + }); 341 + 342 + expect(result[0].bar[0].status).toBe("degraded"); 343 + }); 344 + }); 345 + 346 + describe("cardType: requests", () => { 347 + it("should show request counts for each status", () => { 348 + const data = [createStatusData(0, 100, 50, 10)]; 349 + const events: Event[] = []; 350 + 351 + const result = setDataByType({ 352 + events, 353 + data, 354 + cardType: "requests", 355 + barType: "dominant", 356 + }); 357 + 358 + expect(result[0].card.length).toBe(3); 359 + expect(result[0].card.some((c) => c.value.includes("100 reqs"))).toBe( 360 + true, 361 + ); 362 + expect(result[0].card.some((c) => c.value.includes("50 reqs"))).toBe( 363 + true, 364 + ); 365 + expect(result[0].card.some((c) => c.value.includes("10 reqs"))).toBe( 366 + true, 367 + ); 368 + }); 369 + 370 + it("should format large numbers correctly", () => { 371 + const data = [createStatusData(0, 5000, 0, 0)]; 372 + const events: Event[] = []; 373 + 374 + const result = setDataByType({ 375 + events, 376 + data, 377 + cardType: "requests", 378 + barType: "dominant", 379 + }); 380 + 381 + expect(result[0].card[0].value).toBe("5.0k reqs"); 382 + }); 383 + 384 + it("should show empty card when no data", () => { 385 + const data = [createStatusData(0, 0, 0, 0)]; 386 + const events: Event[] = []; 387 + 388 + const result = setDataByType({ 389 + events, 390 + data, 391 + cardType: "requests", 392 + barType: "dominant", 393 + }); 394 + 395 + expect(result[0].card).toHaveLength(1); 396 + expect(result[0].card[0].value).toBe(""); 397 + expect(result[0].card[0].status).toBe("empty"); 398 + }); 399 + 400 + it("should show event status in empty card when no data but events exist", () => { 401 + const data = [createStatusData(0, 0, 0, 0)]; 402 + const events = [createIncident(1, 0)]; 403 + 404 + const result = setDataByType({ 405 + events, 406 + data, 407 + cardType: "requests", 408 + barType: "dominant", 409 + }); 410 + 411 + expect(result[0].card).toHaveLength(1); 412 + expect(result[0].card[0].status).toBe("error"); 413 + }); 414 + }); 415 + 416 + describe("cardType: duration", () => { 417 + it("should calculate duration for events", () => { 418 + const data = [createStatusData(0, 100, 0, 0)]; 419 + const events = [ 420 + createIncident(1, 0, 1), // 1 hour 421 + createReport(2, 0, 2), // 2 hours 422 + ]; 423 + 424 + const result = setDataByType({ 425 + events, 426 + data, 427 + cardType: "duration", 428 + barType: "absolute", 429 + }); 430 + 431 + expect(result[0].card.length).toBeGreaterThan(0); 432 + // Should have durations for error, degraded, and success 433 + const hasError = result[0].card.some( 434 + (c) => c.status === "error" && c.value.includes("h"), 435 + ); 436 + const hasDegraded = result[0].card.some( 437 + (c) => c.status === "degraded" && c.value.includes("h"), 438 + ); 439 + expect(hasError || hasDegraded).toBe(true); 440 + }); 441 + 442 + it("should format duration in hours and minutes", () => { 443 + const data = [createStatusData(0, 100, 0, 0)]; 444 + const events = [createIncident(1, 0, 1.5)]; // 1.5 hours = 1h 30m 445 + 446 + const result = setDataByType({ 447 + events, 448 + data, 449 + cardType: "duration", 450 + barType: "absolute", 451 + }); 452 + 453 + const errorCard = result[0].card.find((c) => c.status === "error"); 454 + expect(errorCard).toBeDefined(); 455 + // Should contain hour notation and optionally minutes 456 + expect(errorCard?.value).toMatch(/\d+h(\s\d+m)?/); 457 + }); 458 + 459 + it("should show success duration as remaining time", () => { 460 + const data = [createStatusData(0, 100, 0, 0)]; 461 + const events = [createIncident(1, 0, 1)]; // 1 hour downtime 462 + 463 + const result = setDataByType({ 464 + events, 465 + data, 466 + cardType: "duration", 467 + barType: "absolute", 468 + }); 469 + 470 + const successCard = result[0].card.find((c) => c.status === "success"); 471 + expect(successCard).toBeDefined(); 472 + // Success duration should be total time minus downtime 473 + expect(successCard?.value).toBeTruthy(); 474 + }); 475 + 476 + it("should exclude maintenance from success calculation", () => { 477 + const data = [createStatusData(0, 100, 0, 0)]; 478 + const events = [createMaintenance(1, 0, 2)]; // 2 hours maintenance 479 + 480 + const result = setDataByType({ 481 + events, 482 + data, 483 + cardType: "duration", 484 + barType: "absolute", 485 + }); 486 + 487 + const successCard = result[0].card.find((c) => c.status === "success"); 488 + // Success should account for maintenance being excluded from total time 489 + expect(successCard).toBeDefined(); 490 + }); 491 + 492 + it("should show empty card when no data", () => { 493 + const data = [createStatusData(0, 0, 0, 0)]; 494 + const events: Event[] = []; 495 + 496 + const result = setDataByType({ 497 + events, 498 + data, 499 + cardType: "duration", 500 + barType: "absolute", 501 + }); 502 + 503 + expect(result[0].card).toHaveLength(1); 504 + expect(result[0].card[0].value).toBe(""); 505 + }); 506 + }); 507 + 508 + describe("cardType: dominant", () => { 509 + it("should show dominant status without value", () => { 510 + const data = [createStatusData(0, 100, 0, 0)]; 511 + const events = [createIncident(1, 0)]; 512 + 513 + const result = setDataByType({ 514 + events, 515 + data, 516 + cardType: "dominant", 517 + barType: "dominant", 518 + }); 519 + 520 + expect(result[0].card).toHaveLength(1); 521 + expect(result[0].card[0].status).toBe("error"); 522 + expect(result[0].card[0].value).toBe(""); 523 + }); 524 + }); 525 + 526 + describe("cardType: manual", () => { 527 + it("should show degraded for reports", () => { 528 + const data = [createStatusData(0, 100, 0, 0)]; 529 + const events = [createReport(1, 0)]; 530 + 531 + const result = setDataByType({ 532 + events, 533 + data, 534 + cardType: "manual", 535 + barType: "manual", 536 + }); 537 + 538 + expect(result[0].card).toHaveLength(1); 539 + expect(result[0].card[0].status).toBe("degraded"); 540 + expect(result[0].card[0].value).toBe(""); 541 + }); 542 + 543 + it("should show success when no manual events", () => { 544 + const data = [createStatusData(0, 100, 0, 0)]; 545 + const events = [createIncident(1, 0)]; 546 + 547 + const result = setDataByType({ 548 + events, 549 + data, 550 + cardType: "manual", 551 + barType: "manual", 552 + }); 553 + 554 + expect(result[0].card[0].status).toBe("success"); 555 + }); 556 + }); 557 + 558 + describe("event bundling", () => { 559 + it("should bundle more than 4 incidents into single event", () => { 560 + const data = [createStatusData(0, 100, 0, 0)]; 561 + const events = [ 562 + createIncident(1, 0), 563 + createIncident(2, 0), 564 + createIncident(3, 0), 565 + createIncident(4, 0), 566 + createIncident(5, 0), 567 + ]; 568 + 569 + const result = setDataByType({ 570 + events, 571 + data, 572 + cardType: "requests", 573 + barType: "absolute", 574 + }); 575 + 576 + // Should have bundled incident with special id -1 577 + const bundledIncident = result[0].events.find((e) => e.id === -1); 578 + expect(bundledIncident).toBeDefined(); 579 + expect(bundledIncident?.name).toContain("5 incidents"); 580 + }); 581 + 582 + it("should not bundle 4 or fewer incidents", () => { 583 + const data = [createStatusData(0, 100, 0, 0)]; 584 + const events = [ 585 + createIncident(1, 0), 586 + createIncident(2, 0), 587 + createIncident(3, 0), 588 + createIncident(4, 0), 589 + ]; 590 + 591 + const result = setDataByType({ 592 + events, 593 + data, 594 + cardType: "requests", 595 + barType: "absolute", 596 + }); 597 + 598 + // Should not have bundled incident 599 + const bundledIncident = result[0].events.find((e) => e.id === -1); 600 + expect(bundledIncident).toBeUndefined(); 601 + }); 602 + 603 + it("should not bundle incidents for non-absolute bar types", () => { 604 + const data = [createStatusData(0, 100, 0, 0)]; 605 + const events = [ 606 + createIncident(1, 0), 607 + createIncident(2, 0), 608 + createIncident(3, 0), 609 + createIncident(4, 0), 610 + createIncident(5, 0), 611 + ]; 612 + 613 + const result = setDataByType({ 614 + events, 615 + data, 616 + cardType: "requests", 617 + barType: "dominant", 618 + }); 619 + 620 + // Should not include any incidents in events array 621 + expect(result[0].events.length).toBe(0); 622 + }); 623 + }); 624 + 625 + describe("multiple days", () => { 626 + it("should handle data across multiple days", () => { 627 + const data = [ 628 + createStatusData(0, 100, 0, 0), 629 + createStatusData(1, 80, 20, 0), 630 + createStatusData(2, 60, 30, 10), 631 + ]; 632 + const events = [ 633 + createIncident(1, 0), 634 + createReport(2, 1), 635 + createMaintenance(3, 2), 636 + ]; 637 + 638 + const result = setDataByType({ 639 + events, 640 + data, 641 + cardType: "requests", 642 + barType: "dominant", 643 + }); 644 + 645 + expect(result).toHaveLength(3); 646 + expect(result[0].bar[0].status).toBe("error"); // Day 0 has incident 647 + expect(result[1].bar[0].status).toBe("degraded"); // Day 1 has report 648 + expect(result[2].bar[0].status).toBe("info"); // Day 2 has maintenance 649 + }); 650 + }); 651 + 652 + describe("edge cases", () => { 653 + it("should handle empty data array", () => { 654 + const data: StatusData[] = []; 655 + const events: Event[] = []; 656 + 657 + const result = setDataByType({ 658 + events, 659 + data, 660 + cardType: "requests", 661 + barType: "dominant", 662 + }); 663 + 664 + expect(result).toHaveLength(0); 665 + }); 666 + 667 + it("should handle events with null end date", () => { 668 + const data = [createStatusData(0, 100, 0, 0)]; 669 + const events: Event[] = [ 670 + { 671 + id: 1, 672 + name: "Ongoing Incident", 673 + from: new Date(), 674 + to: null, 675 + type: "incident", 676 + status: "error", 677 + }, 678 + ]; 679 + 680 + const result = setDataByType({ 681 + events, 682 + data, 683 + cardType: "duration", 684 + barType: "absolute", 685 + }); 686 + 687 + expect(result[0].bar.some((b) => b.status === "error")).toBe(true); 688 + }); 689 + 690 + it("should handle events spanning multiple days", () => { 691 + const data = [ 692 + createStatusData(0, 100, 0, 0), 693 + createStatusData(1, 100, 0, 0), 694 + ]; 695 + const from = new Date(); 696 + from.setDate(from.getDate() - 1); 697 + from.setHours(12, 0, 0, 0); 698 + const to = new Date(); 699 + to.setHours(12, 0, 0, 0); 700 + 701 + const events: Event[] = [ 702 + { 703 + id: 1, 704 + name: "Multi-day Incident", 705 + from, 706 + to, 707 + type: "incident", 708 + status: "error", 709 + }, 710 + ]; 711 + 712 + const result = setDataByType({ 713 + events, 714 + data, 715 + cardType: "requests", 716 + barType: "dominant", 717 + }); 718 + 719 + // Both days should show error status 720 + expect(result[0].bar[0].status).toBe("error"); 721 + expect(result[1].bar[0].status).toBe("error"); 722 + }); 723 + }); 724 + }); 725 + 726 + describe("fillStatusDataFor45Days", () => { 727 + it("should fill all 45 days", () => { 728 + const data: StatusData[] = []; 729 + const result = fillStatusDataFor45Days(data, "1"); 730 + 731 + expect(result).toHaveLength(45); 732 + }); 733 + 734 + it("should sort data by day oldest first", () => { 735 + const data: StatusData[] = []; 736 + const result = fillStatusDataFor45Days(data, "1"); 737 + 738 + for (let i = 1; i < result.length; i++) { 739 + const prev = new Date(result[i - 1].day); 740 + const curr = new Date(result[i].day); 741 + expect(curr.getTime()).toBeGreaterThan(prev.getTime()); 742 + } 743 + }); 744 + 745 + it("should preserve existing data", () => { 746 + const existingData = [createStatusData(5, 100, 50, 10)]; 747 + const result = fillStatusDataFor45Days(existingData, "1"); 748 + 749 + expect(result).toHaveLength(45); 750 + const matchingDay = result.find( 751 + (d) => d.ok === 100 && d.degraded === 50 && d.error === 10, 752 + ); 753 + expect(matchingDay).toBeDefined(); 754 + }); 755 + 756 + it("should fill missing days with zeros", () => { 757 + const existingData = [createStatusData(5, 100, 0, 0)]; 758 + const result = fillStatusDataFor45Days(existingData, "1"); 759 + 760 + const emptyDays = result.filter((d) => d.count === 0); 761 + expect(emptyDays.length).toBe(44); 762 + }); 763 + }); 764 + 765 + describe("getUptime", () => { 766 + describe("manual bar type", () => { 767 + it("should calculate uptime based on report durations", () => { 768 + const data = Array.from({ length: 45 }, (_, i) => 769 + createStatusData(i, 100, 0, 0), 770 + ); 771 + const events = [createReport(1, 0, 24)]; // 1 day downtime 772 + 773 + const uptime = getUptime({ 774 + data, 775 + events, 776 + barType: "manual", 777 + cardType: "manual", 778 + }); 779 + 780 + // Should be approximately 97.78% (44/45 days) 781 + expect(Number.parseFloat(uptime)).toBeGreaterThan(97); 782 + expect(Number.parseFloat(uptime)).toBeLessThan(98); 783 + }); 784 + 785 + it("should only consider reports not incidents", () => { 786 + const data = Array.from({ length: 45 }, (_, i) => 787 + createStatusData(i, 100, 0, 0), 788 + ); 789 + const events = [createIncident(1, 0, 24)]; // Should be ignored 790 + 791 + const uptime = getUptime({ 792 + data, 793 + events, 794 + barType: "manual", 795 + cardType: "manual", 796 + }); 797 + 798 + expect(uptime).toBe("100%"); 799 + }); 800 + }); 801 + 802 + describe("duration card type", () => { 803 + it("should calculate uptime based on incident durations", () => { 804 + const data = Array.from({ length: 45 }, (_, i) => 805 + createStatusData(i, 100, 0, 0), 806 + ); 807 + const events = [createIncident(1, 0, 24)]; // 1 day downtime 808 + 809 + const uptime = getUptime({ 810 + data, 811 + events, 812 + barType: "absolute", 813 + cardType: "duration", 814 + }); 815 + 816 + // Should be approximately 97.78% (44/45 days) 817 + expect(Number.parseFloat(uptime)).toBeGreaterThan(97); 818 + expect(Number.parseFloat(uptime)).toBeLessThan(98); 819 + }); 820 + it("should ignore reports when calculating duration uptime", () => { 821 + const data = Array.from({ length: 45 }, (_, i) => 822 + createStatusData(i, 100, 0, 0), 823 + ); 824 + const events = [createReport(2, 0, 24)]; // Should be ignored 825 + 826 + const uptime = getUptime({ 827 + data, 828 + events, 829 + barType: "absolute", 830 + cardType: "duration", 831 + }); 832 + 833 + expect(uptime).toBe("100%"); 834 + }); 835 + }); 836 + 837 + describe("request card type", () => { 838 + it("should calculate uptime based on ok vs total requests", () => { 839 + const data = [ 840 + createStatusData(0, 90, 5, 5), // 95 ok, 100 total 841 + createStatusData(1, 100, 0, 0), // 100 ok, 100 total 842 + ]; 843 + const events: Event[] = []; 844 + 845 + const uptime = getUptime({ 846 + data, 847 + events, 848 + barType: "absolute", 849 + cardType: "requests", 850 + }); 851 + 852 + // (90+5+100) / (90+5+5+100) = 195/200 = 97.5% 853 + expect(uptime).toBe("97.5%"); 854 + }); 855 + 856 + it("should count degraded as ok", () => { 857 + const data = [createStatusData(0, 80, 20, 0)]; 858 + const events: Event[] = []; 859 + 860 + const uptime = getUptime({ 861 + data, 862 + events, 863 + barType: "absolute", 864 + cardType: "requests", 865 + }); 866 + 867 + expect(uptime).toBe("100%"); 868 + }); 869 + 870 + it("should return 100% for empty data", () => { 871 + const data: StatusData[] = []; 872 + const events: Event[] = []; 873 + 874 + const uptime = getUptime({ 875 + data, 876 + events, 877 + barType: "absolute", 878 + cardType: "requests", 879 + }); 880 + 881 + expect(uptime).toBe("100%"); 882 + }); 883 + 884 + it("should return 100% when total is zero", () => { 885 + const data = [createStatusData(0, 0, 0, 0)]; 886 + const events: Event[] = []; 887 + 888 + const uptime = getUptime({ 889 + data, 890 + events, 891 + barType: "absolute", 892 + cardType: "requests", 893 + }); 894 + 895 + expect(uptime).toBe("100%"); 896 + }); 897 + }); 898 + });
+31 -3
packages/api/src/router/statusPage.utils.ts
··· 447 447 })); 448 448 } 449 449 450 + function createOperationalBarData(): UptimeData["bar"] { 451 + return [ 452 + { 453 + status: "success", 454 + height: 100, 455 + }, 456 + ]; 457 + } 458 + 450 459 function createEmptyBarData(): UptimeData["bar"] { 451 460 return [ 452 461 { ··· 603 612 // Empty day - no data available 604 613 barData = createEmptyBarData(); 605 614 } else { 606 - // Multiple segments for absolute view - show proportional distribution of status data 607 - const statusSegments = createStatusSegments(dayData); 608 - barData = segmentsToBarData(statusSegments, total); 615 + if (cardType === "duration") { 616 + // If no eventStatus and cardType is duration, show operational bar 617 + barData = createOperationalBarData(); 618 + } else { 619 + // Multiple segments for absolute view - show proportional distribution of status data 620 + const statusSegments = createStatusSegments(dayData); 621 + barData = segmentsToBarData(statusSegments, total); 622 + } 609 623 } 610 624 break; 611 625 case "dominant": ··· 755 769 data, 756 770 events, 757 771 barType, 772 + cardType, 758 773 }: { 759 774 data: StatusData[]; 760 775 events: Event[]; 761 776 barType: "absolute" | "dominant" | "manual"; 777 + cardType: "requests" | "duration" | "dominant" | "manual"; 762 778 }): string { 763 779 if (barType === "manual") { 764 780 const duration = events ··· 771 787 772 788 const total = data.length * MILLISECONDS_PER_DAY; 773 789 790 + return `${Math.round(((total - duration) / total) * 10000) / 100}%`; 791 + } 792 + 793 + if (cardType === "duration") { 794 + const duration = events 795 + .filter((e) => e.type === "incident") 796 + .reduce((acc, item) => { 797 + if (!item.from) return acc; 798 + return acc + ((item.to || new Date()).getTime() - item.from.getTime()); 799 + }, 0); 800 + 801 + const total = data.length * MILLISECONDS_PER_DAY; 774 802 return `${Math.round(((total - duration) / total) * 10000) / 100}%`; 775 803 } 776 804