SprocketTab.tsx raw

   1  import { useCallback, useEffect, useRef, useState } from 'react'
   2  import relayAdmin from '@/services/relay-admin.service'
   3  import { useRelayAdmin } from '@/providers/RelayAdminProvider'
   4  import { useNostr } from '@/providers/NostrProvider'
   5  import { Button } from '@/components/ui/button'
   6  import { cn } from '@/lib/utils'
   7  import { toast } from 'sonner'
   8  
   9  interface SprocketStatus {
  10    is_running: boolean
  11    pid?: number
  12    script_exists: boolean
  13  }
  14  
  15  interface SprocketVersion {
  16    name: string
  17    modified: string
  18    is_current: boolean
  19  }
  20  
  21  export default function SprocketTab() {
  22    const { pubkey } = useNostr()
  23    const { isOwner, userRole } = useRelayAdmin()
  24  
  25    const [script, setScript] = useState('')
  26    const [status, setStatus] = useState<SprocketStatus | null>(null)
  27    const [versions, setVersions] = useState<SprocketVersion[]>([])
  28    const [isLoading, setIsLoading] = useState(false)
  29    const [uploadFile, setUploadFile] = useState<File | null>(null)
  30    const fileInputRef = useRef<HTMLInputElement>(null)
  31  
  32    const loadStatus = useCallback(async () => {
  33      try {
  34        const data = (await relayAdmin.loadSprocketStatus()) as unknown as SprocketStatus
  35        setStatus(data)
  36      } catch (e) {
  37        console.error('Failed to load sprocket status:', e)
  38      }
  39    }, [])
  40  
  41    const loadScript = useCallback(async () => {
  42      setIsLoading(true)
  43      try {
  44        const text = await relayAdmin.loadSprocketScript()
  45        setScript(text)
  46        toast.success('Script loaded')
  47      } catch (e) {
  48        toast.error(`Failed to load script: ${e instanceof Error ? e.message : String(e)}`)
  49      } finally {
  50        setIsLoading(false)
  51      }
  52    }, [])
  53  
  54    const loadVersions = useCallback(async () => {
  55      try {
  56        const data = (await relayAdmin.loadSprocketVersions()) as unknown as SprocketVersion[]
  57        setVersions(data)
  58      } catch (e) {
  59        toast.error(`Failed to load versions: ${e instanceof Error ? e.message : String(e)}`)
  60      }
  61    }, [])
  62  
  63    useEffect(() => {
  64      if (!isOwner) return
  65      loadStatus()
  66      loadScript()
  67      loadVersions()
  68    }, [isOwner, loadStatus, loadScript, loadVersions])
  69  
  70    const handleSave = async () => {
  71      setIsLoading(true)
  72      try {
  73        await relayAdmin.saveSprocketScript(script)
  74        toast.success('Script saved and updated')
  75        await loadStatus()
  76        await loadVersions()
  77      } catch (e) {
  78        toast.error(`Save failed: ${e instanceof Error ? e.message : String(e)}`)
  79      } finally {
  80        setIsLoading(false)
  81      }
  82    }
  83  
  84    const handleRestart = async () => {
  85      setIsLoading(true)
  86      try {
  87        await relayAdmin.restartSprocket()
  88        toast.success('Sprocket restarted')
  89        await loadStatus()
  90      } catch (e) {
  91        toast.error(`Restart failed: ${e instanceof Error ? e.message : String(e)}`)
  92      } finally {
  93        setIsLoading(false)
  94      }
  95    }
  96  
  97    const handleDelete = async () => {
  98      if (!confirm('Delete the sprocket script? This cannot be undone.')) return
  99      setIsLoading(true)
 100      try {
 101        await relayAdmin.deleteSprocket()
 102        setScript('')
 103        toast.success('Script deleted')
 104        await loadStatus()
 105        await loadVersions()
 106      } catch (e) {
 107        toast.error(`Delete failed: ${e instanceof Error ? e.message : String(e)}`)
 108      } finally {
 109        setIsLoading(false)
 110      }
 111    }
 112  
 113    const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
 114      const file = e.target.files?.[0] || null
 115      setUploadFile(file)
 116    }
 117  
 118    const handleUpload = async () => {
 119      if (!uploadFile) return
 120      setIsLoading(true)
 121      try {
 122        const text = await uploadFile.text()
 123        setScript(text)
 124        await relayAdmin.saveSprocketScript(text)
 125        toast.success('Script uploaded and updated')
 126        setUploadFile(null)
 127        if (fileInputRef.current) fileInputRef.current.value = ''
 128        await loadStatus()
 129        await loadVersions()
 130      } catch (e) {
 131        toast.error(`Upload failed: ${e instanceof Error ? e.message : String(e)}`)
 132      } finally {
 133        setIsLoading(false)
 134      }
 135    }
 136  
 137    const handleLoadVersion = async (version: SprocketVersion) => {
 138      setIsLoading(true)
 139      try {
 140        const text = await relayAdmin.loadSprocketVersion(version.name)
 141        setScript(text)
 142        toast.success(`Loaded version: ${version.name}`)
 143      } catch (e) {
 144        toast.error(`Failed to load version: ${e instanceof Error ? e.message : String(e)}`)
 145      } finally {
 146        setIsLoading(false)
 147      }
 148    }
 149  
 150    const handleDeleteVersion = async (versionName: string) => {
 151      if (!confirm(`Delete version "${versionName}"?`)) return
 152      setIsLoading(true)
 153      try {
 154        await relayAdmin.deleteSprocketVersion(versionName)
 155        toast.success(`Version "${versionName}" deleted`)
 156        await loadVersions()
 157      } catch (e) {
 158        toast.error(`Failed to delete version: ${e instanceof Error ? e.message : String(e)}`)
 159      } finally {
 160        setIsLoading(false)
 161      }
 162    }
 163  
 164    if (!pubkey) {
 165      return (
 166        <div className="p-8 text-center">
 167          <p className="text-muted-foreground mb-4">Please log in to access sprocket management.</p>
 168        </div>
 169      )
 170    }
 171  
 172    if (!isOwner) {
 173      return (
 174        <div className="p-8 text-center space-y-2">
 175          <p className="text-muted-foreground">Owner permission required for sprocket management.</p>
 176          <p className="text-sm text-muted-foreground">
 177            Set the <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">ORLY_OWNERS</code> environment
 178            variable with your npub when starting the relay.
 179          </p>
 180          <p className="text-sm text-muted-foreground">
 181            Current role: <span className="font-semibold">{userRole || 'none'}</span>
 182          </p>
 183        </div>
 184      )
 185    }
 186  
 187    return (
 188      <div className="p-4 space-y-4 w-full max-w-2xl">
 189        <h3 className="text-lg font-semibold">Sprocket Script Management</h3>
 190  
 191        {/* Script Editor Section */}
 192        <div className="rounded-lg border bg-card p-4 space-y-4">
 193          <div className="flex items-center justify-between">
 194            <h4 className="font-semibold">Script Editor</h4>
 195            <div className="flex gap-2">
 196              <Button
 197                variant="outline"
 198                size="sm"
 199                onClick={handleRestart}
 200                disabled={isLoading}
 201              >
 202                Restart
 203              </Button>
 204              <Button
 205                variant="destructive"
 206                size="sm"
 207                onClick={handleDelete}
 208                disabled={isLoading || !status?.script_exists}
 209              >
 210                Delete Script
 211              </Button>
 212            </div>
 213          </div>
 214  
 215          {/* Upload */}
 216          <div className="space-y-2">
 217            <label className="text-sm font-medium">Upload Script</label>
 218            <div className="flex items-center gap-2">
 219              <input
 220                ref={fileInputRef}
 221                type="file"
 222                accept=".sh,.bash"
 223                onChange={handleFileSelect}
 224                disabled={isLoading}
 225                className="flex-1 text-sm file:mr-2 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-primary-foreground hover:file:bg-primary-hover"
 226              />
 227              <Button size="sm" onClick={handleUpload} disabled={isLoading || !uploadFile}>
 228                Upload
 229              </Button>
 230            </div>
 231          </div>
 232  
 233          {/* Status */}
 234          <div className="rounded-md border bg-background p-3 space-y-1 text-sm">
 235            <div className="flex justify-between">
 236              <span className="font-medium">Status</span>
 237              <span className={cn(status?.is_running ? 'text-green-500' : 'text-red-500')}>
 238                {status?.is_running ? 'Running' : 'Stopped'}
 239              </span>
 240            </div>
 241            {status?.pid != null && (
 242              <div className="flex justify-between">
 243                <span className="font-medium">PID</span>
 244                <span>{status.pid}</span>
 245              </div>
 246            )}
 247            <div className="flex justify-between">
 248              <span className="font-medium">Script</span>
 249              <span>{status?.script_exists ? 'Exists' : 'Not found'}</span>
 250            </div>
 251          </div>
 252  
 253          {/* Editor */}
 254          <textarea
 255            value={script}
 256            onChange={(e) => setScript(e.target.value)}
 257            placeholder={'#!/bin/bash\n# Enter your sprocket script here...'}
 258            disabled={isLoading}
 259            className="w-full h-72 rounded-md border bg-background p-3 font-mono text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
 260          />
 261  
 262          {/* Actions */}
 263          <div className="flex gap-2">
 264            <Button size="sm" onClick={handleSave} disabled={isLoading}>
 265              Save & Update
 266            </Button>
 267            <Button variant="outline" size="sm" onClick={loadScript} disabled={isLoading}>
 268              Load Current
 269            </Button>
 270          </div>
 271        </div>
 272  
 273        {/* Versions Section */}
 274        <div className="rounded-lg border bg-card p-4 space-y-3">
 275          <h4 className="font-semibold">Script Versions</h4>
 276  
 277          <div className="space-y-2">
 278            {versions.length === 0 ? (
 279              <p className="text-sm text-muted-foreground text-center py-4">No versions found.</p>
 280            ) : (
 281              versions.map((v) => (
 282                <div
 283                  key={v.name}
 284                  className={cn(
 285                    'flex items-center justify-between rounded-md border p-3',
 286                    v.is_current ? 'border-primary bg-primary/5' : 'bg-background'
 287                  )}
 288                >
 289                  <div className="min-w-0 flex-1">
 290                    <div className="font-medium text-sm truncate">{v.name}</div>
 291                    <div className="text-xs text-muted-foreground flex items-center gap-2">
 292                      {new Date(v.modified).toLocaleString()}
 293                      {v.is_current && (
 294                        <span className="rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
 295                          Current
 296                        </span>
 297                      )}
 298                    </div>
 299                  </div>
 300                  <div className="flex gap-1 ml-2 shrink-0">
 301                    <Button
 302                      variant="outline"
 303                      size="sm"
 304                      onClick={() => handleLoadVersion(v)}
 305                      disabled={isLoading}
 306                    >
 307                      Load
 308                    </Button>
 309                    {!v.is_current && (
 310                      <Button
 311                        variant="destructive"
 312                        size="sm"
 313                        onClick={() => handleDeleteVersion(v.name)}
 314                        disabled={isLoading}
 315                      >
 316                        Delete
 317                      </Button>
 318                    )}
 319                  </div>
 320                </div>
 321              ))
 322            )}
 323          </div>
 324  
 325          <Button variant="outline" size="sm" onClick={loadVersions} disabled={isLoading}>
 326            Refresh Versions
 327          </Button>
 328        </div>
 329      </div>
 330    )
 331  }
 332