repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources16d ago| #1 | import { useState, useEffect, useCallback } from 'react' |
| #2 | import { |
| #3 | listDevices, |
| #4 | approveDevice, |
| #5 | approveAllDevices, |
| #6 | restartGateway, |
| #7 | getStorageStatus, |
| #8 | triggerSync, |
| #9 | AuthError, |
| #10 | type PendingDevice, |
| #11 | type PairedDevice, |
| #12 | type DeviceListResponse, |
| #13 | type StorageStatusResponse, |
| #14 | } from '../api' |
| #15 | import './AdminPage.css' |
| #16 | |
| #17 | // Small inline spinner for buttons |
| #18 | function ButtonSpinner() { |
| #19 | return <span className="btn-spinner" /> |
| #20 | } |
| #21 | |
| #22 | export default function AdminPage() { |
| #23 | const [pending, setPending] = useState<PendingDevice[]>([]) |
| #24 | const [paired, setPaired] = useState<PairedDevice[]>([]) |
| #25 | const [storageStatus, setStorageStatus] = useState<StorageStatusResponse | null>(null) |
| #26 | const [loading, setLoading] = useState(true) |
| #27 | const [error, setError] = useState<string | null>(null) |
| #28 | const [actionInProgress, setActionInProgress] = useState<string | null>(null) |
| #29 | const [restartInProgress, setRestartInProgress] = useState(false) |
| #30 | const [syncInProgress, setSyncInProgress] = useState(false) |
| #31 | |
| #32 | const fetchDevices = useCallback(async () => { |
| #33 | try { |
| #34 | setError(null) |
| #35 | const data: DeviceListResponse = await listDevices() |
| #36 | setPending(data.pending || []) |
| #37 | setPaired(data.paired || []) |
| #38 | |
| #39 | if (data.error) { |
| #40 | setError(data.error) |
| #41 | } else if (data.parseError) { |
| #42 | setError(`Parse error: ${data.parseError}`) |
| #43 | } |
| #44 | } catch (err) { |
| #45 | if (err instanceof AuthError) { |
| #46 | setError('Authentication required. Please log in via Cloudflare Access.') |
| #47 | } else { |
| #48 | setError(err instanceof Error ? err.message : 'Failed to fetch devices') |
| #49 | } |
| #50 | } finally { |
| #51 | setLoading(false) |
| #52 | } |
| #53 | }, []) |
| #54 | |
| #55 | const fetchStorageStatus = useCallback(async () => { |
| #56 | try { |
| #57 | const status = await getStorageStatus() |
| #58 | setStorageStatus(status) |
| #59 | } catch (err) { |
| #60 | // Don't show error for storage status - it's not critical |
| #61 | console.error('Failed to fetch storage status:', err) |
| #62 | } |
| #63 | }, []) |
| #64 | |
| #65 | useEffect(() => { |
| #66 | fetchDevices() |
| #67 | fetchStorageStatus() |
| #68 | }, [fetchDevices, fetchStorageStatus]) |
| #69 | |
| #70 | const handleApprove = async (requestId: string) => { |
| #71 | setActionInProgress(requestId) |
| #72 | try { |
| #73 | const result = await approveDevice(requestId) |
| #74 | if (result.success) { |
| #75 | // Refresh the list |
| #76 | await fetchDevices() |
| #77 | } else { |
| #78 | setError(result.error || 'Approval failed') |
| #79 | } |
| #80 | } catch (err) { |
| #81 | setError(err instanceof Error ? err.message : 'Failed to approve device') |
| #82 | } finally { |
| #83 | setActionInProgress(null) |
| #84 | } |
| #85 | } |
| #86 | |
| #87 | const handleApproveAll = async () => { |
| #88 | if (pending.length === 0) return |
| #89 | |
| #90 | setActionInProgress('all') |
| #91 | try { |
| #92 | const result = await approveAllDevices() |
| #93 | if (result.failed && result.failed.length > 0) { |
| #94 | setError(`Failed to approve ${result.failed.length} device(s)`) |
| #95 | } |
| #96 | // Refresh the list |
| #97 | await fetchDevices() |
| #98 | } catch (err) { |
| #99 | setError(err instanceof Error ? err.message : 'Failed to approve devices') |
| #100 | } finally { |
| #101 | setActionInProgress(null) |
| #102 | } |
| #103 | } |
| #104 | |
| #105 | const handleRestartGateway = async () => { |
| #106 | if (!confirm('Are you sure you want to restart the gateway? This will disconnect all clients temporarily.')) { |
| #107 | return |
| #108 | } |
| #109 | |
| #110 | setRestartInProgress(true) |
| #111 | try { |
| #112 | const result = await restartGateway() |
| #113 | if (result.success) { |
| #114 | setError(null) |
| #115 | // Show success message briefly |
| #116 | alert('Gateway restart initiated. Clients will reconnect automatically.') |
| #117 | } else { |
| #118 | setError(result.error || 'Failed to restart gateway') |
| #119 | } |
| #120 | } catch (err) { |
| #121 | setError(err instanceof Error ? err.message : 'Failed to restart gateway') |
| #122 | } finally { |
| #123 | setRestartInProgress(false) |
| #124 | } |
| #125 | } |
| #126 | |
| #127 | const handleSync = async () => { |
| #128 | setSyncInProgress(true) |
| #129 | try { |
| #130 | const result = await triggerSync() |
| #131 | if (result.success) { |
| #132 | // Update the storage status with new lastSync time |
| #133 | setStorageStatus(prev => prev ? { ...prev, lastSync: result.lastSync || null } : null) |
| #134 | setError(null) |
| #135 | } else { |
| #136 | setError(result.error || 'Sync failed') |
| #137 | } |
| #138 | } catch (err) { |
| #139 | setError(err instanceof Error ? err.message : 'Failed to sync') |
| #140 | } finally { |
| #141 | setSyncInProgress(false) |
| #142 | } |
| #143 | } |
| #144 | |
| #145 | const formatSyncTime = (isoString: string | null) => { |
| #146 | if (!isoString) return 'Never' |
| #147 | try { |
| #148 | const date = new Date(isoString) |
| #149 | return date.toLocaleString() |
| #150 | } catch { |
| #151 | return isoString |
| #152 | } |
| #153 | } |
| #154 | |
| #155 | const formatTimestamp = (ts: number) => { |
| #156 | const date = new Date(ts) |
| #157 | return date.toLocaleString() |
| #158 | } |
| #159 | |
| #160 | const formatTimeAgo = (ts: number) => { |
| #161 | const seconds = Math.floor((Date.now() - ts) / 1000) |
| #162 | if (seconds < 60) return `${seconds}s ago` |
| #163 | const minutes = Math.floor(seconds / 60) |
| #164 | if (minutes < 60) return `${minutes}m ago` |
| #165 | const hours = Math.floor(minutes / 60) |
| #166 | if (hours < 24) return `${hours}h ago` |
| #167 | const days = Math.floor(hours / 24) |
| #168 | return `${days}d ago` |
| #169 | } |
| #170 | |
| #171 | return ( |
| #172 | <div className="devices-page"> |
| #173 | {error && ( |
| #174 | <div className="error-banner"> |
| #175 | <span>{error}</span> |
| #176 | <button onClick={() => setError(null)} className="dismiss-btn"> |
| #177 | Dismiss |
| #178 | </button> |
| #179 | </div> |
| #180 | )} |
| #181 | |
| #182 | {storageStatus && !storageStatus.configured && ( |
| #183 | <div className="warning-banner"> |
| #184 | <div className="warning-content"> |
| #185 | <strong>R2 Storage Not Configured</strong> |
| #186 | <p> |
| #187 | Paired devices and conversations will be lost when the container restarts. |
| #188 | To enable persistent storage, configure R2 credentials. |
| #189 | See the <a href="https://github.com/cloudflare/moltworker" target="_blank" rel="noopener noreferrer">README</a> for setup instructions. |
| #190 | </p> |
| #191 | {storageStatus.missing && ( |
| #192 | <p className="missing-secrets"> |
| #193 | Missing: {storageStatus.missing.join(', ')} |
| #194 | </p> |
| #195 | )} |
| #196 | </div> |
| #197 | </div> |
| #198 | )} |
| #199 | |
| #200 | {storageStatus?.configured && ( |
| #201 | <div className="success-banner"> |
| #202 | <div className="storage-status"> |
| #203 | <div className="storage-info"> |
| #204 | <span>R2 storage is configured. Your data will persist across container restarts.</span> |
| #205 | <span className="last-sync"> |
| #206 | Last backup: {formatSyncTime(storageStatus.lastSync)} |
| #207 | </span> |
| #208 | </div> |
| #209 | <button |
| #210 | className="btn btn-secondary btn-sm" |
| #211 | onClick={handleSync} |
| #212 | disabled={syncInProgress} |
| #213 | > |
| #214 | {syncInProgress && <ButtonSpinner />} |
| #215 | {syncInProgress ? 'Syncing...' : 'Backup Now'} |
| #216 | </button> |
| #217 | </div> |
| #218 | </div> |
| #219 | )} |
| #220 | |
| #221 | <section className="devices-section gateway-section"> |
| #222 | <div className="section-header"> |
| #223 | <h2>Gateway Controls</h2> |
| #224 | <button |
| #225 | className="btn btn-danger" |
| #226 | onClick={handleRestartGateway} |
| #227 | disabled={restartInProgress} |
| #228 | > |
| #229 | {restartInProgress && <ButtonSpinner />} |
| #230 | {restartInProgress ? 'Restarting...' : 'Restart Gateway'} |
| #231 | </button> |
| #232 | </div> |
| #233 | <p className="hint"> |
| #234 | Restart the gateway to apply configuration changes or recover from errors. |
| #235 | All connected clients will be temporarily disconnected. |
| #236 | </p> |
| #237 | </section> |
| #238 | |
| #239 | {loading ? ( |
| #240 | <div className="loading"> |
| #241 | <div className="spinner"></div> |
| #242 | <p>Loading devices...</p> |
| #243 | </div> |
| #244 | ) : ( |
| #245 | <> |
| #246 | <section className="devices-section"> |
| #247 | <div className="section-header"> |
| #248 | <h2>Pending Pairing Requests</h2> |
| #249 | <div className="header-actions"> |
| #250 | {pending.length > 0 && ( |
| #251 | <button |
| #252 | className="btn btn-primary" |
| #253 | onClick={handleApproveAll} |
| #254 | disabled={actionInProgress !== null} |
| #255 | > |
| #256 | {actionInProgress === 'all' && <ButtonSpinner />} |
| #257 | {actionInProgress === 'all' ? 'Approving...' : `Approve All (${pending.length})`} |
| #258 | </button> |
| #259 | )} |
| #260 | <button className="btn btn-secondary" onClick={fetchDevices} disabled={loading}> |
| #261 | Refresh |
| #262 | </button> |
| #263 | </div> |
| #264 | </div> |
| #265 | |
| #266 | {pending.length === 0 ? ( |
| #267 | <div className="empty-state"> |
| #268 | <p>No pending pairing requests</p> |
| #269 | <p className="hint"> |
| #270 | Devices will appear here when they attempt to connect without being paired. |
| #271 | </p> |
| #272 | </div> |
| #273 | ) : ( |
| #274 | <div className="devices-grid"> |
| #275 | {pending.map((device) => ( |
| #276 | <div key={device.requestId} className="device-card pending"> |
| #277 | <div className="device-header"> |
| #278 | <span className="device-name"> |
| #279 | {device.displayName || device.deviceId || 'Unknown Device'} |
| #280 | </span> |
| #281 | <span className="device-badge pending">Pending</span> |
| #282 | </div> |
| #283 | <div className="device-details"> |
| #284 | {device.platform && ( |
| #285 | <div className="detail-row"> |
| #286 | <span className="label">Platform:</span> |
| #287 | <span className="value">{device.platform}</span> |
| #288 | </div> |
| #289 | )} |
| #290 | {device.clientId && ( |
| #291 | <div className="detail-row"> |
| #292 | <span className="label">Client:</span> |
| #293 | <span className="value">{device.clientId}</span> |
| #294 | </div> |
| #295 | )} |
| #296 | {device.clientMode && ( |
| #297 | <div className="detail-row"> |
| #298 | <span className="label">Mode:</span> |
| #299 | <span className="value">{device.clientMode}</span> |
| #300 | </div> |
| #301 | )} |
| #302 | {device.role && ( |
| #303 | <div className="detail-row"> |
| #304 | <span className="label">Role:</span> |
| #305 | <span className="value">{device.role}</span> |
| #306 | </div> |
| #307 | )} |
| #308 | {device.remoteIp && ( |
| #309 | <div className="detail-row"> |
| #310 | <span className="label">IP:</span> |
| #311 | <span className="value">{device.remoteIp}</span> |
| #312 | </div> |
| #313 | )} |
| #314 | <div className="detail-row"> |
| #315 | <span className="label">Requested:</span> |
| #316 | <span className="value" title={formatTimestamp(device.ts)}> |
| #317 | {formatTimeAgo(device.ts)} |
| #318 | </span> |
| #319 | </div> |
| #320 | </div> |
| #321 | <div className="device-actions"> |
| #322 | <button |
| #323 | className="btn btn-success" |
| #324 | onClick={() => handleApprove(device.requestId)} |
| #325 | disabled={actionInProgress !== null} |
| #326 | > |
| #327 | {actionInProgress === device.requestId && <ButtonSpinner />} |
| #328 | {actionInProgress === device.requestId ? 'Approving...' : 'Approve'} |
| #329 | </button> |
| #330 | </div> |
| #331 | </div> |
| #332 | ))} |
| #333 | </div> |
| #334 | )} |
| #335 | </section> |
| #336 | |
| #337 | <section className="devices-section"> |
| #338 | <div className="section-header"> |
| #339 | <h2>Paired Devices</h2> |
| #340 | </div> |
| #341 | |
| #342 | {paired.length === 0 ? ( |
| #343 | <div className="empty-state"> |
| #344 | <p>No paired devices</p> |
| #345 | </div> |
| #346 | ) : ( |
| #347 | <div className="devices-grid"> |
| #348 | {paired.map((device, index) => ( |
| #349 | <div key={device.deviceId || index} className="device-card paired"> |
| #350 | <div className="device-header"> |
| #351 | <span className="device-name"> |
| #352 | {device.displayName || device.deviceId || 'Unknown Device'} |
| #353 | </span> |
| #354 | <span className="device-badge paired">Paired</span> |
| #355 | </div> |
| #356 | <div className="device-details"> |
| #357 | {device.platform && ( |
| #358 | <div className="detail-row"> |
| #359 | <span className="label">Platform:</span> |
| #360 | <span className="value">{device.platform}</span> |
| #361 | </div> |
| #362 | )} |
| #363 | {device.clientId && ( |
| #364 | <div className="detail-row"> |
| #365 | <span className="label">Client:</span> |
| #366 | <span className="value">{device.clientId}</span> |
| #367 | </div> |
| #368 | )} |
| #369 | {device.clientMode && ( |
| #370 | <div className="detail-row"> |
| #371 | <span className="label">Mode:</span> |
| #372 | <span className="value">{device.clientMode}</span> |
| #373 | </div> |
| #374 | )} |
| #375 | {device.role && ( |
| #376 | <div className="detail-row"> |
| #377 | <span className="label">Role:</span> |
| #378 | <span className="value">{device.role}</span> |
| #379 | </div> |
| #380 | )} |
| #381 | <div className="detail-row"> |
| #382 | <span className="label">Paired:</span> |
| #383 | <span className="value" title={formatTimestamp(device.approvedAtMs)}> |
| #384 | {formatTimeAgo(device.approvedAtMs)} |
| #385 | </span> |
| #386 | </div> |
| #387 | </div> |
| #388 | </div> |
| #389 | ))} |
| #390 | </div> |
| #391 | )} |
| #392 | </section> |
| #393 | </> |
| #394 | )} |
| #395 | </div> |
| #396 | ) |
| #397 | } |
| #398 |