chore: update documentation and enhance monitor group functionality, fixes #694

This commit is contained in:
Raj Nandan Sharma
2026-06-06 13:04:13 +05:30
parent 31ba10f434
commit cfc99e2f14
8 changed files with 322 additions and 67 deletions
+14
View File
@@ -121,3 +121,17 @@ Read `.claude/skills/` for specialized instructions on:
- **svelte-code-writer** - Svelte component creation/editing - **svelte-code-writer** - Svelte component creation/editing
- **documentation-writer** - Editing docs in `src/routes/(docs)/docs/content/` - **documentation-writer** - Editing docs in `src/routes/(docs)/docs/content/`
- **tailwindcss** - Tailwind CSS v4 patterns - **tailwindcss** - Tailwind CSS v4 patterns
## Agent skills
### Issue tracker
Issues and PRDs are tracked in GitHub Issues for `rajnandan1/kener`. See `docs/agents/issue-tracker.md`.
### Triage labels
Triage uses the default mattpocock/skills label vocabulary. See `docs/agents/triage-labels.md`.
### Domain docs
This repo uses a single-context domain-doc layout. See `docs/agents/domain.md`.
+31
View File
@@ -0,0 +1,31 @@
# Kener
An open-source status page application providing real-time monitoring, uptime tracking, incident management, and customizable dashboards.
## Language
### Monitoring
**Monitor**:
A single check against a service, with a unique tag, a type (API, Ping, TCP, DNS, SSL, SQL, Heartbeat, GameDig, Group, gRPC, None), and a status (ACTIVE or otherwise).
_Avoid_: Check, probe, service
**Group Monitor**:
A monitor whose status is derived from other monitors via a weighted score (UP=1, DEGRADED=0.5, DOWN=0; maintenance counts as UP). A group cannot contain another group.
_Avoid_: Monitor group, composite monitor
**Member**:
A monitor belonging to a Group Monitor, carrying a weight and a position in the execution order. Membership is an explicit stored list, never a dynamic rule (e.g. tag wildcards).
_Avoid_: Child monitor, sub-monitor
**Weight**:
A member's share of the group score, between 0 and 1. Weights across a group's members must sum to 1. Any membership change (add or remove) redistributes all weights equally; manual tuning happens after membership is settled.
**Execution Order**:
The stored order in which a Group Monitor's members are checked before aggregation. Manually arranged, not derived.
**Eligible Monitor**:
A monitor that may become a Member: ACTIVE, not a Group Monitor, and not the group being edited itself.
**Stale Member**:
A Member whose monitor is no longer an Eligible Monitor (paused or deleted after being added). It remains a Member until explicitly removed, but is excluded from the group score.
@@ -0,0 +1,5 @@
# Group membership is an explicit stored list, not a rule
When making group-monitor member selection searchable (#694), the requester also proposed dynamic membership by tag pattern (e.g. `site1-*` auto-adds matching monitors). We decided group membership stays an explicit, stored list of members. Dynamic membership contradicts the group model: each member carries an explicit weight (weights must sum to 1) and a manual execution order — a rule that adds/removes members over time would need an auto-weighting policy, silent weight redistribution when monitors are created or deleted, and an undefined execution order for matched members. Bulk needs are served in the editor instead: search plus "Add all N matching" makes large explicit groups cheap to build.
If wildcard groups are requested again, the answer is here: it's a different feature (a rule-based aggregate without weights or order), not an extension of Group Monitors.
+37
View File
@@ -0,0 +1,37 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
This repo is configured as a **single-context** repo.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
## File structure
Single-context repo:
```text
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
+22
View File
@@ -0,0 +1,22 @@
# Issue tracker: GitHub
Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
## Conventions
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
- **Comment on an issue**: `gh issue comment <number> --body "..."`
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
- **Close**: `gh issue close <number> --comment "..."`
Infer the repo from `git remote -v``gh` does this automatically when run inside a clone.
## When a skill says "publish to the issue tracker"
Create a GitHub issue.
## When a skill says "fetch the relevant ticket"
Run `gh issue view <number> --comments`.
+15
View File
@@ -0,0 +1,15 @@
# Triage Labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
Edit the right-hand column to match whatever vocabulary you actually use.
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
import * as Command from "$lib/components/ui/command/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import type { MonitorRecord } from "$lib/server/types/db.js";
import CheckIcon from "@lucide/svelte/icons/check";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import ListPlusIcon from "@lucide/svelte/icons/list-plus";
import clientResolver from "$lib/client/resolver.js";
import { resolve } from "$app/paths";
let {
monitors = [],
selectedTags = [],
onToggle,
onAddMany,
placeholder = "Search monitors to add..."
}: {
monitors: MonitorRecord[];
selectedTags: string[];
onToggle: (tag: string) => void;
onAddMany?: (tags: string[]) => void;
placeholder?: string;
} = $props();
let open = $state(false);
let search = $state("");
// Own filtering (shouldFilter={false}) so "Add all matching" counts stay
// consistent with what the list shows. Case-insensitive over name + tag.
const filteredMonitors = $derived.by(() => {
const query = search.trim().toLowerCase();
if (!query) return monitors;
return monitors.filter((m) => m.name.toLowerCase().includes(query) || m.tag.toLowerCase().includes(query));
});
const unselectedMatches = $derived(filteredMonitors.filter((m) => !selectedTags.includes(m.tag)));
const showAddAll = $derived(!!search.trim() && unselectedMatches.length > 0 && !!onAddMany);
function addAllMatching() {
onAddMany?.(unselectedMatches.map((m) => m.tag));
}
</script>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
role="combobox"
aria-expanded={open}
class="w-full justify-between font-normal"
>
<span class="text-muted-foreground">{placeholder}</span>
<ChevronsUpDownIcon class="text-muted-foreground size-4 shrink-0" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-trigger-width)] p-0" align="start">
<Command.Root shouldFilter={false}>
<Command.Input {placeholder} bind:value={search} />
<Command.List class="max-h-64">
<Command.Empty>No monitors found.</Command.Empty>
<Command.Group>
{#each filteredMonitors as monitor (monitor.tag)}
{@const selected = selectedTags.includes(monitor.tag)}
<Command.Item value={monitor.tag} onSelect={() => onToggle(monitor.tag)}>
<CheckIcon class="size-4 {selected ? 'opacity-100' : 'opacity-0'}" />
{#if monitor.image}
<img
src={clientResolver(resolve, monitor.image)}
alt={monitor.name}
class="size-5 rounded object-cover"
/>
{:else}
<div class="bg-muted flex size-5 items-center justify-center rounded text-[10px] font-medium">
{monitor.name.charAt(0).toUpperCase()}
</div>
{/if}
<span class="truncate">{monitor.name}</span>
<span class="text-muted-foreground ml-auto truncate text-xs">{monitor.tag}</span>
</Command.Item>
{/each}
</Command.Group>
{#if showAddAll}
<Command.Separator />
<Command.Group>
<Command.Item value="__add-all-matching__" onSelect={addAllMatching}>
<ListPlusIcon class="size-4" />
Add all {unselectedMatches.length} matching
</Command.Item>
</Command.Group>
{/if}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
@@ -3,12 +3,14 @@
import { Input } from "$lib/components/ui/input/index.js"; import { Input } from "$lib/components/ui/input/index.js";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import * as Select from "$lib/components/ui/select/index.js"; import * as Select from "$lib/components/ui/select/index.js";
import { Switch } from "$lib/components/ui/switch/index.js"; import { Badge } from "$lib/components/ui/badge/index.js";
import MonitorPicker from "$lib/components/MonitorPicker.svelte";
import type { MonitorRecord } from "$lib/server/types/db.js"; import type { MonitorRecord } from "$lib/server/types/db.js";
import type { GroupMonitorMember } from "$lib/server/types/monitor.js"; import type { GroupMonitorMember } from "$lib/server/types/monitor.js";
import ArrowUp from "@lucide/svelte/icons/arrow-up"; import ArrowUp from "@lucide/svelte/icons/arrow-up";
import ArrowDown from "@lucide/svelte/icons/arrow-down"; import ArrowDown from "@lucide/svelte/icons/arrow-down";
import GripVertical from "@lucide/svelte/icons/grip-vertical"; import GripVertical from "@lucide/svelte/icons/grip-vertical";
import X from "@lucide/svelte/icons/x";
import clientResolver from "$lib/client/resolver.js"; import clientResolver from "$lib/client/resolver.js";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
@@ -58,16 +60,20 @@
formData.executionDelay = parsedExecutionDelay; formData.executionDelay = parsedExecutionDelay;
}); });
// Filter out GROUP monitors - groups can't contain other groups // Filter out GROUP monitors - groups can't contain other groups - and the group being edited itself
let eligibleMonitors = $derived(availableMonitors.filter((m) => m.monitor_type !== "GROUP" && m.status === "ACTIVE")); let eligibleMonitors = $derived(
availableMonitors.filter((m) => m.monitor_type !== "GROUP" && m.status === "ACTIVE" && m.tag !== tag)
);
let selectedTags = $derived(formData.monitors.map((m) => m.tag));
/** A stale member's monitor is no longer eligible (paused or deleted after being added). */
function isStale(monitorTag: string): boolean {
return !eligibleMonitors.some((m) => m.tag === monitorTag);
}
let totalWeight = $derived(Math.round(formData.monitors.reduce((sum, m) => sum + m.weight, 0) * 1000) / 1000); let totalWeight = $derived(Math.round(formData.monitors.reduce((sum, m) => sum + m.weight, 0) * 1000) / 1000);
let weightsValid = $derived(Math.abs(totalWeight - 1) < 0.001 || formData.monitors.length === 0); let weightsValid = $derived(Math.abs(totalWeight - 1) < 0.001 || formData.monitors.length === 0);
function findMember(monitorTag: string): GroupMonitorMember | undefined {
return formData.monitors.find((m) => m.tag === monitorTag);
}
function isSelected(monitorTag: string): boolean { function isSelected(monitorTag: string): boolean {
return formData.monitors.some((m) => m.tag === monitorTag); return formData.monitors.some((m) => m.tag === monitorTag);
} }
@@ -93,6 +99,22 @@
distributeEqually(); distributeEqually();
} }
function addMonitors(tags: string[]) {
const newTags = tags.filter((t) => !isSelected(t));
if (newTags.length === 0) return;
formData.monitors = [...formData.monitors, ...newTags.map((t) => ({ tag: t, weight: 0 }))];
distributeEqually();
}
function removeMonitor(monitorTag: string) {
formData.monitors = formData.monitors.filter((m) => m.tag !== monitorTag);
distributeEqually();
}
function clearAll() {
formData.monitors = [];
}
function setWeight(monitorTag: string, weight: number) { function setWeight(monitorTag: string, weight: number) {
formData.monitors = formData.monitors.map((m) => { formData.monitors = formData.monitors.map((m) => {
if (m.tag !== monitorTag) return m; if (m.tag !== monitorTag) return m;
@@ -129,55 +151,8 @@
</p> </p>
</div> </div>
{#if eligibleMonitors.length > 0} {#if eligibleMonitors.length > 0 || formData.monitors.length > 0}
<div class="grid gap-2"> <MonitorPicker monitors={eligibleMonitors} {selectedTags} onToggle={toggleMonitor} onAddMany={addMonitors} />
{#each eligibleMonitors.filter((m) => m.tag !== tag) as monitor (monitor.id ?? monitor.tag)}
{@const member = findMember(monitor.tag)}
{@const selected = !!member}
<div class="rounded-lg border p-3 transition-colors {selected ? 'bg-primary/5' : ''}">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
{#if monitor.image}
<img
src={clientResolver(resolve, monitor.image)}
alt={monitor.name}
class="size-8 rounded object-cover"
/>
{:else}
<div class="bg-muted flex size-8 items-center justify-center rounded text-xs font-medium">
{monitor.name.charAt(0).toUpperCase()}
</div>
{/if}
<div>
<p class="text-sm font-medium">{monitor.name}</p>
<p class="text-muted-foreground text-xs">{monitor.tag}</p>
</div>
</div>
<Switch checked={selected} onCheckedChange={() => toggleMonitor(monitor.tag)} />
</div>
{#if selected && member}
<div class="mt-3 flex items-end gap-4 border-t pt-3">
<div class="space-y-1">
<Label class="text-xs">Weight</Label>
<Input
type="number"
min={0}
max={1}
step={0.01}
value={String(member.weight)}
class="h-8 w-[100px] text-xs"
onchange={(e) => {
const target = e.currentTarget as HTMLInputElement;
setWeight(monitor.tag, Number(target.value));
}}
/>
</div>
</div>
{/if}
</div>
{/each}
</div>
{:else} {:else}
<p class="text-muted-foreground text-sm">No eligible monitors available. Create some non-group monitors first.</p> <p class="text-muted-foreground text-sm">No eligible monitors available. Create some non-group monitors first.</p>
{/if} {/if}
@@ -195,13 +170,24 @@
<span class="text-xs">(must equal 1)</span> <span class="text-xs">(must equal 1)</span>
{/if} {/if}
</div> </div>
<button <Button
type="button" type="button"
class="text-muted-foreground hover:text-foreground text-xs underline underline-offset-2" variant="link"
size="sm"
class="text-muted-foreground hover:text-foreground h-auto p-0 text-xs"
onclick={distributeEqually} onclick={distributeEqually}
> >
Distribute equally Distribute equally
</button> </Button>
<Button
type="button"
variant="link"
size="sm"
class="text-muted-foreground hover:text-destructive h-auto p-0 text-xs"
onclick={clearAll}
>
Clear all
</Button>
</div> </div>
{/if} {/if}
@@ -249,21 +235,59 @@
</div> </div>
{#each formData.monitors as m, index (m.tag)} {#each formData.monitors as m, index (m.tag)}
{@const monitorInfo = availableMonitors.find((am) => am.tag === m.tag)} {@const monitorInfo = availableMonitors.find((am) => am.tag === m.tag)}
{@const stale = isStale(m.tag)}
<div <div
class="flex items-center justify-between px-3 py-2 {index < formData.monitors.length - 1 ? 'border-b' : ''}" class="flex items-center justify-between gap-2 px-3 py-2 {index < formData.monitors.length - 1
? 'border-b'
: ''}"
> >
<div class="flex items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
<GripVertical class="text-muted-foreground h-4 w-4 shrink-0" /> <GripVertical class="text-muted-foreground h-4 w-4 shrink-0" />
<span class="text-muted-foreground text-xs font-medium">{index + 1}.</span> <span class="text-muted-foreground text-xs font-medium">{index + 1}.</span>
<div> {#if monitorInfo?.image}
<p class="text-sm">{monitorInfo?.name || m.tag}</p> <img
<p class="text-muted-foreground text-xs"> src={clientResolver(resolve, monitorInfo.image)}
{m.tag} alt={monitorInfo.name}
<span class="ml-1 text-[10px]">weight {m.weight}</span> class="size-8 shrink-0 rounded object-cover"
/>
{:else}
<div class="bg-muted flex size-8 shrink-0 items-center justify-center rounded text-xs font-medium">
{(monitorInfo?.name || m.tag).charAt(0).toUpperCase()}
</div>
{/if}
<div class="min-w-0">
<p class="flex items-center gap-1.5 truncate text-sm">
{monitorInfo?.name || m.tag}
{#if stale}
<Badge
variant="outline"
class="text-muted-foreground shrink-0 text-[10px]"
title="Not currently checked; excluded from group score"
>
inactive
</Badge>
{/if}
</p> </p>
<p class="text-muted-foreground truncate text-xs">{m.tag}</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex shrink-0 items-center gap-1">
<div class="flex items-center gap-1.5">
<Label class="text-muted-foreground text-xs" for="group-member-weight-{m.tag}">Weight</Label>
<Input
id="group-member-weight-{m.tag}"
type="number"
min={0}
max={1}
step={0.01}
value={String(m.weight)}
class="h-8 w-[80px] text-xs"
onchange={(e) => {
const target = e.currentTarget as HTMLInputElement;
setWeight(m.tag, Number(target.value));
}}
/>
</div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -282,6 +306,15 @@
> >
<ArrowDown class="h-3.5 w-3.5" /> <ArrowDown class="h-3.5 w-3.5" />
</Button> </Button>
<Button
variant="ghost"
size="icon"
class="hover:text-destructive h-7 w-7"
title="Remove from group"
onclick={() => removeMonitor(m.tag)}
>
<X class="h-3.5 w-3.5" />
</Button>
</div> </div>
</div> </div>
{/each} {/each}