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}
+
+ {: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}
+
+ {: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 @@