summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-22 14:39:14 +0000
committerGitHub <noreply@github.com>2026-02-22 14:39:14 +0000
commit6a34a6f3c423a7c57616762eb4cea2b7da52eaf3 (patch)
tree7c596eac896918466e7ef3f149b02333fef09212 /frontend
parent0523765af84492640928d571f481e17b26008b13 (diff)
downloadsoryu-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.tsx229
-rw-r--r--frontend/src/components/DaemonList.tsx125
-rw-r--r--frontend/src/components/VNInterface.tsx13
-rw-r--r--frontend/src/main.tsx10
-rw-r--r--frontend/src/styles/pc98.css59
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; }