feat: add configurable home data range per device

Introduces per-device configuration for maximum data range and selectable days on the homepage, allowing separate settings for desktop and mobile. Updates UI, server logic, and data model to support these options, and uses user agent detection to apply the correct configuration. Improves flexibility for data display across devices.

Relates to #105
This commit is contained in:
Raj Nandan Sharma
2025-05-05 10:28:02 +05:30
parent 1de1e2d6ba
commit f04a930e13
12 changed files with 327 additions and 71 deletions

11
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "kener",
"version": "3.2.14",
"version": "3.2.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kener",
"version": "3.2.14",
"version": "3.2.15",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.10",
@@ -41,6 +41,7 @@
"knex": "^3.1.0",
"lucide-svelte": "^0.483.0",
"marked": "^11.1.1",
"mobile-detect": "^1.4.5",
"mode-watcher": "^0.4.1",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
@@ -5604,6 +5605,12 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/mobile-detect": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/mobile-detect/-/mobile-detect-1.4.5.tgz",
"integrity": "sha512-yc0LhH6tItlvfLBugVUEtgawwFU2sIe+cSdmRJJCTMZ5GEJyLxNyC/NIOAOGk67Fa8GNpOttO3Xz/1bHpXFD/g==",
"license": "MIT"
},
"node_modules/mode-watcher": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-0.4.1.tgz",

View File

@@ -99,6 +99,7 @@
"knex": "^3.1.0",
"lucide-svelte": "^0.483.0",
"marked": "^11.1.1",
"mobile-detect": "^1.4.5",
"mode-watcher": "^0.4.1",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",

View File

@@ -882,7 +882,8 @@ p code:not([class^="language-"]) {
margin-bottom: 1.3em;
}
input[type="checkbox"]:disabled + .peer {
input[type="checkbox"]:disabled + .peer,
label:has(input[type="checkbox"]:disabled) {
cursor: not-allowed;
opacity: 0.5;
}

View File

@@ -11,6 +11,8 @@
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import Loader from "lucide-svelte/icons/loader";
import LaptopMinimal from "lucide-svelte/icons/laptop-minimal";
import SmartPhone from "lucide-svelte/icons/smartphone";
import X from "lucide-svelte/icons/x";
import Plus from "lucide-svelte/icons/plus";
import Info from "lucide-svelte/icons/info";
@@ -42,6 +44,18 @@
let homeIncidentStartTimeWithin = 30;
let incidentGroupView = "EXPAND_FIRST";
let kenerTheme = "default";
let homeDataMaxDays = {
desktop: {
maxDays: 90,
selectableDays: [1, 7, 14, 30, 60, 90]
},
mobile: {
maxDays: 90,
selectableDays: [1, 7, 14, 30, 60, 90]
}
};
let selectableDaysList = [1, 7, 14, 30, 60, 90, 120, 150, 180];
let availableThemes = [
{ name: "Default", value: "default" },
@@ -93,6 +107,9 @@
if (data.siteData.kenerTheme) {
kenerTheme = data.siteData.kenerTheme;
}
if (data.siteData.homeDataMaxDays) {
homeDataMaxDays = data.siteData.homeDataMaxDays;
}
if (data.siteData.tzToggle) {
tzToggle = data.siteData.tzToggle;
}
@@ -288,6 +305,23 @@
return;
}
}
let dataErrorMessage = "";
let formStateData = "idle";
async function formSubmitData() {
dataErrorMessage = "";
formStateData = "loading";
let resp = await storeSiteData({
homeDataMaxDays: JSON.stringify(homeDataMaxDays)
});
//print data
let data = await resp.json();
formStateData = "idle";
if (data.error) {
dataErrorMessage = data.error;
return;
}
}
</script>
<Card.Root class="mt-4">
@@ -380,6 +414,175 @@
</form>
</Card.Content>
</Card.Root>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Data</Card.Title>
<Card.Description>
Configure the data shown on the homepage. This includes the time range for incidents.
</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 flex flex-col space-y-4" on:submit|preventDefault={formSubmitData}>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-y-2">
<Label class="text-lg">
<LaptopMinimal class="mr-2 inline h-4 w-4" />
Desktop
</Label>
<div class="border-b pb-4">
<Label for="desktopmaxdata" class="text-sm font-medium">Select Data Range</Label>
<Select.Root
portal={null}
onSelectedChange={(e) => {
homeDataMaxDays.desktop.maxDays = e.value;
//uncheck all checkboxes greater than maxDays
homeDataMaxDays.desktop.selectableDays = homeDataMaxDays.desktop.selectableDays.filter(
(day) => day <= e.value
);
//push all days less than maxDays
selectableDaysList.forEach((day) => {
if (day <= e.value && !homeDataMaxDays.desktop.selectableDays.includes(day)) {
homeDataMaxDays.desktop.selectableDays.push(day);
}
});
}}
selected={{
value: homeDataMaxDays.desktop.maxDays,
label: homeDataMaxDays.desktop.maxDays + " Days"
}}
>
<Select.Trigger class=" mt-2 w-[200px]" id="desktopmaxdata">
<Select.Value bind:value={homeDataMaxDays.desktop.maxDays} placeholder="Select Range" />
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Max Days</Select.Label>
{#each selectableDaysList as day}
<Select.Item value={day} label={day + " Days"} class="text-sm font-medium">
{day} Days
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</div>
<div class="border-b pb-4">
<!-- use checkboxes selectableDaysList -->
<Label for="desktopdropdown" class="text-sm font-medium">Select Dropdown Range</Label>
<div class="mt-2 flex flex-row flex-wrap gap-x-2">
{#each selectableDaysList as day}
<label>
<input
on:change={(e) => {
if (e.target.checked) {
homeDataMaxDays.desktop.selectableDays.push(day);
} else {
homeDataMaxDays.desktop.selectableDays = homeDataMaxDays.desktop.selectableDays.filter(
(d) => d !== day
);
}
}}
type="checkbox"
disabled={day >= homeDataMaxDays.desktop.maxDays}
checked={homeDataMaxDays.desktop.selectableDays.includes(day)}
/>
{day} Days
</label>
{/each}
</div>
</div>
</div>
<div class="flex flex-col gap-y-2">
<Label class="text-lg">
<SmartPhone class="mr-2 inline h-4 w-4" />
Mobile
</Label>
<div class="border-b pb-4">
<Label for="mobilemaxdata" class="text-sm font-medium">Select Data Range</Label>
<Select.Root
portal={null}
onSelectedChange={(e) => {
homeDataMaxDays.mobile.maxDays = e.value;
//uncheck all checkboxes greater than maxDays
homeDataMaxDays.mobile.selectableDays = homeDataMaxDays.mobile.selectableDays.filter(
(day) => day <= e.value
);
//push all days less than maxDays
selectableDaysList.forEach((day) => {
if (day <= e.value && !homeDataMaxDays.mobile.selectableDays.includes(day)) {
homeDataMaxDays.mobile.selectableDays.push(day);
}
});
}}
selected={{
value: homeDataMaxDays.mobile.maxDays,
label: homeDataMaxDays.mobile.maxDays + " Days"
}}
>
<Select.Trigger class=" mt-2 w-[200px]" id="mobilemaxdata">
<Select.Value bind:value={homeDataMaxDays.mobile.maxDays} placeholder="Select Range" />
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Max Days</Select.Label>
{#each selectableDaysList as day}
<Select.Item value={day} label={day + " Days"} class="text-sm font-medium">
{day} Days
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</div>
<div class="border-b pb-4">
<!-- use checkboxes selectableDaysList -->
<Label for="mobiledropdown" class="text-sm font-medium">Select Dropdown Range</Label>
<div class="mt-2 flex flex-row flex-wrap gap-x-2">
{#each selectableDaysList as day}
<label>
<input
on:change={(e) => {
if (e.target.checked) {
homeDataMaxDays.mobile.selectableDays.push(day);
} else {
homeDataMaxDays.mobile.selectableDays = homeDataMaxDays.mobile.selectableDays.filter(
(d) => d !== day
);
}
}}
type="checkbox"
disabled={day >= homeDataMaxDays.mobile.maxDays}
checked={homeDataMaxDays.mobile.selectableDays.includes(day)}
/>
{day} Days
</label>
{/each}
</div>
</div>
</div>
</div>
<p>
<span class="text-xs font-medium text-muted-foreground">
Select the maximum number of days to show data for. This will be used to filter the data shown on the
homepage. The dropdown will show all the days that are less than or equal to the selected max days. The max
value in the dropdown will be the entire value of data range it cannot be changed
</span>
</p>
{#if !!dataErrorMessage}
<p class="text-sm font-medium text-destructive">{dataErrorMessage}</p>
{/if}
<div class="flex w-full justify-end gap-x-2">
<Button type="submit" disabled={formStateData === "loading"}>
Save Data Settings
{#if formStateData === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Incidents</Card.Title>

View File

@@ -39,6 +39,15 @@
let uptime90Day = monitor.pageData.uptime90Day;
let incidents = {};
let dayIncidentsFull = [];
let homeDataMaxDays = monitor.pageData.homeDataMaxDays;
let dimension = {
x1: 6,
x2: 4
};
dimension.x1 = ($page.data.isMobile ? 346 : 546) / homeDataMaxDays.maxDays;
dimension.x2 = (4 / 6) * dimension.x1;
function loadIncidents() {
axios
@@ -91,39 +100,37 @@
});
}, 1000 * 0.2);
}
let uptimesRollers = [
{
text: `${l(lang, "90 Days")}`,
startTs: monitor.pageData.startOfTheDay - 86400 * 89,
endTs: monitor.pageData.maxDateTodayTimestamp,
value: uptime90Day
},
{
text: `${l(lang, "60 Days")}`,
startTs: monitor.pageData.startOfTheDay - 86400 * 59,
endTs: monitor.pageData.maxDateTodayTimestamp
},
{
text: `${l(lang, "30 Days")}`,
startTs: monitor.pageData.startOfTheDay - 86400 * 29,
endTs: monitor.pageData.maxDateTodayTimestamp
},
{
text: `${l(lang, "14 Days")}`,
startTs: monitor.pageData.startOfTheDay - 86400 * 13,
endTs: monitor.pageData.maxDateTodayTimestamp
},
{
text: `${l(lang, "7 Days")}`,
startTs: monitor.pageData.startOfTheDay - 86400 * 6,
endTs: monitor.pageData.maxDateTodayTimestamp
},
{
text: l(lang, "Today"),
startTs: monitor.pageData.startOfTheDay,
endTs: monitor.pageData.maxDateTodayTimestamp
function returnUptimeRollers() {
let rollers = homeDataMaxDays.selectableDays;
//sort descending
rollers.sort((a, b) => b - a);
let ret = [];
for (let i = 0; i < rollers.length; i++) {
let roller = rollers[i];
if (roller == 1) {
ret.push({
text: `${l(lang, "Today")}`,
startTs: monitor.pageData.startOfTheDay,
endTs: monitor.pageData.maxDateTodayTimestamp
});
} else {
ret.push({
text: `${roller} ${l(lang, "Days")}`,
startTs: monitor.pageData.startOfTheDay - 86400 * (roller - 1),
endTs: monitor.pageData.maxDateTodayTimestamp
});
}
//if last index
if (i == 0) {
ret[i].value = uptime90Day;
}
}
];
return ret;
}
let uptimesRollers = returnUptimeRollers();
//start of the week moment
let rolledAt = 0;
@@ -316,12 +323,14 @@
href="#"
class="oneline h-[34px] w-[6px]
{bar.border ? 'opacity-100' : 'opacity-20'} pb-1"
style="width: {dimension.x1}px"
>
<div
class="oneline-in h-[30px] bg-{bar.cssClass} mx-auto w-[4px] rounded-{monitor.pageData.barRoundness.toUpperCase() ==
'SHARP'
? 'none'
: 'sm'}"
style="width: {dimension.x2}px"
></div>
{#if !!incidents[ts]}
<div

View File

@@ -26,6 +26,11 @@ export const siteDataKeys = [
isValid: IsValidURL,
data_type: "string",
},
{
key: "homeDataMaxDays",
isValid: IsValidJSONString,
data_type: "object",
},
{
key: "home",
isValid: (value) => typeof value === "string" && value.trim().length > 0,

View File

@@ -100,6 +100,16 @@ const seedSiteData = {
categories: [{ name: "Home", description: "Monitors for Home Page", isHidden: false }],
homeIncidentCount: 5,
homeIncidentStartTimeWithin: 30,
homeDataMaxDays: {
desktop: {
maxDays: 90,
selectableDays: [1, 7, 14, 30, 60, 90],
},
mobile: {
maxDays: 90,
selectableDays: [1, 7, 14, 30, 60, 90],
},
},
kenerTheme: "default",
showSiteStatus: "NO",
};

View File

@@ -78,13 +78,16 @@ function getCountOfSimilarStatuesEnd(arr, statusType) {
return count;
}
const FetchData = async function (site, monitor, localTz, selectedLang, lang) {
const FetchData = async function (site, monitor, localTz, selectedLang, lang, isMobile) {
const secondsInDay = 24 * 60 * 60;
//get offset from utc in minutes
const nowUTC = GetMinuteStartNowTimestampUTC();
let deviceType = isMobile ? "mobile" : "desktop";
let homeDataMaxDays = site.homeDataMaxDays[deviceType];
const midnightUTC = GetDayStartTimestampUTC(nowUTC);
const midnightTz = BeginningOfDay({ timeZone: localTz });
const midnight90DaysAgoTz = midnightTz - 90 * 24 * 60 * 60;
const midnight90DaysAgoTz = midnightTz - homeDataMaxDays.maxDays * 24 * 60 * 60;
const NO_DATA = "No Data";
let offsetInMinutes = parseInt((GetDayStartTimestampUTC(nowUTC) - midnightTz) / 60);
const maxDateTodayTimestampTz = BeginningOfMinute({ timeZone: localTz });
@@ -198,6 +201,7 @@ const FetchData = async function (site, monitor, localTz, selectedLang, lang) {
midnight90DaysAgo: midnight90DaysAgoTz,
maxDateTodayTimestamp: maxDateTodayTimestampTz,
startOfTheDay: midnightTz,
homeDataMaxDays,
};
};
export { FetchData };

View File

@@ -2,9 +2,13 @@
import i18n from "$lib/i18n/server";
import { redirect } from "@sveltejs/kit";
import { base } from "$app/paths";
import MobileDetect from "mobile-detect";
import { GetAllSiteData, IsSetupComplete, IsLoggedInSession } from "$lib/server/controllers/controller.js";
export async function load({ params, route, url, cookies, request }) {
const userAgent = request.headers.get("user-agent");
const md = new MobileDetect(userAgent);
const isMobile = !!md.mobile();
let isSetupComplete = await IsSetupComplete();
if (!isSetupComplete) {
throw redirect(302, base + "/manage/setup");
@@ -18,7 +22,6 @@ export async function load({ params, route, url, cookies, request }) {
let site = await GetAllSiteData();
const headers = request.headers;
const userAgent = headers.get("user-agent");
let localTz = "UTC";
const localTzQuery = query.get("tz");
@@ -56,5 +59,6 @@ export async function load({ params, route, url, cookies, request }) {
embed,
bgc,
isLoggedIn: !!isLoggedIn.user,
isMobile: isMobile,
};
}

View File

@@ -3,33 +3,34 @@ import { FetchData } from "$lib/server/page";
import { GetMonitors } from "$lib/server/controllers/controller.js";
export async function load({ params, route, url, parent }) {
let monitors = await GetMonitors({ status: "ACTIVE" });
const parentData = await parent();
const siteData = parentData.site;
const monitorsActive = [];
const query = url.searchParams;
const theme = query.get("theme");
for (let i = 0; i < monitors.length; i++) {
//only return monitors that have category as home or category is not present
if (monitors[i].tag !== params.tag) {
continue;
}
delete monitors[i].api;
delete monitors[i].default_status;
monitors[i].embed = true;
let data = await FetchData(
siteData,
monitors[i],
parentData.localTz,
parentData.selectedLang,
parentData.lang
);
monitors[i].pageData = data;
monitorsActive.push(monitors[i]);
}
return {
monitors: monitorsActive,
theme,
openIncidents: []
};
let monitors = await GetMonitors({ status: "ACTIVE" });
const parentData = await parent();
const siteData = parentData.site;
const monitorsActive = [];
const query = url.searchParams;
const theme = query.get("theme");
for (let i = 0; i < monitors.length; i++) {
//only return monitors that have category as home or category is not present
if (monitors[i].tag !== params.tag) {
continue;
}
delete monitors[i].api;
delete monitors[i].default_status;
monitors[i].embed = true;
let data = await FetchData(
siteData,
monitors[i],
parentData.localTz,
parentData.selectedLang,
parentData.lang,
parentData.isMobile,
);
monitors[i].pageData = data;
monitorsActive.push(monitors[i]);
}
return {
monitors: monitorsActive,
theme,
openIncidents: [],
};
}

View File

@@ -1,6 +1,8 @@
// @ts-nocheck
import i18n from "$lib/i18n/server";
import { redirect } from "@sveltejs/kit";
import MobileDetect from "mobile-detect";
import { base } from "$app/paths";
import {
GetAllSiteData,
@@ -11,6 +13,10 @@ import {
} from "$lib/server/controllers/controller.js";
export async function load({ params, route, url, cookies, request }) {
const userAgent = request.headers.get("user-agent");
const md = new MobileDetect(userAgent);
const isMobile = !!md.mobile();
let isSetupComplete = await IsSetupComplete();
if (!isSetupComplete) {
throw redirect(302, base + "/manage/setup");
@@ -24,7 +30,6 @@ export async function load({ params, route, url, cookies, request }) {
let site = await GetAllSiteData();
const headers = request.headers;
const userAgent = headers.get("user-agent");
let localTz = "UTC";
const localTzCookie = cookies.get("localTz");
if (!!localTzCookie) {
@@ -50,5 +55,6 @@ export async function load({ params, route, url, cookies, request }) {
selectedLang: selectedLang,
isLoggedIn: !!isLoggedIn.user,
canSendEmail: IsEmailSetup(),
isMobile: isMobile,
};
}

View File

@@ -3,7 +3,6 @@ import { FetchData } from "$lib/server/page";
import { GetMonitors, GetIncidentsOpenHome, SystemDataMessage } from "$lib/server/controllers/controller.js";
import { SortMonitor } from "$lib/clientTools.js";
import moment from "moment";
import { page } from "$app/stores";
import { redirect, error } from "@sveltejs/kit";
function removeTags(str) {
@@ -108,7 +107,14 @@ export async function load({ parent, url }) {
monitors = SortMonitor(siteData.monitorSort, monitors);
const monitorsActive = [];
for (let i = 0; i < monitors.length; i++) {
let data = await FetchData(siteData, monitors[i], parentData.localTz, parentData.selectedLang, parentData.lang);
let data = await FetchData(
siteData,
monitors[i],
parentData.localTz,
parentData.selectedLang,
parentData.lang,
parentData.isMobile,
);
monitors[i].pageData = data;
monitors[i].activeIncidents = [];
@@ -183,7 +189,6 @@ export async function load({ parent, url }) {
return isPresent;
});
}
allOpenIncidents = allOpenIncidents.map((incident) => {
let incidentMonitors = incident.monitors;
let monitorTags = incidentMonitors.map((monitor) => monitor.monitor_tag);