diff --git a/src/components/SessionRelaysTab/index.tsx b/src/components/SessionRelaysTab/index.tsx index b186e0ee..83e375a6 100644 --- a/src/components/SessionRelaysTab/index.tsx +++ b/src/components/SessionRelaysTab/index.tsx @@ -1,10 +1,13 @@ import client from '@/services/client.service' import relayInfoService from '@/services/relay-info.service' -import type { RelayStrikeDebugSnapshot } from '@/lib/relay-strikes' +import { + isRelayStrikeEntryActive, + type RelayStrikeDebugSnapshot +} from '@/lib/relay-strikes' import { isHttpRelayUrl } from '@/lib/url' import { useTranslation } from 'react-i18next' import { useCallback, useEffect, useMemo, useState } from 'react' -import { RefreshCw, CheckCircle2, Zap } from 'lucide-react' +import { RefreshCw, CheckCircle2, Zap, AlertTriangle } from 'lucide-react' import { Button } from '@/components/ui/button' import type { TRelayInfo } from '@/types' import { useNostr } from '@/providers/NostrProvider' @@ -15,30 +18,95 @@ type SessionDebug = { relayStrikes: RelayStrikeDebugSnapshot } +type StrikeEntry = RelayStrikeDebugSnapshot['entries'][number]['entry'] + function loadDebug(): SessionDebug { return client.getSessionRelayDebug() } +function formatSkipUntil(ts: number, now: number): string | null { + if (ts <= now) return null + const sec = Math.ceil((ts - now) / 1000) + if (sec < 90) return `${sec}s` + if (sec < 7200) return `${Math.ceil(sec / 60)}m` + return new Date(ts).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) +} + +function strikeStatusChips( + entry: StrikeEntry, + cacheRelay: boolean, + now: number, + t: (key: string, opts?: Record) => string +): string[] { + const chips: string[] = [] + if (cacheRelay) chips.push(t('Session relay strike cache relay')) + if (now < entry.readStrikeSkipUntil) chips.push(t('Session relay strike read skipped')) + if (now < entry.publishStrikeSkipUntil) chips.push(t('Session relay strike publish skipped')) + if (now < entry.rateLimitUntil) chips.push(t('Session relay strike rate limited')) + if (now < entry.slowParkUntil) chips.push(t('Session relay strike slow parked')) + return chips +} + +function strikeDetailLines( + entry: StrikeEntry, + now: number, + t: (key: string, opts?: Record) => string +): string[] { + const lines: string[] = [] + if (entry.readFailures > 0) { + lines.push(t('Session relay strike read failures', { count: entry.readFailures })) + } + if (entry.publishFailures > 0) { + lines.push(t('Session relay strike publish failures', { count: entry.publishFailures })) + } + if (entry.slowSignals > 0) { + lines.push(t('Session relay strike slow signals', { count: entry.slowSignals })) + } + for (const [ts, label] of [ + [entry.readStrikeSkipUntil, t('Session relay strike read skipped')], + [entry.publishStrikeSkipUntil, t('Session relay strike publish skipped')], + [entry.rateLimitUntil, t('Session relay strike rate limited')], + [entry.slowParkUntil, t('Session relay strike slow parked')] + ] as const) { + const until = formatSkipUntil(ts, now) + if (until) lines.push(`${label} ${t('Session relay strike until', { time: until })}`) + } + return lines +} + export default function SessionRelaysTab() { const { t } = useTranslation() const { httpRelayListEvent } = useNostr() const [debug, setDebug] = useState(null) const [relayInfoByUrl, setRelayInfoByUrl] = useState>({}) + const [now, setNow] = useState(() => Date.now()) const refresh = useCallback(() => { setDebug(loadDebug()) + setNow(Date.now()) }, []) useEffect(() => { refresh() }, [refresh]) + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 30_000) + return () => window.clearInterval(id) + }, []) + + const activeStrikes = useMemo(() => { + if (!debug) return [] + return debug.relayStrikes.entries.filter(({ entry }) => isRelayStrikeEntryActive(entry, now)) + }, [debug, now]) + useEffect(() => { if (debug === null) return const urls = Array.from( new Set([ ...debug.presetWorking, - ...debug.scoredRelays.map((r) => r.url) + ...debug.scoredRelays.map((r) => r.url), + ...activeStrikes.map((s) => s.key) ]) ) if (urls.length === 0) return @@ -54,12 +122,12 @@ export default function SessionRelaysTab() { return () => { cancelled = true } - }, [debug]) + }, [debug, activeStrikes]) const formatRelayAddress = (url: string) => { try { const u = new URL(url) - return u.host || url // host keeps explicit port when present + return u.host || url } catch { return url } @@ -90,6 +158,14 @@ export default function SessionRelaysTab() { return configuredHttpRelayAddresses.has(formatRelayAddress(url).toLowerCase()) } + const freeRelay = useCallback( + (key: string) => { + client.clearSessionRelayStrike(key) + refresh() + }, + [refresh] + ) + if (debug === null) return null const RelayNameWithTransport = ({ url, mono = true }: { url: string; mono?: boolean }) => ( @@ -108,9 +184,7 @@ export default function SessionRelaysTab() { return (
-

- {t('Session relays tab description')} -

+

{t('Session relays tab description')}

+ + ) + }) + )} + -
) } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 88e08b85..bbe397f3 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -624,6 +624,21 @@ export default { "Session relays preset working hint": "URLs aus den fest eingebauten Relay-Listen der App (schnell lesen/schreiben, durchsuchbar, Starter-Favoriten) — keine Live-Gesundheitsprüfung und kein „Strich“-Sperren mehr.", "Session relays scored random": "Bewertete Zufallsrelays", "Session relays scored random hint": "Relays, die in dieser Session mindestens ein Publish angenommen haben; werden beim Auswählen von Zufallsrelays bevorzugt. Sortiert nach durchschnittlicher Latenz.", + "Session relay strikes": "Session-Relay-Strafen", + "Session relay strikes hint": + "Relays, die vorübergehend übersprungen werden (Fehler, langsame Wellen, Rate-Limit). Fünf Lese- oder Publish-Fehler → 3 Minuten Pause. Rate-Limit-Hinweis → 10 Minuten Cooldown (ohne Strafzähler). Cache-Relays (Kind 10432) zählen Fehler immer.", + "Session relay strikes none active": "Derzeit keine bestraften oder pausierten Relays.", + "Cache relay keys": "Cache-Relay-Schlüssel", + "Session relay strike free": "Freigeben", + "Session relay strike read skipped": "Lesen übersprungen", + "Session relay strike publish skipped": "Publish übersprungen", + "Session relay strike rate limited": "Rate-Limit", + "Session relay strike slow parked": "Langsam geparkt", + "Session relay strike cache relay": "Cache-Relay", + "Session relay strike read failures": "{{count}} Lese-Fehler", + "Session relay strike publish failures": "{{count}} Publish-Fehler", + "Session relay strike slow signals": "{{count}} langsame Signale", + "Session relay strike until": "bis {{time}}", successes: "Erfolge", None: "Keine", "Cache & offline storage": "Cache & Offline-Speicher", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 6025f8f3..86fb0b8a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -643,6 +643,21 @@ export default { "Session relays preset working hint": "URLs from the app’s built-in relay lists (fast read/write, searchable, starter favorites). Shown for reference — not a live health check.", "Session relays scored random": "Scored random relays", "Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.", + "Session relay strikes": "Session relay strikes", + "Session relay strikes hint": + "Relays temporarily skipped after failures, slow batches, or rate-limit notices. Five read or publish failures → 3 minute skip. Rate-limit NOTICE → 10 minute cooldown (no strike count). Cache relays (kind 10432) always accrue failures.", + "Session relay strikes none active": "No relays are currently struck or cooling down.", + "Cache relay keys": "Cache relay keys", + "Session relay strike free": "Free", + "Session relay strike read skipped": "Read skipped", + "Session relay strike publish skipped": "Publish skipped", + "Session relay strike rate limited": "Rate limited", + "Session relay strike slow parked": "Slow parked", + "Session relay strike cache relay": "Cache relay", + "Session relay strike read failures": "{{count}} read failures", + "Session relay strike publish failures": "{{count}} publish failures", + "Session relay strike slow signals": "{{count}} slow signals", + "Session relay strike until": "until {{time}}", successes: "successes", None: "None", "Cache & offline storage": "Cache & offline storage", diff --git a/src/lib/relay-strikes.test.ts b/src/lib/relay-strikes.test.ts index 3b57e46b..0c0c717c 100644 --- a/src/lib/relay-strikes.test.ts +++ b/src/lib/relay-strikes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach } from 'vitest' -import { relaySessionStrikes } from './relay-strikes' +import { isRelayStrikeEntryActive, relaySessionStrikes } from './relay-strikes' import type { RelayOpTerminalRow } from '@/services/relay-operation-log.service' function row( @@ -42,3 +42,37 @@ describe('relaySessionStrikes.observeSubscribeBatch', () => { expect(relaySessionStrikes.isReadHttpSkipped(url)).toBe(false) }) }) + +describe('relaySessionStrikes.clearKey', () => { + beforeEach(() => { + relaySessionStrikes.reset() + }) + + it('removes strike state so relay is no longer skipped', () => { + const url = 'ws://localhost:4000/' + relaySessionStrikes.applyRateLimitCooldownForUrl(url) + expect(relaySessionStrikes.isReadHttpSkipped(url)).toBe(true) + relaySessionStrikes.clearKey(url) + expect(relaySessionStrikes.isReadHttpSkipped(url)).toBe(false) + const snap = relaySessionStrikes.getDebugSnapshot() + expect(snap.entries.find((e) => e.key.includes('localhost'))).toBeUndefined() + }) +}) + +describe('isRelayStrikeEntryActive', () => { + it('is false for empty entry', () => { + expect( + isRelayStrikeEntryActive({ + readFailures: 0, + readLastStrikeIncrementAt: 0, + readStrikeSkipUntil: 0, + slowSignals: 0, + slowParkUntil: 0, + publishFailures: 0, + publishLastStrikeIncrementAt: 0, + publishStrikeSkipUntil: 0, + rateLimitUntil: 0 + }) + ).toBe(false) + }) +}) diff --git a/src/lib/relay-strikes.ts b/src/lib/relay-strikes.ts index 78be33b8..fe0b0c10 100644 --- a/src/lib/relay-strikes.ts +++ b/src/lib/relay-strikes.ts @@ -51,6 +51,16 @@ export type RelayStrikeDebugSnapshot = { cacheRelayKeys: string[] } +/** True when the relay is skipped or has accrued session strike / cooldown state. */ +export function isRelayStrikeEntryActive(entry: StrikeEntry, now = Date.now()): boolean { + if (entry.readFailures > 0 || entry.publishFailures > 0 || entry.slowSignals > 0) return true + if (now < entry.readStrikeSkipUntil) return true + if (now < entry.publishStrikeSkipUntil) return true + if (now < entry.rateLimitUntil) return true + if (now < entry.slowParkUntil) return true + return false +} + function emptyEntry(): StrikeEntry { return { readFailures: 0, @@ -304,6 +314,13 @@ class RelaySessionStrikes { } } + /** Remove session strike / cooldown state for one relay (settings “free relay”). */ + clearKey(urlOrSessionKey: string): void { + const key = sessionKey(urlOrSessionKey) || urlOrSessionKey.trim() + if (!key) return + this.byKey.delete(key) + } + reset(): void { this.byKey.clear() this.cacheRelayKeys.clear() diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 4b2eb02e..0b34b6ca 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1443,6 +1443,11 @@ class ClientService extends EventTarget { return { scoredRelays, presetWorking: preset, relayStrikes: relaySessionStrikes.getDebugSnapshot() } } + /** Clear session strike / cooldown for one relay (Settings → Session relays). */ + clearSessionRelayStrike(urlOrSessionKey: string): void { + relaySessionStrikes.clearKey(urlOrSessionKey) + } + /** * From a list of candidate relay URLs (e.g. public lively), return up to `count` relays, * preferring those that have succeeded and been fast this session. Excludes read-only relays.