feat(frontend): watches, webhooks and alerts panels read humanly, with chat picker, url filter, test send and delivery log

This commit is contained in:
hh committed 2026-10-19 02:57:27 +00:00
1 parent 6a4fd6ad47
commit ea3a7c0a61
19 files changed
+3699 -458

No files matched your search

+12 -3
View File
@@ -19,6 +19,7 @@
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
import WebhooksPanel from "#lib/components/watches/WebhooksPanel.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
@@ -27,7 +28,7 @@
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
const titles: Record<RightPanel, string> = $derived({
alerts: "Алерты",
alerts: t("settings.alerts"),
annotations: "Заметки",
bots: "Боты",
callbacks: "Callback-данные",
@@ -46,12 +47,14 @@
stories: "Сторис",
"stories-all": "Все сторис",
versions: "Версии",
watches: "Отслеживания",
watches: t("settings.watches"),
webhooks: t("watches.webhooks"),
});
const parents: Partial<Record<RightPanel, RightPanel>> = {
"storage-chat": "storage",
"storage-trash": "storage",
webhooks: "watches",
};
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
@@ -118,7 +121,13 @@
{:else if ui.rightPanel === "stories-all"}
<AllStoriesArchive />
{:else if ui.rightPanel === "watches"}
<WatchesPanel />
{#key accounts.selectedId}
<WatchesPanel />
{/key}
{:else if ui.rightPanel === "webhooks"}
{#key accounts.selectedId}
<WebhooksPanel />
{/key}
{:else if ui.rightPanel === "alerts"}
<AlertsPanel />
{:else if ui.rightPanel === "annotations"}
@@ -1,82 +0,0 @@
<script lang="ts">
import type { Alert } from "#lib/api/types.js";
import Button from "../ui/Button.svelte";
import Icon from "../ui/Icon.svelte";
interface Props {
alerts: Alert[];
onseen: (alert: Alert) => void;
}
const { alerts, onseen }: Props = $props();
function text(alert: Alert): string {
const p = alert.payload;
const value = p.text ?? p.message ?? p.keyword;
return typeof value === "string" ? value : JSON.stringify(p);
}
</script>
<ul class="alert-list">
{#each alerts as alert (alert.id)}
<li class="alert-item" class:unseen={!alert.seen}>
<div class="alert-main">
<p class="alert-text">{text(alert)}</p>
<time class="alert-date">{new Date(alert.ts).toLocaleString()}</time>
</div>
{#if !alert.seen}
<Button
aria-label="Отметить прочитанным"
onclick={() => onseen(alert)}
round
smaller
variant="translucent"
>
<Icon name="check" />
</Button>
{/if}
</li>
{/each}
</ul>
<style lang="scss">
.alert-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.alert-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
&.unseen {
border-color: var(--color-primary);
}
}
.alert-main {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.alert-text {
margin: 0;
overflow-wrap: anywhere;
}
.alert-date {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
</style>
@@ -1,72 +1,640 @@
<script lang="ts">
import { listAlerts, markAlertSeen } from "#lib/api/endpoints.js";
import type { Alert } from "#lib/api/types.js";
import { onMount, untrack } from "svelte";
import {
type AlertItem,
listAlerts,
markAlertSeen,
markAllAlertsSeen,
} from "#lib/api/watches.js";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Button from "#lib/components/ui/Button.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull, formatListDate } from "#lib/format/datetime.js";
import {
alertAvatar,
alertLabel,
alertPreview,
alertTitle,
EVENT_ICONS,
} from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import Spinner from "../ui/Spinner.svelte";
import AlertList from "./AlertList.svelte";
import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
let alerts = $state<Alert[]>([]);
type Filter = "all" | "unseen";
const PAGE = 50;
let items = $state<AlertItem[]>([]);
let nextBefore = $state<number | null>(null);
let loading = $state(true);
let unseenOnly = $state(false);
let loadingMore = $state(false);
let failed = $state(false);
let notifyProblem = $state(false);
let token = 0;
async function load(_account: number | null, unseen: boolean) {
loading = true;
let filter = $state<Filter>(
untrack(() => page.url.searchParams.get(ALERTS_PARAM)) === "unseen"
? "unseen"
: "all"
);
function writeParam(value: Filter | null) {
const url = new URL(page.url.href);
if (value === null) {
url.searchParams.delete(ALERTS_PARAM);
} else {
url.searchParams.set(ALERTS_PARAM, value);
}
if (url.href !== page.url.href) {
goto(url, { replace: true, reset: false });
}
}
function setFilter(next: Filter) {
filter = next;
writeParam(next);
}
async function load(current: Filter, before: number | null = null) {
token += 1;
const mine = token;
if (before === null) {
loading = true;
} else {
loadingMore = true;
}
try {
alerts = await listAlerts(unseen ? { seen: false } : {});
const result = await listAlerts({
before: before ?? undefined,
limit: PAGE,
seen: current === "unseen" ? false : undefined,
});
if (mine !== token) {
return;
}
items = before === null ? result.items : [...items, ...result.items];
nextBefore = result.next_before;
failed = false;
} catch {
if (mine === token) {
failed = true;
}
} finally {
loading = false;
if (mine === token) {
loading = false;
loadingMore = false;
}
}
}
$effect(() => {
load(accounts.selectedId, unseenOnly);
if (accounts.selectedId !== null) {
load(filter);
}
});
async function seen(alert: Alert) {
await markAlertSeen(alert.id);
if (unseenOnly) {
alerts = alerts.filter((a) => a.id !== alert.id);
} else {
alerts = alerts.map((a) =>
a.id === alert.id ? { ...a, seen: true } : a
);
onMount(() => {
writeParam(filter);
const stop = alerts.onAlert((alert) => {
if (!items.some((item) => item.id === alert.id)) {
items = [alert, ...items];
}
});
return () => {
stop();
writeParam(null);
};
});
function markLocal(alert: AlertItem) {
if (alert.seen) {
return;
}
items = items.map((item) =>
item.id === alert.id ? { ...item, seen: true } : item
);
alerts.read();
}
async function seen(alert: AlertItem) {
if (alert.seen) {
return;
}
markLocal(alert);
await markAlertSeen(alert.id).catch(() => undefined);
}
async function openAlert(alert: AlertItem) {
seen(alert);
await goto(alert.path);
if (isMobile()) {
ui.closePanel();
}
}
async function seeAll() {
items = items.map((item) => ({ ...item, seen: true }));
alerts.readAll();
await markAllAlertsSeen().catch(() => undefined);
}
async function toggleNotify() {
notifyProblem = false;
const wanted = !alerts.notify;
const done = await alerts.setNotify(wanted);
notifyProblem = wanted && !done;
}
</script>
<div class="panel-content">
<label class="toggle">
<input type="checkbox" bind:checked={unseenOnly}>
<span>Только непрочитанные</span>
</label>
{#if loading}
<Spinner />
{:else if alerts.length === 0}
<p class="empty">Алертов нет.</p>
{:else}
<AlertList {alerts} onseen={seen} />
{/if}
<div class="tabs">
<button
aria-pressed={filter === "all"}
class="tab"
onclick={() => setFilter("all")}
type="button"
class:on={filter === "all"}
>
{t("alerts.all")}
</button>
<button
aria-pressed={filter === "unseen"}
class="tab"
onclick={() => setFilter("unseen")}
type="button"
class:on={filter === "unseen"}
>
{t("alerts.unseen")}
</button>
</div>
{#if alerts.unseen > 0}
<button class="act" onclick={seeAll} type="button">
<Icon name="readchats" size="1.375rem" />
<span class="label">{t("alerts.markAll")}</span>
<span class="value">{alerts.unseen}</span>
</button>
{/if}
{#if alerts.notifySupported}
<button
aria-checked={alerts.notify}
class="toggle"
onclick={toggleNotify}
role="switch"
type="button"
>
<Icon name="unmute" size="1.375rem" />
<span class="label">{t("alerts.notify")}</span>
<span class="switch" class:on={alerts.notify}
><span class="knob"></span></span
>
</button>
{#if notifyProblem}
<p class="problem">{t("alerts.notifyDenied")}</p>
{/if}
{/if}
{#if loading && items.length === 0}
<div class="state"><Spinner /></div>
{:else if failed && items.length === 0}
<div class="state">
<p>{t("alerts.loadFailed")}</p>
<Button onclick={() => load(filter)} smaller variant="text">
{t("alerts.retry")}
</Button>
</div>
{:else if items.length === 0}
<div class="empty">
<EmptyState
description={filter === "unseen"
? undefined
: t("alerts.empty.description")}
title={filter === "unseen"
? t("alerts.emptyUnseen.title")
: t("alerts.empty.title")}
/>
{#if filter === "all"}
<Button onclick={() => ui.openPanel("watches")} smaller variant="text">
{t("alerts.setup")}
</Button>
{/if}
</div>
{:else}
<ul class="list">
{#each items as alert (alert.id)}
{@const avatar = alertAvatar(alert)}
{@const preview = alertPreview(alert)}
<li>
<ContextMenu>
{#snippet children({
props,
})}
<button
{...props}
class="alert"
onclick={() => openAlert(alert)}
type="button"
class:unseen={!alert.seen}
>
<span class="lead">
<Avatar
avatar={{ id: avatar.id, kind: avatar.kind }}
colorKey={avatar.id}
hasAvatar={avatar.hasAvatar}
name={avatar.name}
size={3}
/>
<span class="kind {alert.event}">
<Icon name={EVENT_ICONS[alert.event]} size="0.75rem" />
</span>
</span>
<span class="text">
<span class="top">
<span class="title">{alertTitle(alert)}</span>
<time datetime={alert.ts} title={formatFull(alert.ts)}>
{formatListDate(alert.ts)}
</time>
</span>
<span class="bottom">
<span class="line">
<span class="what" class:alone={!preview}>
{alertLabel(alert)}
</span>
{preview}
</span>
{#if !alert.seen}
<span class="dot"></span>
{/if}
</span>
</span>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem
icon={alert.message ? "reply" : "message"}
onselect={() => openAlert(alert)}
>
{alert.message ? t("alerts.open") : t("alerts.openChat")}
</ContextMenuItem>
{#if !alert.seen}
<ContextMenuItem icon="check" onselect={() => seen(alert)}>
{t("alerts.markSeen")}
</ContextMenuItem>
{/if}
{/snippet}
</ContextMenu>
</li>
{/each}
</ul>
{#if nextBefore !== null}
<button
class="more"
disabled={loadingMore}
onclick={() => load(filter, nextBefore)}
type="button"
>
{t("alerts.more")}
</button>
{/if}
{/if}
<style lang="scss">
.panel-content {
.tabs {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
flex-shrink: 0;
border-bottom: 1px solid var(--color-borders);
}
.tab {
cursor: pointer;
flex: 1;
height: 2.75rem;
border: 0;
border-bottom: 2px solid transparent;
font: inherit;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
background: none;
transition: color 0.15s ease-out;
&.on {
border-bottom-color: var(--color-primary);
color: var(--color-primary);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:not(.on):hover {
color: var(--color-text);
}
}
}
.act,
.toggle {
cursor: pointer;
display: flex;
gap: 0.875rem;
align-items: center;
width: 100%;
height: 3rem;
padding: 0 1rem 0 1.125rem;
border: 0;
border-bottom: 1px solid var(--color-borders);
font: inherit;
font-size: 0.9375rem;
color: var(--color-primary);
text-align: start;
background: none;
.label {
flex: 1;
}
.value {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
&:focus-visible {
outline: 2px solid currentcolor;
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.toggle {
color: var(--color-text);
:global(.icon) {
color: var(--color-icon-secondary);
}
}
.switch {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders-input);
transition: background-color 0.15s ease-out;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease-out;
.on & {
transform: translateX(0.875rem);
}
}
.problem {
margin: 0;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--color-borders);
font-size: 0.8125rem;
color: var(--color-error);
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
p {
margin: 0;
}
}
.empty {
color: var(--color-text-secondary);
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
padding: 2.5rem 1rem 2rem;
text-align: center;
}
.list {
margin: 0;
padding: 0.5rem;
list-style: none;
}
.alert {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
min-height: 4.5rem;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: 0.625rem;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
transition: background-color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.lead {
position: relative;
flex-shrink: 0;
}
.kind {
position: absolute;
right: -0.125rem;
bottom: -0.125rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-background);
&.delete {
background-color: var(--color-error);
}
&.online {
background-color: var(--color-green);
}
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.top,
.bottom {
display: flex;
gap: 0.5rem;
align-items: center;
min-width: 0;
}
.title {
overflow: hidden;
flex: 1;
min-width: 0;
font-size: 1rem;
text-overflow: ellipsis;
white-space: nowrap;
.unseen & {
font-weight: var(--font-weight-medium);
}
}
time {
flex-shrink: 0;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.line {
overflow: hidden;
flex: 1;
min-width: 0;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.what {
color: var(--color-text);
&:not(.alone)::after {
content: ":";
}
}
.dot {
flex-shrink: 0;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background-color: var(--color-primary);
}
.more {
cursor: pointer;
width: calc(100% - 1rem);
margin: 0 0.5rem 1rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
@media (pointer: coarse) {
min-height: 2.75rem;
}
}
</style>
@@ -0,0 +1,305 @@
<script lang="ts">
import type { Chat } from "#lib/api/types.js";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import type { Picked } from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
interface Props {
empty: string;
icon: string;
label: string;
onpick: (value: Picked | null) => void;
people?: boolean;
required?: boolean;
value: Picked | null;
}
const {
empty,
icon,
label,
onpick,
people = false,
required = false,
value,
}: Props = $props();
const SHOWN = 8;
let open = $state(false);
let query = $state("");
const picker = createChatPicker();
const results = $derived(
picker.results
.filter((chat) => !people || (chat.chat_id > 0 && !chat.is_bot))
.slice(0, SHOWN)
);
function toggle() {
open = !open;
if (open) {
picker.search(query);
}
}
function choose(chat: Chat) {
onpick({
hasAvatar: chat.has_avatar,
id: chat.chat_id,
title: chat.title ?? t("watches.unknownChat", { id: chat.chat_id }),
});
open = false;
query = "";
}
function focus(node: HTMLInputElement) {
node.focus();
}
</script>
<div class="field" class:open>
<div class="line">
<button aria-expanded={open} class="main" onclick={toggle} type="button">
{#if value}
<Avatar
avatar={{ id: value.id, kind: value.id > 0 ? "peer" : "chat" }}
colorKey={value.id}
hasAvatar={value.hasAvatar}
name={value.title}
size={2.25}
/>
{:else}
<span class="glyph"><Icon name={icon} size="1.5rem" /></span>
{/if}
<span class="text">
<span class="label">{label}</span>
<span class="value" class:placeholder={!value}>
{value?.title ?? (required ? t("watches.choose") : empty)}
</span>
</span>
</button>
{#if value && !required}
<button
aria-label={t("watches.clear")}
class="clear"
onclick={() => onpick(null)}
title={t("watches.clear")}
type="button"
>
<Icon name="close" size="1.125rem" />
</button>
{/if}
</div>
{#if open}
<div class="picker">
<input
aria-label={people ? t("watches.searchPeople") : t("watches.search")}
class="filter"
oninput={() => picker.search(query)}
placeholder={people ? t("watches.searchPeople") : t("watches.search")}
type="text"
bind:value={query}
{@attach focus}
>
{#each results as chat (chat.chat_id)}
<button class="pick" onclick={() => choose(chat)} type="button">
<Avatar
avatar={{
id: chat.chat_id,
kind: chat.chat_id > 0 ? "peer" : "chat",
}}
colorKey={chat.chat_id}
hasAvatar={chat.has_avatar}
name={chat.title ?? ""}
size={2}
/>
<span class="pick-title">
{chat.title ?? t("watches.unknownChat", { id: chat.chat_id })}
</span>
</button>
{:else}
<p class="nothing">{t("watches.nothing")}</p>
{/each}
</div>
{/if}
</div>
<style lang="scss">
.line {
display: flex;
align-items: center;
padding-right: 0.5rem;
}
.open .line {
background-color: var(--color-chat-hover);
}
.main {
cursor: pointer;
display: flex;
flex: 1;
gap: 0.875rem;
align-items: center;
min-width: 0;
min-height: 3.25rem;
padding: 0.375rem 0.5rem 0.375rem 1rem;
border: 0;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.glyph {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
color: var(--color-icon-secondary);
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.0625rem;
min-width: 0;
}
.label {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.value {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
&.placeholder {
color: var(--color-primary);
}
}
.clear {
cursor: pointer;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
}
@media (hover: hover) {
&:hover {
background-color: var(--color-interactive-element-hover);
}
}
}
.picker {
display: flex;
flex-direction: column;
padding: 0.25rem 0.5rem 0.5rem;
}
.filter {
margin: 0.25rem 0.5rem 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-borders-input);
border-radius: var(--border-radius-default);
font: inherit;
font-size: 0.9375rem;
color: var(--color-text);
background-color: var(--color-background);
&:focus {
border-color: var(--color-primary);
outline: none;
}
}
.pick {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
padding: 0.375rem 0.75rem;
border: 0;
border-radius: var(--border-radius-default-small, 0.625rem);
font: inherit;
font-size: 0.9375rem;
color: var(--color-text);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.pick-title {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.nothing {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
</style>
@@ -1,121 +0,0 @@
<script lang="ts">
import Button from "../ui/Button.svelte";
interface Props {
onsave: (data: {
kind: string;
params: Record<string, unknown>;
enabled: boolean;
}) => void;
saving?: boolean;
}
const { saving = false, onsave }: Props = $props();
const KINDS = [
{ label: "Keyword", value: "keyword" },
{ label: "Peer online", value: "peer_online" },
];
let kind = $state("keyword");
let keyword = $state("");
let peerId = $state("");
let enabled = $state(true);
const isKeyword = $derived(kind === "keyword");
const valid = $derived(
isKeyword ? keyword.trim() !== "" : peerId.trim() !== ""
);
function submit() {
if (!valid) {
return;
}
const params: Record<string, unknown> = isKeyword
? { keyword: keyword.trim() }
: { peer_id: Number(peerId.trim()) };
onsave({ enabled, kind, params });
keyword = "";
peerId = "";
enabled = true;
}
</script>
<form
class="watch-editor"
onsubmit={(e) => {
e.preventDefault();
submit();
}}
>
<label class="field">
<span class="field-label">Type</span>
<select bind:value={kind}>
{#each KINDS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</label>
{#if isKeyword}
<label class="field">
<span class="field-label">Keyword</span>
<input placeholder="Text to match…" type="text" bind:value={keyword}>
</label>
{:else}
<label class="field">
<span class="field-label">Peer ID</span>
<input
inputmode="numeric"
placeholder="123456789"
type="text"
bind:value={peerId}
>
</label>
{/if}
<label class="field-inline">
<input type="checkbox" bind:checked={enabled}>
<span>Enabled</span>
</label>
<Button disabled={saving || !valid} type="submit">Add watch</Button>
</form>
<style lang="scss">
.watch-editor {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field-label {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.field-inline {
display: flex;
align-items: center;
gap: 0.5rem;
}
input[type="text"],
select {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--color-borders);
border-radius: 0.5rem;
background: var(--color-background);
color: var(--color-text);
}
</style>
@@ -0,0 +1,388 @@
<script lang="ts">
import { untrack } from "svelte";
import { ApiError } from "#lib/api/client.js";
import {
createWatch,
updateWatch,
type Watch,
type WatchDraft,
type WatchKind,
type WatchParams,
} from "#lib/api/watches.js";
import Button from "#lib/components/ui/Button.svelte";
import RadioList from "#lib/components/ui/RadioList.svelte";
import {
chatTitle,
type Picked,
patternOf,
personName,
WATCH_KINDS,
} from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import ChatPickerField from "./ChatPickerField.svelte";
interface Props {
oncancel: () => void;
onsaved: (watch: Watch) => void;
watch?: Watch | null;
}
const { watch = null, oncancel, onsaved }: Props = $props();
const INVALID_REGEX = "invalid regex: ";
const NESTED_REGEX = "regex repeats inside a repeat";
function pickedChat(source: Watch | null): Picked | null {
const id = source?.params.chat_id;
if (id === undefined || !source) {
return null;
}
return {
hasAvatar: source.chat?.has_avatar ?? false,
id,
title: chatTitle(source.chat, id),
};
}
function pickedPerson(source: Watch | null): Picked | null {
const id = source?.params.sender_id ?? source?.params.peer_id;
if (id === undefined || !source) {
return null;
}
return {
hasAvatar: source.person?.has_avatar ?? false,
id,
title: personName(source.person, id),
};
}
function initial(source: Watch | null) {
return {
chat: pickedChat(source),
kind: source?.kind ?? "keyword",
name: source?.name ?? "",
pattern: source ? patternOf(source.params) : "",
person: pickedPerson(source),
regex: source?.params.regex ?? false,
};
}
const start = untrack(() => initial(watch));
let kind = $state<WatchKind>(start.kind);
let pattern = $state(start.pattern);
let regex = $state(start.regex);
let chat = $state<Picked | null>(start.chat);
let person = $state<Picked | null>(start.person);
let name = $state(start.name);
let issue = $state<string | null>(null);
let saving = $state(false);
const kinds = $derived(
WATCH_KINDS.map((value) => ({ label: t(`watches.kind.${value}`), value }))
);
const scoped = $derived(kind === "keyword" || kind === "new_message");
const chatEmpty = $derived(
kind === "message_deleted" || kind === "message_edited"
? t("watches.anyDm")
: t("watches.anyChat")
);
function params(): WatchParams {
if (kind === "peer_online") {
return { peer_id: person?.id };
}
const scope: WatchParams = {};
if (chat) {
scope.chat_id = chat.id;
}
if (scoped && person) {
scope.sender_id = person.id;
}
if (kind === "keyword") {
return { ...scope, pattern: pattern.trim(), regex };
}
return scope;
}
function validate(): string | null {
if (kind === "keyword" && pattern.trim() === "") {
return t("watches.error.needPattern");
}
if (kind === "new_message" && !(chat || person)) {
return t("watches.error.needScope");
}
if (kind === "peer_online" && !person) {
return t("watches.error.needPerson");
}
return null;
}
function explain(failure: unknown): string {
const detail =
failure instanceof ApiError ? failure.detail : String(failure);
if (detail.startsWith(INVALID_REGEX)) {
return t("watches.error.badRegex", {
detail: detail.slice(INVALID_REGEX.length),
});
}
if (detail.startsWith(NESTED_REGEX)) {
return t("watches.error.slowRegex");
}
return t("watches.error.failed", { detail });
}
async function save() {
issue = validate();
if (issue) {
return;
}
const draft: WatchDraft = {
enabled: watch?.enabled ?? true,
kind,
name: name.trim() || null,
params: params(),
};
saving = true;
try {
onsaved(
watch ? await updateWatch(watch.id, draft) : await createWatch(draft)
);
} catch (failure) {
issue = explain(failure);
} finally {
saving = false;
}
}
</script>
<form
class="watch-form"
onsubmit={(event) => {
event.preventDefault();
save();
}}
>
{#if !watch}
<div class="section-title">{t("watches.kind")}</div>
<RadioList
onselect={(value) => {
kind = value;
issue = null;
}}
options={kinds}
selected={kind}
/>
{/if}
<div class="fields">
{#if kind === "keyword"}
<div class="input-group" class:touched={pattern !== ""}>
<input
class="form-control"
id="watch-pattern-{watch?.id ?? "new"}"
maxlength="200"
spellcheck="false"
type="text"
bind:value={pattern}
>
<label for="watch-pattern-{watch?.id ?? "new"}">
{regex ? t("watches.expression") : t("watches.pattern")}
</label>
</div>
<button
aria-checked={regex}
class="toggle"
onclick={() => {
regex = !regex;
issue = null;
}}
role="switch"
type="button"
>
<span class="toggle-label">{t("watches.regex")}</span>
<span class="switch" class:on={regex}><span class="knob"></span></span>
</button>
{/if}
</div>
{#if kind === "peer_online"}
<ChatPickerField
empty={t("watches.choose")}
icon="user"
label={t("watches.person")}
onpick={(value) => {
person = value;
issue = null;
}}
people
required
value={person}
/>
{:else}
<ChatPickerField
empty={chatEmpty}
icon="group"
label={t("watches.chat")}
onpick={(value) => {
chat = value;
issue = null;
}}
value={chat}
/>
{#if scoped}
<ChatPickerField
empty={t("watches.anySender")}
icon="user"
label={t("watches.sender")}
onpick={(value) => {
person = value;
issue = null;
}}
people
value={person}
/>
{/if}
{/if}
<div class="fields">
<div class="input-group name" class:touched={name !== ""}>
<input
class="form-control"
id="watch-name-{watch?.id ?? "new"}"
maxlength="120"
type="text"
bind:value={name}
>
<label for="watch-name-{watch?.id ?? "new"}">{t("watches.name")}</label>
</div>
</div>
{#if issue}
<p class="error">{issue}</p>
{/if}
<div class="actions">
<Button onclick={oncancel} smaller variant="text">
{t("common.cancel")}
</Button>
<Button disabled={saving} pill smaller type="submit">
{t("common.save")}
</Button>
</div>
</form>
<style lang="scss">
.watch-form {
display: flex;
flex-direction: column;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--color-borders);
}
.section-title {
padding: 0.875rem 1rem 0.25rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.fields {
padding: 0 1rem;
}
.input-group {
margin: 0.75rem 0 0.25rem;
&.name {
margin: 0.75rem 0 0.5rem;
}
}
.toggle {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: calc(100% + 2rem);
min-height: 2.75rem;
margin: 0 -1rem;
padding: 0 1rem;
border: 0;
font: inherit;
font-size: 0.9375rem;
color: var(--color-text);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.toggle-label {
flex: 1;
}
.switch {
display: flex;
flex-shrink: 0;
align-items: center;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders-input);
transition: background-color 0.15s ease-out;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease-out;
.on & {
transform: translateX(0.875rem);
}
}
.error {
margin: 0;
padding: 0.25rem 1rem;
font-size: 0.8125rem;
color: var(--color-error);
overflow-wrap: anywhere;
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
padding: 0.5rem 1rem 0;
:global(.Button) {
padding-inline: 1rem;
}
}
</style>
@@ -1,111 +0,0 @@
<script lang="ts">
import type { Watch } from "#lib/api/types.js";
import { peerName } from "#lib/format/peer.js";
import { peers } from "#lib/stores/peers.svelte.js";
import Button from "../ui/Button.svelte";
import Icon from "../ui/Icon.svelte";
interface Props {
ondelete: (watch: Watch) => void;
ontoggle: (watch: Watch) => void;
watches: Watch[];
}
const { watches, ontoggle, ondelete }: Props = $props();
$effect(() => {
const ids = watches
.filter((w) => w.kind === "peer_online")
.map((w) => Number(w.params.peer_id))
.filter((id) => Number.isFinite(id));
peers.ensure(ids);
});
function summary(watch: Watch): string {
if (watch.kind === "keyword") {
return `“${String(watch.params.keyword ?? "")}”`;
}
const id = Number(watch.params.peer_id);
return peerName(peers.get(id) ?? null) || `#${id}`;
}
</script>
<ul class="watch-list">
{#each watches as watch (watch.id)}
<li class="watch-item" class:disabled={!watch.enabled}>
<div class="watch-main">
<span class="watch-kind">{watch.kind}</span>
<span class="watch-summary">{summary(watch)}</span>
</div>
<div class="row-actions">
<Button
aria-label="Переключить"
onclick={() => ontoggle(watch)}
round
smaller
variant="translucent"
>
<Icon name={watch.enabled ? "eye" : "eye-crossed"} />
</Button>
<Button
aria-label="Удалить"
onclick={() => ondelete(watch)}
round
smaller
variant="translucent"
>
<Icon name="delete" />
</Button>
</div>
</li>
{/each}
</ul>
<style lang="scss">
.watch-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.watch-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
&.disabled {
opacity: 0.55;
}
}
.watch-main {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.watch-kind {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.watch-summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-actions {
display: flex;
flex-shrink: 0;
}
</style>
@@ -1,80 +1,475 @@
<script lang="ts">
import { onMount } from "svelte";
import {
createWatch,
deleteWatch,
listWatches,
listWebhooks,
updateWatch,
} from "#lib/api/endpoints.js";
import type { Watch } from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import Spinner from "../ui/Spinner.svelte";
import WatchEditor from "./WatchEditor.svelte";
import WatchList from "./WatchList.svelte";
type Watch,
} from "#lib/api/watches.js";
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Button from "#lib/components/ui/Button.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import {
chatTitle,
describeWatch,
KIND_ICONS,
personName,
} from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import WatchForm from "./WatchForm.svelte";
let watches = $state<Watch[]>([]);
let hookCount = $state(0);
let loading = $state(true);
let saving = $state(false);
let failed = $state(false);
let editing = $state<number | "new" | null>(null);
let confirming = $state<number | null>(null);
let problem = $state<string | null>(null);
let token = 0;
async function load(_account: number | null) {
loading = true;
async function load() {
token += 1;
const current = token;
try {
watches = await listWatches();
const [rows, hooks] = await Promise.all([listWatches(), listWebhooks()]);
if (current === token) {
watches = rows;
hookCount = hooks.length;
failed = false;
}
} catch {
if (current === token) {
failed = true;
}
} finally {
loading = false;
if (current === token) {
loading = false;
}
}
}
$effect(() => {
load(accounts.selectedId);
});
onMount(load);
async function add(data: {
kind: string;
params: Record<string, unknown>;
enabled: boolean;
}) {
saving = true;
try {
const created = await createWatch(data.kind, data.params, data.enabled);
watches = [created, ...watches];
} finally {
saving = false;
function stats(watch: Watch): string {
if (watch.alert_count === 0 || watch.last_alert_at === null) {
return t("watches.never");
}
return t("watches.fired", {
count: watch.alert_count,
date: formatListDate(watch.last_alert_at),
});
}
function subtitle(watch: Watch): string {
const line = watch.name ? describeWatch(watch) : stats(watch);
return watch.enabled ? line : `${t("watches.off")} · ${line}`;
}
function saved(watch: Watch) {
const known = watches.some((item) => item.id === watch.id);
watches = known
? watches.map((item) => (item.id === watch.id ? watch : item))
: [watch, ...watches];
editing = null;
}
async function toggle(watch: Watch) {
const updated = await updateWatch(watch.id, watch.params, !watch.enabled);
watches = watches.map((w) => (w.id === updated.id ? updated : w));
problem = null;
try {
saved(
await updateWatch(watch.id, {
enabled: !watch.enabled,
kind: watch.kind,
name: watch.name,
params: watch.params,
})
);
} catch {
problem = t("watches.error.action");
}
}
async function remove(watch: Watch) {
await deleteWatch(watch.id);
watches = watches.filter((w) => w.id !== watch.id);
problem = null;
try {
await deleteWatch(watch.id);
watches = watches.filter((item) => item.id !== watch.id);
} catch {
problem = t("watches.error.action");
} finally {
confirming = null;
}
}
function edit(watch: Watch) {
confirming = null;
editing = editing === watch.id ? null : watch.id;
}
function reveal(node: HTMLElement) {
node.scrollIntoView({ block: "nearest" });
}
</script>
<div class="panel-content">
<WatchEditor onsave={add} {saving} />
{#snippet lead(
watch: Watch
)}
{@const chatId = watch.params.chat_id}
{@const personId = watch.params.sender_id ?? watch.params.peer_id}
{#if chatId !== undefined}
<Avatar
avatar={{ id: chatId, kind: chatId > 0 ? "peer" : "chat" }}
colorKey={chatId}
hasAvatar={watch.chat?.has_avatar ?? false}
name={chatTitle(watch.chat, chatId)}
size={2.625}
/>
{:else if personId !== undefined}
<Avatar
avatar={{ id: personId, kind: "peer" }}
colorKey={personId}
hasAvatar={watch.person?.has_avatar ?? false}
name={personName(watch.person, personId)}
size={2.625}
/>
{:else}
<span class="glyph"
><Icon name={KIND_ICONS[watch.kind]} size="1.5rem" /></span
>
{/if}
{/snippet}
<div class="watches">
<button
aria-expanded={editing === "new"}
class="add"
onclick={() => {
editing = editing === "new" ? null : "new";
}}
type="button"
>
<Icon name="add" size="1.25rem" />
<span>{t("watches.add")}</span>
</button>
{#if editing === "new"}
<div {@attach reveal}>
<WatchForm
oncancel={() => {
editing = null;
}}
onsaved={saved}
/>
</div>
{/if}
{#if problem}
<p class="problem">{problem}</p>
{/if}
{#if loading}
<Spinner />
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>{t("watches.loadFailed")}</p>
<Button onclick={load} smaller variant="text"
>{t("watches.retry")}</Button
>
</div>
{:else if watches.length === 0}
<p class="empty">Пока нет отслеживаний.</p>
<div class="empty">
<EmptyState
description={t("watches.empty.description")}
title={t("watches.empty.title")}
/>
</div>
{:else}
<WatchList ondelete={remove} ontoggle={toggle} {watches} />
<ul class="list">
{#each watches as watch (watch.id)}
<li
class="item"
class:off={!watch.enabled}
class:open={editing === watch.id}
>
{#if confirming === watch.id}
<div class="confirm">
<p>{t("watches.deleteConfirm")}</p>
<div class="confirm-actions">
<Button
onclick={() => {
confirming = null;
}}
smaller
variant="text"
>
{t("common.cancel")}
</Button>
<Button onclick={() => remove(watch)} smaller variant="danger">
{t("watches.delete")}
</Button>
</div>
</div>
{:else}
<ContextMenu>
{#snippet children({
props,
})}
<button
{...props}
aria-expanded={editing === watch.id}
class="row"
onclick={() => edit(watch)}
type="button"
>
<span class="lead">{@render lead(watch)}</span>
<span class="text">
<span class="title"
>{watch.name ?? describeWatch(watch)}</span
>
<span class="sub">{subtitle(watch)}</span>
</span>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="edit" onselect={() => edit(watch)}>
{t("watches.edit")}
</ContextMenuItem>
<ContextMenuItem
icon={watch.enabled ? "eye-crossed" : "eye"}
onselect={() => toggle(watch)}
>
{watch.enabled ? t("watches.disable") : t("watches.enable")}
</ContextMenuItem>
<ContextMenuItem
icon="delete"
onselect={() => {
editing = null;
confirming = watch.id;
}}
>
{t("watches.delete")}
</ContextMenuItem>
{/snippet}
</ContextMenu>
{/if}
{#if editing === watch.id}
<div {@attach reveal}>
<WatchForm
oncancel={() => {
editing = null;
}}
onsaved={saved}
{watch}
/>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
<div class="section">
<SettingsItem
icon="web"
label={t("watches.webhooks")}
onclick={() => ui.openPanel("webhooks")}
value={hookCount > 0 ? String(hookCount) : undefined}
/>
</div>
</div>
<style lang="scss">
.panel-content {
.watches {
padding-bottom: 1rem;
}
.add {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
height: 3rem;
padding: 0 1rem 0 1.5rem;
border: 0;
border-bottom: 1px solid var(--color-borders);
font: inherit;
font-size: 0.9375rem;
color: var(--color-primary);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.problem {
margin: 0;
padding: 0.75rem 1rem 0;
font-size: 0.8125rem;
color: var(--color-error);
}
.state {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
gap: 0.25rem;
align-items: center;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
p {
margin: 0;
}
}
.empty {
color: var(--color-text-secondary);
padding: 2.5rem 1rem 2rem;
text-align: center;
}
.list {
margin: 0;
padding: 0.5rem 0;
list-style: none;
}
.item.open {
.row {
background-color: var(--color-chat-hover);
}
}
.row {
cursor: pointer;
display: flex;
gap: 0.875rem;
align-items: center;
width: 100%;
min-height: 3.75rem;
padding: 0.5rem 1rem;
border: 0;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
transition: background-color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.lead {
display: flex;
flex-shrink: 0;
}
.off .lead,
.off .title {
opacity: 0.55;
}
.glyph {
display: flex;
align-items: center;
justify-content: center;
width: 2.625rem;
height: 2.625rem;
border-radius: 50%;
color: var(--color-text-secondary);
background-color: var(--color-background-secondary);
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.title,
.sub {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title {
font-size: 0.9375rem;
}
.sub {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.confirm {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-height: 3.75rem;
padding: 0.625rem 1rem 0.375rem;
background-color: var(--color-chat-hover);
p {
margin: 0;
font-size: 0.9375rem;
}
}
.confirm-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
:global(.Button) {
padding-inline: 1rem;
}
}
.section {
padding: 0.5rem 0;
border-top: 1px solid var(--color-borders);
}
</style>
@@ -0,0 +1,676 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { ApiError } from "#lib/api/client.js";
import {
type Delivery,
listDeliveries,
rotateWebhookSecret,
testWebhook,
updateWebhook,
type Watch,
type Webhook,
type WebhookDraft,
} from "#lib/api/watches.js";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { deliveryStatus, describeWatch } from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { alerts } from "#lib/stores/alerts.svelte.js";
interface Props {
intent?: "test" | "delete" | null;
onchange: (webhook: Webhook) => void;
ondelete: () => void;
watches: Watch[];
webhook: Webhook;
}
const {
webhook,
watches,
onchange,
ondelete,
intent = null,
}: Props = $props();
const COPIED_MS = 1500;
const LOG_DELAY_MS = 1500;
const STATUS_ICONS: Record<Delivery["status"], string> = {
delivered: "check",
failed: "warning",
pending: "recent",
sending: "recent",
};
let url = $state(untrack(() => webhook.url));
let log = $state<Delivery[]>([]);
let logLoaded = $state(false);
let copied = $state(false);
let testing = $state(false);
let result = $state<Delivery | null>(null);
let problem = $state<string | null>(null);
let confirming = $state(false);
const dirty = $derived(url.trim() !== webhook.url);
const chosen = $derived(new Set(webhook.watch_ids ?? []));
async function loadLog() {
try {
log = await listDeliveries(webhook.id);
} finally {
logLoaded = true;
}
}
function draft(changes: Partial<WebhookDraft>): WebhookDraft {
return {
enabled: webhook.enabled,
url: webhook.url,
watch_ids: webhook.watch_ids,
...changes,
};
}
async function save(changes: Partial<WebhookDraft>) {
problem = null;
try {
onchange(await updateWebhook(webhook.id, draft(changes)));
} catch (failure) {
problem =
failure instanceof ApiError && failure.status === 422
? t("webhooks.badUrl")
: t("watches.error.action");
}
}
function pick(watchId: number | null) {
if (watchId === null) {
save({ watch_ids: null });
return;
}
const current = webhook.watch_ids ?? [];
const next = current.includes(watchId)
? current.filter((id) => id !== watchId)
: [...current, watchId];
save({ watch_ids: next.length > 0 ? next : null });
}
async function copy() {
await navigator.clipboard.writeText(webhook.secret);
copied = true;
setTimeout(() => {
copied = false;
}, COPIED_MS);
}
async function rotate() {
problem = null;
try {
onchange(await rotateWebhookSecret(webhook.id));
} catch {
problem = t("watches.error.action");
}
}
async function test() {
testing = true;
result = null;
problem = null;
try {
result = await testWebhook(webhook.id);
await loadLog();
} catch {
problem = t("watches.error.action");
} finally {
testing = false;
}
}
function detail(delivery: Delivery): string {
const line = deliveryStatus(delivery);
return delivery.attempts > 1
? `${line} · ${t("webhooks.attempts", { count: delivery.attempts })}`
: line;
}
onMount(() => {
loadLog();
if (intent === "test") {
test();
}
confirming = intent === "delete";
let timer: ReturnType<typeof setTimeout> | undefined;
const stop = alerts.onAlert(() => {
clearTimeout(timer);
timer = setTimeout(loadLog, LOG_DELAY_MS);
});
return () => {
clearTimeout(timer);
stop();
};
});
</script>
{#snippet choice(
label: string,
checked: boolean,
watchId: number | null
)}
<label class="choice">
<input
{checked}
class="native"
onchange={() => pick(watchId)}
type="checkbox"
>
<span class="circle" class:on={checked}>
<Icon name="check" size="0.875rem" />
</span>
<span class="choice-label">{label}</span>
</label>
{/snippet}
<div class="card">
<div class="fields">
<div class="input-group touched">
<input
class="form-control"
id="webhook-url-{webhook.id}"
inputmode="url"
spellcheck="false"
type="url"
bind:value={url}
>
<label for="webhook-url-{webhook.id}">{t("webhooks.url")}</label>
</div>
{#if dirty}
<div class="row-actions">
<Button
onclick={() => {
({ url } = webhook);
}}
smaller
variant="text"
>
{t("common.cancel")}
</Button>
<Button onclick={() => save({ url: url.trim() })} pill smaller>
{t("common.save")}
</Button>
</div>
{/if}
</div>
<div class="secret">
<div class="secret-text">
<span class="secret-label">{t("webhooks.secret")}</span>
<code class="secret-value">{webhook.secret}</code>
</div>
<button
aria-label={t("webhooks.copy")}
class="icon-button"
onclick={copy}
title={copied ? t("webhooks.copied") : t("webhooks.copy")}
type="button"
class:done={copied}
>
<Icon name={copied ? "check" : "copy"} size="1.25rem" />
</button>
</div>
<p class="hint">
{copied ? t("webhooks.copied") : t("webhooks.signature")}
</p>
<div class="row-actions start">
<Button onclick={rotate} smaller variant="text">
{t("webhooks.rotate")}
</Button>
</div>
<button
aria-checked={webhook.enabled}
class="toggle"
onclick={() => save({ enabled: !webhook.enabled })}
role="switch"
type="button"
>
<span class="toggle-label">{t("webhooks.enabled")}</span>
<span class="switch" class:on={webhook.enabled}>
<span class="knob"></span>
</span>
</button>
<div class="section-title">{t("webhooks.filter")}</div>
{@render choice(t("webhooks.allWatches"), chosen.size === 0, null)}
{#each watches as watch (watch.id)}
{@render choice(
watch.name ?? describeWatch(watch),
chosen.has(watch.id),
watch.id
)}
{/each}
{#if problem}
<p class="problem">{problem}</p>
{/if}
{#if confirming}
<div class="confirm">
<p>{t("webhooks.deleteConfirm")}</p>
<div class="row-actions">
<Button
onclick={() => {
confirming = false;
}}
smaller
variant="text"
>
{t("common.cancel")}
</Button>
<Button onclick={ondelete} smaller variant="danger">
{t("watches.delete")}
</Button>
</div>
</div>
{:else}
<div class="row-actions split">
<Button
onclick={() => {
confirming = true;
}}
smaller
variant="text"
>
<span class="danger-text">{t("watches.delete")}</span>
</Button>
<Button disabled={testing} onclick={test} pill smaller>
{testing ? t("webhooks.testing") : t("webhooks.test")}
</Button>
</div>
{/if}
{#if result}
<p class="result" class:failed={result.status !== "delivered"}>
{result.status === "delivered"
? t("webhooks.testOk", { code: result.response_code ?? "" })
: t("webhooks.testFailed", {
error: result.last_error ?? String(result.response_code ?? ""),
})}
</p>
{/if}
<div class="section-title">{t("webhooks.log")}</div>
{#if !logLoaded}
<div class="state"><Spinner /></div>
{:else if log.length === 0}
<p class="nothing">{t("webhooks.logEmpty")}</p>
{:else}
<ul class="log">
{#each log as delivery (delivery.id)}
<li class="entry {delivery.status}">
<span class="entry-icon">
<Icon name={STATUS_ICONS[delivery.status]} size="1.125rem" />
</span>
<span class="entry-text">
<span class="entry-title">
{t(`webhooks.event.${delivery.event}`)}
{#if delivery.alert_id !== null}
<span class="entry-id">#{delivery.alert_id}</span>
{/if}
</span>
<span class="entry-detail">{detail(delivery)}</span>
</span>
<time class="entry-time" datetime={delivery.created_at}>
{formatListDate(delivery.created_at)}
</time>
</li>
{/each}
</ul>
{/if}
</div>
<style lang="scss">
.card {
display: flex;
flex-direction: column;
padding: 0.5rem 0 0.75rem;
border-bottom: 1px solid var(--color-borders);
}
.fields {
padding: 0.5rem 1rem 0;
}
.input-group {
margin-bottom: 0.5rem;
}
.row-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
padding: 0 1rem;
&.start {
justify-content: flex-start;
padding: 0 0.5rem;
}
&.split {
justify-content: space-between;
padding: 0.5rem 1rem 0 0.5rem;
}
:global(.Button) {
padding-inline: 1rem;
}
}
.fields .row-actions {
padding: 0 0 0.5rem;
}
.danger-text {
color: var(--color-error);
}
.secret {
display: flex;
gap: 0.5rem;
align-items: center;
margin: 0.25rem 1rem 0;
padding: 0.625rem 0.5rem 0.625rem 0.875rem;
border-radius: 0.75rem;
background-color: var(--color-background-secondary);
}
.secret-text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.secret-label {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.secret-value {
overflow: hidden;
font-family: var(--font-family-monospace, monospace);
font-size: 0.8125rem;
color: var(--color-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-button {
cursor: pointer;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
background: none;
&.done {
color: var(--color-primary);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
}
@media (hover: hover) {
&:hover {
background-color: var(--color-interactive-element-hover);
}
}
}
.hint {
margin: 0;
padding: 0.375rem 1rem 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
overflow-wrap: anywhere;
}
.toggle,
.choice {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
min-height: 2.75rem;
padding: 0 1rem;
border: 0;
font: inherit;
font-size: 0.9375rem;
color: var(--color-text);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.native {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.choice {
position: relative;
&:focus-within {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.toggle-label,
.choice-label {
overflow: hidden;
flex: 1;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.switch {
display: flex;
flex-shrink: 0;
align-items: center;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders-input);
transition: background-color 0.15s ease-out;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease-out;
.on & {
transform: translateX(0.875rem);
}
}
.circle {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
border: 2px solid var(--color-borders-input);
border-radius: 50%;
color: transparent;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
&.on {
border-color: var(--color-primary);
color: var(--color-white);
background-color: var(--color-primary);
}
}
.section-title {
padding: 0.875rem 1rem 0.25rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.problem,
.result {
margin: 0;
padding: 0.375rem 1rem 0;
font-size: 0.8125rem;
overflow-wrap: anywhere;
}
.problem,
.result.failed {
color: var(--color-error);
}
.result {
text-align: end;
color: var(--color-text-green);
}
.confirm {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem 0 0;
p {
margin: 0;
padding: 0 1rem;
font-size: 0.9375rem;
}
}
.state {
display: flex;
justify-content: center;
padding: 0.75rem 0;
}
.nothing {
margin: 0;
padding: 0.25rem 1rem 0.5rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.log {
margin: 0;
padding: 0;
list-style: none;
}
.entry {
display: flex;
gap: 0.75rem;
align-items: center;
min-height: 2.75rem;
padding: 0.25rem 1rem;
}
.entry-icon {
display: flex;
flex-shrink: 0;
color: var(--color-text-secondary);
.delivered & {
color: var(--color-text-green);
}
.failed & {
color: var(--color-error);
}
}
.entry-text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.entry-title {
font-size: 0.875rem;
}
.entry-id {
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.entry-detail {
overflow: hidden;
font-size: 0.8125rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-time {
flex-shrink: 0;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,443 @@
<script lang="ts">
import { onMount } from "svelte";
import { ApiError } from "#lib/api/client.js";
import {
createWebhook,
deleteWebhook,
listWatches,
listWebhooks,
updateWebhook,
type Watch,
type Webhook,
} from "#lib/api/watches.js";
import Button from "#lib/components/ui/Button.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { deliveryStatus } from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import WebhookCard from "./WebhookCard.svelte";
type Intent = "test" | "delete";
const SCHEME = /^https?:\/\//;
let hooks = $state<Webhook[]>([]);
let watches = $state<Watch[]>([]);
let loading = $state(true);
let failed = $state(false);
let expanded = $state<number | null>(null);
let intent = $state<{ action: Intent; id: number } | null>(null);
let adding = $state(false);
let newUrl = $state("");
let addError = $state<string | null>(null);
let problem = $state<string | null>(null);
async function load() {
try {
[hooks, watches] = await Promise.all([listWebhooks(), listWatches()]);
failed = false;
} catch {
failed = true;
} finally {
loading = false;
}
}
function replace(next: Webhook) {
hooks = hooks.map((hook) => (hook.id === next.id ? next : hook));
}
function status(hook: Webhook): string {
const last = hook.last_delivery;
const line = last
? `${deliveryStatus(last)} · ${formatListDate(last.created_at)}`
: t("webhooks.never");
return hook.enabled ? line : `${t("webhooks.off")} · ${line}`;
}
async function add() {
addError = null;
try {
const hook = await createWebhook({
enabled: true,
url: newUrl.trim(),
watch_ids: null,
});
hooks = [...hooks, hook];
adding = false;
newUrl = "";
expanded = hook.id;
} catch (failure) {
addError =
failure instanceof ApiError && failure.status === 422
? t("webhooks.badUrl")
: t("watches.error.action");
}
}
async function toggle(hook: Webhook) {
problem = null;
try {
replace(
await updateWebhook(hook.id, {
enabled: !hook.enabled,
url: hook.url,
watch_ids: hook.watch_ids,
})
);
} catch {
problem = t("watches.error.action");
}
}
async function remove(hook: Webhook) {
problem = null;
try {
await deleteWebhook(hook.id);
hooks = hooks.filter((item) => item.id !== hook.id);
expanded = null;
} catch {
problem = t("watches.error.action");
}
}
function open(hook: Webhook, action: Intent | null = null) {
intent = action ? { action, id: hook.id } : null;
expanded = expanded === hook.id && !action ? null : hook.id;
}
function reveal(node: HTMLElement) {
node.parentElement?.scrollIntoView({ block: "nearest" });
}
onMount(load);
</script>
<div class="webhooks">
<button
aria-expanded={adding}
class="add"
onclick={() => {
adding = !adding;
addError = null;
}}
type="button"
>
<Icon name="add" size="1.25rem" />
<span>{t("webhooks.add")}</span>
</button>
{#if adding}
<form
class="new"
onsubmit={(event) => {
event.preventDefault();
add();
}}
>
<div
class="input-group"
class:error={addError}
class:touched={newUrl !== ""}
>
<input
class="form-control"
id="webhook-new-url"
inputmode="url"
placeholder="https://"
spellcheck="false"
type="url"
bind:value={newUrl}
>
<label for="webhook-new-url">{addError ?? t("webhooks.url")}</label>
</div>
<div class="actions">
<Button
onclick={() => {
adding = false;
newUrl = "";
}}
smaller
variant="text"
>
{t("common.cancel")}
</Button>
{#if newUrl.trim() !== ""}
<Button pill smaller type="submit">{t("common.save")}</Button>
{/if}
</div>
</form>
{/if}
{#if problem}
<p class="problem">{problem}</p>
{/if}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>{t("watches.loadFailed")}</p>
<Button onclick={load} smaller variant="text"
>{t("watches.retry")}</Button
>
</div>
{:else if hooks.length === 0 && !adding}
<div class="empty">
<EmptyState
description={t("webhooks.empty.description")}
title={t("webhooks.empty.title")}
/>
</div>
{:else}
<ul class="list">
{#each hooks as hook (hook.id)}
<li
class="item"
class:off={!hook.enabled}
class:open={expanded === hook.id}
>
<ContextMenu>
{#snippet children({
props,
})}
<button
{...props}
aria-expanded={expanded === hook.id}
class="row"
onclick={() => open(hook)}
type="button"
>
<span class="glyph"><Icon name="web" size="1.5rem" /></span>
<span class="text">
<span class="title">{hook.url.replace(SCHEME, "")}</span>
<span
class="sub"
class:failed={hook.last_delivery?.status === "failed"}
>
{status(hook)}
</span>
</span>
<Icon
name={expanded === hook.id ? "up" : "down"}
size="1.125rem"
/>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="send" onselect={() => open(hook, "test")}>
{t("webhooks.test")}
</ContextMenuItem>
<ContextMenuItem
icon={hook.enabled ? "eye-crossed" : "eye"}
onselect={() => toggle(hook)}
>
{hook.enabled ? t("watches.disable") : t("watches.enable")}
</ContextMenuItem>
<ContextMenuItem
icon="delete"
onselect={() => open(hook, "delete")}
>
{t("watches.delete")}
</ContextMenuItem>
{/snippet}
</ContextMenu>
{#if expanded === hook.id}
{#key intent}
<div {@attach reveal}>
<WebhookCard
intent={intent?.id === hook.id ? intent.action : null}
onchange={replace}
ondelete={() => remove(hook)}
{watches}
webhook={hook}
/>
</div>
{/key}
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<style lang="scss">
.webhooks {
padding-bottom: 1rem;
}
.add {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
height: 3rem;
padding: 0 1rem 0 1.5rem;
border: 0;
border-bottom: 1px solid var(--color-borders);
font: inherit;
font-size: 0.9375rem;
color: var(--color-primary);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.new {
padding: 1rem 1rem 0.75rem;
border-bottom: 1px solid var(--color-borders);
.input-group {
margin-bottom: 0.5rem;
}
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
:global(.Button) {
padding-inline: 1rem;
}
}
.problem {
margin: 0;
padding: 0.75rem 1rem 0;
font-size: 0.8125rem;
color: var(--color-error);
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
p {
margin: 0;
}
}
.empty {
padding: 2.5rem 1rem 2rem;
text-align: center;
}
.list {
margin: 0;
padding: 0.5rem 0;
list-style: none;
}
.item.open .row {
background-color: var(--color-chat-hover);
}
.row {
cursor: pointer;
display: flex;
gap: 0.875rem;
align-items: center;
width: 100%;
min-height: 3.75rem;
padding: 0.5rem 1rem;
border: 0;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
transition: background-color 0.15s ease;
:global(.icon) {
color: var(--color-text-secondary);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.glyph {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.625rem;
height: 2.625rem;
border-radius: 50%;
background-color: var(--color-background-secondary);
}
.off .glyph,
.off .title {
opacity: 0.55;
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.title,
.sub {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title {
font-size: 0.9375rem;
}
.sub {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
&.failed {
color: var(--color-error);
}
}
</style>