Browse Source

fix session strike relays

imwald
Silberengel 4 weeks ago
parent
commit
da0d6a7cfd
  1. 173
      src/components/SessionRelaysTab/index.tsx
  2. 15
      src/i18n/locales/de.ts
  3. 15
      src/i18n/locales/en.ts
  4. 36
      src/lib/relay-strikes.test.ts
  5. 17
      src/lib/relay-strikes.ts
  6. 5
      src/services/client.service.ts

173
src/components/SessionRelaysTab/index.tsx

@ -1,10 +1,13 @@
import client from '@/services/client.service' import client from '@/services/client.service'
import relayInfoService from '@/services/relay-info.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 { isHttpRelayUrl } from '@/lib/url'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useMemo, useState } from 'react' 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 { Button } from '@/components/ui/button'
import type { TRelayInfo } from '@/types' import type { TRelayInfo } from '@/types'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -15,30 +18,95 @@ type SessionDebug = {
relayStrikes: RelayStrikeDebugSnapshot relayStrikes: RelayStrikeDebugSnapshot
} }
type StrikeEntry = RelayStrikeDebugSnapshot['entries'][number]['entry']
function loadDebug(): SessionDebug { function loadDebug(): SessionDebug {
return client.getSessionRelayDebug() 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, unknown>) => 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, unknown>) => 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() { export default function SessionRelaysTab() {
const { t } = useTranslation() const { t } = useTranslation()
const { httpRelayListEvent } = useNostr() const { httpRelayListEvent } = useNostr()
const [debug, setDebug] = useState<SessionDebug | null>(null) const [debug, setDebug] = useState<SessionDebug | null>(null)
const [relayInfoByUrl, setRelayInfoByUrl] = useState<Record<string, TRelayInfo | undefined>>({}) const [relayInfoByUrl, setRelayInfoByUrl] = useState<Record<string, TRelayInfo | undefined>>({})
const [now, setNow] = useState(() => Date.now())
const refresh = useCallback(() => { const refresh = useCallback(() => {
setDebug(loadDebug()) setDebug(loadDebug())
setNow(Date.now())
}, []) }, [])
useEffect(() => { useEffect(() => {
refresh() refresh()
}, [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(() => { useEffect(() => {
if (debug === null) return if (debug === null) return
const urls = Array.from( const urls = Array.from(
new Set([ new Set([
...debug.presetWorking, ...debug.presetWorking,
...debug.scoredRelays.map((r) => r.url) ...debug.scoredRelays.map((r) => r.url),
...activeStrikes.map((s) => s.key)
]) ])
) )
if (urls.length === 0) return if (urls.length === 0) return
@ -54,12 +122,12 @@ export default function SessionRelaysTab() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [debug]) }, [debug, activeStrikes])
const formatRelayAddress = (url: string) => { const formatRelayAddress = (url: string) => {
try { try {
const u = new URL(url) const u = new URL(url)
return u.host || url // host keeps explicit port when present return u.host || url
} catch { } catch {
return url return url
} }
@ -90,6 +158,14 @@ export default function SessionRelaysTab() {
return configuredHttpRelayAddresses.has(formatRelayAddress(url).toLowerCase()) return configuredHttpRelayAddresses.has(formatRelayAddress(url).toLowerCase())
} }
const freeRelay = useCallback(
(key: string) => {
client.clearSessionRelayStrike(key)
refresh()
},
[refresh]
)
if (debug === null) return null if (debug === null) return null
const RelayNameWithTransport = ({ url, mono = true }: { url: string; mono?: boolean }) => ( const RelayNameWithTransport = ({ url, mono = true }: { url: string; mono?: boolean }) => (
@ -108,9 +184,7 @@ export default function SessionRelaysTab() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">{t('Session relays tab description')}</p>
{t('Session relays tab description')}
</p>
<Button variant="outline" size="sm" onClick={refresh} className="shrink-0"> <Button variant="outline" size="sm" onClick={refresh} className="shrink-0">
<RefreshCw className="h-4 w-4 mr-1" /> <RefreshCw className="h-4 w-4 mr-1" />
{t('Refresh')} {t('Refresh')}
@ -122,9 +196,7 @@ export default function SessionRelaysTab() {
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-500" /> <CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-500" />
{t('Session relays preset working')} {t('Session relays preset working')}
</h3> </h3>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">{t('Session relays preset working hint')}</p>
{t('Session relays preset working hint')}
</p>
<ul className="rounded-lg border bg-muted/30 p-3 space-y-1 text-sm font-mono"> <ul className="rounded-lg border bg-muted/30 p-3 space-y-1 text-sm font-mono">
{debug.presetWorking.length === 0 ? ( {debug.presetWorking.length === 0 ? (
<li className="text-muted-foreground">{t('None')}</li> <li className="text-muted-foreground">{t('None')}</li>
@ -143,9 +215,7 @@ export default function SessionRelaysTab() {
<Zap className="h-4 w-4 text-blue-600 dark:text-blue-400" /> <Zap className="h-4 w-4 text-blue-600 dark:text-blue-400" />
{t('Session relays scored random')} {t('Session relays scored random')}
</h3> </h3>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">{t('Session relays scored random hint')}</p>
{t('Session relays scored random hint')}
</p>
<ul className="rounded-lg border bg-muted/30 p-3 space-y-2 text-sm"> <ul className="rounded-lg border bg-muted/30 p-3 space-y-2 text-sm">
{debug.scoredRelays.length === 0 ? ( {debug.scoredRelays.length === 0 ? (
<li className="text-muted-foreground">{t('None')}</li> <li className="text-muted-foreground">{t('None')}</li>
@ -163,26 +233,65 @@ export default function SessionRelaysTab() {
</section> </section>
<section className="space-y-2"> <section className="space-y-2">
<h3 className="text-sm font-medium">{t('Session relay strikes', { defaultValue: 'Session relay strikes' })}</h3> <h3 className="text-sm font-medium flex items-center gap-2">
<p className="text-muted-foreground text-xs"> <AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-500" />
{t('Session relay strikes hint', { {t('Session relay strikes')}
defaultValue: </h3>
'Session-only: failed reads/publishes accrue strikes; five failures skip a relay for three minutes. Rate-limit NOTICEs apply a ten-minute cooldown without strikes. Cache relays (kind 10432) always count failures even during cooldown.' <p className="text-muted-foreground text-xs">{t('Session relay strikes hint')}</p>
})} {debug.relayStrikes.cacheRelayKeys.length > 0 ? (
</p> <p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground font-mono break-all"> <span className="font-medium">{t('Cache relay keys')}:</span>{' '}
{t('Cache relay keys', { defaultValue: 'Cache relay keys' })}:{' '} <span className="font-mono break-all">
{debug.relayStrikes.cacheRelayKeys.length === 0 {debug.relayStrikes.cacheRelayKeys.join(', ')}
? t('None') </span>
: debug.relayStrikes.cacheRelayKeys.join(', ')}
</p> </p>
<pre className="rounded-lg border bg-muted/30 p-3 text-[11px] font-mono overflow-x-auto max-h-48 overflow-y-auto"> ) : null}
{debug.relayStrikes.entries.length === 0 <ul className="rounded-lg border bg-muted/30 divide-y divide-border text-sm">
? t('None') {activeStrikes.length === 0 ? (
: JSON.stringify(debug.relayStrikes.entries, null, 2)} <li className="p-3 text-muted-foreground">{t('Session relay strikes none active')}</li>
</pre> ) : (
activeStrikes.map(({ key, entry, cacheRelay }) => {
const chips = strikeStatusChips(entry, cacheRelay, now, t)
const details = strikeDetailLines(entry, now, t)
return (
<li key={key} className="flex flex-col gap-2 p-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1 space-y-1.5">
<RelayNameWithTransport url={key} />
{chips.length > 0 ? (
<div className="flex flex-wrap gap-1">
{chips.map((chip) => (
<span
key={chip}
className="rounded border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:text-amber-200"
>
{chip}
</span>
))}
</div>
) : null}
{details.length > 0 ? (
<ul className="text-xs text-muted-foreground space-y-0.5">
{details.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
) : null}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 self-end sm:self-start"
onClick={() => freeRelay(key)}
>
{t('Session relay strike free')}
</Button>
</li>
)
})
)}
</ul>
</section> </section>
</div> </div>
) )
} }

15
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 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": "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 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", successes: "Erfolge",
None: "Keine", None: "Keine",
"Cache & offline storage": "Cache & Offline-Speicher", "Cache & offline storage": "Cache & Offline-Speicher",

15
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 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": "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 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", successes: "successes",
None: "None", None: "None",
"Cache & offline storage": "Cache & offline storage", "Cache & offline storage": "Cache & offline storage",

36
src/lib/relay-strikes.test.ts

@ -1,5 +1,5 @@
import { describe, expect, it, beforeEach } from 'vitest' 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' import type { RelayOpTerminalRow } from '@/services/relay-operation-log.service'
function row( function row(
@ -42,3 +42,37 @@ describe('relaySessionStrikes.observeSubscribeBatch', () => {
expect(relaySessionStrikes.isReadHttpSkipped(url)).toBe(false) 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)
})
})

17
src/lib/relay-strikes.ts

@ -51,6 +51,16 @@ export type RelayStrikeDebugSnapshot = {
cacheRelayKeys: string[] 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 { function emptyEntry(): StrikeEntry {
return { return {
readFailures: 0, 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 { reset(): void {
this.byKey.clear() this.byKey.clear()
this.cacheRelayKeys.clear() this.cacheRelayKeys.clear()

5
src/services/client.service.ts

@ -1443,6 +1443,11 @@ class ClientService extends EventTarget {
return { scoredRelays, presetWorking: preset, relayStrikes: relaySessionStrikes.getDebugSnapshot() } 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, * 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. * preferring those that have succeeded and been fast this session. Excludes read-only relays.

Loading…
Cancel
Save