diff --git a/CLAUDE.md b/CLAUDE.md index 01580c68..a5edd369 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..80309b65 --- /dev/null +++ b/CONTEXT.md @@ -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. diff --git a/docs/adr/0001-explicit-group-membership.md b/docs/adr/0001-explicit-group-membership.md new file mode 100644 index 00000000..09914f1e --- /dev/null +++ b/docs/adr/0001-explicit-group-membership.md @@ -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. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 00000000..2ed13ff1 --- /dev/null +++ b/docs/agents/domain.md @@ -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…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 00000000..cce77ecb --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -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 --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 --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --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 --comments`. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 00000000..b716855d --- /dev/null +++ b/docs/agents/triage-labels.md @@ -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. diff --git a/src/lib/components/MonitorPicker.svelte b/src/lib/components/MonitorPicker.svelte new file mode 100644 index 00000000..69e2760e --- /dev/null +++ b/src/lib/components/MonitorPicker.svelte @@ -0,0 +1,98 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + + + + No monitors found. + + {#each filteredMonitors as monitor (monitor.tag)} + {@const selected = selectedTags.includes(monitor.tag)} + onToggle(monitor.tag)}> + + {#if monitor.image} + {monitor.name} + {:else} +
+ {monitor.name.charAt(0).toUpperCase()} +
+ {/if} + {monitor.name} + {monitor.tag} +
+ {/each} +
+ {#if showAddAll} + + + + + Add all {unselectedMatches.length} matching + + + {/if} +
+
+
+
diff --git a/src/routes/(manage)/manage/app/monitors/[tag]/types/monitor-group.svelte b/src/routes/(manage)/manage/app/monitors/[tag]/types/monitor-group.svelte index 66df7799..83521495 100644 --- a/src/routes/(manage)/manage/app/monitors/[tag]/types/monitor-group.svelte +++ b/src/routes/(manage)/manage/app/monitors/[tag]/types/monitor-group.svelte @@ -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 @@

- {#if eligibleMonitors.length > 0} -
- {#each eligibleMonitors.filter((m) => m.tag !== tag) as monitor (monitor.id ?? monitor.tag)} - {@const member = findMember(monitor.tag)} - {@const selected = !!member} -
-
-
- {#if monitor.image} - {monitor.name} - {:else} -
- {monitor.name.charAt(0).toUpperCase()} -
- {/if} -
-

{monitor.name}

-

{monitor.tag}

-
-
- toggleMonitor(monitor.tag)} /> -
- - {#if selected && member} -
-
- - { - const target = e.currentTarget as HTMLInputElement; - setWeight(monitor.tag, Number(target.value)); - }} - /> -
-
- {/if} -
- {/each} -
+ {#if eligibleMonitors.length > 0 || formData.monitors.length > 0} + {:else}

No eligible monitors available. Create some non-group monitors first.

{/if} @@ -195,13 +170,24 @@ (must equal 1) {/if} - + + {/if} @@ -249,21 +235,59 @@ {#each formData.monitors as m, index (m.tag)} {@const monitorInfo = availableMonitors.find((am) => am.tag === m.tag)} + {@const stale = isStale(m.tag)}
-
+
{index + 1}. -
-

{monitorInfo?.name || m.tag}

-

- {m.tag} - weight {m.weight} + {#if monitorInfo?.image} + {monitorInfo.name} + {:else} +

+ {(monitorInfo?.name || m.tag).charAt(0).toUpperCase()} +
+ {/if} +
+

+ {monitorInfo?.name || m.tag} + {#if stale} + + inactive + + {/if}

+

{m.tag}

-
+
+
+ + { + const target = e.currentTarget as HTMLInputElement; + setWeight(m.tag, Number(target.value)); + }} + /> +
+
{/each}