refactor: update heartbeat URL format to use path segments and implement legacy URL rewriting

This commit is contained in:
Raj Nandan Sharma
2026-06-15 23:19:24 +05:30
parent f257cdc2c4
commit 734b062626
4 changed files with 44 additions and 16 deletions
+22
View File
@@ -0,0 +1,22 @@
import type { Reroute } from "@sveltejs/kit";
// Back-compat for issue #759: heartbeat URLs used to be `/ext/heartbeat/<tag>:<secret>`,
// one path segment joined by a colon. A `:` is illegal in Windows file paths, so the
// route is now `/ext/heartbeat/<tag>/<secret>` (two segments). Legacy colon-form URLs
// live forever in external cron jobs / uptime pingers, so rewrite them internally to
// the new path. Returns a 200 (no redirect) — heartbeat clients often don't follow 3xx.
//
// `reroute` is a *universal* hook: it MUST be in src/hooks.ts. A `reroute` exported from
// src/hooks.server.ts is silently ignored by SvelteKit. Keep this file free of
// server-only imports — it is bundled for the client too. Must stay pure/side-effect-free.
//
// The transform is in-place (no path reconstruction), so any KENER_BASE_PATH prefix is
// preserved automatically. `[^/:]+` matches the validated tag charset; only the first
// colon after `/ext/heartbeat/<tag>` is rewritten.
const LEGACY_HEARTBEAT = /(\/ext\/heartbeat\/[^/:]+):/;
export const reroute: Reroute = ({ url }) => {
if (LEGACY_HEARTBEAT.test(url.pathname)) {
return url.pathname.replace(LEGACY_HEARTBEAT, "$1/");
}
};
@@ -10,11 +10,13 @@ Heartbeat monitors are push-based: your job calls a URL, and Kener measures how
URL format:
```
/ext/heartbeat/{tag}:{secret}
/ext/heartbeat/{tag}/{secret}
```
Accepted methods: `GET` and `POST`.
> Older heartbeat URLs used a colon — `/ext/heartbeat/{tag}:{secret}`. Those still work; they are rewritten to the path-separated form automatically, so existing cron jobs need no changes.
## Minimum setup {#minimum-setup}
Set:
@@ -53,11 +55,11 @@ Latency is recorded as elapsed time since the last heartbeat (ms).
Minimal cron usage pattern:
```bash
*/5 * * * * /path/to/job.sh && curl -s "https://your-kener-host/ext/heartbeat/my-job:my-secret"
*/5 * * * * /path/to/job.sh && curl -s "https://your-kener-host/ext/heartbeat/my-job/my-secret"
```
## Troubleshooting {#troubleshooting}
- **Always NO_DATA**: endpoint never called or wrong `tag:secret`
- **Always NO_DATA**: endpoint never called or wrong `tag`/`secret`
- **Always DOWN/DEGRADED**: thresholds too low for actual job interval
- **Signal accepted but stale**: ensure heartbeat is sent only after successful completion
@@ -2,18 +2,16 @@
import { Button } from "$lib/components/ui/button/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import * as Breadcrumb from "$lib/components/ui/breadcrumb/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import PlusIcon from "@lucide/svelte/icons/plus";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import ExternalLinkIcon from "@lucide/svelte/icons/external-link";
import PencilIcon from "@lucide/svelte/icons/pencil";
import SirenIcon from "@lucide/svelte/icons/siren";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { format, formatDistanceToNow } from "date-fns";
import GC from "$lib/global-constants";
import { resolve } from "$app/paths";
@@ -104,12 +102,12 @@
// Navigate to incident
function openIncident(id: number) {
goto(clientResolver(resolve, `/manage/app/incidents/${id}`));
goto(resolve(`/manage/app/incidents/${id}`));
}
// Create new incident
function createNewIncident() {
goto(clientResolver(resolve, "/manage/app/incidents/new"));
goto(resolve("/manage/app/incidents/new"));
}
// Handle state filter change
@@ -127,7 +125,7 @@
fetchData();
}
$effect(() => {
onMount(() => {
fetchData();
});
</script>
@@ -142,7 +140,7 @@
{stateOptions.find((o) => o.value === stateFilter)?.label || "All States"}
</Select.Trigger>
<Select.Content>
{#each stateOptions as option}
{#each stateOptions as option (option.value)}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
@@ -166,6 +164,7 @@
<Table.Row>
<Table.Head class="w-16">ID</Table.Head>
<Table.Head>Title</Table.Head>
<Table.Head class="w-40">Started</Table.Head>
<Table.Head class="w-32">Duration</Table.Head>
<Table.Head class="w-36">State</Table.Head>
<Table.Head class="w-40">Affects</Table.Head>
@@ -175,10 +174,10 @@
<Table.Body>
{#if incidents.length === 0 && !loading}
<Table.Row>
<Table.Cell colspan={6} class="text-muted-foreground py-8 text-center">No incidents found</Table.Cell>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">No incidents found</Table.Cell>
</Table.Row>
{:else}
{#each incidents as incident}
{#each incidents as incident (incident.id)}
<Table.Row class="hover:bg-muted/50 cursor-pointer" onclick={() => openIncident(incident.id)}>
<Table.Cell class="font-medium">{incident.id}</Table.Cell>
<Table.Cell>
@@ -191,6 +190,11 @@
</Tooltip.Content>
</Tooltip.Root>
</Table.Cell>
<Table.Cell>
<span class="text-muted-foreground text-sm whitespace-nowrap">
{format(new Date(incident.start_date_time * 1000), "yyyy-MM-dd HH:mm")}
</span>
</Table.Cell>
<Table.Cell>
<Tooltip.Root>
<Tooltip.Trigger>
@@ -227,7 +231,7 @@
</Tooltip.Trigger>
<Tooltip.Content>
<div class="space-y-1">
{#each incident.monitors as monitor}
{#each incident.monitors as monitor (monitor.tag || monitor.monitor_tag)}
<div class="text-sm">
<span class="font-medium">{monitor.tag || monitor.monitor_tag}</span>
<span class="text-muted-foreground ml-1"
@@ -274,7 +278,7 @@
</Button>
<div class="flex items-center gap-1">
{#if totalPages <= 7}
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page (page)}
<Button variant={page === pageNo ? "default" : "ghost"} size="sm" onclick={() => goToPage(page)}>
{page}
</Button>
@@ -288,7 +292,7 @@
{/if}
<!-- Middle pages -->
{#each Array.from({ length: 3 }, (_, i) => pageNo - 1 + i).filter((p) => p > 1 && p < totalPages) as page}
{#each Array.from({ length: 3 }, (_, i) => pageNo - 1 + i).filter((p) => p > 1 && p < totalPages) as page (page)}
<Button variant={page === pageNo ? "default" : "ghost"} size="sm" onclick={() => goToPage(page)}>
{page}
</Button>
@@ -24,7 +24,7 @@
// Generate heartbeat URL
let heartbeatUrl = $derived(
tag
? window.location.origin + clientResolve(resolve, `/ext/heartbeat/${tag}:${data.secretString}`)
? window.location.origin + clientResolve(resolve, `/ext/heartbeat/${tag}/${data.secretString}`)
: "Save the monitor first to get the heartbeat URL"
);