index.tsx raw

   1  import { Button } from '@/components/ui/button'
   2  import { Label } from '@/components/ui/label'
   3  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
   4  import { Switch } from '@/components/ui/switch'
   5  import managedOutboxService from '@/services/managed-outbox.service'
   6  import relayStatsService from '@/services/relay-stats.service'
   7  import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
   8  import type { TRelayEntry } from '@/types/relay-management'
   9  import type { TOutboxMode } from '@/types/relay-management'
  10  import { Check, ChevronDown, ChevronUp, Shield, ShieldAlert, ShieldOff, X } from 'lucide-react'
  11  import { useCallback, useMemo, useState } from 'react'
  12  import { useTranslation } from 'react-i18next'
  13  
  14  type RelayTab = 'pending' | 'approved' | 'rejected'
  15  
  16  function failureRateColor(rate: number): string {
  17    if (rate >= 0.99) return 'text-red-900 dark:text-red-300'
  18    if (rate > 0.5) return 'text-red-600 dark:text-red-400'
  19    if (rate > 0.1) return 'text-yellow-600 dark:text-yellow-400'
  20    return 'text-green-600 dark:text-green-400'
  21  }
  22  
  23  function RelayRow({ entry, onAction }: { entry: TRelayEntry; onAction: () => void }) {
  24    const { t } = useTranslation()
  25    const [expanded, setExpanded] = useState(false)
  26    const failureRate = relayStatsService.getFailureRate(entry.url)
  27    const autoDisabled = relayStatsService.isAutoDisabled(entry.url)
  28  
  29    return (
  30      <div className="border rounded-lg p-3 space-y-2">
  31        <div className="flex items-center justify-between gap-2">
  32          <div className="flex items-center gap-2 min-w-0 flex-1">
  33            <button
  34              type="button"
  35              onClick={() => setExpanded(!expanded)}
  36              className="text-muted-foreground hover:text-foreground shrink-0"
  37            >
  38              {expanded ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
  39            </button>
  40            <span className="truncate text-sm font-mono">{entry.url}</span>
  41            {autoDisabled && (
  42              <span title={t('Auto-disabled')}><ShieldAlert className="size-4 text-red-500 shrink-0" /></span>
  43            )}
  44            {entry.manualExclude && (
  45              <span title={t('Manually excluded')}><ShieldOff className="size-4 text-orange-500 shrink-0" /></span>
  46            )}
  47          </div>
  48          <div className="flex items-center gap-1 shrink-0">
  49            <span className="text-xs text-muted-foreground capitalize px-1.5 py-0.5 bg-muted rounded">
  50              {entry.direction}
  51            </span>
  52            <span className={`text-xs font-mono ${failureRateColor(failureRate)}`}>
  53              {(failureRate * 100).toFixed(0)}%
  54            </span>
  55          </div>
  56        </div>
  57        {expanded && (
  58          <div className="pl-6 space-y-2">
  59            {entry.reason && (
  60              <div className="text-xs text-muted-foreground">{entry.reason}</div>
  61            )}
  62            {entry.relayIp && (
  63              <div className="text-xs text-muted-foreground">IP: {entry.relayIp}</div>
  64            )}
  65            <div className="flex gap-2 flex-wrap">
  66              {entry.status !== 'approved' && (
  67                <Button
  68                  size="sm"
  69                  variant="outline"
  70                  onClick={() => { managedOutboxService.approve(entry.url); onAction() }}
  71                >
  72                  <Check className="size-3 mr-1" /> {t('Approve')}
  73                </Button>
  74              )}
  75              {entry.status !== 'rejected' && (
  76                <Button
  77                  size="sm"
  78                  variant="outline"
  79                  onClick={() => { managedOutboxService.reject(entry.url); onAction() }}
  80                >
  81                  <X className="size-3 mr-1" /> {t('Reject')}
  82                </Button>
  83              )}
  84              {entry.status !== 'pending' && (
  85                <Button
  86                  size="sm"
  87                  variant="outline"
  88                  onClick={() => { managedOutboxService.resetStatus(entry.url); onAction() }}
  89                >
  90                  {t('Reset')}
  91                </Button>
  92              )}
  93              <div className="flex items-center gap-1.5">
  94                <Switch
  95                  checked={entry.manualExclude}
  96                  onCheckedChange={(checked) => {
  97                    managedOutboxService.setManualExclude(entry.url, checked)
  98                    onAction()
  99                  }}
 100                />
 101                <span className="text-xs text-muted-foreground">{t('Exclude')}</span>
 102              </div>
 103            </div>
 104          </div>
 105        )}
 106      </div>
 107    )
 108  }
 109  
 110  export default function ManagedOutboxSetting() {
 111    const { t } = useTranslation()
 112    const [outboxMode, setOutboxMode] = useState<TOutboxMode>(
 113      storage.getOutboxMode() as TOutboxMode
 114    )
 115    const [tab, setTab] = useState<RelayTab>('pending')
 116    const [refreshKey, setRefreshKey] = useState(0)
 117  
 118    const refresh = useCallback(() => setRefreshKey((k) => k + 1), [])
 119  
 120    const pending = useMemo(() => managedOutboxService.getPendingRelays(), [refreshKey])
 121    const approved = useMemo(() => managedOutboxService.getApprovedRelays(), [refreshKey])
 122    const rejected = useMemo(() => managedOutboxService.getRejectedRelays(), [refreshKey])
 123    const excluded = useMemo(() => managedOutboxService.getExcludedRelays(), [refreshKey])
 124    const autoDisabled = useMemo(() => managedOutboxService.getAutoDisabledRelays(), [refreshKey])
 125  
 126    const currentList = tab === 'pending' ? pending : tab === 'approved' ? approved : rejected
 127  
 128    const handleModeChange = (mode: string) => {
 129      storage.setOutboxMode(mode)
 130      setOutboxMode(mode as TOutboxMode)
 131      dispatchSettingsChanged()
 132    }
 133  
 134    return (
 135      <div className="space-y-4">
 136        <div className="text-sm text-muted-foreground space-y-2">
 137          <p>
 138            {t('Relays discovered via outbox model (NIP-65) are tracked here with per-network failure stats. In')}
 139            {' '}<strong>{t('Automatic')}</strong> {t('mode, all discovered relays are used unless manually excluded or auto-disabled. In')}
 140            {' '}<strong>{t('Managed')}</strong> {t('mode, relays must be explicitly approved before use.')}
 141          </p>
 142          <p>
 143            {t('Approve/Reject controls whether a relay is used in managed mode. Exclude is a manual override that blocks a relay in both modes, independent of approval status. Relays with 99%+ failure rate on your current network are auto-disabled.')}
 144          </p>
 145          <p>
 146            {t('Failure rates are tracked per network (based on your external IP), so a relay that fails on one connection may work fine on another.')}
 147          </p>
 148        </div>
 149        <div className="flex items-center justify-between">
 150          <Label className="text-base font-normal">{t('Outbox mode')}</Label>
 151          <Select value={outboxMode} onValueChange={handleModeChange}>
 152            <SelectTrigger className="w-40">
 153              <SelectValue />
 154            </SelectTrigger>
 155            <SelectContent>
 156              <SelectItem value="automatic">{t('Automatic')}</SelectItem>
 157              <SelectItem value="managed">{t('Managed')}</SelectItem>
 158            </SelectContent>
 159          </Select>
 160        </div>
 161  
 162        <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
 163          <span>{pending.length} {t('pending')}</span>
 164          <span>·</span>
 165          <span>{approved.length} {t('approved')}</span>
 166          <span>·</span>
 167          <span>{rejected.length} {t('rejected')}</span>
 168          <span>·</span>
 169          <span>{excluded.length} {t('excluded')}</span>
 170          <span>·</span>
 171          <span>{autoDisabled.length} {t('auto-disabled')}</span>
 172        </div>
 173  
 174        <div className="flex gap-1 border-b">
 175          {(['pending', 'approved', 'rejected'] as const).map((t_) => (
 176            <button
 177              key={t_}
 178              type="button"
 179              onClick={() => setTab(t_)}
 180              className={`px-3 py-1.5 text-sm capitalize border-b-2 transition-colors ${
 181                tab === t_
 182                  ? 'border-primary text-foreground'
 183                  : 'border-transparent text-muted-foreground hover:text-foreground'
 184              }`}
 185            >
 186              {t(t_)} ({t_ === 'pending' ? pending.length : t_ === 'approved' ? approved.length : rejected.length})
 187            </button>
 188          ))}
 189        </div>
 190  
 191        {tab === 'pending' && pending.length > 1 && (
 192          <div className="flex gap-2">
 193            <Button
 194              size="sm"
 195              variant="outline"
 196              onClick={() => {
 197                managedOutboxService.bulkApprove(pending.map((e) => e.url))
 198                refresh()
 199              }}
 200            >
 201              <Shield className="size-3 mr-1" /> {t('Approve all')}
 202            </Button>
 203            <Button
 204              size="sm"
 205              variant="outline"
 206              onClick={() => {
 207                managedOutboxService.bulkReject(pending.map((e) => e.url))
 208                refresh()
 209              }}
 210            >
 211              <ShieldOff className="size-3 mr-1" /> {t('Reject all')}
 212            </Button>
 213          </div>
 214        )}
 215  
 216        <div className="space-y-2 max-h-96 overflow-y-auto">
 217          {currentList.length === 0 ? (
 218            <div className="text-sm text-muted-foreground py-4 text-center">
 219              {t('No relays')}
 220            </div>
 221          ) : (
 222            currentList.map((entry) => (
 223              <RelayRow key={entry.url} entry={entry} onAction={refresh} />
 224            ))
 225          )}
 226        </div>
 227      </div>
 228    )
 229  }
 230