feat: add Monaco editor, API error handling, and CI/CD
Build and Deploy / build (push) Failing after 32s
Build and Deploy / docker (push) Has been skipped
Build and Deploy / deploy (push) Has been skipped

- Monaco Editor: script editor with syntax highlighting
- API: error handling with ApiError class and token management
- CI/CD: Gitea Actions workflow for build, Docker, and deploy
- Docker: multi-stage build with nginx
- nginx.conf: SPA fallback and API proxy
This commit is contained in:
2026-07-20 17:00:21 +00:00
parent 2f76060c39
commit 6ffa2ecdfb
9 changed files with 686 additions and 197 deletions
+84 -29
View File
@@ -1,21 +1,40 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import * as api from '@/api/endpoints'
import * as api from './index'
import type * as types from './types'
// Auth
// Re-export types
export type * from './types'
// Auth hooks
export function useUser() {
return useQuery({
queryKey: ['user'],
queryFn: api.getCurrentUser,
retry: false,
staleTime: 5 * 60 * 1000,
})
}
// Dashboard
export function useLogin() {
return useMutation({
mutationFn: ({ username, password }: { username: string; password: string }) =>
api.login(username, password),
})
}
export function useLogout() {
return useMutation({
mutationFn: api.logout,
})
}
// Dashboard hooks
export function useMonitorStats() {
return useQuery({
queryKey: ['monitor'],
queryFn: api.getMonitor,
queryFn: api.getMonitorStats,
refetchInterval: 5000,
retry: 2,
})
}
@@ -27,8 +46,8 @@ export function useTaskStats() {
})
}
// Tasks
export function useTasks(params?: { status?: string; tag?: string }) {
// Task hooks
export function useTasks(params?: Record<string, string>) {
return useQuery({
queryKey: ['tasks', params],
queryFn: () => api.getTasks(params),
@@ -47,15 +66,17 @@ export function useCreateTask() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.createTask,
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tasks'] })
qc.invalidateQueries({ queryKey: ['taskStats'] })
},
})
}
export function useUpdateTask() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Parameters<typeof api.updateTask>[1] }) =>
api.updateTask(id, data),
mutationFn: ({ id, data }: { id: number; data: any }) => api.updateTask(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
})
}
@@ -64,7 +85,10 @@ export function useDeleteTask() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.deleteTask,
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tasks'] })
qc.invalidateQueries({ queryKey: ['taskStats'] })
},
})
}
@@ -76,7 +100,14 @@ export function useExecuteTask() {
})
}
// Scripts
export function useTags() {
return useQuery({
queryKey: ['tags'],
queryFn: api.getTags,
})
}
// Script hooks
export function useScripts() {
return useQuery({
queryKey: ['scripts'],
@@ -100,6 +131,14 @@ export function useCreateScript() {
})
}
export function useUpdateScript() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: number; data: any }) => api.updateScript(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['scripts'] }),
})
}
export function useDeleteScript() {
const qc = useQueryClient()
return useMutation({
@@ -108,15 +147,39 @@ export function useDeleteScript() {
})
}
// Logs
export function useLogs(params?: { task_id?: number; status?: string; page?: number }) {
// Env hooks
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'] }),
})
}
// Log hooks
export function useLogs(params?: Record<string, string>) {
return useQuery({
queryKey: ['logs', params],
queryFn: () => api.getLogs(params),
})
}
// Interconnect
// Node hooks
export function useNodes() {
return useQuery({
queryKey: ['nodes'],
@@ -141,26 +204,18 @@ export function useDeleteNode() {
})
}
// Env
export function useEnvs() {
// Settings hooks
export function useSettings() {
return useQuery({
queryKey: ['envs'],
queryFn: api.getEnvs,
queryKey: ['settings'],
queryFn: api.getSettings,
})
}
export function useCreateEnv() {
export function useUpdateSettings() {
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'] }),
mutationFn: api.updateSettings,
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
})
}
+159 -18
View File
@@ -2,32 +2,173 @@ const BASE_URL = (window as any).__BASE_URL__ || ''
const API_VERSION = (window as any).__API_VERSION__ || '/api/v1'
const API_BASE_URL = BASE_URL + API_VERSION
export class ApiError extends Error {
code: number
data?: any
constructor(message: string, code: number, data?: any) {
super(message)
this.name = 'ApiError'
this.code = code
this.data = data
}
}
interface ApiResponse<T> {
code: number
msg: string
message: string
data: T
}
export async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE_URL}${url}`, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options?.headers
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const token = localStorage.getItem('token')
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((options?.headers as Record<string, string>) || {}),
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
try {
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
headers,
})
// Handle non-JSON responses
const contentType = response.headers.get('content-type')
if (!contentType?.includes('application/json')) {
if (response.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
throw new ApiError('Unauthorized', 401)
}
if (!response.ok) {
throw new ApiError(`HTTP ${response.status}`, response.status)
}
return {} as T
}
const result: ApiResponse<T> = await response.json()
// Business error
if (result.code !== 0 && result.code !== 200) {
throw new ApiError(result.message || 'Request failed', result.code, result.data)
}
return result.data
} catch (error) {
if (error instanceof ApiError) {
throw error
}
// Network error
throw new ApiError(
error instanceof Error ? error.message : 'Network error',
0
)
}
}
// Auth
export const login = async (username: string, password: string) => {
const data = await request<{ token: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
localStorage.setItem('token', data.token)
return data
}
const json: ApiResponse<T> = await res.json()
if (json.code === 401) {
window.location.href = BASE_URL + '/login'
throw new Error(json.msg || 'Unauthorized')
export const logout = async () => {
try {
await request('/auth/logout', { method: 'POST' })
} finally {
localStorage.removeItem('token')
}
}
if (json.code !== 200) {
throw new Error(json.msg || 'Request failed')
}
export const getCurrentUser = () => request<{ id: number; username: string; role: string }>('/auth/me')
return json.data
}
// Dashboard
export const getMonitorStats = () => request<any>('/monitor')
export const getTaskStats = () => request<{ total: number; running: number; scheduled: number }>('/taskstats')
export const getSentence = () => request<{ content: string; author: string }>('/sentence')
// Tasks
export const getTasks = (params?: Record<string, string>) => {
const query = params ? '?' + new URLSearchParams(params).toString() : ''
return request<any[]>(`/tasks${query}`)
}
export const getTask = (id: number) => request<any>(`/tasks/${id}`)
export const createTask = (data: any) => request<any>('/tasks', {
method: 'POST',
body: JSON.stringify(data),
})
export const updateTask = (id: number, data: any) => request<any>(`/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<any[]>('/scripts')
export const getScript = (id: number) => request<any>(`/scripts/${id}`)
export const createScript = (data: any) => request<any>('/scripts', {
method: 'POST',
body: JSON.stringify(data),
})
export const updateScript = (id: number, data: any) => request<any>(`/scripts/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
export const deleteScript = (id: number) => request<void>(`/scripts/${id}`, { method: 'DELETE' })
// Environment Variables
export const getEnvs = () => request<any[]>('/env')
export const createEnv = (data: any) => request<any>('/env', {
method: 'POST',
body: JSON.stringify(data),
})
export const deleteEnv = (id: number) => request<void>(`/env/${id}`, { method: 'DELETE' })
// Logs
export const getLogs = (params?: Record<string, string>) => {
const query = params ? '?' + new URLSearchParams(params).toString() : ''
return request<any[]>(`/logs${query}`)
}
// Interconnect Nodes
export const getNodes = () => request<any[]>('/interconnect/nodes')
export const createNode = (data: any) => request<any>('/interconnect/nodes', {
method: 'POST',
body: JSON.stringify(data),
})
export const deleteNode = (id: string) => request<void>(`/interconnect/nodes/${id}`, { method: 'DELETE' })
// Settings
export const getSettings = () => request<any>('/settings')
export const updateSettings = (data: any) => request<any>('/settings', {
method: 'PUT',
body: JSON.stringify(data),
})
export { request }
+208 -128
View File
@@ -1,166 +1,246 @@
import { useScripts, useDeleteScript } from '@/api/hooks'
import { Plus, Trash2, FileCode, RefreshCw } from 'lucide-react'
import type { Script } from '@/api/types'
import { useState } from 'react'
import Editor from '@monaco-editor/react'
import { useScripts, useCreateScript, useDeleteScript } from '@/api/hooks'
import { Plus, Trash2, Save, FileCode, Play, FolderOpen } from 'lucide-react'
const LANGUAGES = [
{ id: 'python', name: 'Python', extension: '.py', monaco: 'python' },
{ id: 'javascript', name: 'JavaScript', extension: '.js', monaco: 'javascript' },
{ id: 'typescript', name: 'TypeScript', extension: '.ts', monaco: 'typescript' },
{ id: 'bash', name: 'Bash', extension: '.sh', monaco: 'shell' },
{ id: 'go', name: 'Go', extension: '.go', monaco: 'go' },
]
export default function Scripts() {
const { data: scripts, isLoading, refetch } = useScripts()
const createScript = useCreateScript()
const deleteScript = useDeleteScript()
const [selectedScript, setSelectedScript] = useState<any>(null)
const [code, setCode] = useState('')
const [language, setLanguage] = useState('python')
const [name, setName] = useState('')
const handleNewScript = () => {
setSelectedScript(null)
setCode('# New script\nprint("Hello World")')
setName('untitled')
setLanguage('python')
}
const handleSave = async () => {
await createScript.mutateAsync({
name,
filename: name + LANGUAGES.find(l => l.id === language)?.extension,
content: code,
language,
})
refetch()
}
const handleDelete = async (id: number) => {
if (confirm('Delete this script?')) {
await deleteScript.mutateAsync(id)
if (selectedScript?.id === id) {
setSelectedScript(null)
setCode('')
}
refetch()
}
}
const handleSelectScript = (script: any) => {
setSelectedScript(script)
setCode(script.content || '')
setName(script.name)
setLanguage(script.language?.toLowerCase() || 'python')
}
if (isLoading) {
return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Loading...</div>
}
return (
<div>
{/* 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 }}>
<div style={{ display: 'flex', height: 'calc(100vh - 140px)', gap: 16 }}>
{/* Sidebar */}
<div style={{
width: 240,
flexShrink: 0,
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
display: 'flex',
flexDirection: 'column',
}}>
{/* Header */}
<div style={{
padding: 12,
borderBottom: '1px solid var(--border)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<span style={{ fontWeight: 500, fontSize: 13 }}>Scripts</span>
<button
onClick={() => refetch()}
onClick={handleNewScript}
title="New script"
style={{
width: 28,
height: 28,
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',
justifyContent: 'center',
background: 'var(--bg-accent)',
border: 'none',
borderRadius: 6,
borderRadius: 4,
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>
{/* List */}
<div style={{ flex: 1, overflow: 'auto', padding: 8 }}>
{scripts?.map(script => (
<div
key={script.id}
onClick={() => handleSelectScript(script)}
style={{
padding: '8px 12px',
borderRadius: 4,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 8,
background: selectedScript?.id === script.id ? 'var(--bg-tertiary)' : 'transparent',
marginBottom: 2,
}}
>
<FileCode size={14} strokeWidth={1.5} style={{ color: 'var(--text-muted)' }} />
<span style={{ fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{script.name}
</span>
</div>
))}
{(!scripts || scripts.length === 0) && (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text-muted)', fontSize: 12 }}>
No scripts
</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,
{/* Editor */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
overflow: 'hidden',
}}>
{/* Toolbar */}
<div style={{
padding: '8px 12px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
gap: 12,
}}>
{script.language || 'Unknown'}
</span>
</div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Script name"
style={{
padding: '6px 10px',
background: 'var(--bg-primary)',
border: '1px solid var(--border)',
borderRadius: 4,
color: 'var(--text-primary)',
fontSize: 13,
width: 200,
}}
/>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
style={{
padding: '6px 10px',
background: 'var(--bg-primary)',
border: '1px solid var(--border)',
borderRadius: 4,
color: 'var(--text-primary)',
fontSize: 13,
}}
>
{LANGUAGES.map(lang => (
<option key={lang.id} value={lang.id}>{lang.name}</option>
))}
</select>
<div style={{ flex: 1 }} />
{selectedScript && (
<button
onClick={() => handleDelete(selectedScript.id)}
title="Delete"
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '6px 10px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: 4,
color: 'var(--text-secondary)',
cursor: 'pointer',
fontSize: 12,
}}
>
<Trash2 size={12} strokeWidth={1.5} />
Delete
</button>
)}
<button
onClick={handleSave}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '6px 12px',
background: 'var(--bg-accent)',
border: 'none',
borderRadius: 4,
color: 'white',
cursor: 'pointer',
fontSize: 12,
}}
>
<Save size={12} strokeWidth={1.5} />
Save
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
Updated: {formatDate(script.updated_at)}
{/* Monaco Editor */}
<div style={{ flex: 1 }}>
<Editor
height="100%"
language={LANGUAGES.find(l => l.id === language)?.monaco || 'python'}
value={code}
onChange={(value) => setCode(value || '')}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
fontFamily: 'JetBrains Mono, Menlo, monospace',
lineNumbers: 'on',
scrollBeyondLastLine: false,
padding: { top: 12 },
}}
/>
</div>
</div>
</div>
)