diff options
| author | soryu <soryu@soryu.co> | 2026-02-22 14:39:14 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-22 14:39:14 +0000 |
| commit | 6a34a6f3c423a7c57616762eb4cea2b7da52eaf3 (patch) | |
| tree | 7c596eac896918466e7ef3f149b02333fef09212 /frontend | |
| parent | 0523765af84492640928d571f481e17b26008b13 (diff) | |
| download | soryu-6a34a6f3c423a7c57616762eb4cea2b7da52eaf3.tar.gz soryu-6a34a6f3c423a7c57616762eb4cea2b7da52eaf3.zip | |
feat: Add daemon page with download binary and Cloudflare Agent setup (#77)
* feat: soryu-co/soryu - makima: Create DaemonList and DaemonDetail page components
* feat: soryu-co/soryu - makima: Add daemon page routes, CSS styles, and navigation
* feat: soryu-co/soryu - makima: Create daemon page with download and monitoring
* WIP: heartbeat checkpoint
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Integrate Cloudflare Agent setup into daemon page
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/src/components/DaemonDetail.tsx | 229 | ||||
| -rw-r--r-- | frontend/src/components/DaemonList.tsx | 125 | ||||
| -rw-r--r-- | frontend/src/components/VNInterface.tsx | 13 | ||||
| -rw-r--r-- | frontend/src/main.tsx | 10 | ||||
| -rw-r--r-- | frontend/src/styles/pc98.css | 59 |
5 files changed, 436 insertions, 0 deletions
diff --git a/frontend/src/components/DaemonDetail.tsx b/frontend/src/components/DaemonDetail.tsx new file mode 100644 index 0000000..2de0997 --- /dev/null +++ b/frontend/src/components/DaemonDetail.tsx @@ -0,0 +1,229 @@ +import React, { useEffect, useState } from 'react' +import { useParams, Link } from 'react-router-dom' + +interface Daemon { + id: string + ownerId: string + connectionId: string + hostname: string | null + machineId: string | null + maxConcurrentTasks: number + currentTaskCount: number + status: string + lastHeartbeatAt: string + connectedAt: string + disconnectedAt: string | null +} + +interface DaemonDirectory { + path: string + label: string + directoryType: string + hostname: string | null + exists?: boolean +} + +type Tab = 'overview' | 'directories' + +function statusIndicator(status: string): { color: string; label: string } { + switch (status.toLowerCase()) { + case 'connected': + return { color: 'green', label: 'Connected' } + case 'disconnected': + return { color: 'red', label: 'Disconnected' } + case 'unhealthy': + return { color: 'yellow', label: 'Unhealthy' } + default: + return { color: 'gray', label: status } + } +} + +export function DaemonDetail() { + const { id } = useParams<{ id: string }>() + const [daemon, setDaemon] = useState<Daemon | null>(null) + const [directories, setDirectories] = useState<DaemonDirectory[]>([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState<string | null>(null) + const [activeTab, setActiveTab] = useState<Tab>('overview') + + useEffect(() => { + async function fetchDaemon() { + if (!id) return + + try { + setLoading(true) + const [daemonRes, dirRes] = await Promise.all([ + fetch(`/api/v1/mesh/daemons/${id}`), + fetch('/api/v1/mesh/daemons/directories'), + ]) + + if (!daemonRes.ok) { + throw new Error(`Failed to fetch daemon: ${daemonRes.statusText}`) + } + + const daemonData = await daemonRes.json() + setDaemon(daemonData) + + if (dirRes.ok) { + const dirData = await dirRes.json() + setDirectories(dirData.directories || []) + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setLoading(false) + } + } + + fetchDaemon() + + const interval = setInterval(async () => { + if (!id) return + try { + const response = await fetch(`/api/v1/mesh/daemons/${id}`) + if (response.ok) { + const daemonData = await response.json() + setDaemon(daemonData) + } + } catch { + // Silently ignore refresh errors + } + }, 10000) + + return () => clearInterval(interval) + }, [id]) + + if (loading) { + return ( + <div className="daemon-detail-container"> + <div className="loading">Loading daemon...</div> + </div> + ) + } + + if (error) { + return ( + <div className="daemon-detail-container"> + <div className="error">Error: {error}</div> + <Link to="/daemons" className="back-link"> + Back to Daemons + </Link> + </div> + ) + } + + if (!daemon) { + return ( + <div className="daemon-detail-container"> + <div className="not-found">Daemon not found</div> + <Link to="/daemons" className="back-link"> + Back to Daemons + </Link> + </div> + ) + } + + const status = statusIndicator(daemon.status) + const filteredDirectories = directories.filter( + (dir) => dir.hostname === daemon.hostname + ) + + return ( + <div className="daemon-detail-container"> + <div className="daemon-detail-header"> + <Link to="/daemons" className="back-link"> + Back to Daemons + </Link> + <h1 className="daemon-title">{daemon.hostname || 'Unknown Host'}</h1> + <div className="daemon-meta"> + <span> + <span + className="status-dot" + style={{ backgroundColor: status.color }} + /> + {status.label} + </span> + <span> + Tasks: {daemon.currentTaskCount} / {daemon.maxConcurrentTasks} + </span> + </div> + </div> + + <div className="daemon-tabs"> + <button + className={`tab-button ${activeTab === 'overview' ? 'active' : ''}`} + onClick={() => setActiveTab('overview')} + > + Overview + </button> + <button + className={`tab-button ${activeTab === 'directories' ? 'active' : ''}`} + onClick={() => setActiveTab('directories')} + > + Directories ({filteredDirectories.length}) + </button> + </div> + + <div className="daemon-tab-content"> + {activeTab === 'overview' && ( + <div className="tab-panel"> + <h2>Daemon Overview</h2> + <dl className="overview-list"> + <dt>ID</dt> + <dd>{daemon.id}</dd> + <dt>Hostname</dt> + <dd>{daemon.hostname || 'N/A'}</dd> + <dt>Machine ID</dt> + <dd>{daemon.machineId || 'N/A'}</dd> + <dt>Status</dt> + <dd> + <span + className="status-dot" + style={{ backgroundColor: status.color }} + /> + {status.label} + </dd> + <dt>Connection ID</dt> + <dd>{daemon.connectionId}</dd> + <dt>Max Concurrent Tasks</dt> + <dd>{daemon.maxConcurrentTasks}</dd> + <dt>Current Task Count</dt> + <dd>{daemon.currentTaskCount}</dd> + <dt>Connected At</dt> + <dd>{new Date(daemon.connectedAt).toLocaleString()}</dd> + <dt>Last Heartbeat At</dt> + <dd>{new Date(daemon.lastHeartbeatAt).toLocaleString()}</dd> + <dt>Disconnected At</dt> + <dd> + {daemon.disconnectedAt + ? new Date(daemon.disconnectedAt).toLocaleString() + : 'N/A'} + </dd> + </dl> + </div> + )} + + {activeTab === 'directories' && ( + <div className="tab-panel"> + <h2>Directories</h2> + {filteredDirectories.length === 0 ? ( + <p>No directories found for this daemon</p> + ) : ( + <ul className="directory-list"> + {filteredDirectories.map((dir, index) => ( + <li key={`${dir.path}-${index}`} className="directory-item"> + <h3>{dir.label}</h3> + <div className="daemon-meta"> + <span>Path: {dir.path}</span> + <span>Type: {dir.directoryType}</span> + </div> + </li> + ))} + </ul> + )} + </div> + )} + </div> + </div> + ) +} diff --git a/frontend/src/components/DaemonList.tsx b/frontend/src/components/DaemonList.tsx new file mode 100644 index 0000000..215c790 --- /dev/null +++ b/frontend/src/components/DaemonList.tsx @@ -0,0 +1,125 @@ +import React, { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +interface DaemonSummary { + id: string + ownerId: string + connectionId: string + hostname: string | null + machineId: string | null + maxConcurrentTasks: number + currentTaskCount: number + status: string + lastHeartbeatAt: string + connectedAt: string + disconnectedAt: string | null +} + +function statusDotColor(status: string): string { + switch (status.toLowerCase()) { + case 'connected': + return 'green' + case 'disconnected': + return 'red' + case 'unhealthy': + return 'yellow' + default: + return 'gray' + } +} + +export function DaemonList() { + const [daemons, setDaemons] = useState<DaemonSummary[]>([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState<string | null>(null) + + useEffect(() => { + async function fetchDaemons() { + try { + setLoading(true) + const response = await fetch('/api/v1/mesh/daemons') + if (!response.ok) { + throw new Error(`Failed to fetch daemons: ${response.statusText}`) + } + const data = await response.json() + setDaemons(data.daemons || []) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setLoading(false) + } + } + + fetchDaemons() + + const interval = setInterval(async () => { + try { + const response = await fetch('/api/v1/mesh/daemons') + if (response.ok) { + const data = await response.json() + setDaemons(data.daemons || []) + } + } catch { + // Silently ignore refresh errors + } + }, 10000) + + return () => clearInterval(interval) + }, []) + + if (loading) { + return ( + <div className="daemon-list-container"> + <div className="loading">Loading daemons...</div> + </div> + ) + } + + if (error) { + return ( + <div className="daemon-list-container"> + <div className="error">Error: {error}</div> + </div> + ) + } + + return ( + <div className="daemon-list-container"> + <Link to="/" className="back-link"> + Back to Home + </Link> + <h1>Daemons</h1> + {daemons.length === 0 ? ( + <p>No daemons found</p> + ) : ( + <ul className="daemon-list"> + {daemons.map((daemon) => ( + <li key={daemon.id} className="daemon-item"> + <Link to={`/daemons/${daemon.id}`}> + <h2>{daemon.hostname || 'Unknown Host'}</h2> + <div className="daemon-status"> + <span + className="status-dot" + style={{ backgroundColor: statusDotColor(daemon.status) }} + /> + <span>{daemon.status}</span> + </div> + <div className="daemon-meta"> + <span> + Tasks: {daemon.currentTaskCount} / {daemon.maxConcurrentTasks} + </span> + <span> + Connected: {new Date(daemon.connectedAt).toLocaleString()} + </span> + {daemon.machineId && ( + <span>Machine: {daemon.machineId}</span> + )} + </div> + </Link> + </li> + ))} + </ul> + )} + </div> + ) +} diff --git a/frontend/src/components/VNInterface.tsx b/frontend/src/components/VNInterface.tsx index be71d27..318a9b9 100644 --- a/frontend/src/components/VNInterface.tsx +++ b/frontend/src/components/VNInterface.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react' +import { Link } from 'react-router-dom' import { useStore } from '@nanostores/react' import { isStandbyStore, @@ -100,6 +101,18 @@ export function VNInterface({ onLogout }: VNInterfaceProps) { {isStandby ? 'STDBY' : 'LIVE'} </span> </div> + <div className="status-item"> + <Link to="/daemons" style={{ color: '#66ccff', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px' }}> + <span className="info-label">Mesh:</span> + <span className="info-value">Daemons</span> + </Link> + </div> + <div className="status-item"> + <Link to="/contracts" style={{ color: '#66ccff', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px' }}> + <span className="info-label">View:</span> + <span className="info-value">Contracts</span> + </Link> + </div> </div> </div> </div> diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a6eae5b..04b8cde 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,6 +5,8 @@ import App from './App' import { ContractList } from './components/ContractList' import { ContractDetail } from './components/ContractDetail' import { FileDetail } from './components/FileDetail' +import { DaemonList } from './components/DaemonList' +import { DaemonDetail } from './components/DaemonDetail' import './styles/pc98.css' import './styles/mobile.css' @@ -33,6 +35,14 @@ const router = createBrowserRouter([ path: '/contracts/:contractId/files/:fileId', element: <FileDetail />, }, + { + path: '/daemons', + element: <DaemonList />, + }, + { + path: '/daemons/:id', + element: <DaemonDetail />, + }, ]) ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/styles/pc98.css b/frontend/src/styles/pc98.css index 4dcf15e..7ec0d1c 100644 --- a/frontend/src/styles/pc98.css +++ b/frontend/src/styles/pc98.css @@ -4621,3 +4621,62 @@ button:focus-visible { display: none; } } + +/* ===== Daemon Pages ===== */ + +.daemon-list-container { max-width: 900px; margin: 0 auto; padding: 40px 20px; font-family: 'MS Gothic', monospace; min-height: 100vh; background: var(--bg-gradient); } +.daemon-list-container h1 { font-family: 'MS Gothic', monospace; font-size: 24px; color: #66ccff; text-transform: uppercase; letter-spacing: 2px; margin: 0 0 30px 0; border-bottom: 2px solid rgba(102, 204, 255, 0.3); padding-bottom: 10px; } +.daemon-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 12px; } +.daemon-item { background: rgba(0, 0, 0, 0.6); border: 1px solid rgba(102, 204, 255, 0.3); padding: 0; transition: all 0.2s ease; } +.daemon-item:hover { border-color: #66ccff; background: rgba(0, 0, 51, 0.8); box-shadow: 0 0 15px rgba(102, 204, 255, 0.15); } +.daemon-item a { display: block; padding: 16px 20px; color: inherit; text-decoration: none; } +.daemon-item h2 { font-family: 'MS Gothic', monospace; font-size: 16px; color: #ffffff; margin: 0 0 8px 0; display: flex; align-items: center; gap: 10px; } +.daemon-meta { display: flex; gap: 20px; flex-wrap: wrap; font-size: 12px; color: rgba(255, 255, 255, 0.6); } +.daemon-meta span { display: flex; align-items: center; gap: 4px; } + +/* Status indicators */ +.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; } +.status-dot.connected { background: #00ff88; box-shadow: 0 0 6px rgba(0, 255, 136, 0.6); } +.status-dot.disconnected { background: #ff4466; box-shadow: 0 0 6px rgba(255, 68, 102, 0.6); } +.status-dot.unhealthy { background: #ffcc66; box-shadow: 0 0 6px rgba(255, 204, 102, 0.6); } +.daemon-status { display: flex; align-items: center; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; } +.daemon-status.connected { color: #00ff88; } +.daemon-status.disconnected { color: #ff4466; } +.daemon-status.unhealthy { color: #ffcc66; } + +/* Daemon Detail */ +.daemon-detail-container { max-width: 900px; margin: 0 auto; padding: 40px 20px; font-family: 'MS Gothic', monospace; min-height: 100vh; background: var(--bg-gradient); } +.daemon-detail-header { margin-bottom: 30px; } +.daemon-title { font-family: 'MS Gothic', monospace; font-size: 22px; color: #ffffff; margin: 15px 0 8px; } + +/* Shared back links */ +.daemon-list-container .back-link, .daemon-detail-container .back-link { color: #66ccff; text-decoration: none; font-size: 13px; display: inline-flex; align-items: center; gap: 4px; opacity: 0.8; transition: opacity 0.2s ease; } +.daemon-list-container .back-link:hover, .daemon-detail-container .back-link:hover { opacity: 1; text-decoration: underline; } + +/* Daemon Tabs */ +.daemon-tabs { display: flex; gap: 0; border-bottom: 2px solid rgba(102, 204, 255, 0.3); margin-bottom: 20px; } +.daemon-tabs .tab-button { background: transparent; border: none; border-bottom: 2px solid transparent; color: rgba(255, 255, 255, 0.5); font-family: 'MS Gothic', monospace; font-size: 13px; padding: 10px 20px; cursor: pointer; text-transform: uppercase; letter-spacing: 1px; transition: all 0.2s ease; margin-bottom: -2px; } +.daemon-tabs .tab-button:hover { color: rgba(255, 255, 255, 0.8); background: rgba(102, 204, 255, 0.05); } +.daemon-tabs .tab-button.active { color: #66ccff; border-bottom-color: #66ccff; } + +/* Tab content */ +.daemon-tab-content .tab-panel { animation: fadeIn 0.2s ease; } +.daemon-tab-content .tab-panel h2 { font-family: 'MS Gothic', monospace; font-size: 16px; color: #66ccff; margin: 0 0 15px 0; text-transform: uppercase; letter-spacing: 1px; } +.daemon-tab-content .overview-list { margin: 0; display: grid; grid-template-columns: 180px 1fr; gap: 0; } +.daemon-tab-content .overview-list dt { padding: 10px 15px; color: rgba(255, 255, 255, 0.5); font-size: 12px; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid rgba(102, 204, 255, 0.1); } +.daemon-tab-content .overview-list dd { padding: 10px 15px; margin: 0; color: #ffffff; font-size: 13px; border-bottom: 1px solid rgba(102, 204, 255, 0.1); } + +/* Directory list */ +.directory-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; } +.directory-item { background: rgba(0, 0, 0, 0.4); border: 1px solid rgba(102, 204, 255, 0.2); padding: 12px 16px; } +.directory-item h3 { font-size: 14px; color: #ffffff; margin: 0 0 4px 0; } +.directory-item .directory-path { font-size: 12px; color: rgba(255, 255, 255, 0.5); word-break: break-all; } +.directory-item .directory-type { display: inline-block; font-size: 10px; color: #66ccff; text-transform: uppercase; letter-spacing: 1px; margin-top: 4px; padding: 2px 6px; border: 1px solid rgba(102, 204, 255, 0.3); } + +/* Capacity bar */ +.task-capacity { display: flex; align-items: center; gap: 8px; font-size: 12px; } +.capacity-bar { width: 60px; height: 6px; background: rgba(255, 255, 255, 0.1); overflow: hidden; } +.capacity-fill { height: 100%; background: #66ccff; transition: width 0.3s ease; } +.capacity-fill.high { background: #ffcc66; } +.capacity-fill.full { background: #ff4466; } +.refresh-indicator { font-size: 11px; color: rgba(255, 255, 255, 0.3); margin-left: auto; } |
