mirror of
https://github.com/rajnandan1/kener.git
synced 2026-06-22 20:00:44 +00:00
chore: update documentation and enhance monitor group functionality, fixes #694
This commit is contained in:
@@ -121,3 +121,17 @@ Read `.claude/skills/` for specialized instructions on:
|
||||
- **svelte-code-writer** - Svelte component creation/editing
|
||||
- **documentation-writer** - Editing docs in `src/routes/(docs)/docs/content/`
|
||||
- **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
@@ -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.
|
||||
@@ -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…_
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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 { Button } from "$lib/components/ui/button/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 { GroupMonitorMember } from "$lib/server/types/monitor.js";
|
||||
import ArrowUp from "@lucide/svelte/icons/arrow-up";
|
||||
import ArrowDown from "@lucide/svelte/icons/arrow-down";
|
||||
import GripVertical from "@lucide/svelte/icons/grip-vertical";
|
||||
import X from "@lucide/svelte/icons/x";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { resolve } from "$app/paths";
|
||||
|
||||
@@ -58,16 +60,20 @@
|
||||
formData.executionDelay = parsedExecutionDelay;
|
||||
});
|
||||
|
||||
// Filter out GROUP monitors - groups can't contain other groups
|
||||
let eligibleMonitors = $derived(availableMonitors.filter((m) => m.monitor_type !== "GROUP" && m.status === "ACTIVE"));
|
||||
// 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" && 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 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 {
|
||||
return formData.monitors.some((m) => m.tag === monitorTag);
|
||||
}
|
||||
@@ -93,6 +99,22 @@
|
||||
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) {
|
||||
formData.monitors = formData.monitors.map((m) => {
|
||||
if (m.tag !== monitorTag) return m;
|
||||
@@ -129,55 +151,8 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if eligibleMonitors.length > 0}
|
||||
<div class="grid gap-2">
|
||||
{#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>
|
||||
{#if eligibleMonitors.length > 0 || formData.monitors.length > 0}
|
||||
<MonitorPicker monitors={eligibleMonitors} {selectedTags} onToggle={toggleMonitor} onAddMany={addMonitors} />
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-sm">No eligible monitors available. Create some non-group monitors first.</p>
|
||||
{/if}
|
||||
@@ -195,13 +170,24 @@
|
||||
<span class="text-xs">(must equal 1)</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
<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}
|
||||
>
|
||||
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>
|
||||
{/if}
|
||||
|
||||
@@ -249,21 +235,59 @@
|
||||
</div>
|
||||
{#each formData.monitors as m, index (m.tag)}
|
||||
{@const monitorInfo = availableMonitors.find((am) => am.tag === m.tag)}
|
||||
{@const stale = isStale(m.tag)}
|
||||
<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" />
|
||||
<span class="text-muted-foreground text-xs font-medium">{index + 1}.</span>
|
||||
<div>
|
||||
<p class="text-sm">{monitorInfo?.name || m.tag}</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{m.tag}
|
||||
<span class="ml-1 text-[10px]">weight {m.weight}</span>
|
||||
{#if monitorInfo?.image}
|
||||
<img
|
||||
src={clientResolver(resolve, monitorInfo.image)}
|
||||
alt={monitorInfo.name}
|
||||
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 class="text-muted-foreground truncate text-xs">{m.tag}</p>
|
||||
</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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -282,6 +306,15 @@
|
||||
>
|
||||
<ArrowDown class="h-3.5 w-3.5" />
|
||||
</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>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user