diff --git a/migrations/20260412120000_add_page_monitor_groups.ts b/migrations/20260412120000_add_page_monitor_groups.ts new file mode 100644 index 000000000..1288e9404 --- /dev/null +++ b/migrations/20260412120000_add_page_monitor_groups.ts @@ -0,0 +1,87 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable("pages_monitor_groups"))) { + await knex.schema.createTable("pages_monitor_groups", (table) => { + table.increments("id").primary(); + table.integer("page_id").unsigned().notNullable(); + table.string("name", 255).notNullable(); + table.text("description").nullable(); + table.boolean("default_expanded").notNullable().defaultTo(false); + table.boolean("adopt_child_status").notNullable().defaultTo(false); + table.integer("position").notNullable().defaultTo(0); + table.timestamp("created_at").defaultTo(knex.fn.now()); + table.timestamp("updated_at").defaultTo(knex.fn.now()); + + table.foreign("page_id").references("id").inTable("pages").onDelete("CASCADE"); + table.index(["page_id"], "idx_pages_monitor_groups_page_id"); + table.index(["page_id", "position"], "idx_pages_monitor_groups_page_id_position"); + }); + } + + if ( + (await knex.schema.hasTable("pages_monitor_groups")) && + !(await knex.schema.hasColumn("pages_monitor_groups", "description")) + ) { + await knex.schema.alterTable("pages_monitor_groups", (table) => { + table.text("description").nullable(); + }); + } + + if ( + (await knex.schema.hasTable("pages_monitor_groups")) && + !(await knex.schema.hasColumn("pages_monitor_groups", "default_expanded")) + ) { + await knex.schema.alterTable("pages_monitor_groups", (table) => { + table.boolean("default_expanded").notNullable().defaultTo(false); + }); + } + + if ((await knex.schema.hasTable("pages_monitors")) && !(await knex.schema.hasColumn("pages_monitors", "page_monitor_group_id"))) { + await knex.schema.alterTable("pages_monitors", (table) => { + table.integer("page_monitor_group_id").unsigned().nullable(); + table + .foreign("page_monitor_group_id") + .references("id") + .inTable("pages_monitor_groups") + .onDelete("SET NULL"); + table.index(["page_monitor_group_id"], "idx_pages_monitors_group_id"); + }); + } +} + +export async function down(knex: Knex): Promise { + if ((await knex.schema.hasTable("pages_monitors")) && (await knex.schema.hasColumn("pages_monitors", "page_monitor_group_id"))) { + try { + await knex.schema.alterTable("pages_monitors", (table) => { + table.dropIndex(["page_monitor_group_id"], "idx_pages_monitors_group_id"); + }); + } catch {} + + try { + await knex.schema.alterTable("pages_monitors", (table) => { + table.dropForeign(["page_monitor_group_id"]); + }); + } catch {} + + await knex.schema.alterTable("pages_monitors", (table) => { + table.dropColumn("page_monitor_group_id"); + }); + } + + if (await knex.schema.hasTable("pages_monitor_groups")) { + try { + await knex.schema.alterTable("pages_monitor_groups", (table) => { + table.dropIndex(["page_id", "position"], "idx_pages_monitor_groups_page_id_position"); + }); + } catch {} + + try { + await knex.schema.alterTable("pages_monitor_groups", (table) => { + table.dropIndex(["page_id"], "idx_pages_monitor_groups_page_id"); + }); + } catch {} + } + + await knex.schema.dropTableIfExists("pages_monitor_groups"); +} diff --git a/src/lib/allPerms.ts b/src/lib/allPerms.ts index a95186644..3ac52c152 100644 --- a/src/lib/allPerms.ts +++ b/src/lib/allPerms.ts @@ -14,7 +14,7 @@ * maintenances.write → createMaintenance, updateMaintenance, deleteMaintenance, createMaintenanceEvent, updateMaintenanceEvent, deleteMaintenanceEvent, addMonitorToMaintenance, removeMonitorFromMaintenance, updateMaintenanceMonitorImpact * * pages.read → getPages - * pages.write → createPage, updatePage, deletePage, addMonitorToPage, removeMonitorFromPage, reorderPageMonitors + * pages.write → createPage, updatePage, deletePage, addMonitorToPage, removeMonitorFromPage, reorderPageMonitors, createPageMonitorGroup, updatePageMonitorGroup, deletePageMonitorGroup, movePageMonitorToGroup, reorderPageTopLevelItems, reorderPageGroupMonitors, replacePageMonitorStructure * * triggers.read → getTriggers * triggers.write → createUpdateTrigger, updateMonitorTriggers, deleteTrigger, testTrigger @@ -162,6 +162,13 @@ export const ACTION_PERMISSION_MAP: Record = { addMonitorToPage: "pages.write", removeMonitorFromPage: "pages.write", reorderPageMonitors: "pages.write", + createPageMonitorGroup: "pages.write", + updatePageMonitorGroup: "pages.write", + deletePageMonitorGroup: "pages.write", + movePageMonitorToGroup: "pages.write", + reorderPageTopLevelItems: "pages.write", + reorderPageGroupMonitors: "pages.write", + replacePageMonitorStructure: "pages.write", // Triggers getTriggers: "triggers.read", diff --git a/src/lib/components/GroupDayDetail.svelte b/src/lib/components/GroupDayDetail.svelte new file mode 100644 index 000000000..a24fbdbf5 --- /dev/null +++ b/src/lib/components/GroupDayDetail.svelte @@ -0,0 +1,221 @@ + + + + + + + + + {selectedDay ? $formatDate(new Date(selectedDay.timestamp * 1000), page.data.dateAndTimeFormat.dateOnly) : ""} + + + + {$t("Minute-by-minute status data for this day")} + {#if groupName} + · {groupName} + {/if} + + + + + + + {$t("Status")} + + + {$t("Incidents")} + + + {$t("Maintenances")} + + + + + {#if dayDetailPromise} + {#await dayDetailPromise} +
+
+ + +
+
+ +
+
+ {:then dayDetailData} + + {:catch} +
+

{$t("Failed to load status data for this day")}

+
+ {/await} + {:else} +
+

{$t("Failed to load status data for this day")}

+
+ {/if} +
+ + + {#if dayIncidentsPromise} + {#await dayIncidentsPromise} +
+
+ + +
+ +
+ {:then result} + {#if result.incidents.length > 0} +
+
+ {#each result.incidents as incident (incident.id)} +
+ +
+ {/each} +
+
+ {:else} +
+

{$t("No incidents for this day")}

+
+ {/if} + {:catch} +
+

{$t("No incidents for this day")}

+
+ {/await} + {:else} +
+

{$t("No incidents for this day")}

+
+ {/if} +
+ + + {#if dayMaintenancesPromise} + {#await dayMaintenancesPromise} +
+
+ + +
+ +
+ {:then result} + {#if result.maintenances.length > 0} +
+ {#each result.maintenances as maintenance (maintenance.id)} +
+ +
+ {/each} +
+ {:else} +
+

{$t("No maintenances for this day")}

+
+ {/if} + {:catch} +
+

{$t("No maintenances for this day")}

+
+ {/await} + {:else} +
+

{$t("No maintenances for this day")}

+
+ {/if} +
+
+
+
diff --git a/src/lib/components/GroupMonitorInlineExpansion.svelte b/src/lib/components/GroupMonitorInlineExpansion.svelte new file mode 100644 index 000000000..14512a97b --- /dev/null +++ b/src/lib/components/GroupMonitorInlineExpansion.svelte @@ -0,0 +1,135 @@ + + +
+ + + {#if expanded} +
+ {#if tags.length === 0} +
{$t("No monitors available.")}
+ {:else} + {#each tags as tag, index (tag)} +
+ {#if prefetchedDataByTag[tag] || prefetchedErrorByTag[tag]} + + {:else if monitorBarPromiseByTag[tag]} + {#await monitorBarPromiseByTag[tag]} + + {:then monitorBarData} + + {:catch err} + + {/await} + {:else} + + {/if} +
+ {/each} + {/if} +
+ {/if} +
diff --git a/src/lib/components/MonitorBar.svelte b/src/lib/components/MonitorBar.svelte index 332bd2809..0e87cbec7 100644 --- a/src/lib/components/MonitorBar.svelte +++ b/src/lib/components/MonitorBar.svelte @@ -9,7 +9,7 @@ import clientResolver from "$lib/client/resolver.js"; import { formatDate } from "$lib/stores/datetime"; import { GetInitials } from "$lib/clientTools.js"; - import GroupMonitorPopover from "./GroupMonitorPopover.svelte"; + import GroupMonitorInlineExpansion from "./GroupMonitorInlineExpansion.svelte"; import { t } from "$lib/stores/i18n"; import { page } from "$app/state"; @@ -18,6 +18,7 @@ prefetchedData?: MonitorBarResponse; prefetchedError?: string; groupChildTags?: string[]; + groupChildTagsByTag?: Record; days?: number; endOfDayTodayAtTz?: number; compact?: boolean; @@ -29,6 +30,7 @@ prefetchedData, prefetchedError, groupChildTags = [], + groupChildTagsByTag = {}, days, endOfDayTodayAtTz, compact = false, @@ -147,13 +149,15 @@ {/if} {#if showGroupPopover}
- {$t("Included Monitors (%count)", { count: String(groupChildTags.length) })} - +
{/if} {/if} diff --git a/src/lib/components/MonitorOverview.svelte b/src/lib/components/MonitorOverview.svelte index c6ee111f5..d2c82259c 100644 --- a/src/lib/components/MonitorOverview.svelte +++ b/src/lib/components/MonitorOverview.svelte @@ -16,7 +16,7 @@ import { requestMonitorBar, clearMonitorBarCache } from "$lib/client/monitor-bar-client"; import * as Popover from "$lib/components/ui/popover/index.js"; import * as ToggleGroup from "$lib/components/ui/toggle-group/index.js"; - import GroupMonitorPopover from "$lib/components/GroupMonitorPopover.svelte"; + import GroupMonitorInlineExpansion from "$lib/components/GroupMonitorInlineExpansion.svelte"; import { page } from "$app/state"; interface Props { @@ -24,9 +24,10 @@ class?: string; maxDays?: number; groupTags?: string[]; + groupChildTagsByTag?: Record; } - let { monitorTag, class: className = "", maxDays = 90, groupTags = [] }: Props = $props(); + let { monitorTag, class: className = "", maxDays = 90, groupTags = [], groupChildTagsByTag = {} }: Props = $props(); // State let loading = $state(true); @@ -215,13 +216,18 @@

- + {#if groupTags.length > 0}
- + {$t("Included Monitors (%count)", { count: String(groupTags.length) })} - +
{/if} diff --git a/src/lib/components/PageMonitorGroupSection.svelte b/src/lib/components/PageMonitorGroupSection.svelte new file mode 100644 index 000000000..6a8084de2 --- /dev/null +++ b/src/lib/components/PageMonitorGroupSection.svelte @@ -0,0 +1,174 @@ + + +
+ + + {#if expanded} +
+ {#if group.monitors.length === 0} +
{$t("No monitors available.")}
+ {:else} + {#each group.monitors as monitor, index (monitor.monitor_tag)} +
+ +
+ {/each} + {/if} +
+ {/if} +
diff --git a/src/lib/components/PageMonitorTree.svelte b/src/lib/components/PageMonitorTree.svelte new file mode 100644 index 000000000..b4a843e2a --- /dev/null +++ b/src/lib/components/PageMonitorTree.svelte @@ -0,0 +1,129 @@ + + +
+
+ {#each items as item, index (`${item.kind}-${item.kind === "monitor" ? item.monitor_tag : item.id}`)} +
+ {#if item.kind === "monitor"} + + {:else} + + {/if} +
+ {/each} +
+
diff --git a/src/lib/components/StatusBarCalendar.svelte b/src/lib/components/StatusBarCalendar.svelte index b6fca57a8..a8cd1ad5f 100644 --- a/src/lib/components/StatusBarCalendar.svelte +++ b/src/lib/components/StatusBarCalendar.svelte @@ -4,6 +4,7 @@ import { page } from "$app/state"; import { GetStatusSummary, ParseLatency } from "$lib/clientTools"; import MonitorDayDetail from "$lib/components/MonitorDayDetail.svelte"; + import GroupDayDetail from "$lib/components/GroupDayDetail.svelte"; import type { TimestampStatusCount } from "$lib/server/types/db"; import { t } from "$lib/stores/i18n"; import { formatDate } from "$lib/stores/datetime"; @@ -16,9 +17,22 @@ radius?: number; class?: string; disableClick?: boolean; + detailKind?: "monitor" | "group"; + detailTitle?: string; + detailMonitorTags?: string[]; } - let { data, monitorTag, barHeight = 40, radius = 8, class: className = "", disableClick = false }: Props = $props(); + let { + data, + monitorTag, + barHeight = 40, + radius = 8, + class: className = "", + disableClick = false, + detailKind = "monitor", + detailTitle = "", + detailMonitorTags = [], + }: Props = $props(); // Canvas state let canvas = $state(null); @@ -359,7 +373,7 @@ {$t(GetStatusSummary(hoveredBar.data))} @ {$formatDate(hoveredBar.data.ts, page.data.dateAndTimeFormat.dateOnly)} - {#if hoveredBar.data.avgLatency > 0} + {#if detailKind !== "group" && hoveredBar.data.avgLatency > 0} | {ParseLatency(hoveredBar.data.avgLatency)} {/if} @@ -385,7 +399,11 @@ - +{#if detailKind === "group"} + +{:else} + +{/if}