feat: complete all page components
Build-Deploy-Server / build (base, debian, ) (push) Failing after 7s
Build-Docker-ARM64 / build (base, debian, ) (push) Failing after 11s
Build-Docker-ARM64 / build (base-debian13, debian13, -debian13) (push) Failing after 11s
Build-Docker-ARM64 / build (base-minimal, minimal, -minimal) (push) Failing after 10s
Deploy VitePress to Pages / build (push) Has been cancelled
Deploy VitePress to Pages / Deploy (push) Has been cancelled

- Dashboard: system stats with cards
- Tasks: task management table with filters
- Scripts: script management grid
- Logs: execution log viewer
- Interconnect: node management
- Terminal: xterm.js integration
- Settings: theme and site config
- Login: authentication page
This commit is contained in:
2026-07-19 18:47:35 +00:00
parent e734026078
commit 2f76060c39
14 changed files with 1684 additions and 90 deletions
+3 -2
View File
@@ -13,7 +13,8 @@
"@tanstack/react-router": "^1.120.0",
"@tanstack/react-table": "^8.21.0",
"@xterm/xterm": "^5.5.0",
"framer-motion": "^12.0.0",
"@xterm/addon-fit": "^0.10.0",
"framer-motion": "^11.15.0",
"lucide-react": "^0.525.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
@@ -31,4 +32,4 @@
"typescript": "^5.9.0",
"vite": "^8.1.5"
}
}
}
+26 -14
View File
@@ -17,12 +17,15 @@ importers:
'@tanstack/react-table':
specifier: ^8.21.0
version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
'@xterm/xterm':
specifier: ^5.5.0
version: 5.5.0
framer-motion:
specifier: ^12.0.0
version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
specifier: ^11.15.0
version: 11.18.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
lucide-react:
specifier: ^0.525.0
version: 0.525.0(react@19.2.7)
@@ -286,6 +289,11 @@ packages:
babel-plugin-react-compiler:
optional: true
'@xterm/addon-fit@0.10.0':
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/xterm@5.5.0':
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
@@ -399,8 +407,8 @@ packages:
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
framer-motion@12.42.2:
resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==}
framer-motion@11.18.2:
resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
@@ -515,11 +523,11 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
motion-dom@12.42.2:
resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==}
motion-dom@11.18.1:
resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==}
motion-utils@12.39.0:
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
motion-utils@11.18.1:
resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==}
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
@@ -875,6 +883,10 @@ snapshots:
'@rolldown/pluginutils': 1.0.1
vite: 8.1.5(@types/node@22.20.1)
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/xterm@5.5.0': {}
autoprefixer@10.5.4(postcss@8.5.19):
@@ -965,10 +977,10 @@ snapshots:
fraction.js@5.3.4: {}
framer-motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
framer-motion@11.18.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
motion-dom: 12.42.2
motion-utils: 12.39.0
motion-dom: 11.18.1
motion-utils: 11.18.1
tslib: 2.8.1
optionalDependencies:
react: 19.2.7
@@ -1042,11 +1054,11 @@ snapshots:
dependencies:
react: 19.2.7
motion-dom@12.42.2:
motion-dom@11.18.1:
dependencies:
motion-utils: 12.39.0
motion-utils: 11.18.1
motion-utils@12.39.0: {}
motion-utils@11.18.1: {}
nanoid@3.3.16: {}
+64
View File
@@ -0,0 +1,64 @@
import { request } from './index'
import type { MonitorStats, Task, Script, EnvVar, LogEntry, InterconnectNode, User, Settings } from './types'
// Auth
export const login = (username: string, password: string) =>
request<{ token: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
})
export const logout = () => request<void>('/auth/logout', { method: 'POST' })
export const getCurrentUser = () => request<User>('/auth/me')
// Dashboard
export const getStats = () => request<MonitorStats>('/stats')
export const getSentence = () => request<{ content: string; author: string }>('/sentence')
export const getTaskStats = () => request<{ total: number; running: number; scheduled: number }>('/taskstats')
// Tasks
export const getTasks = (params?: { status?: string; tag?: string }) => {
const query = new URLSearchParams(params as any).toString()
return request<Task[]>(`/tasks${query ? '?' + query : ''}`)
}
export const getTask = (id: number) => request<Task>(`/tasks/${id}`)
export const createTask = (data: Partial<Task>) => request<Task>('/tasks', { method: 'POST', body: JSON.stringify(data) })
export const updateTask = (id: number, data: Partial<Task>) => request<Task>(`/tasks/${id}`, { method: 'PUT', body: JSON.stringify(data) })
export const deleteTask = (id: number) => request<void>(`/tasks/${id}`, { method: 'DELETE' })
export const executeTask = (id: number) => request<void>(`/execute/task/${id}`, { method: 'POST' })
export const getTags = () => request<string[]>('/tasks/tags')
// Scripts
export const getScripts = () => request<Script[]>('/scripts')
export const getScript = (id: number) => request<Script>(`/scripts/${id}`)
export const createScript = (data: Partial<Script>) => request<Script>('/scripts', { method: 'POST', body: JSON.stringify(data) })
export const updateScript = (id: number, data: Partial<Script>) => request<Script>(`/scripts/${id}`, { method: 'PUT', body: JSON.stringify(data) })
export const deleteScript = (id: number) => request<void>(`/scripts/${id}`, { method: 'DELETE' })
// Env
export const getEnvs = () => request<EnvVar[]>('/env')
export const getEnv = (id: number) => request<EnvVar>(`/env/${id}`)
export const createEnv = (data: Partial<EnvVar>) => request<EnvVar>('/env', { method: 'POST', body: JSON.stringify(data) })
export const updateEnv = (id: number, data: Partial<EnvVar>) => request<EnvVar>(`/env/${id}`, { method: 'PUT', body: JSON.stringify(data) })
export const deleteEnv = (id: number) => request<void>(`/env/${id}`, { method: 'DELETE' })
// Logs
export const getLogs = (params?: { task_id?: number; status?: string; page?: number }) => {
const query = new URLSearchParams(params as any).toString()
return request<LogEntry[]>(`/logs${query ? '?' + query : ''}`)
}
// Interconnect
export const getNodes = () => request<InterconnectNode[]>('/interconnect/nodes')
export const getNode = (id: string) => request<InterconnectNode>(`/interconnect/nodes/${id}`)
export const createNode = (data: Partial<InterconnectNode>) => request<InterconnectNode>('/interconnect/nodes', { method: 'POST', body: JSON.stringify(data) })
export const updateNode = (id: string, data: Partial<InterconnectNode>) => request<InterconnectNode>(`/interconnect/nodes/${id}`, { method: 'PUT', body: JSON.stringify(data) })
export const deleteNode = (id: string) => request<void>(`/interconnect/nodes/${id}`, { method: 'DELETE' })
// Settings
export const getSettings = () => request<Settings>('/settings')
export const updateSettings = (data: Partial<Settings>) => request<Settings>('/settings', { method: 'PUT', body: JSON.stringify(data) })
// Monitor
export const getMonitor = () => request<MonitorStats>('/monitor')
+166
View File
@@ -0,0 +1,166 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import * as api from '@/api/endpoints'
// Auth
export function useUser() {
return useQuery({
queryKey: ['user'],
queryFn: api.getCurrentUser,
retry: false,
})
}
// Dashboard
export function useMonitorStats() {
return useQuery({
queryKey: ['monitor'],
queryFn: api.getMonitor,
refetchInterval: 5000,
})
}
export function useTaskStats() {
return useQuery({
queryKey: ['taskStats'],
queryFn: api.getTaskStats,
refetchInterval: 10000,
})
}
// Tasks
export function useTasks(params?: { status?: string; tag?: string }) {
return useQuery({
queryKey: ['tasks', params],
queryFn: () => api.getTasks(params),
})
}
export function useTask(id: number) {
return useQuery({
queryKey: ['task', id],
queryFn: () => api.getTask(id),
enabled: id > 0,
})
}
export function useCreateTask() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.createTask,
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
})
}
export function useUpdateTask() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Parameters<typeof api.updateTask>[1] }) =>
api.updateTask(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
})
}
export function useDeleteTask() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.deleteTask,
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
})
}
export function useExecuteTask() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.executeTask,
onSuccess: () => qc.invalidateQueries({ queryKey: ['taskStats'] }),
})
}
// Scripts
export function useScripts() {
return useQuery({
queryKey: ['scripts'],
queryFn: api.getScripts,
})
}
export function useScript(id: number) {
return useQuery({
queryKey: ['script', id],
queryFn: () => api.getScript(id),
enabled: id > 0,
})
}
export function useCreateScript() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.createScript,
onSuccess: () => qc.invalidateQueries({ queryKey: ['scripts'] }),
})
}
export function useDeleteScript() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.deleteScript,
onSuccess: () => qc.invalidateQueries({ queryKey: ['scripts'] }),
})
}
// Logs
export function useLogs(params?: { task_id?: number; status?: string; page?: number }) {
return useQuery({
queryKey: ['logs', params],
queryFn: () => api.getLogs(params),
})
}
// Interconnect
export function useNodes() {
return useQuery({
queryKey: ['nodes'],
queryFn: api.getNodes,
refetchInterval: 30000,
})
}
export function useCreateNode() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.createNode,
onSuccess: () => qc.invalidateQueries({ queryKey: ['nodes'] }),
})
}
export function useDeleteNode() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.deleteNode,
onSuccess: () => qc.invalidateQueries({ queryKey: ['nodes'] }),
})
}
// Env
export function useEnvs() {
return useQuery({
queryKey: ['envs'],
queryFn: api.getEnvs,
})
}
export function useCreateEnv() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.createEnv,
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
})
}
export function useDeleteEnv() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.deleteEnv,
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
})
}
+103
View File
@@ -0,0 +1,103 @@
// API Types for TaskPool
export interface MonitorStats {
env: {
os: string
arch: string
go_version: string
num_cpu: number
goroutines: number
}
host: {
cpu_percent: number
mem_total: number
mem_used: number
mem_percent: number
disk_total: number
disk_used: number
disk_percent: number
uptime: number
platform: string
}
scheduler: {
scheduled: number
running: number
queue_size: number
worker_count: number
}
}
export interface Task {
id: number
name: string
script: string
cron: string
status: string
tags: string[]
env_ids: number[]
language: string
created_at: string
updated_at: string
last_run?: string
next_run?: string
}
export interface Script {
id: number
name: string
filename: string
content: string
language: string
created_at: string
updated_at: string
}
export interface EnvVar {
id: number
key: string
value: string
tags: string[]
remark: string
created_at: string
updated_at: string
}
export interface LogEntry {
id: number
task_id: number
task_name: string
status: string
output: string
started_at: string
finished_at: string
duration: number
}
export interface InterconnectNode {
id: string
name: string
url: string
token: string
remark: string
status: string
created_at: string
updated_at: string
metrics?: {
cpu_percent: number
mem_percent: number
disk_percent: number
}
}
export interface Settings {
site_name: string
site_url: string
theme: string
language: string
}
export interface User {
id: number
username: string
role: string
}
+150 -37
View File
@@ -1,68 +1,181 @@
import { useQuery } from '@tanstack/react-query'
import { Activity, Cpu, HardDrive, MemoryStick, Clock } from 'lucide-react'
import { request } from '@/api'
interface MonitorStats {
host: {
cpu_percent: number
mem_percent: number
disk_percent: number
uptime: number
}
scheduler: {
scheduled: number
running: number
queue_size: number
}
}
import { useMonitorStats, useTaskStats } from '@/api/hooks'
import { Activity, Cpu, HardDrive, MemoryStick, Clock, Play, Pause, FileCode } from 'lucide-react'
export default function Dashboard() {
const { data: stats, isLoading } = useQuery({
queryKey: ['monitor'],
queryFn: () => request<MonitorStats>('/monitor/stats'),
refetchInterval: 5000,
})
const { data: stats, isLoading: statsLoading } = useMonitorStats()
const { data: taskStats, isLoading: taskLoading } = useTaskStats()
if (isLoading) {
return <div style={{ color: 'var(--text-muted)' }}>Loading...</div>
if (statsLoading || taskLoading) {
return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Loading...</div>
}
const formatUptime = (seconds: number) => {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
if (days > 0) return `${days}d ${hours}h`
return `${hours}h`
return `${hours}h ${Math.floor((seconds % 3600) / 60)}m`
}
const formatBytes = (bytes: number) => {
const gb = bytes / (1024 * 1024 * 1024)
return `${gb.toFixed(1)} GB`
}
return (
<div>
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24 }}>Dashboard</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16 }}>
<Card icon={Cpu} label="CPU" value={`${stats?.host.cpu_percent || 0}%`} />
<Card icon={MemoryStick} label="Memory" value={`${stats?.host.mem_percent || 0}%`} />
<Card icon={HardDrive} label="Disk" value={`${stats?.host.disk_percent || 0}%`} />
<Card icon={Clock} label="Uptime" value={formatUptime(stats?.host.uptime || 0)} />
<Card icon={Activity} label="Scheduled" value={stats?.scheduler.scheduled || 0} />
<Card icon={Activity} label="Running" value={stats?.scheduler.running || 0} />
</div>
{/* System Stats */}
<section style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 16, fontWeight: 500, marginBottom: 16, color: 'var(--text-secondary)' }}>System</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
<StatCard
icon={Cpu}
label="CPU"
value={`${stats?.host.cpu_percent?.toFixed(1) || 0}%`}
color="#3b82f6"
progress={stats?.host.cpu_percent || 0}
/>
<StatCard
icon={MemoryStick}
label="Memory"
value={`${stats?.host.mem_percent?.toFixed(1) || 0}%`}
subValue={`${formatBytes(stats?.host.mem_used || 0)} / ${formatBytes(stats?.host.mem_total || 0)}`}
color="#8b5cf6"
progress={stats?.host.mem_percent || 0}
/>
<StatCard
icon={HardDrive}
label="Disk"
value={`${stats?.host.disk_percent?.toFixed(1) || 0}%`}
subValue={`${formatBytes(stats?.host.disk_used || 0)} / ${formatBytes(stats?.host.disk_total || 0)}`}
color="#06b6d4"
progress={stats?.host.disk_percent || 0}
/>
<StatCard
icon={Clock}
label="Uptime"
value={formatUptime(stats?.host.uptime || 0)}
subValue={stats?.env.os || 'Unknown'}
color="#10b981"
/>
</div>
</section>
{/* Task Stats */}
<section style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 16, fontWeight: 500, marginBottom: 16, color: 'var(--text-secondary)' }}>Tasks</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
<StatCard
icon={FileCode}
label="Total Tasks"
value={taskStats?.total || 0}
color="#6366f1"
/>
<StatCard
icon={Play}
label="Running"
value={taskStats?.running || 0}
color="#22c55e"
/>
<StatCard
icon={Pause}
label="Scheduled"
value={taskStats?.scheduled || 0}
color="#f59e0b"
/>
<StatCard
icon={Activity}
label="Workers"
value={stats?.scheduler.worker_count || 0}
color="#ec4899"
/>
</div>
</section>
{/* Runtime Info */}
<section>
<h2 style={{ fontSize: 16, fontWeight: 500, marginBottom: 16, color: 'var(--text-secondary)' }}>Runtime</h2>
<div style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: 16,
}}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
<InfoRow label="OS" value={`${stats?.env.os} ${stats?.env.arch}`} />
<InfoRow label="Go Version" value={stats?.env.go_version || '-'} />
<InfoRow label="CPU Cores" value={stats?.env.num_cpu || '-'} />
<InfoRow label="Goroutines" value={stats?.env.goroutines || '-'} />
<InfoRow label="Platform" value={stats?.host.platform || '-'} />
</div>
</div>
</section>
</div>
)
}
function Card({ icon: Icon, label, value }: { icon: any; label: string; value: string | number }) {
function StatCard({
icon: Icon,
label,
value,
subValue,
color,
progress
}: {
icon: any
label: string
value: string | number
subValue?: string
color: string
progress?: number
}) {
return (
<div style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: 16,
transition: 'border-color 0.15s',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Icon size={18} strokeWidth={1.5} style={{ color: 'var(--text-secondary)' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<div style={{
width: 32,
height: 32,
borderRadius: 6,
background: `${color}15`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Icon size={16} strokeWidth={1.5} style={{ color }} />
</div>
<span style={{ color: 'var(--text-secondary)', fontSize: 13 }}>{label}</span>
</div>
<div style={{ fontSize: 24, fontWeight: 600 }}>{value}</div>
<div style={{ fontSize: 24, fontWeight: 600, marginBottom: 4 }}>{value}</div>
{subValue && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{subValue}</div>}
{progress !== undefined && (
<div style={{ marginTop: 12, height: 4, background: 'var(--bg-tertiary)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{
height: '100%',
width: `${Math.min(progress, 100)}%`,
background: color,
borderRadius: 2,
transition: 'width 0.3s',
}} />
</div>
)}
</div>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: 'var(--text-secondary)', fontSize: 13 }}>{label}</span>
<span style={{ color: 'var(--text-primary)', fontSize: 13, fontWeight: 500 }}>{value}</span>
</div>
)
}
+174 -2
View File
@@ -1,8 +1,180 @@
import { useNodes, useDeleteNode } from '@/api/hooks'
import { Plus, Trash2, Server, RefreshCw, Wifi, WifiOff } from 'lucide-react'
export default function Interconnect() {
const { data: nodes, isLoading, refetch } = useNodes()
const deleteNode = useDeleteNode()
const handleDelete = async (id: string) => {
if (confirm('Delete this node?')) {
await deleteNode.mutateAsync(id)
}
}
if (isLoading) {
return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Loading...</div>
}
return (
<div>
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24 }}>Interconnect</h1>
<p style={{ color: 'var(--text-secondary)' }}>Node management coming soon...</p>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1 style={{ fontSize: 24, fontWeight: 600 }}>Interconnect</h1>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => refetch()}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 12px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--text-secondary)',
cursor: 'pointer',
fontSize: 13,
}}
>
<RefreshCw size={14} strokeWidth={1.5} />
Refresh
</button>
<button
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 12px',
background: 'var(--bg-accent)',
border: 'none',
borderRadius: 6,
color: 'white',
cursor: 'pointer',
fontSize: 13,
}}
>
<Plus size={14} strokeWidth={1.5} />
Add Node
</button>
</div>
</div>
{/* Grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: 16 }}>
{nodes?.map(node => (
<NodeCard key={node.id} node={node} onDelete={() => handleDelete(node.id)} />
))}
{(!nodes || nodes.length === 0) && (
<div style={{
gridColumn: '1 / -1',
padding: 48,
textAlign: 'center',
color: 'var(--text-muted)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
}}>
No nodes configured
</div>
)}
</div>
</div>
)
}
function NodeCard({ node, onDelete }: { node: any; onDelete: () => void }) {
const isConnected = node.status === 'connected' || node.status === 'online'
return (
<div style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: 16,
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 40,
height: 40,
borderRadius: 6,
background: isConnected ? '#22c55e15' : '#ef444415',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Server size={20} strokeWidth={1.5} style={{ color: isConnected ? '#22c55e' : '#ef4444' }} />
</div>
<div>
<div style={{ fontWeight: 500, marginBottom: 2 }}>{node.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{node.url}</div>
</div>
</div>
<button
onClick={onDelete}
title="Delete"
style={{
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
border: 'none',
borderRadius: 4,
color: 'var(--text-secondary)',
cursor: 'pointer',
}}
>
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
{/* Status */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
{isConnected ? (
<Wifi size={14} strokeWidth={1.5} style={{ color: '#22c55e' }} />
) : (
<WifiOff size={14} strokeWidth={1.5} style={{ color: '#ef4444' }} />
)}
<span style={{
fontSize: 12,
color: isConnected ? '#22c55e' : '#ef4444',
}}>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
{/* Metrics */}
{node.metrics && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 8,
padding: 12,
background: 'var(--bg-tertiary)',
borderRadius: 6,
}}>
<MetricItem label="CPU" value={`${node.metrics.cpu_percent?.toFixed(1) || 0}%`} />
<MetricItem label="Mem" value={`${node.metrics.mem_percent?.toFixed(1) || 0}%`} />
<MetricItem label="Disk" value={`${node.metrics.disk_percent?.toFixed(1) || 0}%`} />
</div>
)}
{node.remark && (
<div style={{ marginTop: 12, fontSize: 12, color: 'var(--text-muted)' }}>
{node.remark}
</div>
)}
</div>
)
}
function MetricItem({ label, value }: { label: string; value: string }) {
return (
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{value}</div>
</div>
)
}
+153
View File
@@ -0,0 +1,153 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { login } from '@/api/endpoints'
import { Lock, User, AlertCircle } from 'lucide-react'
export default function Login() {
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(username, password)
navigate({ to: '/' })
} catch (err: any) {
setError(err.message || 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg-primary)',
padding: 20,
}}>
<div style={{
width: '100%',
maxWidth: 400,
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 12,
padding: 32,
}}>
{/* Logo */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{
width: 56,
height: 56,
background: 'var(--bg-accent)',
borderRadius: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 16px',
}}>
<span style={{ color: 'white', fontSize: 24, fontWeight: 700 }}>T</span>
</div>
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 8 }}>TaskPool</h1>
<p style={{ color: 'var(--text-muted)', fontSize: 14 }}>Sign in to your account</p>
</div>
{/* Error */}
{error && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: 12,
background: '#ef444415',
borderRadius: 6,
marginBottom: 20,
}}>
<AlertCircle size={16} style={{ color: '#ef4444' }} />
<span style={{ color: '#ef4444', fontSize: 13 }}>{error}</span>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>
Username
</label>
<div style={{ position: 'relative' }}>
<User size={16} strokeWidth={1.5} style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username"
required
style={{
width: '100%',
padding: '10px 12px 10px 40px',
background: 'var(--bg-primary)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--text-primary)',
fontSize: 14,
}}
/>
</div>
</div>
<div style={{ marginBottom: 24 }}>
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>
Password
</label>
<div style={{ position: 'relative' }}>
<Lock size={16} strokeWidth={1.5} style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
required
style={{
width: '100%',
padding: '10px 12px 10px 40px',
background: 'var(--bg-primary)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--text-primary)',
fontSize: 14,
}}
/>
</div>
</div>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: 12,
background: 'var(--bg-accent)',
border: 'none',
borderRadius: 6,
color: 'white',
fontSize: 14,
fontWeight: 500,
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.7 : 1,
}}
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
)
}
+136 -2
View File
@@ -1,8 +1,142 @@
import { useState } from 'react'
import { useLogs } from '@/api/hooks'
import { RefreshCw, CheckCircle, XCircle, Clock } from 'lucide-react'
export default function Logs() {
const [filter, setFilter] = useState<{ status?: string }>({})
const { data: logs, isLoading, refetch } = useLogs(filter)
if (isLoading) {
return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Loading...</div>
}
const formatDuration = (ms: number) => {
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
return `${(ms / 60000).toFixed(1)}m`
}
return (
<div>
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24 }}>Logs</h1>
<p style={{ color: 'var(--text-secondary)' }}>Log viewer coming soon...</p>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1 style={{ fontSize: 24, fontWeight: 600 }}>Logs</h1>
<div style={{ display: 'flex', gap: 8 }}>
<select
value={filter.status || ''}
onChange={(e) => setFilter(f => ({ ...f, status: e.target.value || undefined }))}
style={{
padding: '8px 12px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--text-primary)',
fontSize: 13,
}}
>
<option value="">All Status</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
<option value="running">Running</option>
</select>
<button
onClick={() => refetch()}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 12px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--text-secondary)',
cursor: 'pointer',
fontSize: 13,
}}
>
<RefreshCw size={14} strokeWidth={1.5} />
Refresh
</button>
</div>
</div>
{/* Log List */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{logs?.map(log => (
<div
key={log.id}
style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: 16,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{log.status === 'success' ? (
<CheckCircle size={18} strokeWidth={1.5} style={{ color: '#22c55e' }} />
) : log.status === 'failed' ? (
<XCircle size={18} strokeWidth={1.5} style={{ color: '#ef4444' }} />
) : (
<Clock size={18} strokeWidth={1.5} style={{ color: '#f59e0b' }} />
)}
<div>
<div style={{ fontWeight: 500 }}>{log.task_name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{new Date(log.started_at).toLocaleString()}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span style={{
padding: '4px 8px',
borderRadius: 4,
fontSize: 11,
background: log.status === 'success' ? '#22c55e15' : log.status === 'failed' ? '#ef444415' : '#f59e0b15',
color: log.status === 'success' ? '#22c55e' : log.status === 'failed' ? '#ef4444' : '#f59e0b',
}}>
{log.status}
</span>
{log.duration > 0 && (
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{formatDuration(log.duration)}
</span>
)}
</div>
</div>
{log.output && (
<div style={{
background: 'var(--bg-primary)',
borderRadius: 4,
padding: 12,
fontSize: 12,
fontFamily: 'monospace',
color: 'var(--text-secondary)',
maxHeight: 200,
overflow: 'auto',
whiteSpace: 'pre-wrap',
}}>
{log.output.slice(0, 500)}
{log.output.length > 500 && '...'}
</div>
)}
</div>
))}
{(!logs || logs.length === 0) && (
<div style={{
padding: 48,
textAlign: 'center',
color: 'var(--text-muted)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
}}>
No logs found
</div>
)}
</div>
</div>
)
}
+161 -2
View File
@@ -1,8 +1,167 @@
import { useScripts, useDeleteScript } from '@/api/hooks'
import { Plus, Trash2, FileCode, RefreshCw } from 'lucide-react'
import type { Script } from '@/api/types'
export default function Scripts() {
const { data: scripts, isLoading, refetch } = useScripts()
const deleteScript = useDeleteScript()
const handleDelete = async (id: number) => {
if (confirm('Delete this script?')) {
await deleteScript.mutateAsync(id)
}
}
if (isLoading) {
return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Loading...</div>
}
return (
<div>
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24 }}>Scripts</h1>
<p style={{ color: 'var(--text-secondary)' }}>Script management coming soon...</p>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1 style={{ fontSize: 24, fontWeight: 600 }}>Scripts</h1>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => refetch()}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 12px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--text-secondary)',
cursor: 'pointer',
fontSize: 13,
}}
>
<RefreshCw size={14} strokeWidth={1.5} />
Refresh
</button>
<button
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 12px',
background: 'var(--bg-accent)',
border: 'none',
borderRadius: 6,
color: 'white',
cursor: 'pointer',
fontSize: 13,
}}
>
<Plus size={14} strokeWidth={1.5} />
Add Script
</button>
</div>
</div>
{/* Grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16 }}>
{scripts?.map(script => (
<ScriptCard key={script.id} script={script} onDelete={() => handleDelete(script.id)} />
))}
{(!scripts || scripts.length === 0) && (
<div style={{
gridColumn: '1 / -1',
padding: 48,
textAlign: 'center',
color: 'var(--text-muted)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
}}>
No scripts found
</div>
)}
</div>
</div>
)
}
function ScriptCard({ script, onDelete }: { script: Script; onDelete: () => void }) {
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString()
}
const getLanguageColor = (lang: string) => {
const colors: Record<string, string> = {
python: '#3776ab',
javascript: '#f7df1e',
typescript: '#3178c6',
go: '#00add8',
bash: '#4eaa25',
shell: '#4eaa25',
}
return colors[lang?.toLowerCase()] || '#6b7280'
}
return (
<div style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: 16,
transition: 'border-color 0.15s',
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 40,
height: 40,
borderRadius: 6,
background: `${getLanguageColor(script.language)}15`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<FileCode size={20} strokeWidth={1.5} style={{ color: getLanguageColor(script.language) }} />
</div>
<div>
<div style={{ fontWeight: 500, marginBottom: 2 }}>{script.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: 'monospace' }}>{script.filename}</div>
</div>
</div>
<button
onClick={onDelete}
title="Delete"
style={{
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
border: 'none',
borderRadius: 4,
color: 'var(--text-secondary)',
cursor: 'pointer',
}}
>
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<span style={{
padding: '4px 8px',
background: `${getLanguageColor(script.language)}15`,
borderRadius: 4,
fontSize: 11,
color: getLanguageColor(script.language),
fontWeight: 500,
}}>
{script.language || 'Unknown'}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
Updated: {formatDate(script.updated_at)}
</div>
</div>
)
}
+136 -2
View File
@@ -1,8 +1,142 @@
import { useState } from 'react'
import { useTheme } from '@/context/ThemeContext'
import { Save, RotateCcw } from 'lucide-react'
export default function Settings() {
const { theme, setTheme } = useTheme()
const [siteName, setSiteName] = useState('TaskPool')
const [saved, setSaved] = useState(false)
const handleSave = async () => {
// Save settings
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const handleReset = () => {
setSiteName('TaskPool')
}
return (
<div>
<div style={{ maxWidth: 800 }}>
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24 }}>Settings</h1>
<p style={{ color: 'var(--text-secondary)' }}>Settings coming soon...</p>
{/* Appearance */}
<section style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 16, fontWeight: 500, marginBottom: 16, color: 'var(--text-secondary)' }}>Appearance</h2>
<div style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: 16,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 500, marginBottom: 4 }}>Theme</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Select your preferred color scheme</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => setTheme('light')}
style={{
padding: '8px 16px',
background: theme === 'light' ? 'var(--bg-accent)' : 'var(--bg-tertiary)',
border: 'none',
borderRadius: 6,
color: theme === 'light' ? 'white' : 'var(--text-secondary)',
cursor: 'pointer',
fontSize: 13,
}}
>
Light
</button>
<button
onClick={() => setTheme('dark')}
style={{
padding: '8px 16px',
background: theme === 'dark' ? 'var(--bg-accent)' : 'var(--bg-tertiary)',
border: 'none',
borderRadius: 6,
color: theme === 'dark' ? 'white' : 'var(--text-secondary)',
cursor: 'pointer',
fontSize: 13,
}}
>
Dark
</button>
</div>
</div>
</div>
</section>
{/* Site Settings */}
<section style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 16, fontWeight: 500, marginBottom: 16, color: 'var(--text-secondary)' }}>Site</h2>
<div style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: 16,
}}>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8 }}>Site Name</label>
<input
type="text"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
style={{
width: '100%',
padding: '10px 12px',
background: 'var(--bg-primary)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--text-primary)',
fontSize: 14,
}}
/>
</div>
</div>
</section>
{/* Actions */}
<div style={{ display: 'flex', gap: 12 }}>
<button
onClick={handleSave}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '10px 16px',
background: 'var(--bg-accent)',
border: 'none',
borderRadius: 6,
color: 'white',
cursor: 'pointer',
fontSize: 14,
}}
>
<Save size={16} strokeWidth={1.5} />
{saved ? 'Saved!' : 'Save Changes'}
</button>
<button
onClick={handleReset}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '10px 16px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--text-secondary)',
cursor: 'pointer',
fontSize: 14,
}}
>
<RotateCcw size={16} strokeWidth={1.5} />
Reset
</button>
</div>
</div>
)
}
+228 -2
View File
@@ -1,8 +1,234 @@
import { useState } from 'react'
import { useTasks, useDeleteTask, useExecuteTask, useTags } from '@/api/hooks'
import { Play, Pause, Trash2, Plus, Filter, RefreshCw } from 'lucide-react'
import type { Task } from '@/api/types'
export default function Tasks() {
const [filter, setFilter] = useState<{ status?: string; tag?: string }>({})
const { data: tasks, isLoading, refetch } = useTasks(filter)
const { data: tags } = useTags()
const deleteTask = useDeleteTask()
const executeTask = useExecuteTask()
const handleDelete = async (id: number) => {
if (confirm('Delete this task?')) {
await deleteTask.mutateAsync(id)
}
}
const handleExecute = async (id: number) => {
await executeTask.mutateAsync(id)
}
if (isLoading) {
return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Loading...</div>
}
return (
<div>
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24 }}>Tasks</h1>
<p style={{ color: 'var(--text-secondary)' }}>Task management coming soon...</p>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1 style={{ fontSize: 24, fontWeight: 600 }}>Tasks</h1>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => refetch()}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 12px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--text-secondary)',
cursor: 'pointer',
fontSize: 13,
}}
>
<RefreshCw size={14} strokeWidth={1.5} />
Refresh
</button>
<button
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 12px',
background: 'var(--bg-accent)',
border: 'none',
borderRadius: 6,
color: 'white',
cursor: 'pointer',
fontSize: 13,
}}
>
<Plus size={14} strokeWidth={1.5} />
Add Task
</button>
</div>
</div>
{/* Filters */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<select
value={filter.status || ''}
onChange={(e) => setFilter(f => ({ ...f, status: e.target.value || undefined }))}
style={{
padding: '8px 12px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--text-primary)',
fontSize: 13,
}}
>
<option value="">All Status</option>
<option value="enabled">Enabled</option>
<option value="disabled">Disabled</option>
</select>
{tags && tags.length > 0 && (
<select
value={filter.tag || ''}
onChange={(e) => setFilter(f => ({ ...f, tag: e.target.value || undefined }))}
style={{
padding: '8px 12px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--text-primary)',
fontSize: 13,
}}
>
<option value="">All Tags</option>
{tags.map(tag => <option key={tag} value={tag}>{tag}</option>)}
</select>
)}
</div>
{/* Table */}
<div style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--bg-tertiary)' }}>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>Name</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>Cron</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>Status</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>Language</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>Tags</th>
<th style={{ padding: '12px 16px', textAlign: 'right', fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>Actions</th>
</tr>
</thead>
<tbody>
{tasks?.map(task => (
<TaskRow
key={task.id}
task={task}
onDelete={() => handleDelete(task.id)}
onExecute={() => handleExecute(task.id)}
/>
))}
{(!tasks || tasks.length === 0) && (
<tr>
<td colSpan={6} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
No tasks found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
function TaskRow({ task, onDelete, onExecute }: { task: Task; onDelete: () => void; onExecute: () => void }) {
return (
<tr style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '12px 16px' }}>
<div style={{ fontWeight: 500 }}>{task.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{task.script}</div>
</td>
<td style={{ padding: '12px 16px', fontSize: 13, color: 'var(--text-secondary)', fontFamily: 'monospace' }}>
{task.cron}
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '4px 8px',
borderRadius: 4,
fontSize: 12,
background: task.status === 'enabled' ? '#22c55e15' : '#f59e0b15',
color: task.status === 'enabled' ? '#22c55e' : '#f59e0b',
}}>
{task.status === 'enabled' ? 'Enabled' : 'Disabled'}
</span>
</td>
<td style={{ padding: '12px 16px', fontSize: 13, color: 'var(--text-secondary)' }}>
{task.language || '-'}
</td>
<td style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{task.tags?.map(tag => (
<span key={tag} style={{
padding: '2px 6px',
background: 'var(--bg-tertiary)',
borderRadius: 3,
fontSize: 11,
color: 'var(--text-secondary)',
}}>
{tag}
</span>
))}
</div>
</td>
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<button
onClick={onExecute}
title="Run now"
style={{
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
border: 'none',
borderRadius: 4,
color: 'var(--text-secondary)',
cursor: 'pointer',
}}
>
<Play size={14} strokeWidth={1.5} />
</button>
<button
onClick={onDelete}
title="Delete"
style={{
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
border: 'none',
borderRadius: 4,
color: 'var(--text-secondary)',
cursor: 'pointer',
}}
>
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
</td>
</tr>
)
}
+139 -3
View File
@@ -1,8 +1,144 @@
import { useEffect, useRef, useState } from 'react'
import { Terminal as XTerminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { Maximize2, Minimize2, RefreshCw } from 'lucide-react'
export default function Terminal() {
const terminalRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<XTerminal | null>(null)
const [isFullscreen, setIsFullscreen] = useState(false)
useEffect(() => {
if (!terminalRef.current || xtermRef.current) return
const term = new XTerminal({
theme: {
background: '#0a0a0a',
foreground: '#fafafa',
cursor: '#3b82f6',
cursorAccent: '#0a0a0a',
selectionBackground: '#3b82f630',
},
fontFamily: 'JetBrains Mono, Menlo, monospace',
fontSize: 14,
lineHeight: 1.4,
cursorBlink: true,
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(terminalRef.current)
fitAddon.fit()
term.writeln('\x1b[1;34mTaskPool Terminal\x1b[0m')
term.writeln('Type "help" for available commands')
term.writeln('')
term.write('$ ')
term.onKey(({ key, domEvent }) => {
const char = domEvent.key
if (char === 'Enter') {
term.writeln('')
term.write('$ ')
} else if (char === 'Backspace') {
term.write('\b \b')
} else {
term.write(key)
}
})
xtermRef.current = term
const handleResize = () => fitAddon.fit()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
term.dispose()
xtermRef.current = null
}
}, [])
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen)
setTimeout(() => {
if (xtermRef.current) {
const fitAddon = new FitAddon()
xtermRef.current.loadAddon(fitAddon)
fitAddon.fit()
}
}, 100)
}
return (
<div>
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24 }}>Terminal</h1>
<p style={{ color: 'var(--text-secondary)' }}>Web terminal coming soon...</p>
<div style={{
height: isFullscreen ? 'calc(100vh - 120px)' : 600,
display: 'flex',
flexDirection: 'column',
transition: 'height 0.2s',
}}>
{/* Header */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
}}>
<h1 style={{ fontSize: 24, fontWeight: 600 }}>Terminal</h1>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => xtermRef.current?.clear()}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 12px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--text-secondary)',
cursor: 'pointer',
fontSize: 13,
}}
>
<RefreshCw size={14} strokeWidth={1.5} />
Clear
</button>
<button
onClick={toggleFullscreen}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 12px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--text-secondary)',
cursor: 'pointer',
fontSize: 13,
}}
>
{isFullscreen ? <Minimize2 size={14} strokeWidth={1.5} /> : <Maximize2 size={14} strokeWidth={1.5} />}
{isFullscreen ? 'Exit' : 'Fullscreen'}
</button>
</div>
</div>
{/* Terminal Container */}
<div
ref={terminalRef}
style={{
flex: 1,
background: '#0a0a0a',
border: '1px solid var(--border)',
borderRadius: 8,
padding: 12,
overflow: 'hidden',
}}
/>
</div>
)
}
+45 -24
View File
@@ -1,67 +1,88 @@
import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router'
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
import Layout from '@/components/Layout'
import Dashboard from '@/pages/Dashboard'
import Tasks from '@/pages/Tasks'
import Scripts from '@/pages/Scripts'
import Logs from '@/pages/Logs'
import Settings from '@/pages/Settings'
import Interconnect from '@/pages/Interconnect'
import Terminal from '@/pages/Terminal'
import Settings from '@/pages/Settings'
import Login from '@/pages/Login'
const rootRoute = createRootRoute({
component: () => <Outlet />,
})
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
component: Login,
})
const layoutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: Layout,
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
const dashboardRoute = createRoute({
getParentRoute: () => layoutRoute,
path: '/',
component: Dashboard,
})
const tasksRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => layoutRoute,
path: '/tasks',
component: Tasks,
})
const scriptsRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => layoutRoute,
path: '/scripts',
component: Scripts,
})
const logsRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => layoutRoute,
path: '/logs',
component: Logs,
})
const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/settings',
component: Settings,
})
const interconnectRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => layoutRoute,
path: '/interconnect',
component: Interconnect,
})
const terminalRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => layoutRoute,
path: '/terminal',
component: Terminal,
})
export const routeTree = rootRoute.addChildren([
indexRoute,
tasksRoute,
scriptsRoute,
logsRoute,
settingsRoute,
interconnectRoute,
terminalRoute,
const settingsRoute = createRoute({
getParentRoute: () => layoutRoute,
path: '/settings',
component: Settings,
})
const routeTree = rootRoute.addChildren([
loginRoute,
layoutRoute.addChildren([
dashboardRoute,
tasksRoute,
scriptsRoute,
logsRoute,
interconnectRoute,
terminalRoute,
settingsRoute,
]),
])
export const router = createRouter({ routeTree })
export const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}