Initial commit
This commit is contained in:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>arma3-frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1949
frontend/package-lock.json
generated
Normal file
1949
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "arma3-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
28
frontend/src/App.tsx
Normal file
28
frontend/src/App.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Settings from './pages/Settings'
|
||||
import Configs from './pages/Configs'
|
||||
import ConfigEditor from './pages/ConfigEditor'
|
||||
import Modlists from './pages/Modlists'
|
||||
import ModlistEditor from './pages/ModlistEditor'
|
||||
import Logs from './pages/Logs'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/configs" element={<Configs />} />
|
||||
<Route path="/configs/:name" element={<ConfigEditor />} />
|
||||
<Route path="/modlists" element={<Modlists />} />
|
||||
<Route path="/modlists/:id" element={<ModlistEditor />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
91
frontend/src/api/client.ts
Normal file
91
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { ServerSettings, ServerPaths, ConfigInfo, Modlist, ModlistListItem, ModEntry } from '../types'
|
||||
|
||||
const BASE = '/api'
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
...options,
|
||||
})
|
||||
if (res.status === 204) return undefined as T
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.error || res.statusText)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => request<ServerSettings>('/server/settings'),
|
||||
update: (data: Partial<ServerSettings>) => request<ServerSettings>('/server/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
start: () => request<{ status: string }>('/server/start', { method: 'POST' }),
|
||||
stop: () => request<{ status: string }>('/server/stop', { method: 'POST' }),
|
||||
restart: () => request<{ status: string }>('/server/restart', { method: 'POST' }),
|
||||
status: () => request<{ running: boolean }>('/server/status'),
|
||||
steamcmd: () => request<{ branch: string; user: string; platform: string }>('/server/steamcmd'),
|
||||
paths: () => request<ServerPaths>('/server/paths'),
|
||||
}
|
||||
|
||||
export const configsApi = {
|
||||
list: () => request<ConfigInfo[]>('/configs'),
|
||||
get: async (name: string) => {
|
||||
const r = await fetch(`${BASE}/configs/${name}`)
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({ error: r.statusText }))
|
||||
throw new Error(err.error || r.statusText)
|
||||
}
|
||||
return r.text()
|
||||
},
|
||||
create: (name: string, content?: string) => request<{ name: string }>('/configs', { method: 'POST', body: JSON.stringify({ name, content: content || '' }) }),
|
||||
update: (name: string, content: string) => request<{ status: string }>(`/configs/${name}`, { method: 'PUT', body: JSON.stringify({ content }) }),
|
||||
delete: (name: string) => request<void>(`/configs/${name}`, { method: 'DELETE' }),
|
||||
duplicate: (name: string, newName: string) => request<{ name: string }>(`/configs/${name}/duplicate`, { method: 'POST', body: JSON.stringify({ new_name: newName }) }),
|
||||
}
|
||||
|
||||
export interface ModStatus {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
downloaded: boolean
|
||||
}
|
||||
|
||||
export const modlistsApi = {
|
||||
list: () => request<ModlistListItem[]>('/modlists'),
|
||||
get: (id: string) => request<Modlist>(`/modlists/${id}`),
|
||||
create: (name: string) => request<Modlist>('/modlists', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
update: (id: string, data: { name: string; mods: ModEntry[] }) => request<Modlist>(`/modlists/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => request<void>(`/modlists/${id}`, { method: 'DELETE' }),
|
||||
duplicate: (id: string, name: string) => request<Modlist>(`/modlists/${id}/duplicate`, { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
importHtml: (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return fetch(`${BASE}/modlists/import`, { method: 'POST', body: form }).then(r => {
|
||||
if (!r.ok) return r.json().then(e => { throw new Error(e.error) })
|
||||
return r.json() as Promise<Modlist>
|
||||
})
|
||||
},
|
||||
checkMods: (id: string) => request<ModStatus[]>(`/modlists/${id}/check`),
|
||||
downloadMissing: (id: string) => request<{ status: string; count?: number }>(`/modlists/${id}/download-missing`, { method: 'POST' }),
|
||||
updateAll: (id: string) => request<{ status: string; count?: number }>(`/modlists/${id}/update-all`, { method: 'POST' }),
|
||||
}
|
||||
|
||||
function wsUrl(path: string): string {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${window.location.host}${path}`
|
||||
}
|
||||
|
||||
export const steamcmdApi = {
|
||||
updateGame: (branch: string, user: string) =>
|
||||
request<{ status: string }>('/server/steamcmd/update-game', { method: 'POST', body: JSON.stringify({ branch, user }) }),
|
||||
downloadMod: (modId: string) =>
|
||||
request<{ status: string }>('/server/steamcmd/download-mod', { method: 'POST', body: JSON.stringify({ mod_id: modId }) }),
|
||||
status: () => request<{ running: boolean }>('/server/steamcmd/status'),
|
||||
}
|
||||
|
||||
export const logsApi = {
|
||||
list: () => request<string[]>('/server/logs'),
|
||||
get: (file: string) => fetch(`${BASE}/server/logs/${file}`).then(r => r.text()),
|
||||
wsUrl: () => wsUrl('/ws/server/logs'),
|
||||
steamcmdWsUrl: () => wsUrl('/ws/steamcmd/logs'),
|
||||
rptWsUrl: () => wsUrl('/ws/server/rpt'),
|
||||
}
|
||||
30
frontend/src/components/ConfigEditor.tsx
Normal file
30
frontend/src/components/ConfigEditor.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import Editor from '@monaco-editor/react'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
language?: string
|
||||
}
|
||||
|
||||
export default function ConfigEditor({ value, onChange, language = 'plaintext' }: Props) {
|
||||
return (
|
||||
<div className="border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="500px"
|
||||
language={language}
|
||||
value={value}
|
||||
onChange={v => onChange(v ?? '')}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
frontend/src/components/Layout.tsx
Normal file
37
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const nav = [
|
||||
{ to: '/', label: 'Dashboard', icon: '⊞' },
|
||||
{ to: '/settings', label: 'Settings', icon: '🖥' },
|
||||
{ to: '/configs', label: 'Configs', icon: '⚙' },
|
||||
{ to: '/modlists', label: 'Modlists', icon: '📦' },
|
||||
{ to: '/logs', label: 'Logs', icon: '📋' },
|
||||
]
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
const loc = useLocation()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-neutral-950 text-neutral-100">
|
||||
<aside className="w-56 border-r border-neutral-800 p-4 flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold mb-4 tracking-tight">Arma3 Web</h1>
|
||||
{nav.map(item => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
loc.pathname === item.to || (item.to !== '/' && loc.pathname.startsWith(item.to))
|
||||
? 'bg-neutral-800 text-white'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-neutral-800/50'
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</aside>
|
||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
frontend/src/components/LiveTerminal.tsx
Normal file
87
frontend/src/components/LiveTerminal.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
interface Props {
|
||||
wsUrl: string
|
||||
}
|
||||
|
||||
export default function LiveTerminal({ wsUrl }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const fitRef = useRef<FitAddon | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const term = new Terminal({
|
||||
theme: { background: '#0a0a0a', foreground: '#e0e0e0', cursor: '#e0e0e0' },
|
||||
fontSize: 13,
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
rows: 30,
|
||||
})
|
||||
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(containerRef.current)
|
||||
fit.fit()
|
||||
term.write('Connecting...\r\n')
|
||||
|
||||
terminalRef.current = term
|
||||
fitRef.current = fit
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout>
|
||||
let disposed = false
|
||||
|
||||
function connect() {
|
||||
if (disposed) return
|
||||
ws = new WebSocket(wsUrl)
|
||||
ws.onopen = () => {
|
||||
if (disposed) { ws?.close(); return }
|
||||
term.clear()
|
||||
term.write('--- Connected ---\r\n')
|
||||
}
|
||||
ws.onmessage = (event) => {
|
||||
const line = event.data as string
|
||||
if (line === '[SERVER_PROCESS_EXITED]') {
|
||||
term.write('\r\n\x1b[31m--- Process exited ---\x1b[0m\r\n')
|
||||
} else if (line.startsWith('[STEAMCMD] SUCCESS:')) {
|
||||
term.write('\r\n\x1b[32m' + line + '\x1b[0m\r\n')
|
||||
} else if (line.startsWith('[STEAMCMD] ERROR:')) {
|
||||
term.write('\r\n\x1b[31m' + line + '\x1b[0m\r\n')
|
||||
} else if (line.startsWith('[STEAMCMD]')) {
|
||||
term.write('\r\n\x1b[33m' + line + '\x1b[0m\r\n')
|
||||
} else {
|
||||
term.writeln(line)
|
||||
}
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (disposed) return
|
||||
term.write('\r\n\x1b[33m--- Disconnected, reconnecting in 3s ---\x1b[0m\r\n')
|
||||
reconnectTimer = setTimeout(connect, 3000)
|
||||
}
|
||||
ws.onerror = () => ws?.close()
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
try { fit.fit() } catch {}
|
||||
})
|
||||
resizeObserver.observe(containerRef.current)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
clearTimeout(reconnectTimer)
|
||||
resizeObserver.disconnect()
|
||||
ws?.close()
|
||||
term.dispose()
|
||||
}
|
||||
}, [wsUrl])
|
||||
|
||||
return <div ref={containerRef} className="h-full min-h-[400px] bg-[#0a0a0a] rounded-lg" />
|
||||
}
|
||||
1
frontend/src/index.css
Normal file
1
frontend/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
18
frontend/src/main.tsx
Normal file
18
frontend/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
56
frontend/src/pages/ConfigEditor.tsx
Normal file
56
frontend/src/pages/ConfigEditor.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { configsApi } from '../api/client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Editor from '@monaco-editor/react'
|
||||
|
||||
export default function ConfigEditor() {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const qc = useQueryClient()
|
||||
const { data: content, isLoading } = useQuery({ queryKey: ['config', name], queryFn: () => configsApi.get(name!) })
|
||||
const [value, setValue] = useState('')
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
useEffect(() => { if (content !== undefined) { setValue(content); setDirty(false) } }, [content])
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () => configsApi.update(name!, value),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['config', name] }); setDirty(false) },
|
||||
})
|
||||
|
||||
if (isLoading) return <p className="text-neutral-500">Loading...</p>
|
||||
if (!name) return <p className="text-red-400">No config specified</p>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<Link to="/configs" className="text-sm text-blue-400 hover:underline">← Configs</Link>
|
||||
<h2 className="text-2xl font-semibold mt-1">{name}.cfg</h2>
|
||||
</div>
|
||||
<button onClick={() => saveMut.mutate()} disabled={!dirty || saveMut.isPending} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded text-sm">
|
||||
{saveMut.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="600px"
|
||||
language="plaintext"
|
||||
value={value}
|
||||
onChange={v => { setValue(v ?? ''); setDirty(true) }}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
frontend/src/pages/Configs.tsx
Normal file
59
frontend/src/pages/Configs.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { configsApi } from '../api/client'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function Configs() {
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const { data: configs, isLoading } = useQuery({ queryKey: ['configs'], queryFn: configsApi.list })
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => configsApi.create(name),
|
||||
onSuccess: (res) => { qc.invalidateQueries({ queryKey: ['configs'] }); setShowCreate(false); setName(''); navigate(`/configs/${res.name}`) },
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (name: string) => configsApi.delete(name),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['configs'] }),
|
||||
})
|
||||
|
||||
const duplicateMut = useMutation({
|
||||
mutationFn: (name: string) => configsApi.duplicate(name, `${name}-copy`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['configs'] }),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold">Configs</h2>
|
||||
<button onClick={() => setShowCreate(!showCreate)} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-md text-sm font-medium">+ New Config</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="mb-6 p-4 bg-neutral-900 border border-neutral-800 rounded-lg flex gap-3">
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="Config name" className="bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm flex-1" />
|
||||
<button onClick={() => createMut.mutate()} disabled={!name || createMut.isPending} className="px-4 py-2 bg-green-700 hover:bg-green-600 disabled:opacity-50 rounded text-sm">Create</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <p className="text-neutral-500">Loading...</p>}
|
||||
|
||||
<div className="space-y-2">
|
||||
{configs?.map(c => (
|
||||
<div key={c.name} className="flex items-center justify-between p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
||||
<Link to={`/configs/${c.name}`} className="font-medium hover:text-blue-400 transition-colors">{c.name}.cfg</Link>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-neutral-500">{(c.size / 1024).toFixed(1)} KB</span>
|
||||
<button onClick={() => duplicateMut.mutate(c.name)} className="text-neutral-400 hover:text-white">Duplicate</button>
|
||||
<button onClick={() => deleteMut.mutate(c.name)} className="text-red-500 hover:text-red-400">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{configs?.length === 0 && <p className="text-neutral-500 text-sm">No configs yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
frontend/src/pages/Dashboard.tsx
Normal file
66
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { settingsApi, configsApi, modlistsApi } from '../api/client'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: settingsApi.get })
|
||||
const { data: status } = useQuery({ queryKey: ['server-status'], queryFn: settingsApi.status, refetchInterval: 5000 })
|
||||
const { data: configs } = useQuery({ queryKey: ['configs'], queryFn: configsApi.list })
|
||||
const { data: modlists } = useQuery({ queryKey: ['modlists'], queryFn: modlistsApi.list })
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-6">Dashboard</h2>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<div className="text-sm text-neutral-500">Server</div>
|
||||
<div className={`text-xl font-bold ${status?.running ? 'text-green-400' : 'text-neutral-400'}`}>
|
||||
{status?.running ? 'Running' : 'Stopped'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<div className="text-sm text-neutral-500">Configs</div>
|
||||
<div className="text-xl font-bold text-blue-400">{configs?.length ?? 0}</div>
|
||||
</div>
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<div className="text-sm text-neutral-500">Modlists</div>
|
||||
<div className="text-xl font-bold text-blue-400">{modlists?.length ?? 0}</div>
|
||||
</div>
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<div className="text-sm text-neutral-500">Platform</div>
|
||||
<div className="text-xl font-bold">{settings?.platform ?? '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<h3 className="font-medium mb-3">Server Info</h3>
|
||||
{settings ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<Row label="IP:Port" value={settings.ip_port} />
|
||||
<Row label="Branch" value={settings.steam_branch} />
|
||||
<Row label="User" value={settings.steam_user} />
|
||||
<Row label="Active Config" value={settings.active_config || 'none'} />
|
||||
<Row label="Active Modlist" value={settings.active_modlist || 'none'} />
|
||||
</div>
|
||||
) : <p className="text-sm text-neutral-500">Loading...</p>}
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<h3 className="font-medium mb-3">Quick Actions</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link to="/settings" className="px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 rounded text-sm">Settings</Link>
|
||||
<Link to="/configs" className="px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 rounded text-sm">Configs</Link>
|
||||
<Link to="/modlists" className="px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 rounded text-sm">Modlists</Link>
|
||||
<Link to="/logs" className="px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 rounded text-sm">Logs</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return <div className="flex justify-between"><span className="text-neutral-500">{label}</span><span>{value}</span></div>
|
||||
}
|
||||
83
frontend/src/pages/Logs.tsx
Normal file
83
frontend/src/pages/Logs.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { logsApi } from '../api/client'
|
||||
import LiveTerminal from '../components/LiveTerminal'
|
||||
import { useState } from 'react'
|
||||
|
||||
type TermTab = 'server' | 'rpt' | 'steamcmd'
|
||||
|
||||
export default function Logs() {
|
||||
const { data: logFiles } = useQuery({ queryKey: ['logs'], queryFn: logsApi.list })
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const { data: fileContent } = useQuery({
|
||||
queryKey: ['log', selectedFile],
|
||||
queryFn: () => logsApi.get(selectedFile!),
|
||||
enabled: !!selectedFile,
|
||||
})
|
||||
const [termTab, setTermTab] = useState<TermTab>('server')
|
||||
|
||||
const wsUrl = termTab === 'server' ? logsApi.wsUrl() : termTab === 'rpt' ? logsApi.rptWsUrl() : logsApi.steamcmdWsUrl()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<h2 className="text-2xl font-semibold">Live Console</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-4 border-b border-neutral-800">
|
||||
<button
|
||||
onClick={() => setTermTab('server')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 ${
|
||||
termTab === 'server' ? 'border-blue-500 text-white' : 'border-transparent text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
Server Console
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTermTab('rpt')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 ${
|
||||
termTab === 'rpt' ? 'border-blue-500 text-white' : 'border-transparent text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
RPT Log
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTermTab('steamcmd')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 ${
|
||||
termTab === 'steamcmd' ? 'border-blue-500 text-white' : 'border-transparent text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
SteamCMD Output
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<LiveTerminal wsUrl={wsUrl} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-medium mb-3">Log Files</h3>
|
||||
<div className="flex gap-4">
|
||||
<div className="w-48 space-y-1">
|
||||
{logFiles?.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setSelectedFile(f)}
|
||||
className={`block w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
|
||||
selectedFile === f ? 'bg-neutral-800 text-white' : 'text-neutral-400 hover:text-white hover:bg-neutral-800/50'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
{(!logFiles || logFiles.length === 0) && <p className="text-neutral-500 text-sm">No log files</p>}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{fileContent ? (
|
||||
<pre className="bg-neutral-900 border border-neutral-800 rounded-lg p-4 text-sm font-mono overflow-auto max-h-96 whitespace-pre-wrap">{fileContent}</pre>
|
||||
) : (
|
||||
<p className="text-neutral-500 text-sm">Select a log file</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
175
frontend/src/pages/ModlistEditor.tsx
Normal file
175
frontend/src/pages/ModlistEditor.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { modlistsApi, steamcmdApi, logsApi } from '../api/client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import LiveTerminal from '../components/LiveTerminal'
|
||||
|
||||
interface ModEntry {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export default function ModlistEditor() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const qc = useQueryClient()
|
||||
const { data: modlist, isLoading } = useQuery({ queryKey: ['modlist', id], queryFn: () => modlistsApi.get(id!) })
|
||||
const [name, setName] = useState('')
|
||||
const [mods, setMods] = useState<ModEntry[]>([])
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [newId, setNewId] = useState('')
|
||||
const [newName, setNewName] = useState('')
|
||||
|
||||
const { data: steamcmdStatus } = useQuery({ queryKey: ['steamcmd-status'], queryFn: steamcmdApi.status, refetchInterval: 3000 })
|
||||
const steamcmdBusy = steamcmdStatus?.running ?? false
|
||||
|
||||
const { data: modStatuses } = useQuery({
|
||||
queryKey: ['modlist-check', id],
|
||||
queryFn: () => modlistsApi.checkMods(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: steamcmdBusy ? 3000 : false,
|
||||
})
|
||||
const downloadedMap = new Map(modStatuses?.map(m => [m.id, m.downloaded]) ?? [])
|
||||
|
||||
useEffect(() => {
|
||||
if (modlist) { setName(modlist.name); setMods(modlist.mods); setDirty(false) }
|
||||
}, [modlist])
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () => modlistsApi.update(id!, { name, mods }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['modlist', id] }); qc.invalidateQueries({ queryKey: ['modlists'] }); setDirty(false) },
|
||||
})
|
||||
|
||||
const toggleMod = (index: number) => {
|
||||
setMods(prev => prev.map((m, i) => i === index ? { ...m, enabled: !m.enabled } : m))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const removeMod = (index: number) => {
|
||||
setMods(prev => prev.filter((_, i) => i !== index))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const addMod = () => {
|
||||
if (!newId.trim() || !newName.trim()) return
|
||||
setMods(prev => [...prev, { id: newId.trim(), name: newName.trim(), enabled: true }])
|
||||
setNewId(''); setNewName('')
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const moveMod = (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction
|
||||
if (target < 0 || target >= mods.length) return
|
||||
setMods(prev => {
|
||||
const next = [...prev]
|
||||
;[next[index], next[target]] = [next[target], next[index]]
|
||||
return next
|
||||
})
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const [showSteamCMD, setShowSteamCMD] = useState(false)
|
||||
|
||||
const downloadMissingMut = useMutation({
|
||||
mutationFn: () => modlistsApi.downloadMissing(id!),
|
||||
onSuccess: () => { setShowSteamCMD(true); qc.invalidateQueries({ queryKey: ['steamcmd-status'] }) },
|
||||
})
|
||||
const updateAllMut = useMutation({
|
||||
mutationFn: () => modlistsApi.updateAll(id!),
|
||||
onSuccess: () => { setShowSteamCMD(true); qc.invalidateQueries({ queryKey: ['steamcmd-status'] }) },
|
||||
})
|
||||
|
||||
if (isLoading) return <p className="text-neutral-500">Loading...</p>
|
||||
if (!modlist) return <p className="text-red-400">Modlist not found</p>
|
||||
|
||||
const modCount = mods.length
|
||||
const missingCount = mods.filter(m => m.id && !downloadedMap.get(m.id)).length
|
||||
const hasWorkshopMods = mods.some(m => m.id)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<Link to="/modlists" className="text-sm text-blue-400 hover:underline">← Modlists</Link>
|
||||
<h2 className="text-2xl font-semibold mt-1">{modlist.name}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{hasWorkshopMods && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => downloadMissingMut.mutate()}
|
||||
disabled={missingCount === 0 || steamcmdBusy || downloadMissingMut.isPending}
|
||||
className="px-3 py-1.5 bg-green-800 hover:bg-green-700 disabled:opacity-50 rounded text-sm"
|
||||
>
|
||||
{downloadMissingMut.isPending ? 'Starting...' : `Download Missing (${missingCount})`}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateAllMut.mutate()}
|
||||
disabled={steamcmdBusy || updateAllMut.isPending}
|
||||
className="px-3 py-1.5 bg-amber-800 hover:bg-amber-700 disabled:opacity-50 rounded text-sm"
|
||||
>
|
||||
{updateAllMut.isPending ? 'Starting...' : `Update All (${modCount})`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => saveMut.mutate()} disabled={!dirty || saveMut.isPending} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded text-sm">
|
||||
{saveMut.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 text-xs text-neutral-500">
|
||||
{steamcmdBusy ? (
|
||||
<span className="text-yellow-400">SteamCMD running — status auto-refreshing</span>
|
||||
) : modStatuses && (
|
||||
<span>{downloadedMap.size} mods checked — {missingCount} missing</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="text-xs text-neutral-500 block mb-1">Name</label>
|
||||
<input value={name} onChange={e => { setName(e.target.value); setDirty(true) }} className="bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm w-full max-w-md" />
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex gap-2">
|
||||
<input value={newId} onChange={e => setNewId(e.target.value)} placeholder="Workshop ID" className="bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm w-40" />
|
||||
<input value={newName} onChange={e => setNewName(e.target.value)} placeholder="Mod name" className="bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm flex-1 max-w-xs" />
|
||||
<button onClick={addMod} disabled={!newId.trim() || !newName.trim()} className="px-3 py-2 bg-green-700 hover:bg-green-600 disabled:opacity-50 rounded text-sm">Add</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{mods.map((mod, i) => {
|
||||
const status = downloadedMap.get(mod.id)
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3 p-3 bg-neutral-900 border border-neutral-800 rounded-lg">
|
||||
<button onClick={() => moveMod(i, -1)} disabled={i === 0} className="text-neutral-500 hover:text-white disabled:opacity-30 text-lg leading-none">↑</button>
|
||||
<button onClick={() => moveMod(i, 1)} disabled={i === mods.length - 1} className="text-neutral-500 hover:text-white disabled:opacity-30 text-lg leading-none">↓</button>
|
||||
<input type="checkbox" checked={mod.enabled} onChange={() => toggleMod(i)} className="accent-blue-500" />
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<span className={`text-sm ${mod.enabled ? 'text-white' : 'text-neutral-500'}`}>{mod.name}</span>
|
||||
<span className="text-xs text-neutral-600">({mod.id})</span>
|
||||
</div>
|
||||
{mod.id && status !== undefined && (
|
||||
status
|
||||
? <span className="text-green-500 text-xs font-medium" title="Downloaded">✓</span>
|
||||
: <span className="text-red-500 text-xs font-medium" title="Not downloaded">✗</span>
|
||||
)}
|
||||
<button onClick={() => removeMod(i)} className="text-red-500 hover:text-red-400 text-sm">Remove</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{mods.length === 0 && <p className="text-neutral-500 text-sm py-4">No mods. Add one using the form above.</p>}
|
||||
</div>
|
||||
|
||||
{showSteamCMD && (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-neutral-400">SteamCMD Output</h4>
|
||||
<button onClick={() => setShowSteamCMD(false)} className="text-xs text-neutral-600 hover:text-neutral-400">Close</button>
|
||||
</div>
|
||||
<LiveTerminal wsUrl={logsApi.steamcmdWsUrl()} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
frontend/src/pages/Modlists.tsx
Normal file
80
frontend/src/pages/Modlists.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { modlistsApi } from '../api/client'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
export default function Modlists() {
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const { data: modlists, isLoading } = useQuery({ queryKey: ['modlists'], queryFn: modlistsApi.list })
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => modlistsApi.create(name),
|
||||
onSuccess: (res) => { qc.invalidateQueries({ queryKey: ['modlists'] }); setShowCreate(false); setName(''); navigate(`/modlists/${res.id}`) },
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => modlistsApi.delete(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['modlists'] }),
|
||||
})
|
||||
|
||||
const duplicateMut = useMutation({
|
||||
mutationFn: (id: string) => {
|
||||
const orig = modlists?.find(m => m.id === id)
|
||||
return modlistsApi.duplicate(id, `${orig?.name ?? 'copy'} (copy)`)
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['modlists'] }),
|
||||
})
|
||||
|
||||
const importMut = useMutation({
|
||||
mutationFn: (file: File) => modlistsApi.importHtml(file),
|
||||
onSuccess: (res) => { qc.invalidateQueries({ queryKey: ['modlists'] }); navigate(`/modlists/${res.id}`) },
|
||||
})
|
||||
|
||||
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) importMut.mutate(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input ref={fileRef} type="file" accept=".html" onChange={handleImport} className="hidden" />
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold">Modlists</h2>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => fileRef.current?.click()} disabled={importMut.isPending} className="px-4 py-2 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 rounded-md text-sm font-medium">Import HTML</button>
|
||||
<button onClick={() => setShowCreate(!showCreate)} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-md text-sm font-medium">+ New Modlist</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="mb-6 p-4 bg-neutral-900 border border-neutral-800 rounded-lg flex gap-3">
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="Modlist name" className="bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm flex-1" />
|
||||
<button onClick={() => createMut.mutate()} disabled={!name || createMut.isPending} className="px-4 py-2 bg-green-700 hover:bg-green-600 disabled:opacity-50 rounded text-sm">Create</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <p className="text-neutral-500">Loading...</p>}
|
||||
|
||||
<div className="space-y-2">
|
||||
{modlists?.map(m => (
|
||||
<div key={m.id} className="flex items-center justify-between p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
||||
<div>
|
||||
<Link to={`/modlists/${m.id}`} className="font-medium hover:text-blue-400 transition-colors">{m.name}</Link>
|
||||
<span className="text-xs text-neutral-500 ml-2">{m.mod_count} mods</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<button onClick={() => duplicateMut.mutate(m.id)} className="text-neutral-400 hover:text-white">Duplicate</button>
|
||||
<button onClick={() => deleteMut.mutate(m.id)} className="text-red-500 hover:text-red-400">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{modlists?.length === 0 && <p className="text-neutral-500 text-sm">No modlists yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
247
frontend/src/pages/Settings.tsx
Normal file
247
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { settingsApi, configsApi, modlistsApi, steamcmdApi, logsApi } from '../api/client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import ConfigEditor from '../components/ConfigEditor'
|
||||
import LiveTerminal from '../components/LiveTerminal'
|
||||
|
||||
type Tab = 'main' | 'cba' | 'ai' | 'difficulty'
|
||||
|
||||
export default function Settings() {
|
||||
const qc = useQueryClient()
|
||||
const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: settingsApi.get })
|
||||
const { data: configs } = useQuery({ queryKey: ['configs'], queryFn: configsApi.list })
|
||||
const { data: modlists } = useQuery({ queryKey: ['modlists'], queryFn: modlistsApi.list })
|
||||
const { data: paths } = useQuery({ queryKey: ['paths'], queryFn: settingsApi.paths })
|
||||
const { data: status } = useQuery({ queryKey: ['server-status'], queryFn: settingsApi.status, refetchInterval: 3000 })
|
||||
const [tab, setTab] = useState<Tab>('main')
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
const [ipPort, setIpPort] = useState('')
|
||||
const [params, setParams] = useState('')
|
||||
const [branch, setBranch] = useState('stable')
|
||||
const [user, setUser] = useState('anonymous')
|
||||
const [platform, setPlatform] = useState<'linux' | 'windows'>('linux')
|
||||
const [cba, setCba] = useState('')
|
||||
const [aiLevel, setAiLevel] = useState('')
|
||||
const [difficulty, setDifficulty] = useState('')
|
||||
const [activeConfig, setActiveConfig] = useState('')
|
||||
const [activeModlist, setActiveModlist] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setIpPort(settings.ip_port)
|
||||
setParams(settings.server_parameters)
|
||||
setBranch(settings.steam_branch)
|
||||
setUser(settings.steam_user)
|
||||
setPlatform(settings.platform)
|
||||
setCba(settings.cba_settings)
|
||||
setAiLevel(settings.ai_level_presets)
|
||||
setDifficulty(settings.difficulty_presets)
|
||||
setActiveConfig(settings.active_config)
|
||||
setActiveModlist(settings.active_modlist)
|
||||
}
|
||||
}, [settings])
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () => settingsApi.update({
|
||||
ip_port: ipPort,
|
||||
server_parameters: params,
|
||||
steam_branch: branch,
|
||||
steam_user: user,
|
||||
platform,
|
||||
cba_settings: cba,
|
||||
ai_level_presets: aiLevel,
|
||||
difficulty_presets: difficulty,
|
||||
active_config: activeConfig,
|
||||
active_modlist: activeModlist,
|
||||
}),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['settings'] }); setDirty(false) },
|
||||
})
|
||||
|
||||
const startMut = useMutation({ mutationFn: settingsApi.start, onSuccess: () => qc.invalidateQueries({ queryKey: ['server-status'] }) })
|
||||
const stopMut = useMutation({ mutationFn: settingsApi.stop, onSuccess: () => qc.invalidateQueries({ queryKey: ['server-status'] }) })
|
||||
const restartMut = useMutation({ mutationFn: settingsApi.restart, onSuccess: () => qc.invalidateQueries({ queryKey: ['server-status'] }) })
|
||||
|
||||
const [modId, setModId] = useState('')
|
||||
const [showSteamCMD, setShowSteamCMD] = useState(false)
|
||||
const { data: steamcmdStatus } = useQuery({ queryKey: ['steamcmd-status'], queryFn: steamcmdApi.status, refetchInterval: 3000 })
|
||||
|
||||
const updateGameMut = useMutation({
|
||||
mutationFn: () => steamcmdApi.updateGame(branch, user),
|
||||
onSuccess: () => { setShowSteamCMD(true); qc.invalidateQueries({ queryKey: ['steamcmd-status'] }) },
|
||||
})
|
||||
const downloadModMut = useMutation({
|
||||
mutationFn: () => steamcmdApi.downloadMod(modId),
|
||||
onSuccess: () => { setShowSteamCMD(true); qc.invalidateQueries({ queryKey: ['steamcmd-status'] }) },
|
||||
})
|
||||
|
||||
const handleSaveThen = async (action: () => Promise<unknown>) => {
|
||||
if (dirty) {
|
||||
await saveMut.mutateAsync()
|
||||
}
|
||||
await action()
|
||||
}
|
||||
|
||||
if (isLoading) return <p className="text-neutral-500">Loading...</p>
|
||||
|
||||
const tabs: { key: Tab; label: string; note?: string }[] = [
|
||||
{ key: 'main', label: 'Main' },
|
||||
{ key: 'cba', label: 'CBA Settings', note: 'userconfig/cba_settings.sqf' },
|
||||
{ key: 'ai', label: 'AI Level Presets', note: 'userconfig/CfgAILevelPresets.sqf' },
|
||||
{ key: 'difficulty', label: 'Difficulty Presets', note: 'userconfig/CfgDifficultyPresets.sqf' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold">Server Settings</h2>
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className={`text-sm px-2 py-0.5 rounded ${status?.running ? 'bg-green-900 text-green-300' : 'bg-neutral-800 text-neutral-400'}`}>
|
||||
{status?.running ? 'Running' : 'Stopped'}
|
||||
</span>
|
||||
<button onClick={() => handleSaveThen(() => startMut.mutateAsync())} disabled={status?.running || saveMut.isPending} className="px-3 py-1.5 bg-green-700 hover:bg-green-600 disabled:opacity-50 rounded text-sm">Start</button>
|
||||
<button onClick={() => handleSaveThen(() => stopMut.mutateAsync())} disabled={!status?.running || saveMut.isPending} className="px-3 py-1.5 bg-red-700 hover:bg-red-600 disabled:opacity-50 rounded text-sm">Stop</button>
|
||||
<button onClick={() => handleSaveThen(() => restartMut.mutateAsync())} disabled={!status?.running || saveMut.isPending} className="px-3 py-1.5 bg-yellow-700 hover:bg-yellow-600 disabled:opacity-50 rounded text-sm">Restart</button>
|
||||
<button onClick={() => saveMut.mutate()} disabled={!dirty || saveMut.isPending} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded text-sm">
|
||||
{saveMut.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-4 border-b border-neutral-800">
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 ${
|
||||
tab === t.key ? 'border-blue-500 text-white' : 'border-transparent text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
{t.note && <span className="ml-1.5 text-[10px] opacity-60">{t.note}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'main' ? (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div>
|
||||
<label className="text-xs text-neutral-500 block mb-1">IP:Port</label>
|
||||
<input value={ipPort} onChange={e => { setIpPort(e.target.value); setDirty(true) }} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-500 block mb-1">Server Parameters</label>
|
||||
<textarea value={params} onChange={e => { setParams(e.target.value); setDirty(true) }} rows={4} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm font-mono resize-none" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-neutral-500 block mb-1">Steam Branch</label>
|
||||
<select value={branch} onChange={e => { setBranch(e.target.value); setDirty(true) }} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm">
|
||||
<option value="stable">Stable</option>
|
||||
<option value="experimental">Experimental</option>
|
||||
<option value="creatordlc">Creator DLC</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-500 block mb-1">Steam User</label>
|
||||
<input value={user} onChange={e => { setUser(e.target.value); setDirty(true) }} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-500 block mb-1">Platform</label>
|
||||
<select value={platform} onChange={e => { setPlatform(e.target.value as 'linux' | 'windows'); setDirty(true) }} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm">
|
||||
<option value="linux">Linux</option>
|
||||
<option value="windows">Windows</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-neutral-500 block mb-1">Active Config</label>
|
||||
<select value={activeConfig} onChange={e => { setActiveConfig(e.target.value); setDirty(true) }} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm">
|
||||
<option value="">None</option>
|
||||
{configs?.map(c => <option key={c.name} value={c.name}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-500 block mb-1">Active Modlist</label>
|
||||
<select value={activeModlist} onChange={e => { setActiveModlist(e.target.value); setDirty(true) }} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm">
|
||||
<option value="">None</option>
|
||||
{modlists?.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="text-xs text-neutral-400">
|
||||
<summary className="cursor-pointer hover:text-neutral-200 mb-2">Server Paths</summary>
|
||||
<div className="space-y-1 bg-neutral-900 rounded p-3 border border-neutral-800 font-mono">
|
||||
<div>SERVERFILE_DIR: {paths?.serverfile_dir}</div>
|
||||
<div>MODS_DIR: {paths?.mods_dir}</div>
|
||||
<div>CFG_DIR: {paths?.cfg_dir}</div>
|
||||
<div>PROFILES_DIR: {paths?.profiles_dir}</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<hr className="border-neutral-800 my-6" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3">SteamCMD</h3>
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-neutral-500 block mb-1">Update Arma 3 Server</label>
|
||||
<button
|
||||
onClick={() => updateGameMut.mutate()}
|
||||
disabled={steamcmdStatus?.running || updateGameMut.isPending}
|
||||
className="px-4 py-2 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 rounded text-sm"
|
||||
>
|
||||
{updateGameMut.isPending ? 'Starting...' : 'Update Server'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-neutral-500 block mb-1">Download Workshop Mod</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={modId}
|
||||
onChange={e => setModId(e.target.value)}
|
||||
placeholder="Workshop mod ID"
|
||||
className="flex-1 bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => downloadModMut.mutate()}
|
||||
disabled={!modId || steamcmdStatus?.running || downloadModMut.isPending}
|
||||
className="px-4 py-2 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 rounded text-sm"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`inline-block mt-2 text-xs ${steamcmdStatus?.running ? 'text-yellow-400' : 'text-neutral-500'}`}>
|
||||
SteamCMD: {steamcmdStatus?.running ? 'Running' : 'Idle'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showSteamCMD && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-neutral-400">SteamCMD Output</h4>
|
||||
<button onClick={() => setShowSteamCMD(false)} className="text-xs text-neutral-600 hover:text-neutral-400">Close</button>
|
||||
</div>
|
||||
<LiveTerminal wsUrl={logsApi.steamcmdWsUrl()} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ConfigEditor
|
||||
value={tab === 'cba' ? cba : tab === 'ai' ? aiLevel : difficulty}
|
||||
onChange={v => {
|
||||
if (tab === 'cba') setCba(v)
|
||||
else if (tab === 'ai') setAiLevel(v)
|
||||
else setDifficulty(v)
|
||||
setDirty(true)
|
||||
}}
|
||||
language="plaintext"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
frontend/src/types/index.ts
Normal file
47
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export interface ServerSettings {
|
||||
ip_port: string
|
||||
server_parameters: string
|
||||
steam_branch: string
|
||||
steam_user: string
|
||||
platform: 'linux' | 'windows'
|
||||
cba_settings: string
|
||||
ai_level_presets: string
|
||||
difficulty_presets: string
|
||||
active_config: string
|
||||
active_modlist: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ModEntry {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface Modlist {
|
||||
id: string
|
||||
name: string
|
||||
mods: ModEntry[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ModlistListItem {
|
||||
id: string
|
||||
name: string
|
||||
mod_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ConfigInfo {
|
||||
name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface ServerPaths {
|
||||
serverfile_dir: string
|
||||
mods_dir: string
|
||||
cfg_dir: string
|
||||
profiles_dir: string
|
||||
}
|
||||
26
frontend/tsconfig.app.json
Normal file
26
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
frontend/tsconfig.node.json
Normal file
23
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user