mirror of
https://github.com/rajnandan1/kener.git
synced 2026-06-23 04:10:22 +00:00
Merge pull request #746 from rajnandan1/fix/issue-736
refactor(api): enhance page settings management and validation implem…
This commit is contained in:
@@ -47,6 +47,14 @@ A public status page with its own path, title, monitors, and display settings. S
|
||||
The Page served at the site root. Its stored path is empty, it always exists (it can not be deleted), and its path can not be changed. Addressed in the API by the `~home` token.
|
||||
_Avoid_: Default page, base page, root page
|
||||
|
||||
**Status History Window**:
|
||||
The number of days of per-day status shown for a monitor, per device class (desktop/mobile). Configurable at two levels with the same defaults and bounds: per Page (applies to all its monitors) and per Monitor (overrides the page level).
|
||||
_Avoid_: History days, bar count
|
||||
|
||||
**Page Settings**:
|
||||
A Page's display configuration: status-history window per device class, monitor layout style, per-page meta/social overrides, and event display preferences. The admin UI and the API expose the same settings, though each surface may name fields differently; a writer must never drop fields it does not understand.
|
||||
_Avoid_: Display settings (ambiguous with site-wide event display settings)
|
||||
|
||||
### Maintenance
|
||||
|
||||
**Maintenance**:
|
||||
|
||||
@@ -72,6 +72,15 @@ export default {
|
||||
// Special path segment addressing the home page in the v4 API; its stored
|
||||
// page_path is an empty string. See docs/adr/0004-home-page-api-token.md.
|
||||
HOME_PAGE_TOKEN: "~home",
|
||||
// Status history window (days of per-day status shown), shared by pages and
|
||||
// monitors, the manage UI, the public pages, and the v4 API
|
||||
DEFAULT_STATUS_HISTORY_DAYS_DESKTOP: 90,
|
||||
DEFAULT_STATUS_HISTORY_DAYS_MOBILE: 30,
|
||||
STATUS_HISTORY_DAYS_MIN: 1,
|
||||
STATUS_HISTORY_DAYS_MAX: 365,
|
||||
// Monitor layout styles available on status pages
|
||||
MONITOR_LAYOUT_STYLES: ["default-list", "default-grid", "compact-list", "compact-grid"],
|
||||
DEFAULT_MONITOR_LAYOUT_STYLE: "default-list",
|
||||
DOCS_URL: "https://kener.ing/docs",
|
||||
MAX_UPLOAD_BYTES: 2 * 1024 * 1024, // 2MB
|
||||
MAX_IMAGE_DIMENSION: 4096,
|
||||
|
||||
@@ -21,10 +21,10 @@ import type { LayoutServerData } from "./layoutController.js";
|
||||
// Default page settings
|
||||
const defaultPageSettings: PageSettingsType = {
|
||||
monitor_status_history_days: {
|
||||
desktop: 90,
|
||||
mobile: 30,
|
||||
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
|
||||
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE,
|
||||
},
|
||||
monitor_layout_style: "default-list",
|
||||
monitor_layout_style: GC.DEFAULT_MONITOR_LAYOUT_STYLE,
|
||||
};
|
||||
|
||||
export interface NotificationEvent {
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import type { PageSettings, PageSettingsPatch } from "$lib/types/api";
|
||||
import GC from "$lib/global-constants";
|
||||
|
||||
// Stored page_settings_json keys differ from the API contract for the meta
|
||||
// fields: the manage UI writes camelCase (metaPageTitle, metaPageDescription,
|
||||
// socialPagePreviewImage) while the v4 API exposes snake_case. The mapping
|
||||
// lives here, at the storage boundary.
|
||||
interface StoredPageSettings {
|
||||
incidents?: unknown;
|
||||
include_maintenances?: unknown;
|
||||
monitor_status_history_days?: { desktop?: number; mobile?: number };
|
||||
monitor_layout_style?: string;
|
||||
metaPageTitle?: string;
|
||||
metaPageDescription?: string;
|
||||
socialPagePreviewImage?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const HISTORY_DAYS_MIN = GC.STATUS_HISTORY_DAYS_MIN;
|
||||
const HISTORY_DAYS_MAX = GC.STATUS_HISTORY_DAYS_MAX;
|
||||
|
||||
export function getDefaultPageSettings(): PageSettings {
|
||||
return {
|
||||
incidents: {
|
||||
enabled: true,
|
||||
ongoing: { show: true },
|
||||
resolved: { show: true, max_count: 5, days_in_past: 7 },
|
||||
},
|
||||
include_maintenances: {
|
||||
enabled: true,
|
||||
ongoing: {
|
||||
show: true,
|
||||
past: { show: true, max_count: 5, days_in_past: 7 },
|
||||
upcoming: { show: true, max_count: 5, days_in_future: 30 },
|
||||
},
|
||||
},
|
||||
monitor_status_history_days: {
|
||||
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
|
||||
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE,
|
||||
},
|
||||
monitor_layout_style: GC.DEFAULT_MONITOR_LAYOUT_STYLE,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStored(storedJson: string | null | undefined): StoredPageSettings {
|
||||
if (!storedJson) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(storedJson);
|
||||
return typeof parsed === "object" && parsed !== null ? (parsed as StoredPageSettings) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// Recursively merges patch into base: objects merge key-by-key, everything
|
||||
// else replaces. Keys absent from the patch — including ones this module does
|
||||
// not know about — are left untouched.
|
||||
function deepMerge(base: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { ...base };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined) continue;
|
||||
const current = result[key];
|
||||
result[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mergePageSettings(defaults: PageSettings, partial?: PageSettingsPatch): PageSettings {
|
||||
if (!partial) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
const merged: PageSettings = {
|
||||
incidents: {
|
||||
enabled: partial.incidents?.enabled ?? defaults.incidents.enabled,
|
||||
ongoing: {
|
||||
show: partial.incidents?.ongoing?.show ?? defaults.incidents.ongoing.show,
|
||||
},
|
||||
resolved: {
|
||||
show: partial.incidents?.resolved?.show ?? defaults.incidents.resolved.show,
|
||||
max_count: partial.incidents?.resolved?.max_count ?? defaults.incidents.resolved.max_count,
|
||||
days_in_past: partial.incidents?.resolved?.days_in_past ?? defaults.incidents.resolved.days_in_past,
|
||||
},
|
||||
},
|
||||
include_maintenances: {
|
||||
enabled: partial.include_maintenances?.enabled ?? defaults.include_maintenances.enabled,
|
||||
ongoing: {
|
||||
show: partial.include_maintenances?.ongoing?.show ?? defaults.include_maintenances.ongoing.show,
|
||||
past: {
|
||||
show: partial.include_maintenances?.ongoing?.past?.show ?? defaults.include_maintenances.ongoing.past.show,
|
||||
max_count:
|
||||
partial.include_maintenances?.ongoing?.past?.max_count ??
|
||||
defaults.include_maintenances.ongoing.past.max_count,
|
||||
days_in_past:
|
||||
partial.include_maintenances?.ongoing?.past?.days_in_past ??
|
||||
defaults.include_maintenances.ongoing.past.days_in_past,
|
||||
},
|
||||
upcoming: {
|
||||
show:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.show ??
|
||||
defaults.include_maintenances.ongoing.upcoming.show,
|
||||
max_count:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.max_count ??
|
||||
defaults.include_maintenances.ongoing.upcoming.max_count,
|
||||
days_in_future:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.days_in_future ??
|
||||
defaults.include_maintenances.ongoing.upcoming.days_in_future,
|
||||
},
|
||||
},
|
||||
},
|
||||
monitor_status_history_days: {
|
||||
desktop: partial.monitor_status_history_days?.desktop ?? defaults.monitor_status_history_days.desktop,
|
||||
mobile: partial.monitor_status_history_days?.mobile ?? defaults.monitor_status_history_days.mobile,
|
||||
},
|
||||
monitor_layout_style: partial.monitor_layout_style ?? defaults.monitor_layout_style,
|
||||
};
|
||||
|
||||
const metaPageTitle = partial.meta_page_title ?? defaults.meta_page_title;
|
||||
const metaPageDescription = partial.meta_page_description ?? defaults.meta_page_description;
|
||||
const socialPagePreviewImage = partial.social_page_preview_image ?? defaults.social_page_preview_image;
|
||||
if (metaPageTitle !== undefined) merged.meta_page_title = metaPageTitle;
|
||||
if (metaPageDescription !== undefined) merged.meta_page_description = metaPageDescription;
|
||||
if (socialPagePreviewImage !== undefined) merged.social_page_preview_image = socialPagePreviewImage;
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isValidHistoryDays(value: unknown): boolean {
|
||||
return Number.isInteger(value) && (value as number) >= HISTORY_DAYS_MIN && (value as number) <= HISTORY_DAYS_MAX;
|
||||
}
|
||||
|
||||
const boolOrUndefined = (value: unknown): boolean | undefined => (typeof value === "boolean" ? value : undefined);
|
||||
const countOrUndefined = (value: unknown): number | undefined =>
|
||||
Number.isInteger(value) && (value as number) >= 0 ? (value as number) : undefined;
|
||||
|
||||
// Read-side sanitizers: keep only correctly-typed leaves from stored event
|
||||
// branches so wrong-typed values (e.g. enabled: "yes" from manual edits or
|
||||
// older versions) never override defaults in API responses
|
||||
function sanitizeStoredIncidents(value: unknown): PageSettingsPatch["incidents"] {
|
||||
if (!isPlainObject(value)) return undefined;
|
||||
const ongoing = isPlainObject(value.ongoing) ? value.ongoing : {};
|
||||
const resolved = isPlainObject(value.resolved) ? value.resolved : {};
|
||||
return {
|
||||
enabled: boolOrUndefined(value.enabled),
|
||||
ongoing: { show: boolOrUndefined(ongoing.show) },
|
||||
resolved: {
|
||||
show: boolOrUndefined(resolved.show),
|
||||
max_count: countOrUndefined(resolved.max_count),
|
||||
days_in_past: countOrUndefined(resolved.days_in_past),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeStoredMaintenances(value: unknown): PageSettingsPatch["include_maintenances"] {
|
||||
if (!isPlainObject(value)) return undefined;
|
||||
const ongoing = isPlainObject(value.ongoing) ? value.ongoing : {};
|
||||
const past = isPlainObject(ongoing.past) ? ongoing.past : {};
|
||||
const upcoming = isPlainObject(ongoing.upcoming) ? ongoing.upcoming : {};
|
||||
return {
|
||||
enabled: boolOrUndefined(value.enabled),
|
||||
ongoing: {
|
||||
show: boolOrUndefined(ongoing.show),
|
||||
past: {
|
||||
show: boolOrUndefined(past.show),
|
||||
max_count: countOrUndefined(past.max_count),
|
||||
days_in_past: countOrUndefined(past.days_in_past),
|
||||
},
|
||||
upcoming: {
|
||||
show: boolOrUndefined(upcoming.show),
|
||||
max_count: countOrUndefined(upcoming.max_count),
|
||||
days_in_future: countOrUndefined(upcoming.days_in_future),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isValidLayoutStyle(value: unknown): value is PageSettings["monitor_layout_style"] {
|
||||
return (GC.MONITOR_LAYOUT_STYLES as readonly string[]).includes(value as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the API view of stored settings: defaults overlaid with stored
|
||||
* values. Stored values that violate the API contract (unknown layout style,
|
||||
* out-of-range days — e.g. from manual edits or older versions) are ignored
|
||||
* so responses stay schema-compliant.
|
||||
*/
|
||||
export function toApiPageSettings(storedJson: string | null | undefined): PageSettings {
|
||||
const stored = parseStored(storedJson);
|
||||
const storedDays = isPlainObject(stored.monitor_status_history_days) ? stored.monitor_status_history_days : {};
|
||||
const fromStore: PageSettingsPatch = {
|
||||
incidents: sanitizeStoredIncidents(stored.incidents),
|
||||
include_maintenances: sanitizeStoredMaintenances(stored.include_maintenances),
|
||||
monitor_status_history_days: {
|
||||
desktop: isValidHistoryDays(storedDays.desktop) ? (storedDays.desktop as number) : undefined,
|
||||
mobile: isValidHistoryDays(storedDays.mobile) ? (storedDays.mobile as number) : undefined,
|
||||
},
|
||||
monitor_layout_style: isValidLayoutStyle(stored.monitor_layout_style) ? stored.monitor_layout_style : undefined,
|
||||
meta_page_title: typeof stored.metaPageTitle === "string" ? stored.metaPageTitle : undefined,
|
||||
meta_page_description: typeof stored.metaPageDescription === "string" ? stored.metaPageDescription : undefined,
|
||||
social_page_preview_image:
|
||||
typeof stored.socialPagePreviewImage === "string" ? stored.socialPagePreviewImage : undefined,
|
||||
};
|
||||
return mergePageSettings(getDefaultPageSettings(), fromStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-merges a partial API payload into the stored settings JSON and returns
|
||||
* the new JSON string. Only keys present in the patch are written; everything
|
||||
* else in the stored JSON — including nested keys and top-level keys this
|
||||
* module does not know about — is preserved, so an API write can never wipe
|
||||
* settings written by other parts of the app, and clients may persist extra
|
||||
* keys (the schema allows additional properties).
|
||||
*/
|
||||
export function applyPageSettingsPatch(
|
||||
storedJson: string | null | undefined,
|
||||
patch: PageSettingsPatch | undefined,
|
||||
): string {
|
||||
const stored = parseStored(storedJson);
|
||||
if (!patch) {
|
||||
return JSON.stringify(stored);
|
||||
}
|
||||
|
||||
// Map the API's snake_case meta fields to their stored camelCase keys; all
|
||||
// other keys are stored under their API names
|
||||
const { meta_page_title, meta_page_description, social_page_preview_image, ...rest } = patch;
|
||||
const mappedPatch: Record<string, unknown> = { ...rest };
|
||||
if (meta_page_title !== undefined) mappedPatch.metaPageTitle = meta_page_title;
|
||||
if (meta_page_description !== undefined) mappedPatch.metaPageDescription = meta_page_description;
|
||||
if (social_page_preview_image !== undefined) mappedPatch.socialPagePreviewImage = social_page_preview_image;
|
||||
|
||||
return JSON.stringify(deepMerge(stored, mappedPatch));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a partial page_settings payload from the API. Returns an error
|
||||
* message, or null when valid. Bounds mirror the manage UI (history days
|
||||
* 1-365, layout style one of the four shipped styles).
|
||||
*/
|
||||
export function validatePageSettings(partial: unknown): string | null {
|
||||
if (partial === undefined) return null;
|
||||
if (typeof partial !== "object" || partial === null || Array.isArray(partial)) {
|
||||
return "page_settings must be an object";
|
||||
}
|
||||
|
||||
const settings = partial as PageSettingsPatch;
|
||||
|
||||
// The event display branches and their known sub-objects must be objects;
|
||||
// anything else would be deep-merged into storage as-is
|
||||
if (settings.incidents !== undefined) {
|
||||
if (!isPlainObject(settings.incidents)) {
|
||||
return "incidents must be an object";
|
||||
}
|
||||
for (const key of ["ongoing", "resolved"] as const) {
|
||||
if (settings.incidents[key] !== undefined && !isPlainObject(settings.incidents[key])) {
|
||||
return `incidents.${key} must be an object`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.include_maintenances !== undefined) {
|
||||
if (!isPlainObject(settings.include_maintenances)) {
|
||||
return "include_maintenances must be an object";
|
||||
}
|
||||
const ongoing = settings.include_maintenances.ongoing;
|
||||
if (ongoing !== undefined) {
|
||||
if (!isPlainObject(ongoing)) {
|
||||
return "include_maintenances.ongoing must be an object";
|
||||
}
|
||||
for (const key of ["past", "upcoming"] as const) {
|
||||
if (ongoing[key] !== undefined && !isPlainObject(ongoing[key])) {
|
||||
return `include_maintenances.ongoing.${key} must be an object`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leaf types inside the event branches must match the schema
|
||||
const leafChecks: Array<{ path: readonly string[]; kind: "boolean" | "count" }> = [
|
||||
{ path: ["incidents", "enabled"], kind: "boolean" },
|
||||
{ path: ["incidents", "ongoing", "show"], kind: "boolean" },
|
||||
{ path: ["incidents", "resolved", "show"], kind: "boolean" },
|
||||
{ path: ["incidents", "resolved", "max_count"], kind: "count" },
|
||||
{ path: ["incidents", "resolved", "days_in_past"], kind: "count" },
|
||||
{ path: ["include_maintenances", "enabled"], kind: "boolean" },
|
||||
{ path: ["include_maintenances", "ongoing", "show"], kind: "boolean" },
|
||||
{ path: ["include_maintenances", "ongoing", "past", "show"], kind: "boolean" },
|
||||
{ path: ["include_maintenances", "ongoing", "past", "max_count"], kind: "count" },
|
||||
{ path: ["include_maintenances", "ongoing", "past", "days_in_past"], kind: "count" },
|
||||
{ path: ["include_maintenances", "ongoing", "upcoming", "show"], kind: "boolean" },
|
||||
{ path: ["include_maintenances", "ongoing", "upcoming", "max_count"], kind: "count" },
|
||||
{ path: ["include_maintenances", "ongoing", "upcoming", "days_in_future"], kind: "count" },
|
||||
];
|
||||
for (const { path, kind } of leafChecks) {
|
||||
let value: unknown = settings;
|
||||
for (const key of path) {
|
||||
if (!isPlainObject(value)) {
|
||||
value = undefined;
|
||||
break;
|
||||
}
|
||||
value = value[key];
|
||||
}
|
||||
if (value === undefined) continue;
|
||||
if (kind === "boolean" && typeof value !== "boolean") {
|
||||
return `${path.join(".")} must be a boolean`;
|
||||
}
|
||||
if (kind === "count" && !(Number.isInteger(value) && (value as number) >= 0)) {
|
||||
return `${path.join(".")} must be a non-negative integer`;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.monitor_layout_style !== undefined) {
|
||||
if (!GC.MONITOR_LAYOUT_STYLES.includes(settings.monitor_layout_style)) {
|
||||
return `monitor_layout_style must be one of: ${GC.MONITOR_LAYOUT_STYLES.join(", ")}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.monitor_status_history_days !== undefined) {
|
||||
const days = settings.monitor_status_history_days;
|
||||
if (typeof days !== "object" || days === null || Array.isArray(days)) {
|
||||
return "monitor_status_history_days must be an object";
|
||||
}
|
||||
for (const key of ["desktop", "mobile"] as const) {
|
||||
const value = days[key];
|
||||
if (value !== undefined) {
|
||||
if (!Number.isInteger(value) || value < HISTORY_DAYS_MIN || value > HISTORY_DAYS_MAX) {
|
||||
return `monitor_status_history_days.${key} must be an integer between ${HISTORY_DAYS_MIN} and ${HISTORY_DAYS_MAX}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ["meta_page_title", "meta_page_description", "social_page_preview_image"] as const) {
|
||||
const value = settings[key];
|
||||
if (value !== undefined && typeof value !== "string") {
|
||||
return `${key} must be a string`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Server-only database types (based on migrations schema)
|
||||
import type { Knex } from "knex";
|
||||
import type { PageMonitorLayoutStyle } from "$lib/types/api";
|
||||
|
||||
// ============ monitoring_data table ============
|
||||
export interface MonitoringData {
|
||||
@@ -468,7 +469,7 @@ export interface PageSettingsType {
|
||||
desktop: number;
|
||||
mobile: number;
|
||||
};
|
||||
monitor_layout_style: "default-list" | "default-grid" | "compact-list" | "compact-grid";
|
||||
monitor_layout_style: PageMonitorLayoutStyle;
|
||||
metaPageTitle?: string;
|
||||
metaPageDescription?: string;
|
||||
socialPagePreviewImage?: string;
|
||||
|
||||
+34
-2
@@ -3,6 +3,7 @@
|
||||
|
||||
import type { MonitorRecordTyped } from "$lib/server/types/db";
|
||||
import type { MonitorPublicView } from "$lib/types/monitor";
|
||||
import type GC from "$lib/global-constants";
|
||||
|
||||
export type ApiError = {
|
||||
code: string;
|
||||
@@ -449,9 +450,40 @@ export interface PageSettingsMaintenances {
|
||||
ongoing: PageSettingsMaintenancesOngoing;
|
||||
}
|
||||
|
||||
export interface PageSettingsHistoryDays {
|
||||
desktop: number;
|
||||
mobile: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive partial, so patch payloads can update any subset of nested fields.
|
||||
* Recursion applies only to plain object maps; arrays and other special object
|
||||
* types pass through unchanged.
|
||||
*/
|
||||
export type DeepPartial<T> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[K in keyof T]?: T[K] extends (infer U)[] ? U[] : T[K] extends Record<string, any> ? DeepPartial<T[K]> : T[K];
|
||||
};
|
||||
|
||||
/**
|
||||
* Patch payload for page_settings: any subset of nested fields. Provided
|
||||
* fields are deep-merged into the current settings; omitted fields are left
|
||||
* untouched.
|
||||
*/
|
||||
export type PageSettingsPatch = DeepPartial<PageSettings>;
|
||||
|
||||
export type PageMonitorLayoutStyle = (typeof GC.MONITOR_LAYOUT_STYLES)[number];
|
||||
|
||||
export interface PageSettings {
|
||||
incidents: PageSettingsIncidents;
|
||||
include_maintenances: PageSettingsMaintenances;
|
||||
/** Days of status history shown on the page, per device class (1-365). */
|
||||
monitor_status_history_days: PageSettingsHistoryDays;
|
||||
monitor_layout_style: PageMonitorLayoutStyle;
|
||||
/** Per-page meta/social overrides; stored as camelCase keys internally. */
|
||||
meta_page_title?: string;
|
||||
meta_page_description?: string;
|
||||
social_page_preview_image?: string;
|
||||
}
|
||||
|
||||
export interface PageMonitorResponse {
|
||||
@@ -490,7 +522,7 @@ export interface CreatePageRequest {
|
||||
page_header: string;
|
||||
page_subheader?: string | null;
|
||||
page_logo?: string | null;
|
||||
page_settings?: Partial<PageSettings>;
|
||||
page_settings?: PageSettingsPatch;
|
||||
monitors?: string[];
|
||||
}
|
||||
|
||||
@@ -504,7 +536,7 @@ export interface UpdatePageRequest {
|
||||
page_header?: string;
|
||||
page_subheader?: string | null;
|
||||
page_logo?: string | null;
|
||||
page_settings?: Partial<PageSettings>;
|
||||
page_settings?: PageSettingsPatch;
|
||||
monitors?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import db from "$lib/server/db/db";
|
||||
import type {
|
||||
GetPagesListResponse,
|
||||
PageResponse,
|
||||
PageSettings,
|
||||
CreatePageRequest,
|
||||
CreatePageResponse,
|
||||
BadRequestResponse,
|
||||
} from "$lib/types/api";
|
||||
import type { PageRecord } from "$lib/server/types/db";
|
||||
import GC from "$lib/global-constants";
|
||||
import { toApiPageSettings, applyPageSettingsPatch, validatePageSettings } from "$lib/server/pageSettings";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -20,81 +20,8 @@ function formatDateToISO(date: Date | string): string {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function getDefaultPageSettings(): PageSettings {
|
||||
return {
|
||||
incidents: {
|
||||
enabled: true,
|
||||
ongoing: { show: true },
|
||||
resolved: { show: true, max_count: 5, days_in_past: 7 },
|
||||
},
|
||||
include_maintenances: {
|
||||
enabled: true,
|
||||
ongoing: {
|
||||
show: true,
|
||||
past: { show: true, max_count: 5, days_in_past: 7 },
|
||||
upcoming: { show: true, max_count: 5, days_in_future: 30 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergePageSettings(defaults: PageSettings, partial?: Partial<PageSettings>): PageSettings {
|
||||
if (!partial) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
return {
|
||||
incidents: {
|
||||
enabled: partial.incidents?.enabled ?? defaults.incidents.enabled,
|
||||
ongoing: {
|
||||
show: partial.incidents?.ongoing?.show ?? defaults.incidents.ongoing.show,
|
||||
},
|
||||
resolved: {
|
||||
show: partial.incidents?.resolved?.show ?? defaults.incidents.resolved.show,
|
||||
max_count: partial.incidents?.resolved?.max_count ?? defaults.incidents.resolved.max_count,
|
||||
days_in_past: partial.incidents?.resolved?.days_in_past ?? defaults.incidents.resolved.days_in_past,
|
||||
},
|
||||
},
|
||||
include_maintenances: {
|
||||
enabled: partial.include_maintenances?.enabled ?? defaults.include_maintenances.enabled,
|
||||
ongoing: {
|
||||
show: partial.include_maintenances?.ongoing?.show ?? defaults.include_maintenances.ongoing.show,
|
||||
past: {
|
||||
show: partial.include_maintenances?.ongoing?.past?.show ?? defaults.include_maintenances.ongoing.past.show,
|
||||
max_count:
|
||||
partial.include_maintenances?.ongoing?.past?.max_count ??
|
||||
defaults.include_maintenances.ongoing.past.max_count,
|
||||
days_in_past:
|
||||
partial.include_maintenances?.ongoing?.past?.days_in_past ??
|
||||
defaults.include_maintenances.ongoing.past.days_in_past,
|
||||
},
|
||||
upcoming: {
|
||||
show:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.show ??
|
||||
defaults.include_maintenances.ongoing.upcoming.show,
|
||||
max_count:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.max_count ??
|
||||
defaults.include_maintenances.ongoing.upcoming.max_count,
|
||||
days_in_future:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.days_in_future ??
|
||||
defaults.include_maintenances.ongoing.upcoming.days_in_future,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function formatPageResponse(page: PageRecord): Promise<PageResponse> {
|
||||
let pageSettings: PageSettings = getDefaultPageSettings();
|
||||
|
||||
if (page.page_settings_json) {
|
||||
try {
|
||||
const parsed = JSON.parse(page.page_settings_json);
|
||||
pageSettings = mergePageSettings(getDefaultPageSettings(), parsed);
|
||||
} catch {
|
||||
// Use defaults on parse error
|
||||
}
|
||||
}
|
||||
const pageSettings = toApiPageSettings(page.page_settings_json);
|
||||
|
||||
const pageMonitors = await db.getPageMonitors(page.id);
|
||||
|
||||
@@ -206,8 +133,17 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare page settings
|
||||
const pageSettings = mergePageSettings(getDefaultPageSettings(), body.page_settings);
|
||||
// Validate page settings if provided
|
||||
const settingsError = validatePageSettings(body.page_settings);
|
||||
if (settingsError) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: settingsError,
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
// Create the page
|
||||
const pageData = {
|
||||
@@ -216,7 +152,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
page_header: body.page_header.trim(),
|
||||
page_subheader: body.page_subheader ?? null,
|
||||
page_logo: body.page_logo ?? null,
|
||||
page_settings_json: JSON.stringify(pageSettings),
|
||||
page_settings_json: applyPageSettingsPatch(null, body.page_settings),
|
||||
};
|
||||
|
||||
const createdPage = await db.createPage(pageData);
|
||||
|
||||
@@ -3,7 +3,6 @@ import db from "$lib/server/db/db";
|
||||
import type {
|
||||
GetPageResponse,
|
||||
PageResponse,
|
||||
PageSettings,
|
||||
UpdatePageRequest,
|
||||
UpdatePageResponse,
|
||||
DeletePageResponse,
|
||||
@@ -12,6 +11,7 @@ import type {
|
||||
} from "$lib/types/api";
|
||||
import type { PageRecord } from "$lib/server/types/db";
|
||||
import GC from "$lib/global-constants";
|
||||
import { toApiPageSettings, applyPageSettingsPatch, validatePageSettings } from "$lib/server/pageSettings";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -22,81 +22,8 @@ function formatDateToISO(date: Date | string): string {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function getDefaultPageSettings(): PageSettings {
|
||||
return {
|
||||
incidents: {
|
||||
enabled: true,
|
||||
ongoing: { show: true },
|
||||
resolved: { show: true, max_count: 5, days_in_past: 7 },
|
||||
},
|
||||
include_maintenances: {
|
||||
enabled: true,
|
||||
ongoing: {
|
||||
show: true,
|
||||
past: { show: true, max_count: 5, days_in_past: 7 },
|
||||
upcoming: { show: true, max_count: 5, days_in_future: 30 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergePageSettings(defaults: PageSettings, partial?: Partial<PageSettings>): PageSettings {
|
||||
if (!partial) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
return {
|
||||
incidents: {
|
||||
enabled: partial.incidents?.enabled ?? defaults.incidents.enabled,
|
||||
ongoing: {
|
||||
show: partial.incidents?.ongoing?.show ?? defaults.incidents.ongoing.show,
|
||||
},
|
||||
resolved: {
|
||||
show: partial.incidents?.resolved?.show ?? defaults.incidents.resolved.show,
|
||||
max_count: partial.incidents?.resolved?.max_count ?? defaults.incidents.resolved.max_count,
|
||||
days_in_past: partial.incidents?.resolved?.days_in_past ?? defaults.incidents.resolved.days_in_past,
|
||||
},
|
||||
},
|
||||
include_maintenances: {
|
||||
enabled: partial.include_maintenances?.enabled ?? defaults.include_maintenances.enabled,
|
||||
ongoing: {
|
||||
show: partial.include_maintenances?.ongoing?.show ?? defaults.include_maintenances.ongoing.show,
|
||||
past: {
|
||||
show: partial.include_maintenances?.ongoing?.past?.show ?? defaults.include_maintenances.ongoing.past.show,
|
||||
max_count:
|
||||
partial.include_maintenances?.ongoing?.past?.max_count ??
|
||||
defaults.include_maintenances.ongoing.past.max_count,
|
||||
days_in_past:
|
||||
partial.include_maintenances?.ongoing?.past?.days_in_past ??
|
||||
defaults.include_maintenances.ongoing.past.days_in_past,
|
||||
},
|
||||
upcoming: {
|
||||
show:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.show ??
|
||||
defaults.include_maintenances.ongoing.upcoming.show,
|
||||
max_count:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.max_count ??
|
||||
defaults.include_maintenances.ongoing.upcoming.max_count,
|
||||
days_in_future:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.days_in_future ??
|
||||
defaults.include_maintenances.ongoing.upcoming.days_in_future,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function formatPageResponse(page: PageRecord): Promise<PageResponse> {
|
||||
let pageSettings: PageSettings = getDefaultPageSettings();
|
||||
|
||||
if (page.page_settings_json) {
|
||||
try {
|
||||
const parsed = JSON.parse(page.page_settings_json);
|
||||
pageSettings = mergePageSettings(getDefaultPageSettings(), parsed);
|
||||
} catch {
|
||||
// Use defaults on parse error
|
||||
}
|
||||
}
|
||||
const pageSettings = toApiPageSettings(page.page_settings_json);
|
||||
|
||||
const pageMonitors = await db.getPageMonitors(page.id);
|
||||
|
||||
@@ -259,6 +186,18 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate page_settings if provided
|
||||
const settingsError = validatePageSettings(body.page_settings);
|
||||
if (settingsError) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: settingsError,
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
// Build update data
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const updateData: Record<string, any> = {};
|
||||
@@ -283,21 +222,9 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
updateData.page_logo = body.page_logo;
|
||||
}
|
||||
|
||||
// Handle page_settings merge
|
||||
// Handle page_settings merge; unknown stored keys are preserved
|
||||
if (body.page_settings !== undefined) {
|
||||
let currentSettings: PageSettings = getDefaultPageSettings();
|
||||
|
||||
if (page.page_settings_json) {
|
||||
try {
|
||||
const parsed = JSON.parse(page.page_settings_json);
|
||||
currentSettings = mergePageSettings(getDefaultPageSettings(), parsed);
|
||||
} catch {
|
||||
// Use defaults on parse error
|
||||
}
|
||||
}
|
||||
|
||||
const mergedSettings = mergePageSettings(currentSettings, body.page_settings);
|
||||
updateData.page_settings_json = JSON.stringify(mergedSettings);
|
||||
updateData.page_settings_json = applyPageSettingsPatch(page.page_settings_json, body.page_settings);
|
||||
}
|
||||
|
||||
// Update page if there are changes
|
||||
|
||||
@@ -14,22 +14,22 @@
|
||||
import { requestMonitorBar } from "$lib/client/monitor-bar-client";
|
||||
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
import type { PageMonitorLayoutStyle } from "$lib/types/api";
|
||||
import GC from "$lib/global-constants.js";
|
||||
|
||||
let { data } = $props();
|
||||
let pageSettings = $derived(data.pageDetails.page_settings);
|
||||
let barCount = $derived.by(() =>
|
||||
data.isMobile
|
||||
? pageSettings?.monitor_status_history_days.mobile || 30
|
||||
: pageSettings?.monitor_status_history_days.desktop || 90
|
||||
? pageSettings?.monitor_status_history_days.mobile || GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
|
||||
: pageSettings?.monitor_status_history_days.desktop || GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP
|
||||
);
|
||||
let endOfDayTodayAtTz = $derived(getEndOfDayAtTz($selectedTimezone));
|
||||
|
||||
let monitorBarDataByTag = $state<Record<string, MonitorBarResponse>>({});
|
||||
let monitorBarErrorByTag = $state<Record<string, string>>({});
|
||||
let requestVersion = 0;
|
||||
let viewType = $derived<"compact-list" | "default-list" | "default-grid" | "compact-grid" | undefined>(
|
||||
pageSettings?.monitor_layout_style
|
||||
);
|
||||
let viewType = $derived<PageMonitorLayoutStyle | undefined>(pageSettings?.monitor_layout_style);
|
||||
let isCompact = $derived(viewType === "compact-list" || viewType === "compact-grid");
|
||||
|
||||
function getGridItemSpanClass(index: number, total: number, type: typeof viewType): string {
|
||||
|
||||
@@ -14,22 +14,22 @@
|
||||
import { requestMonitorBar } from "$lib/client/monitor-bar-client";
|
||||
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
import type { PageMonitorLayoutStyle } from "$lib/types/api";
|
||||
import GC from "$lib/global-constants.js";
|
||||
|
||||
let { data } = $props();
|
||||
let pageSettings = $derived(data.pageDetails.page_settings);
|
||||
let barCount = $derived.by(() =>
|
||||
data.isMobile
|
||||
? pageSettings?.monitor_status_history_days.mobile || 30
|
||||
: pageSettings?.monitor_status_history_days.desktop || 90
|
||||
? pageSettings?.monitor_status_history_days.mobile || GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
|
||||
: pageSettings?.monitor_status_history_days.desktop || GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP
|
||||
);
|
||||
let endOfDayTodayAtTz = $derived(getEndOfDayAtTz($selectedTimezone));
|
||||
|
||||
let monitorBarDataByTag = $state<Record<string, MonitorBarResponse>>({});
|
||||
let monitorBarErrorByTag = $state<Record<string, string>>({});
|
||||
let requestVersion = 0;
|
||||
let viewType = $derived<"compact-list" | "default-list" | "default-grid" | "compact-grid" | undefined>(
|
||||
pageSettings?.monitor_layout_style
|
||||
);
|
||||
let viewType = $derived<PageMonitorLayoutStyle | undefined>(pageSettings?.monitor_layout_style);
|
||||
let isCompact = $derived(viewType === "compact-list" || viewType === "compact-grid");
|
||||
|
||||
function getGridItemSpanClass(index: number, total: number, type: typeof viewType): string {
|
||||
|
||||
@@ -83,11 +83,13 @@ export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
}
|
||||
}
|
||||
|
||||
let maxDays = parentData.isMobile ? 30 : 90;
|
||||
let maxDays: number = parentData.isMobile
|
||||
? GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
|
||||
: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP;
|
||||
if (monitor.monitor_settings_json?.monitor_status_history_days) {
|
||||
maxDays = parentData.isMobile
|
||||
? monitor.monitor_settings_json.monitor_status_history_days.mobile || 30
|
||||
: monitor.monitor_settings_json.monitor_status_history_days.desktop || 90;
|
||||
? monitor.monitor_settings_json.monitor_status_history_days.mobile || GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
|
||||
: monitor.monitor_settings_json.monitor_status_history_days.desktop || GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP;
|
||||
}
|
||||
return {
|
||||
...{
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import MonitorRecentLogs from "./components/MonitorRecentLogs.svelte";
|
||||
import StatusHistoryDaysCard from "./components/StatusHistoryDaysCard.svelte";
|
||||
import MonitorSharingOptionsCard from "./components/MonitorSharingOptionsCard.svelte";
|
||||
import GC from "$lib/global-constants.js";
|
||||
|
||||
let { params }: PageProps = $props();
|
||||
const isNew = $derived(params.tag === "new");
|
||||
@@ -48,8 +49,8 @@
|
||||
|
||||
// Status history days state
|
||||
let statusHistoryDays = $state({
|
||||
desktop: 90,
|
||||
mobile: 30
|
||||
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP as number,
|
||||
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE as number
|
||||
});
|
||||
|
||||
// Pages state
|
||||
@@ -137,8 +138,8 @@
|
||||
};
|
||||
if (settings.monitor_status_history_days) {
|
||||
statusHistoryDays = {
|
||||
desktop: settings.monitor_status_history_days.desktop ?? 90,
|
||||
mobile: settings.monitor_status_history_days.mobile ?? 30
|
||||
desktop: settings.monitor_status_history_days.desktop ?? GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
|
||||
mobile: settings.monitor_status_history_days.mobile ?? GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
+24
-13
@@ -7,6 +7,7 @@
|
||||
import Loader from "@lucide/svelte/icons/loader";
|
||||
import type { MonitorRecord } from "$lib/server/types/db.js";
|
||||
import { toast } from "svelte-sonner";
|
||||
import GC from "$lib/global-constants.js";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
|
||||
@@ -23,16 +24,23 @@
|
||||
|
||||
let saving = $state(false);
|
||||
|
||||
const isValid = $derived(
|
||||
statusHistoryDays.desktop >= 1 &&
|
||||
statusHistoryDays.desktop <= 365 &&
|
||||
statusHistoryDays.mobile >= 1 &&
|
||||
statusHistoryDays.mobile <= 365
|
||||
const isDesktopValid = $derived(
|
||||
Number.isInteger(statusHistoryDays.desktop) &&
|
||||
statusHistoryDays.desktop >= GC.STATUS_HISTORY_DAYS_MIN &&
|
||||
statusHistoryDays.desktop <= GC.STATUS_HISTORY_DAYS_MAX
|
||||
);
|
||||
const isMobileValid = $derived(
|
||||
Number.isInteger(statusHistoryDays.mobile) &&
|
||||
statusHistoryDays.mobile >= GC.STATUS_HISTORY_DAYS_MIN &&
|
||||
statusHistoryDays.mobile <= GC.STATUS_HISTORY_DAYS_MAX
|
||||
);
|
||||
const isValid = $derived(isDesktopValid && isMobileValid);
|
||||
|
||||
async function save() {
|
||||
if (!isValid) {
|
||||
toast.error("Days must be between 1 and 365");
|
||||
toast.error(
|
||||
`Days must be a whole number between ${GC.STATUS_HISTORY_DAYS_MIN} and ${GC.STATUS_HISTORY_DAYS_MAX}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,10 +107,11 @@
|
||||
<Input
|
||||
id="monitor-history-desktop"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
step="1"
|
||||
min={GC.STATUS_HISTORY_DAYS_MIN}
|
||||
max={GC.STATUS_HISTORY_DAYS_MAX}
|
||||
bind:value={statusHistoryDays.desktop}
|
||||
class={statusHistoryDays.desktop < 1 || statusHistoryDays.desktop > 365 ? "border-destructive" : ""}
|
||||
class={isDesktopValid ? "" : "border-destructive"}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">Number of days shown on desktop screens</p>
|
||||
</div>
|
||||
@@ -111,16 +120,18 @@
|
||||
<Input
|
||||
id="monitor-history-mobile"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
step="1"
|
||||
min={GC.STATUS_HISTORY_DAYS_MIN}
|
||||
max={GC.STATUS_HISTORY_DAYS_MAX}
|
||||
bind:value={statusHistoryDays.mobile}
|
||||
class={statusHistoryDays.mobile < 1 || statusHistoryDays.mobile > 365 ? "border-destructive" : ""}
|
||||
class={isMobileValid ? "" : "border-destructive"}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">Number of days shown on mobile screens</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
This overrides the page-level default for this monitor. Values must be between 1 and 365.
|
||||
This overrides the page-level default for this monitor. Values must be whole numbers between {GC.STATUS_HISTORY_DAYS_MIN}
|
||||
and {GC.STATUS_HISTORY_DAYS_MAX}.
|
||||
</p>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-end">
|
||||
|
||||
@@ -33,10 +33,10 @@
|
||||
// Default page settings
|
||||
const defaultPageSettings: PageSettingsType = {
|
||||
monitor_status_history_days: {
|
||||
desktop: 90,
|
||||
mobile: 30
|
||||
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
|
||||
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
|
||||
},
|
||||
monitor_layout_style: "default-list"
|
||||
monitor_layout_style: GC.DEFAULT_MONITOR_LAYOUT_STYLE
|
||||
};
|
||||
|
||||
interface PageWithMonitors extends PageRecord {
|
||||
@@ -86,6 +86,16 @@
|
||||
|
||||
// Page settings state
|
||||
let pageSettings = $state<PageSettingsType>(structuredClone(defaultPageSettings));
|
||||
const isHistoryDesktopValid = $derived(
|
||||
Number.isInteger(pageSettings.monitor_status_history_days.desktop) &&
|
||||
pageSettings.monitor_status_history_days.desktop >= GC.STATUS_HISTORY_DAYS_MIN &&
|
||||
pageSettings.monitor_status_history_days.desktop <= GC.STATUS_HISTORY_DAYS_MAX
|
||||
);
|
||||
const isHistoryMobileValid = $derived(
|
||||
Number.isInteger(pageSettings.monitor_status_history_days.mobile) &&
|
||||
pageSettings.monitor_status_history_days.mobile >= GC.STATUS_HISTORY_DAYS_MIN &&
|
||||
pageSettings.monitor_status_history_days.mobile <= GC.STATUS_HISTORY_DAYS_MAX
|
||||
);
|
||||
let savingDisplaySettings = $state(false);
|
||||
let savingSeoSettings = $state(false);
|
||||
|
||||
@@ -479,6 +489,13 @@
|
||||
async function savePageSettings(source: "display" | "seo") {
|
||||
if (!currentPage) return;
|
||||
|
||||
if (source === "display" && (!isHistoryDesktopValid || !isHistoryMobileValid)) {
|
||||
toast.error(
|
||||
`Days must be a whole number between ${GC.STATUS_HISTORY_DAYS_MIN} and ${GC.STATUS_HISTORY_DAYS_MAX}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === "display") savingDisplaySettings = true;
|
||||
else savingSeoSettings = true;
|
||||
try {
|
||||
@@ -788,9 +805,11 @@
|
||||
<Input
|
||||
id="history-desktop"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
step="1"
|
||||
min={GC.STATUS_HISTORY_DAYS_MIN}
|
||||
max={GC.STATUS_HISTORY_DAYS_MAX}
|
||||
bind:value={pageSettings.monitor_status_history_days.desktop}
|
||||
class={isHistoryDesktopValid ? "" : "border-destructive"}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">Number of days shown on desktop screens</p>
|
||||
</div>
|
||||
@@ -799,9 +818,11 @@
|
||||
<Input
|
||||
id="history-mobile"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
step="1"
|
||||
min={GC.STATUS_HISTORY_DAYS_MIN}
|
||||
max={GC.STATUS_HISTORY_DAYS_MAX}
|
||||
bind:value={pageSettings.monitor_status_history_days.mobile}
|
||||
class={isHistoryMobileValid ? "" : "border-destructive"}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">Number of days shown on mobile screens</p>
|
||||
</div>
|
||||
|
||||
@@ -2118,8 +2118,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"page_settings": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/components/schemas/PageSettings"
|
||||
},
|
||||
"monitors": {
|
||||
"type": "array",
|
||||
@@ -2153,8 +2152,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"page_settings": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/components/schemas/PageSettings"
|
||||
},
|
||||
"monitors": {
|
||||
"type": "array",
|
||||
@@ -2185,8 +2183,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"page_settings": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/components/schemas/PageSettings"
|
||||
},
|
||||
"monitors": {
|
||||
"type": "array",
|
||||
@@ -2375,6 +2372,59 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PageSettings": {
|
||||
"type": "object",
|
||||
"description": "Per-page display configuration. On update, provided fields are deep-merged into the current settings; omitted fields are left untouched.",
|
||||
"properties": {
|
||||
"incidents": {
|
||||
"type": "object",
|
||||
"description": "Incident display preferences for this page.",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"include_maintenances": {
|
||||
"type": "object",
|
||||
"description": "Maintenance display preferences for this page.",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"monitor_status_history_days": {
|
||||
"type": "object",
|
||||
"description": "Days of status history shown on the page, per device class.",
|
||||
"properties": {
|
||||
"desktop": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 365,
|
||||
"default": 90
|
||||
},
|
||||
"mobile": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 365,
|
||||
"default": 30
|
||||
}
|
||||
}
|
||||
},
|
||||
"monitor_layout_style": {
|
||||
"type": "string",
|
||||
"enum": ["default-list", "default-grid", "compact-list", "compact-grid"],
|
||||
"default": "default-list",
|
||||
"description": "How monitors are laid out on the status page."
|
||||
},
|
||||
"meta_page_title": {
|
||||
"type": "string",
|
||||
"description": "Per-page override for the meta title."
|
||||
},
|
||||
"meta_page_description": {
|
||||
"type": "string",
|
||||
"description": "Per-page override for the meta description."
|
||||
},
|
||||
"social_page_preview_image": {
|
||||
"type": "string",
|
||||
"description": "Per-page override for the social preview image."
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user