mirror of
https://github.com/rajnandan1/kener.git
synced 2026-06-23 04:10:22 +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
|
- **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
@@ -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 { 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}
|
||||||
|
|||||||
Reference in New Issue
Block a user