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