Browse Source

render struck relays in grayscale

imwald
Silberengel 2 weeks ago
parent
commit
0a6a0a6357
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 4
      src/components/ConnectedRelays/active-relays-display.ts
  4. 6
      src/components/RelayIcon/index.tsx
  5. 32
      src/hooks/useRelaySessionStrikeActive.ts
  6. 23
      src/lib/relay-strikes.test.ts
  7. 33
      src/lib/relay-strikes.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.18.2", "version": "23.18.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.18.2", "version": "23.18.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.18.2", "version": "23.18.3",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

4
src/components/ConnectedRelays/active-relays-display.ts

@ -1,3 +1,4 @@
import { relaySessionStrikes } from '@/lib/relay-strikes'
import { simplifyUrl } from '@/lib/url' import { simplifyUrl } from '@/lib/url'
export const ACTIVE_RELAYS_MAX_ICONS = 14 export const ACTIVE_RELAYS_MAX_ICONS = 14
@ -9,5 +10,8 @@ export function activeRelayRowMuted(connected: boolean) {
export function activeRelayRowTitle(url: string, connected: boolean, t: (k: string) => string) { export function activeRelayRowTitle(url: string, connected: boolean, t: (k: string) => string) {
const base = simplifyUrl(url) const base = simplifyUrl(url)
if (!connected) return `${base}${t('Not connected')}` if (!connected) return `${base}${t('Not connected')}`
if (relaySessionStrikes.isSessionStrikeActiveForUrl(url)) {
return `${base}${t('Session relay strikes')}`
}
return base return base
} }

6
src/components/RelayIcon/index.tsx

@ -1,5 +1,6 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useFetchRelayInfo } from '@/hooks' import { useFetchRelayInfo } from '@/hooks'
import { useRelaySessionStrikeActive } from '@/hooks/useRelaySessionStrikeActive'
import { import {
getRelayIconFallbackGlyph, getRelayIconFallbackGlyph,
getRelayIconOverrideSrc, getRelayIconOverrideSrc,
@ -53,6 +54,7 @@ export default function RelayIcon({
relayInfoProp !== undefined || skipRelayInfoFetch ? undefined : url relayInfoProp !== undefined || skipRelayInfoFetch ? undefined : url
) )
const relayInfo = relayInfoProp !== undefined ? relayInfoProp : fetchedRelayInfo const relayInfo = relayInfoProp !== undefined ? relayInfoProp : fetchedRelayInfo
const sessionStrike = useRelaySessionStrikeActive(url)
const [iconLoadFailed, setIconLoadFailed] = useState(false) const [iconLoadFailed, setIconLoadFailed] = useState(false)
useEffect(() => { useEffect(() => {
setIconLoadFailed(false) setIconLoadFailed(false)
@ -79,7 +81,9 @@ export default function RelayIcon({
const fallbackGlyph = useMemo(() => getRelayIconFallbackGlyph(url), [url]) const fallbackGlyph = useMemo(() => getRelayIconFallbackGlyph(url), [url])
return ( return (
<Avatar className={cn('w-6 h-6', className)}> <Avatar
className={cn('w-6 h-6', sessionStrike && 'grayscale opacity-50', className)}
>
{iconUrl && !iconLoadFailed && ( {iconUrl && !iconLoadFailed && (
<AvatarImage <AvatarImage
src={iconUrl} src={iconUrl}

32
src/hooks/useRelaySessionStrikeActive.ts

@ -0,0 +1,32 @@
import { relaySessionStrikes } from '@/lib/relay-strikes'
import { useEffect, useMemo, useState } from 'react'
const COOLDOWN_TICK_MS = 5_000
/** Bump when session strike map changes or skip/cooldown windows may have expired. */
export function useRelaySessionStrikeRevision(): number {
const [revision, setRevision] = useState(0)
useEffect(() => {
const bump = () => setRevision((n) => n + 1)
const unsub = relaySessionStrikes.subscribe(bump)
const id = window.setInterval(bump, COOLDOWN_TICK_MS)
return () => {
unsub()
window.clearInterval(id)
}
}, [])
return revision
}
/**
* True when the relay has session strike / cooldown state (failures, skip windows, rate limit, slow park).
*/
export function useRelaySessionStrikeActive(url: string | undefined): boolean {
const revision = useRelaySessionStrikeRevision()
return useMemo(
() => (url ? relaySessionStrikes.isSessionStrikeActiveForUrl(url) : false),
[url, revision]
)
}

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

@ -152,6 +152,29 @@ describe('relaySessionStrikes publish failures', () => {
}) })
}) })
describe('relaySessionStrikes.isSessionStrikeActiveForUrl', () => {
beforeEach(() => {
relaySessionStrikes.reset()
})
it('is false with no strike state', () => {
expect(relaySessionStrikes.isSessionStrikeActiveForUrl('wss://relay.example/')).toBe(false)
})
it('is true after read failures accrue', () => {
const url = 'wss://relay.example/'
relaySessionStrikes.recordReadFailure(url, 'http')
expect(relaySessionStrikes.isSessionStrikeActiveForUrl(url)).toBe(true)
})
it('is false after clearKey', () => {
const url = 'wss://relay.example/'
relaySessionStrikes.recordReadFailure(url, 'http')
relaySessionStrikes.clearKey(url)
expect(relaySessionStrikes.isSessionStrikeActiveForUrl(url)).toBe(false)
})
})
describe('isRelayStrikeEntryActive', () => { describe('isRelayStrikeEntryActive', () => {
it('is false for empty entry', () => { it('is false for empty entry', () => {
expect( expect(

33
src/lib/relay-strikes.ts

@ -93,6 +93,30 @@ function sessionKey(url: string): string {
class RelaySessionStrikes { class RelaySessionStrikes {
private byKey = new Map<string, StrikeEntry>() private byKey = new Map<string, StrikeEntry>()
private cacheRelayKeys = new Set<string>() private cacheRelayKeys = new Set<string>()
private changeListeners = new Set<() => void>()
/** Subscribe to session strike map changes (for relay icon UI). */
subscribe(listener: () => void): () => void {
this.changeListeners.add(listener)
return () => {
this.changeListeners.delete(listener)
}
}
private emitChange(): void {
for (const listener of this.changeListeners) {
listener()
}
}
/** True when this URL has session strike / cooldown state (see {@link isRelayStrikeEntryActive}). */
isSessionStrikeActiveForUrl(url: string, now = Date.now()): boolean {
const key = sessionKey(url)
if (!key) return false
const e = this.byKey.get(key)
if (!e) return false
return isRelayStrikeEntryActive(e, now)
}
setSessionCacheRelayKeysFromKind10432(ev: Event | null | undefined): void { setSessionCacheRelayKeysFromKind10432(ev: Event | null | undefined): void {
this.cacheRelayKeys.clear() this.cacheRelayKeys.clear()
@ -191,6 +215,7 @@ class RelaySessionStrikes {
private applyRateLimitCooldownKey(key: string, cooldownMs = RATE_LIMIT_COOLDOWN_MS): void { private applyRateLimitCooldownKey(key: string, cooldownMs = RATE_LIMIT_COOLDOWN_MS): void {
const e = this.getEntry(key) const e = this.getEntry(key)
e.rateLimitUntil = Math.max(e.rateLimitUntil, Date.now() + cooldownMs) e.rateLimitUntil = Math.max(e.rateLimitUntil, Date.now() + cooldownMs)
this.emitChange()
} }
/** WS connect failure, HTTP transport failure, etc. */ /** WS connect failure, HTTP transport failure, etc. */
@ -230,6 +255,7 @@ class RelaySessionStrikes {
e.readStrikeSkipUntil = Math.max(e.readStrikeSkipUntil, now + STRIKE_COOLDOWN_MS) e.readStrikeSkipUntil = Math.max(e.readStrikeSkipUntil, now + STRIKE_COOLDOWN_MS)
logger.debug('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures, threshold }) logger.debug('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures, threshold })
} }
this.emitChange()
} }
private readStrikeThresholdForKey( private readStrikeThresholdForKey(
@ -254,6 +280,7 @@ class RelaySessionStrikes {
e.readLastStrikeIncrementAt = 0 e.readLastStrikeIncrementAt = 0
e.slowSignals = 0 e.slowSignals = 0
e.slowParkUntil = 0 e.slowParkUntil = 0
this.emitChange()
} }
/** /**
@ -299,6 +326,7 @@ class RelaySessionStrikes {
const e = this.byKey.get(key) const e = this.byKey.get(key)
if (e && e.slowSignals > 0) { if (e && e.slowSignals > 0) {
e.slowSignals = Math.max(0, e.slowSignals - 1) e.slowSignals = Math.max(0, e.slowSignals - 1)
this.emitChange()
} }
} }
} }
@ -312,6 +340,7 @@ class RelaySessionStrikes {
// Read-only index relays (aggr.nostr.land, search.nos.today, …) are intentionally slower than inbox relays. // Read-only index relays (aggr.nostr.land, search.nos.today, …) are intentionally slower than inbox relays.
if (url && isReadOnlyRelayUrl(url)) return false if (url && isReadOnlyRelayUrl(url)) return false
e.slowSignals += 1 e.slowSignals += 1
this.emitChange()
if (e.slowSignals < RELAY_SLOW_PARK_SIGNALS_THRESHOLD) return false if (e.slowSignals < RELAY_SLOW_PARK_SIGNALS_THRESHOLD) return false
e.slowParkUntil = Math.max(e.slowParkUntil, now + RELAY_SLOW_PARK_COOLDOWN_MS) e.slowParkUntil = Math.max(e.slowParkUntil, now + RELAY_SLOW_PARK_COOLDOWN_MS)
logger.warn('[RelayStrikes] session-parked slow relay', { logger.warn('[RelayStrikes] session-parked slow relay', {
@ -344,6 +373,7 @@ class RelaySessionStrikes {
e.publishStrikeSkipUntil = Math.max(e.publishStrikeSkipUntil, now + STRIKE_COOLDOWN_MS) e.publishStrikeSkipUntil = Math.max(e.publishStrikeSkipUntil, now + STRIKE_COOLDOWN_MS)
logger.warn('[RelayStrikes] publish path strike skip', { key, publishFailures: e.publishFailures }) logger.warn('[RelayStrikes] publish path strike skip', { key, publishFailures: e.publishFailures })
} }
this.emitChange()
} }
/** Successful publish clears publish strikes (existing publish stats stay in ClientService). */ /** Successful publish clears publish strikes (existing publish stats stay in ClientService). */
@ -355,6 +385,7 @@ class RelaySessionStrikes {
e.publishFailures = 0 e.publishFailures = 0
e.publishStrikeSkipUntil = 0 e.publishStrikeSkipUntil = 0
e.publishLastStrikeIncrementAt = 0 e.publishLastStrikeIncrementAt = 0
this.emitChange()
} }
filterPublishUrls(urls: readonly string[]): string[] { filterPublishUrls(urls: readonly string[]): string[] {
@ -389,11 +420,13 @@ class RelaySessionStrikes {
const key = sessionKey(urlOrSessionKey) || urlOrSessionKey.trim() const key = sessionKey(urlOrSessionKey) || urlOrSessionKey.trim()
if (!key) return if (!key) return
this.byKey.delete(key) this.byKey.delete(key)
this.emitChange()
} }
reset(): void { reset(): void {
this.byKey.clear() this.byKey.clear()
this.cacheRelayKeys.clear() this.cacheRelayKeys.clear()
this.emitChange()
} }
} }

Loading…
Cancel
Save