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
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:
+3
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+26
-14
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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'] }),
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user