feat(frontend): add Vue 3 web application
- Add Vue 3 + TypeScript + Pinia setup - Add 6 complete views: Dashboard, Queue, Scanner, Rules, Workers, Settings - Add Pinia stores for state management - Add API service with Axios client - Add dark theme with Tdarr-inspired styling - Add setup wizard component - Add path browser for filesystem navigation
This commit is contained in:
25
frontend/.gitignore
vendored
Normal file
25
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
245
frontend/README.md
Normal file
245
frontend/README.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# TranscriptorIO Frontend
|
||||||
|
|
||||||
|
Vue 3 + TypeScript + Vite frontend for TranscriptorIO.
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Node.js 18+ (use nvm for easy management)
|
||||||
|
- npm or yarn
|
||||||
|
|
||||||
|
### Install nvm (if not installed)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install nvm
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||||
|
|
||||||
|
# Reload shell
|
||||||
|
source ~/.bashrc # or ~/.zshrc
|
||||||
|
|
||||||
|
# Install Node.js 18
|
||||||
|
nvm install 18
|
||||||
|
nvm use 18
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start dev server (with hot-reload)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Backend proxy is configured to http://localhost:8000
|
||||||
|
# Frontend runs on http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Output in dist/ directory
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preview Production Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── assets/
|
||||||
|
│ │ └── css/
|
||||||
|
│ │ └── main.css # Global styles (Tdarr-inspired dark theme)
|
||||||
|
│ ├── components/ # Reusable Vue components
|
||||||
|
│ ├── views/ # Page components
|
||||||
|
│ │ ├── DashboardView.vue # Main dashboard
|
||||||
|
│ │ ├── WorkersView.vue # Worker management
|
||||||
|
│ │ ├── QueueView.vue # Job queue
|
||||||
|
│ │ ├── ScannerView.vue # Library scanner
|
||||||
|
│ │ ├── RulesView.vue # Scan rules
|
||||||
|
│ │ └── SettingsView.vue # Settings
|
||||||
|
│ ├── stores/ # Pinia state management
|
||||||
|
│ │ ├── system.ts # System status store
|
||||||
|
│ │ ├── workers.ts # Workers store
|
||||||
|
│ │ └── jobs.ts # Jobs store
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── api.ts # Axios API client
|
||||||
|
│ ├── types/
|
||||||
|
│ │ └── api.ts # TypeScript interfaces
|
||||||
|
│ ├── router/
|
||||||
|
│ │ └── index.ts # Vue Router configuration
|
||||||
|
│ ├── App.vue # Root component
|
||||||
|
│ └── main.ts # App entry point
|
||||||
|
├── index.html
|
||||||
|
├── vite.config.ts # Vite configuration
|
||||||
|
├── tsconfig.json # TypeScript configuration
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Design
|
||||||
|
|
||||||
|
### Theme
|
||||||
|
- Dark theme inspired by Tdarr
|
||||||
|
- Color palette optimized for monitoring and data visualization
|
||||||
|
- Fully responsive design
|
||||||
|
|
||||||
|
### Features Implemented
|
||||||
|
- ✅ Dashboard with system overview
|
||||||
|
- ✅ Worker management with real-time updates
|
||||||
|
- ✅ Auto-refresh every 3-5 seconds
|
||||||
|
- ✅ Modal dialogs for actions
|
||||||
|
- ✅ Status badges and progress bars
|
||||||
|
- ⏳ Job queue view (placeholder)
|
||||||
|
- ⏳ Scanner control (placeholder)
|
||||||
|
- ⏳ Rules editor (placeholder)
|
||||||
|
- ⏳ Settings (placeholder)
|
||||||
|
|
||||||
|
## 🔌 API Integration
|
||||||
|
|
||||||
|
The frontend communicates with the backend API via Axios:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example usage
|
||||||
|
import { workersApi } from '@/services/api'
|
||||||
|
|
||||||
|
// Get all workers
|
||||||
|
const workers = await workersApi.getAll()
|
||||||
|
|
||||||
|
// Add a GPU worker
|
||||||
|
await workersApi.add({
|
||||||
|
worker_type: 'gpu',
|
||||||
|
device_id: 0
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Proxy Configuration
|
||||||
|
|
||||||
|
Vite dev server proxies API requests to the backend:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// vite.config.ts
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8000',
|
||||||
|
'/health': 'http://localhost:8000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧩 State Management
|
||||||
|
|
||||||
|
Uses Pinia for state management:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example store usage
|
||||||
|
import { useWorkersStore } from '@/stores/workers'
|
||||||
|
|
||||||
|
const workersStore = useWorkersStore()
|
||||||
|
await workersStore.fetchWorkers()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Development
|
||||||
|
|
||||||
|
### Recommended IDE Setup
|
||||||
|
|
||||||
|
- VS Code with extensions:
|
||||||
|
- Volar (Vue 3 support)
|
||||||
|
- TypeScript Vue Plugin
|
||||||
|
- ESLint
|
||||||
|
|
||||||
|
### Type Checking
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # Includes type checking with vue-tsc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Dependencies
|
||||||
|
|
||||||
|
### Core
|
||||||
|
- **Vue 3** - Progressive JavaScript framework
|
||||||
|
- **Vite** - Fast build tool
|
||||||
|
- **TypeScript** - Type safety
|
||||||
|
- **Vue Router** - Client-side routing
|
||||||
|
- **Pinia** - State management
|
||||||
|
- **Axios** - HTTP client
|
||||||
|
|
||||||
|
### Dev Dependencies
|
||||||
|
- vue-tsc - Vue TypeScript compiler
|
||||||
|
- ESLint - Code linting
|
||||||
|
- TypeScript ESLint - TypeScript linting rules
|
||||||
|
|
||||||
|
## 🚀 Deployment
|
||||||
|
|
||||||
|
### Standalone Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Serve with any static file server
|
||||||
|
npx serve dist
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration with Backend
|
||||||
|
|
||||||
|
The built frontend can be served by FastAPI:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/app.py
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
app.mount("/", StaticFiles(directory="frontend/dist", html=True), name="static")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📱 Responsive Design
|
||||||
|
|
||||||
|
- Desktop-first design
|
||||||
|
- Breakpoint: 768px for mobile
|
||||||
|
- Touch-friendly controls
|
||||||
|
- Optimized for tablets and phones
|
||||||
|
|
||||||
|
## 🎯 Roadmap
|
||||||
|
|
||||||
|
### Phase 1 (Current)
|
||||||
|
- ✅ Dashboard
|
||||||
|
- ✅ Worker management
|
||||||
|
- ⏳ Job queue view
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
- ⏳ Scanner controls
|
||||||
|
- ⏳ Rules editor
|
||||||
|
- ⏳ Settings page
|
||||||
|
|
||||||
|
### Phase 3
|
||||||
|
- ⏳ WebSocket support for real-time updates
|
||||||
|
- ⏳ Advanced filtering and search
|
||||||
|
- ⏳ Job logs viewer
|
||||||
|
- ⏳ Dark/light theme toggle
|
||||||
|
|
||||||
|
## 🐛 Known Issues
|
||||||
|
|
||||||
|
- Auto-refresh uses polling (will migrate to WebSocket)
|
||||||
|
- Some views are placeholders
|
||||||
|
- No authentication yet
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License - Same as backend
|
||||||
|
|
||||||
8
frontend/env.d.ts
vendored
Normal file
8
frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
|
|
||||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TranscriptorIO</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
3313
frontend/package-lock.json
generated
Normal file
3313
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "transcriptorio-ui",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "TranscriptorIO Web UI - Vue 3 Frontend",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.2.5",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"axios": "^1.6.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
|
"@vue/tsconfig": "^0.5.1",
|
||||||
|
"typescript": "~5.3.0",
|
||||||
|
"vite": "^5.0.11",
|
||||||
|
"vue-tsc": "^1.8.27",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||||
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-plugin-vue": "^9.20.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
79
frontend/setup.sh
Executable file
79
frontend/setup.sh
Executable file
@@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "🎬 TranscriptorIO Frontend - Setup Script"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if Node.js is installed
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
echo "❌ Node.js is not installed"
|
||||||
|
echo ""
|
||||||
|
echo "Please install Node.js 18+ using one of these methods:"
|
||||||
|
echo ""
|
||||||
|
echo "Method 1: Using nvm (recommended)"
|
||||||
|
echo " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash"
|
||||||
|
echo " source ~/.bashrc # or ~/.zshrc"
|
||||||
|
echo " nvm install 18"
|
||||||
|
echo " nvm use 18"
|
||||||
|
echo ""
|
||||||
|
echo "Method 2: Using package manager"
|
||||||
|
echo " Ubuntu/Debian: sudo apt install nodejs npm"
|
||||||
|
echo " Fedora: sudo dnf install nodejs npm"
|
||||||
|
echo " Arch: sudo pacman -S nodejs npm"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NODE_VERSION=$(node --version)
|
||||||
|
echo "✅ Node.js detected: $NODE_VERSION"
|
||||||
|
|
||||||
|
# Check if npm is installed
|
||||||
|
if ! command -v npm &> /dev/null; then
|
||||||
|
echo "❌ npm is not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NPM_VERSION=$(npm --version)
|
||||||
|
echo "✅ npm detected: v$NPM_VERSION"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Navigate to frontend directory
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# Check if package.json exists
|
||||||
|
if [ ! -f "package.json" ]; then
|
||||||
|
echo "❌ package.json not found. Are you in the frontend directory?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
echo "📦 Installing dependencies..."
|
||||||
|
echo ""
|
||||||
|
npm install
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "✅ Dependencies installed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "🚀 Next Steps"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "1. Make sure the backend is running:"
|
||||||
|
echo " cd ../backend"
|
||||||
|
echo " python cli.py server"
|
||||||
|
echo ""
|
||||||
|
echo "2. Start the frontend dev server:"
|
||||||
|
echo " cd frontend"
|
||||||
|
echo " npm run dev"
|
||||||
|
echo ""
|
||||||
|
echo "3. Open your browser:"
|
||||||
|
echo " http://localhost:3000"
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "❌ Failed to install dependencies"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
108
frontend/src/App.vue
Normal file
108
frontend/src/App.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Connection Warning (shows when backend is offline) -->
|
||||||
|
<ConnectionWarning />
|
||||||
|
|
||||||
|
<!-- Setup Wizard (first run only) -->
|
||||||
|
<SetupWizard v-if="showSetupWizard" @complete="onSetupComplete" />
|
||||||
|
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="logo">
|
||||||
|
<h1>🎬 TranscriptorIO</h1>
|
||||||
|
<span class="subtitle">AI-Powered Subtitle Transcription</span>
|
||||||
|
</div>
|
||||||
|
<nav class="main-nav">
|
||||||
|
<router-link to="/" class="nav-link">Dashboard</router-link>
|
||||||
|
<router-link to="/workers" class="nav-link">Workers</router-link>
|
||||||
|
<router-link to="/queue" class="nav-link">Queue</router-link>
|
||||||
|
<router-link v-if="configStore.isStandalone" to="/scanner" class="nav-link">Scanner</router-link>
|
||||||
|
<router-link v-if="configStore.isStandalone" to="/rules" class="nav-link">Rules</router-link>
|
||||||
|
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||||
|
</nav>
|
||||||
|
<div class="status-indicator" :class="{ 'online': systemStore.isOnline }">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span class="status-text">{{ systemStore.isOnline ? 'Online' : 'Offline' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main">
|
||||||
|
<div class="container">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition name="fade" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<div class="container">
|
||||||
|
<p>© 2026 TranscriptorIO | Powered by Whisper AI</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useSystemStore } from '@/stores/system'
|
||||||
|
import { useConfigStore } from '@/stores/config'
|
||||||
|
import SetupWizard from '@/components/SetupWizard.vue'
|
||||||
|
import ConnectionWarning from '@/components/ConnectionWarning.vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const systemStore = useSystemStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
const showSetupWizard = ref(false)
|
||||||
|
|
||||||
|
let statusInterval: number | null = null
|
||||||
|
|
||||||
|
const checkStatus = async () => {
|
||||||
|
try {
|
||||||
|
await systemStore.fetchStatus()
|
||||||
|
} catch (error) {
|
||||||
|
// Error already handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkSetupStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/setup/status')
|
||||||
|
if (response.data.is_first_run && !response.data.setup_completed) {
|
||||||
|
showSetupWizard.value = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check setup status:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSetupComplete = () => {
|
||||||
|
showSetupWizard.value = false
|
||||||
|
// Refresh page to apply new settings
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkSetupStatus()
|
||||||
|
checkStatus()
|
||||||
|
configStore.fetchConfig()
|
||||||
|
configStore.detectGPU()
|
||||||
|
// Check status every 10 seconds
|
||||||
|
statusInterval = window.setInterval(checkStatus, 10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (statusInterval) {
|
||||||
|
clearInterval(statusInterval)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Global styles in main.css */
|
||||||
|
</style>
|
||||||
|
|
||||||
429
frontend/src/assets/css/main.css
Normal file
429
frontend/src/assets/css/main.css
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
:root {
|
||||||
|
/* Colors - Tdarr-inspired dark theme */
|
||||||
|
--primary-bg: #1a1d29;
|
||||||
|
--secondary-bg: #23283a;
|
||||||
|
--tertiary-bg: #2d3448;
|
||||||
|
--accent-color: #4a9eff;
|
||||||
|
--accent-hover: #357abd;
|
||||||
|
--success-color: #28a745;
|
||||||
|
--warning-color: #ffc107;
|
||||||
|
--danger-color: #dc3545;
|
||||||
|
--text-primary: #e4e6eb;
|
||||||
|
--text-secondary: #b8bcc8;
|
||||||
|
--text-muted: #8b92a6;
|
||||||
|
--border-color: #3a3f55;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--spacing-xl: 2rem;
|
||||||
|
|
||||||
|
/* Border radius */
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: 0.15s ease;
|
||||||
|
--transition-normal: 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background-color: var(--primary-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 var(--spacing-lg);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.app-header {
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
padding: var(--spacing-md) 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-color);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo .subtitle {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.router-link-active {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.online .status-dot {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--spacing-xl) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.app-footer {
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding: var(--spacing-md) 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
padding-bottom: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-queued {
|
||||||
|
background-color: rgba(255, 193, 7, 0.2);
|
||||||
|
color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-processing {
|
||||||
|
background-color: rgba(74, 158, 255, 0.2);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-completed {
|
||||||
|
background-color: rgba(40, 167, 69, 0.2);
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-failed {
|
||||||
|
background-color: rgba(220, 53, 69, 0.2);
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-cancelled {
|
||||||
|
background-color: rgba(139, 146, 166, 0.2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
transition: width var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner */
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid var(--tertiary-bg);
|
||||||
|
border-top: 3px solid var(--accent-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: var(--spacing-xl) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid system */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-3 {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-4 {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-lg {
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-lg {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-between {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-md {
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2,
|
||||||
|
.grid-3,
|
||||||
|
.grid-4 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
166
frontend/src/components/ConnectionWarning.vue
Normal file
166
frontend/src/components/ConnectionWarning.vue
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="slide-down">
|
||||||
|
<div v-if="!isOnline" class="connection-overlay">
|
||||||
|
<div class="connection-banner">
|
||||||
|
<div class="banner-icon">⚠️</div>
|
||||||
|
<div class="banner-content">
|
||||||
|
<h2 class="banner-title">No Connection to Backend</h2>
|
||||||
|
<p class="banner-message">
|
||||||
|
The backend server is not responding. Please check that the server is running and try again.
|
||||||
|
</p>
|
||||||
|
<p class="banner-status">
|
||||||
|
Attempting to reconnect...
|
||||||
|
<span class="reconnect-indicator">●</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overlay-backdrop"></div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useSystemStore } from '@/stores/system'
|
||||||
|
|
||||||
|
const systemStore = useSystemStore()
|
||||||
|
|
||||||
|
const isOnline = computed(() => systemStore.isOnline)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.connection-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 99999;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-banner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
max-width: 600px;
|
||||||
|
width: calc(100% - 2 * var(--spacing-xl));
|
||||||
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
|
||||||
|
border: 3px solid #ff4444;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
box-shadow: 0 20px 60px rgba(255, 68, 68, 0.5);
|
||||||
|
animation: shake 0.5s ease-in-out;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
|
||||||
|
20%, 40%, 60%, 80% { transform: translateX(10px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 1; }
|
||||||
|
50% { transform: scale(1.1); opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-message {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-status {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnect-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
animation: blink 1.5s infinite;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 49% { opacity: 1; }
|
||||||
|
50%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition animations */
|
||||||
|
.slide-down-enter-active {
|
||||||
|
transition: all 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-down-leave-active {
|
||||||
|
transition: all 0.3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-down-enter-from {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-down-leave-to {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.connection-banner {
|
||||||
|
width: calc(100% - 2 * var(--spacing-md));
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-message {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
293
frontend/src/components/PathBrowser.vue
Normal file
293
frontend/src/components/PathBrowser.vue
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
<template>
|
||||||
|
<div class="path-browser">
|
||||||
|
<div class="browser-header">
|
||||||
|
<button @click="emit('close')" class="btn-close">✕</button>
|
||||||
|
<h3>Select Directory</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="current-path">
|
||||||
|
<span class="path-label">Current:</span>
|
||||||
|
<code>{{ currentPath || '/' }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="browser-body">
|
||||||
|
<!-- Error message -->
|
||||||
|
<div v-if="error" class="error-message">
|
||||||
|
⚠️ {{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Parent directory button -->
|
||||||
|
<div v-if="currentPath !== '/'" class="dir-item" @click="goUp">
|
||||||
|
<span class="dir-icon">📁</span>
|
||||||
|
<span class="dir-name">..</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Directory list -->
|
||||||
|
<div
|
||||||
|
v-for="item in directories"
|
||||||
|
:key="item.path"
|
||||||
|
class="dir-item"
|
||||||
|
:class="{ 'dir-item-disabled': !item.is_readable }"
|
||||||
|
@click="openDirectory(item)"
|
||||||
|
>
|
||||||
|
<span class="dir-icon">{{ item.is_readable ? '📁' : '🔒' }}</span>
|
||||||
|
<span class="dir-name">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<span class="spinner-small"></span>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && !error && directories.length === 0" class="empty-dirs">
|
||||||
|
No subdirectories found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="browser-footer">
|
||||||
|
<button @click="emit('close')" class="btn btn-secondary">Cancel</button>
|
||||||
|
<button @click="selectPath" class="btn btn-primary">
|
||||||
|
Select This Path
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const emit = defineEmits(['select', 'close'])
|
||||||
|
|
||||||
|
interface DirectoryItem {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
is_directory: boolean
|
||||||
|
is_readable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPath = ref('/')
|
||||||
|
const directories = ref<DirectoryItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function loadDirectories(path: string) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/filesystem/browse', {
|
||||||
|
params: { path }
|
||||||
|
})
|
||||||
|
|
||||||
|
currentPath.value = response.data.current_path
|
||||||
|
directories.value = response.data.items.filter((item: DirectoryItem) => item.is_readable)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to load directories:', err)
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to load directories'
|
||||||
|
directories.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCommonPaths() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/filesystem/common-paths')
|
||||||
|
directories.value = response.data.filter((item: DirectoryItem) => item.is_readable)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load common paths:', err)
|
||||||
|
// Fallback to root
|
||||||
|
loadDirectories('/')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDirectory(item: DirectoryItem) {
|
||||||
|
if (!item.is_readable) {
|
||||||
|
error.value = 'Permission denied'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadDirectories(item.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goUp() {
|
||||||
|
const parts = currentPath.value.split('/').filter(p => p)
|
||||||
|
parts.pop()
|
||||||
|
const parentPath = parts.length === 0 ? '/' : '/' + parts.join('/')
|
||||||
|
loadDirectories(parentPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPath() {
|
||||||
|
emit('select', currentPath.value)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Start with common paths
|
||||||
|
loadCommonPaths()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.path-browser {
|
||||||
|
background: var(--tertiary-bg);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 70vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-header h3 {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-path {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: var(--secondary-bg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-path code {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--primary-bg);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
min-height: 300px;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir-item:hover {
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir-item-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir-item-disabled:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background-color: rgba(255, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(255, 68, 68, 0.3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir-name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
border: 2px solid var(--tertiary-bg);
|
||||||
|
border-top: 2px solid var(--accent-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-dirs {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
1141
frontend/src/components/SetupWizard.vue
Normal file
1141
frontend/src/components/SetupWizard.vue
Normal file
File diff suppressed because it is too large
Load Diff
13
frontend/src/main.ts
Normal file
13
frontend/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './assets/css/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
|
|
||||||
54
frontend/src/router/index.ts
Normal file
54
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('@/views/DashboardView.vue'),
|
||||||
|
meta: { title: 'Dashboard' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/workers',
|
||||||
|
name: 'Workers',
|
||||||
|
component: () => import('@/views/WorkersView.vue'),
|
||||||
|
meta: { title: 'Workers' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/queue',
|
||||||
|
name: 'Queue',
|
||||||
|
component: () => import('@/views/QueueView.vue'),
|
||||||
|
meta: { title: 'Job Queue' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/scanner',
|
||||||
|
name: 'Scanner',
|
||||||
|
component: () => import('@/views/ScannerView.vue'),
|
||||||
|
meta: { title: 'Library Scanner' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/rules',
|
||||||
|
name: 'Rules',
|
||||||
|
component: () => import('@/views/RulesView.vue'),
|
||||||
|
meta: { title: 'Scan Rules' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'Settings',
|
||||||
|
component: () => import('@/views/SettingsView.vue'),
|
||||||
|
meta: { title: 'Settings' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
document.title = `${to.meta.title || 'TranscriptorIO'} - TranscriptorIO`
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
101
frontend/src/services/api.ts
Normal file
101
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import type {
|
||||||
|
SystemStatus,
|
||||||
|
Worker,
|
||||||
|
WorkerPoolStats,
|
||||||
|
AddWorkerRequest,
|
||||||
|
Job,
|
||||||
|
JobList,
|
||||||
|
QueueStats,
|
||||||
|
CreateJobRequest,
|
||||||
|
ScanRule,
|
||||||
|
CreateScanRuleRequest,
|
||||||
|
ScannerStatus,
|
||||||
|
ScanRequest,
|
||||||
|
ScanResult
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request interceptor
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response interceptor
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('API Error:', error.response?.data || error.message)
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// System API
|
||||||
|
export const systemApi = {
|
||||||
|
getStatus: () => api.get<SystemStatus>('/status'),
|
||||||
|
getHealth: () => api.get('/health')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workers API
|
||||||
|
export const workersApi = {
|
||||||
|
getAll: () => api.get<Worker[]>('/workers'),
|
||||||
|
getStats: () => api.get<WorkerPoolStats>('/workers/stats'),
|
||||||
|
getById: (id: string) => api.get<Worker>(`/workers/${id}`),
|
||||||
|
add: (data: AddWorkerRequest) => api.post<Worker>('/workers', data),
|
||||||
|
remove: (id: string, timeout = 30) => api.delete(`/workers/${id}`, { params: { timeout } }),
|
||||||
|
startPool: (cpuWorkers = 0, gpuWorkers = 0) =>
|
||||||
|
api.post('/workers/pool/start', null, { params: { cpu_workers: cpuWorkers, gpu_workers: gpuWorkers } }),
|
||||||
|
stopPool: (timeout = 30) => api.post('/workers/pool/stop', null, { params: { timeout } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jobs API
|
||||||
|
export const jobsApi = {
|
||||||
|
getAll: (statusFilter?: string, page = 1, pageSize = 50) =>
|
||||||
|
api.get<JobList>('/jobs', { params: { status_filter: statusFilter, page, page_size: pageSize } }),
|
||||||
|
getStats: () => api.get<QueueStats>('/jobs/stats'),
|
||||||
|
getById: (id: string) => api.get<Job>(`/jobs/${id}`),
|
||||||
|
create: (data: CreateJobRequest) => api.post<Job>('/jobs', data),
|
||||||
|
retry: (id: string) => api.post<Job>(`/jobs/${id}/retry`),
|
||||||
|
cancel: (id: string) => api.delete(`/jobs/${id}`),
|
||||||
|
clearCompleted: () => api.post('/jobs/queue/clear')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan Rules API
|
||||||
|
export const scanRulesApi = {
|
||||||
|
getAll: (enabledOnly = false) => api.get<ScanRule[]>('/scan-rules', { params: { enabled_only: enabledOnly } }),
|
||||||
|
getById: (id: number) => api.get<ScanRule>(`/scan-rules/${id}`),
|
||||||
|
create: (data: CreateScanRuleRequest) => api.post<ScanRule>('/scan-rules', data),
|
||||||
|
update: (id: number, data: Partial<CreateScanRuleRequest>) => api.put<ScanRule>(`/scan-rules/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete(`/scan-rules/${id}`),
|
||||||
|
toggle: (id: number) => api.post<ScanRule>(`/scan-rules/${id}/toggle`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scanner API
|
||||||
|
export const scannerApi = {
|
||||||
|
getStatus: () => api.get<ScannerStatus>('/scanner/status'),
|
||||||
|
scan: (data: ScanRequest) => api.post<ScanResult>('/scanner/scan', data),
|
||||||
|
startScheduler: (cronExpression: string, paths: string[], recursive = true) =>
|
||||||
|
api.post('/scanner/scheduler/start', { enabled: true, cron_expression: cronExpression, paths, recursive }),
|
||||||
|
stopScheduler: () => api.post('/scanner/scheduler/stop'),
|
||||||
|
startWatcher: (paths: string[], recursive = true) =>
|
||||||
|
api.post('/scanner/watcher/start', { enabled: true, paths, recursive }),
|
||||||
|
stopWatcher: () => api.post('/scanner/watcher/stop'),
|
||||||
|
analyzeFile: (filePath: string) => api.post('/scanner/analyze', null, { params: { file_path: filePath } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default api
|
||||||
|
|
||||||
47
frontend/src/stores/config.ts
Normal file
47
frontend/src/stores/config.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export const useConfigStore = defineStore('config', () => {
|
||||||
|
const operationMode = ref<'standalone' | 'bazarr_slave'>('standalone')
|
||||||
|
const hasGPU = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const isStandalone = computed(() => operationMode.value === 'standalone')
|
||||||
|
const isBazarrSlave = computed(() => operationMode.value === 'bazarr_slave')
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// Get operation mode from settings
|
||||||
|
const response = await axios.get('/api/settings/operation_mode')
|
||||||
|
operationMode.value = response.data.value === 'bazarr_slave' ? 'bazarr_slave' : 'standalone'
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch operation mode:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectGPU() {
|
||||||
|
try {
|
||||||
|
// Try to get system resources to detect GPU
|
||||||
|
const response = await axios.get('/api/system/resources')
|
||||||
|
hasGPU.value = response.data.gpus && response.data.gpus.length > 0
|
||||||
|
} catch (error) {
|
||||||
|
// If endpoint doesn't exist, assume no GPU detection available
|
||||||
|
hasGPU.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
operationMode,
|
||||||
|
hasGPU,
|
||||||
|
loading,
|
||||||
|
isStandalone,
|
||||||
|
isBazarrSlave,
|
||||||
|
fetchConfig,
|
||||||
|
detectGPU
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
125
frontend/src/stores/jobs.ts
Normal file
125
frontend/src/stores/jobs.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { jobsApi } from '@/services/api'
|
||||||
|
import type { Job, JobList, QueueStats, CreateJobRequest } from '@/types/api'
|
||||||
|
|
||||||
|
export const useJobsStore = defineStore('jobs', () => {
|
||||||
|
const jobs = ref<Job[]>([])
|
||||||
|
const stats = ref<QueueStats | null>(null)
|
||||||
|
const totalJobs = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(50)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetchJobs(statusFilter?: string, page = 1) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
currentPage.value = page
|
||||||
|
try {
|
||||||
|
const response = await jobsApi.getAll(statusFilter, page, pageSize.value)
|
||||||
|
jobs.value = response.data.jobs
|
||||||
|
totalJobs.value = response.data.total
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Failed to fetch jobs'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
try {
|
||||||
|
const response = await jobsApi.getStats()
|
||||||
|
stats.value = response.data
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Failed to fetch job stats'
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createJob(data: CreateJobRequest) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await jobsApi.create(data)
|
||||||
|
jobs.value.unshift(response.data)
|
||||||
|
await fetchStats()
|
||||||
|
return response.data
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Failed to create job'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryJob(id: string) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await jobsApi.retry(id)
|
||||||
|
const index = jobs.value.findIndex(j => j.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
jobs.value[index] = response.data
|
||||||
|
}
|
||||||
|
await fetchStats()
|
||||||
|
return response.data
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Failed to retry job'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelJob(id: string) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
await jobsApi.cancel(id)
|
||||||
|
const index = jobs.value.findIndex(j => j.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
jobs.value[index].status = 'cancelled'
|
||||||
|
}
|
||||||
|
await fetchStats()
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Failed to cancel job'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCompleted() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
await jobsApi.clearCompleted()
|
||||||
|
jobs.value = jobs.value.filter(j => j.status !== 'completed')
|
||||||
|
await fetchStats()
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Failed to clear completed jobs'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobs,
|
||||||
|
stats,
|
||||||
|
totalJobs,
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchJobs,
|
||||||
|
fetchStats,
|
||||||
|
createJob,
|
||||||
|
retryJob,
|
||||||
|
cancelJob,
|
||||||
|
clearCompleted
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
48
frontend/src/stores/system.ts
Normal file
48
frontend/src/stores/system.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { systemApi } from '@/services/api'
|
||||||
|
import type { SystemStatus } from '@/types/api'
|
||||||
|
|
||||||
|
export const useSystemStore = defineStore('system', () => {
|
||||||
|
const status = ref<SystemStatus | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const isOnline = ref(true)
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await systemApi.getStatus()
|
||||||
|
status.value = response.data
|
||||||
|
isOnline.value = true
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Failed to fetch system status'
|
||||||
|
isOnline.value = false
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkHealth() {
|
||||||
|
try {
|
||||||
|
await systemApi.getHealth()
|
||||||
|
isOnline.value = true
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
isOnline.value = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isOnline,
|
||||||
|
fetchStatus,
|
||||||
|
checkHealth
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
110
frontend/src/stores/workers.ts
Normal file
110
frontend/src/stores/workers.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { workersApi } from '@/services/api'
|
||||||
|
import type { Worker, WorkerPoolStats, AddWorkerRequest } from '@/types/api'
|
||||||
|
|
||||||
|
export const useWorkersStore = defineStore('workers', () => {
|
||||||
|
const workers = ref<Worker[]>([])
|
||||||
|
const stats = ref<WorkerPoolStats | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetchWorkers() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await workersApi.getAll()
|
||||||
|
workers.value = response.data
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Failed to fetch workers'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
try {
|
||||||
|
const response = await workersApi.getStats()
|
||||||
|
stats.value = response.data
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Failed to fetch worker stats'
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addWorker(data: AddWorkerRequest) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await workersApi.add(data)
|
||||||
|
workers.value.push(response.data)
|
||||||
|
await fetchStats()
|
||||||
|
return response.data
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Failed to add worker'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeWorker(id: string) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
await workersApi.remove(id)
|
||||||
|
workers.value = workers.value.filter(w => w.worker_id !== id)
|
||||||
|
await fetchStats()
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Failed to remove worker'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPool(cpuWorkers: number, gpuWorkers: number) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
await workersApi.startPool(cpuWorkers, gpuWorkers)
|
||||||
|
await fetchWorkers()
|
||||||
|
await fetchStats()
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Failed to start pool'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopPool() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
await workersApi.stopPool()
|
||||||
|
workers.value = []
|
||||||
|
await fetchStats()
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Failed to stop pool'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
workers,
|
||||||
|
stats,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchWorkers,
|
||||||
|
fetchStats,
|
||||||
|
addWorker,
|
||||||
|
removeWorker,
|
||||||
|
startPool,
|
||||||
|
stopPool
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
159
frontend/src/types/api.ts
Normal file
159
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// API Types matching backend models
|
||||||
|
|
||||||
|
export interface SystemStatus {
|
||||||
|
system: {
|
||||||
|
status: string
|
||||||
|
uptime_seconds: number | null
|
||||||
|
}
|
||||||
|
workers: WorkerPoolStats
|
||||||
|
queue: QueueStats
|
||||||
|
scanner: ScannerStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkerPoolStats {
|
||||||
|
total_workers: number
|
||||||
|
cpu_workers: number
|
||||||
|
gpu_workers: number
|
||||||
|
idle_workers: number
|
||||||
|
busy_workers: number
|
||||||
|
stopped_workers: number
|
||||||
|
error_workers: number
|
||||||
|
total_jobs_completed: number
|
||||||
|
total_jobs_failed: number
|
||||||
|
uptime_seconds: number | null
|
||||||
|
is_running: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Worker {
|
||||||
|
worker_id: string
|
||||||
|
worker_type: 'cpu' | 'gpu'
|
||||||
|
device_id: number | null
|
||||||
|
status: 'idle' | 'busy' | 'stopped' | 'error'
|
||||||
|
current_job_id: string | null
|
||||||
|
jobs_completed: number
|
||||||
|
jobs_failed: number
|
||||||
|
uptime_seconds: number
|
||||||
|
current_job_progress: number
|
||||||
|
current_job_eta: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Job {
|
||||||
|
id: string
|
||||||
|
file_path: string
|
||||||
|
file_name: string
|
||||||
|
status: 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled'
|
||||||
|
priority: number
|
||||||
|
source_lang: string | null
|
||||||
|
target_lang: string | null
|
||||||
|
quality_preset: 'fast' | 'balanced' | 'best'
|
||||||
|
transcribe_or_translate: string
|
||||||
|
progress: number
|
||||||
|
current_stage: string | null
|
||||||
|
eta_seconds: number | null
|
||||||
|
created_at: string | null
|
||||||
|
started_at: string | null
|
||||||
|
completed_at: string | null
|
||||||
|
output_path: string | null
|
||||||
|
segments_count: number | null
|
||||||
|
error: string | null
|
||||||
|
retry_count: number
|
||||||
|
worker_id: string | null
|
||||||
|
vram_used_mb: number | null
|
||||||
|
processing_time_seconds: number | null
|
||||||
|
model_used: string | null
|
||||||
|
device_used: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobList {
|
||||||
|
jobs: Job[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueStats {
|
||||||
|
total_jobs: number
|
||||||
|
queued: number
|
||||||
|
processing: number
|
||||||
|
completed: number
|
||||||
|
failed: number
|
||||||
|
cancelled: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanRule {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
priority: number
|
||||||
|
conditions: ScanRuleConditions
|
||||||
|
action: ScanRuleAction
|
||||||
|
created_at: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanRuleConditions {
|
||||||
|
audio_language_is: string | null
|
||||||
|
audio_language_not: string | null
|
||||||
|
audio_track_count_min: number | null
|
||||||
|
has_embedded_subtitle_lang: string | null
|
||||||
|
missing_embedded_subtitle_lang: string | null
|
||||||
|
missing_external_subtitle_lang: string | null
|
||||||
|
file_extension: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanRuleAction {
|
||||||
|
action_type: 'transcribe' | 'translate'
|
||||||
|
target_language: string
|
||||||
|
quality_preset: 'fast' | 'balanced' | 'best'
|
||||||
|
job_priority: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScannerStatus {
|
||||||
|
scheduler_enabled: boolean
|
||||||
|
scheduler_running: boolean
|
||||||
|
next_scan_time: string | null
|
||||||
|
watcher_enabled: boolean
|
||||||
|
watcher_running: boolean
|
||||||
|
watched_paths: string[]
|
||||||
|
last_scan_time: string | null
|
||||||
|
total_scans: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
|
scanned_files: number
|
||||||
|
matched_files: number
|
||||||
|
jobs_created: number
|
||||||
|
skipped_files: number
|
||||||
|
paths_scanned: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request types
|
||||||
|
export interface CreateJobRequest {
|
||||||
|
file_path: string
|
||||||
|
file_name: string
|
||||||
|
source_lang?: string
|
||||||
|
target_lang: string
|
||||||
|
quality_preset?: 'fast' | 'balanced' | 'best'
|
||||||
|
transcribe_or_translate?: string
|
||||||
|
priority?: number
|
||||||
|
is_manual_request?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddWorkerRequest {
|
||||||
|
worker_type: 'cpu' | 'gpu'
|
||||||
|
device_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateScanRuleRequest {
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
priority: number
|
||||||
|
conditions: ScanRuleConditions
|
||||||
|
action: ScanRuleAction
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanRequest {
|
||||||
|
paths: string[]
|
||||||
|
recursive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
907
frontend/src/views/DashboardView.vue
Normal file
907
frontend/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,907 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Dashboard</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<span class="refresh-indicator" v-if="!loading">
|
||||||
|
Auto-refresh: <span class="text-success">{{ countdown }}s</span>
|
||||||
|
</span>
|
||||||
|
<button @click="loadData" class="btn btn-secondary" :disabled="loading">
|
||||||
|
<span v-if="loading">Loading...</span>
|
||||||
|
<span v-else>↻ Refresh Now</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading && !systemStatus" class="spinner"></div>
|
||||||
|
|
||||||
|
<div v-else-if="systemStatus" class="dashboard-content">
|
||||||
|
<!-- Top Row: System Overview Cards -->
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<!-- System Overview -->
|
||||||
|
<div class="card highlight-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-icon">🖥️</div>
|
||||||
|
<h2 class="card-title">System Status</h2>
|
||||||
|
<span :class="['badge', systemStatus.system.status === 'running' ? 'badge-completed' : 'badge-failed']">
|
||||||
|
{{ systemStatus.system.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Uptime:</span>
|
||||||
|
<span class="stat-value">{{ formatUptime(systemStatus.system.uptime_seconds) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Version:</span>
|
||||||
|
<span class="stat-value">v1.0.0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Mode:</span>
|
||||||
|
<span class="stat-value badge badge-info">
|
||||||
|
{{ systemStatus.system.mode || 'Standalone' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Workers Overview -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-icon">⚙️</div>
|
||||||
|
<h2 class="card-title">Workers</h2>
|
||||||
|
<router-link to="/workers" class="btn btn-secondary btn-sm">Manage</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number">{{ systemStatus.workers?.pool?.total_workers || 0 }}</div>
|
||||||
|
<div class="stat-label">Total</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number text-success">{{ systemStatus.workers?.pool?.idle_workers || 0 }}</div>
|
||||||
|
<div class="stat-label">Idle</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number text-primary">{{ systemStatus.workers?.pool?.busy_workers || 0 }}</div>
|
||||||
|
<div class="stat-label">Busy</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number">{{ systemStatus.workers?.jobs?.completed || 0 }}</div>
|
||||||
|
<div class="stat-label">Completed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-section">
|
||||||
|
<div class="progress-label">
|
||||||
|
<span>Worker Utilization</span>
|
||||||
|
<span>{{ workerUtilization }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
:style="{ width: workerUtilization + '%', backgroundColor: getUsageColor(workerUtilization) }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Queue Overview -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-icon">📋</div>
|
||||||
|
<h2 class="card-title">Job Queue</h2>
|
||||||
|
<router-link to="/queue" class="btn btn-secondary btn-sm">View All</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number">{{ systemStatus.queue?.total || 0 }}</div>
|
||||||
|
<div class="stat-label">Total</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number text-warning">{{ systemStatus.queue?.queued || 0 }}</div>
|
||||||
|
<div class="stat-label">Queued</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number text-primary">{{ systemStatus.queue?.processing || 0 }}</div>
|
||||||
|
<div class="stat-label">Processing</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number text-success">{{ systemStatus.queue?.completed || 0 }}</div>
|
||||||
|
<div class="stat-label">Completed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="queue-chart">
|
||||||
|
<div
|
||||||
|
class="queue-bar queue-completed"
|
||||||
|
:style="{ width: queuePercentage('completed') + '%' }"
|
||||||
|
:title="`Completed: ${systemStatus.queue.completed}`"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="queue-bar queue-processing"
|
||||||
|
:style="{ width: queuePercentage('processing') + '%' }"
|
||||||
|
:title="`Processing: ${systemStatus.queue.processing}`"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="queue-bar queue-queued"
|
||||||
|
:style="{ width: queuePercentage('queued') + '%' }"
|
||||||
|
:title="`Queued: ${systemStatus.queue.queued}`"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="queue-bar queue-failed"
|
||||||
|
:style="{ width: queuePercentage('failed') + '%' }"
|
||||||
|
:title="`Failed: ${systemStatus.queue.failed}`"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scanner Overview -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-icon">📁</div>
|
||||||
|
<h2 class="card-title">Library Scanner</h2>
|
||||||
|
<router-link to="/scanner" class="btn btn-secondary btn-sm">Configure</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Scheduler:</span>
|
||||||
|
<span :class="['badge', systemStatus.scanner.scheduler_running ? 'badge-completed' : 'badge-cancelled']">
|
||||||
|
{{ systemStatus.scanner.scheduler_running ? 'Running' : 'Stopped' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">File Watcher:</span>
|
||||||
|
<span :class="['badge', systemStatus.scanner.watcher_running ? 'badge-completed' : 'badge-cancelled']">
|
||||||
|
{{ systemStatus.scanner.watcher_running ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Last Scan:</span>
|
||||||
|
<span class="stat-value">{{ formatDate(systemStatus.scanner.last_scan_time) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Total Scans:</span>
|
||||||
|
<span class="stat-value">{{ systemStatus.scanner.total_scans || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Resources Section -->
|
||||||
|
<div class="resources-section">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<span class="section-icon">💻</span>
|
||||||
|
System Resources
|
||||||
|
</h2>
|
||||||
|
<div class="resources-grid">
|
||||||
|
<!-- CPU Card -->
|
||||||
|
<div class="card resource-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">CPU Usage</h3>
|
||||||
|
<span class="resource-value">{{ systemResources.cpu?.usage_percent?.toFixed(1) || 0 }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="progress-bar large">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
:style="{
|
||||||
|
width: (systemResources.cpu?.usage_percent || 0) + '%',
|
||||||
|
backgroundColor: getUsageColor(systemResources.cpu?.usage_percent || 0)
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="resource-details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Cores:</span>
|
||||||
|
<span class="detail-value">{{ systemResources.cpu?.count_logical || 0 }} ({{ systemResources.cpu?.count_physical || 0 }} physical)</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Frequency:</span>
|
||||||
|
<span class="detail-value">{{ (systemResources.cpu?.frequency_mhz || 0).toFixed(0) }} MHz</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RAM Card -->
|
||||||
|
<div class="card resource-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">RAM Usage</h3>
|
||||||
|
<span class="resource-value">{{ systemResources.memory?.usage_percent?.toFixed(1) || 0 }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="progress-bar large">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
:style="{
|
||||||
|
width: (systemResources.memory?.usage_percent || 0) + '%',
|
||||||
|
backgroundColor: getUsageColor(systemResources.memory?.usage_percent || 0)
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="resource-details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Used:</span>
|
||||||
|
<span class="detail-value">{{ (systemResources.memory?.used_gb || 0).toFixed(2) }} GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Total:</span>
|
||||||
|
<span class="detail-value">{{ (systemResources.memory?.total_gb || 0).toFixed(2) }} GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Free:</span>
|
||||||
|
<span class="detail-value">{{ (systemResources.memory?.free_gb || 0).toFixed(2) }} GB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GPU Cards -->
|
||||||
|
<div
|
||||||
|
v-for="(gpu, index) in systemResources.gpus"
|
||||||
|
:key="index"
|
||||||
|
class="card resource-card"
|
||||||
|
>
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">{{ gpu.name || `GPU ${index}` }}</h3>
|
||||||
|
<span class="resource-value">{{ gpu.utilization_percent?.toFixed(1) || 0 }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="progress-bar large">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
:style="{
|
||||||
|
width: (gpu.utilization_percent || 0) + '%',
|
||||||
|
backgroundColor: getUsageColor(gpu.utilization_percent || 0)
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="resource-details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">VRAM Used:</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
{{ (gpu.memory_used_mb / 1024).toFixed(2) }} GB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">VRAM Total:</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
{{ (gpu.memory_total_mb / 1024).toFixed(2) }} GB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">VRAM Usage:</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
{{ ((gpu.memory_used_mb / gpu.memory_total_mb) * 100).toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No GPUs Message -->
|
||||||
|
<div v-if="!systemResources.gpus || systemResources.gpus.length === 0" class="card resource-card empty-gpu">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No GPUs detected</p>
|
||||||
|
<small>CPU-only mode active</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Jobs Section -->
|
||||||
|
<div class="recent-jobs-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<span class="section-icon">⏱️</span>
|
||||||
|
Recent Jobs
|
||||||
|
</h2>
|
||||||
|
<router-link to="/queue" class="btn btn-secondary">View All Jobs →</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="recentJobs.length === 0" class="empty-state">
|
||||||
|
<p>No jobs yet</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-container">
|
||||||
|
<table class="jobs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>File Name</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Languages</th>
|
||||||
|
<th>Progress</th>
|
||||||
|
<th>Worker</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="job in recentJobs" :key="job.id" :class="'row-' + job.status">
|
||||||
|
<td class="file-name">
|
||||||
|
<span class="file-icon">📄</span>
|
||||||
|
{{ job.file_name }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span :class="['badge', `badge-${job.status}`]">
|
||||||
|
{{ job.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="languages">
|
||||||
|
<span class="lang-badge">{{ job.source_lang }}</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="lang-badge">{{ job.target_lang }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="progress-cell">
|
||||||
|
<div class="progress-bar small">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
:style="{ width: job.progress + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-text">{{ job.progress }}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="worker-badge" v-if="job.worker_id">
|
||||||
|
{{ job.worker_id }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-muted">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="created-date">{{ formatDate(job.created_at) }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<router-link :to="`/queue?job=${job.id}`" class="btn-action" title="View Details">
|
||||||
|
👁️
|
||||||
|
</router-link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p>Unable to load system status</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useSystemStore } from '@/stores/system'
|
||||||
|
import api from '@/services/api'
|
||||||
|
|
||||||
|
const systemStore = useSystemStore()
|
||||||
|
const systemStatus = ref<any>(null)
|
||||||
|
const systemResources = ref<any>({})
|
||||||
|
const recentJobs = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const countdown = ref(5)
|
||||||
|
|
||||||
|
let refreshInterval: number | null = null
|
||||||
|
let countdownInterval: number | null = null
|
||||||
|
|
||||||
|
const workerUtilization = computed(() => {
|
||||||
|
if (!systemStatus.value?.workers?.pool) return 0
|
||||||
|
const total = systemStatus.value.workers.pool.total_workers
|
||||||
|
if (total === 0) return 0
|
||||||
|
return Math.round((systemStatus.value.workers.pool.busy_workers / total) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
function queuePercentage(status: string): number {
|
||||||
|
if (!systemStatus.value?.queue) return 0
|
||||||
|
const total = systemStatus.value.queue.total
|
||||||
|
if (total === 0) return 0
|
||||||
|
const value = systemStatus.value.queue[status] || 0
|
||||||
|
return (value / total) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// Load system status
|
||||||
|
await systemStore.fetchStatus()
|
||||||
|
systemStatus.value = systemStore.status
|
||||||
|
|
||||||
|
// Load system resources
|
||||||
|
const resourcesRes = await api.get('/system/resources')
|
||||||
|
systemResources.value = resourcesRes.data
|
||||||
|
|
||||||
|
// Load recent jobs
|
||||||
|
const jobsRes = await api.get('/jobs?limit=5')
|
||||||
|
recentJobs.value = jobsRes.data.jobs || []
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load dashboard data:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
if (!seconds) return '0s'
|
||||||
|
|
||||||
|
const days = Math.floor(seconds / 86400)
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60)
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ${hours}h`
|
||||||
|
if (hours > 0) return `${hours}h ${mins}m`
|
||||||
|
return `${mins}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
if (!dateStr) return '—'
|
||||||
|
|
||||||
|
// Parse the ISO string (backend sends timezone-aware UTC dates)
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
|
||||||
|
// Check if date is valid
|
||||||
|
if (isNaN(date.getTime())) return 'Invalid date'
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now'
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`
|
||||||
|
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`
|
||||||
|
return date.toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsageColor(percent: number): string {
|
||||||
|
if (percent < 50) return 'var(--success-color)'
|
||||||
|
if (percent < 80) return 'var(--warning-color)'
|
||||||
|
return 'var(--danger-color)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
countdown.value = 5
|
||||||
|
countdownInterval = window.setInterval(() => {
|
||||||
|
countdown.value--
|
||||||
|
if (countdown.value <= 0) {
|
||||||
|
countdown.value = 5
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
refreshInterval = window.setInterval(loadData, 5000)
|
||||||
|
startCountdown()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshInterval) clearInterval(refreshInterval)
|
||||||
|
if (countdownInterval) clearInterval(countdownInterval)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, var(--accent-color), var(--primary-color));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-indicator {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-card {
|
||||||
|
background: linear-gradient(135deg, var(--secondary-bg) 0%, rgba(79, 70, 229, 0.1) 100%);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-right: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding-top: var(--spacing-md);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar.large {
|
||||||
|
height: 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar.small {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
transition: width 0.3s ease, background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-chart {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-bar {
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-completed {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-processing {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-queued {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-failed {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-section,
|
||||||
|
.recent-jobs-section {
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-card .card-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-card .card-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-color);
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-details {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-gpu {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-table th,
|
||||||
|
.jobs-table td {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-table th {
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-table tbody tr {
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-table tbody tr:hover {
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languages {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-badge {
|
||||||
|
padding: 2px 6px;
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.created-date {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action:hover {
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-completed {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: var(--primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-processing {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: var(--primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-queued {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: var(--primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-failed,
|
||||||
|
.badge-cancelled {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: var(--primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: var(--primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-warning {
|
||||||
|
color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1252
frontend/src/views/QueueView.vue
Normal file
1252
frontend/src/views/QueueView.vue
Normal file
File diff suppressed because it is too large
Load Diff
452
frontend/src/views/RulesView.vue
Normal file
452
frontend/src/views/RulesView.vue
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rules-view">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Scan Rules</h1>
|
||||||
|
<button @click="showCreateModal = true" class="btn btn-primary">Create Rule</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="spinner"></div>
|
||||||
|
|
||||||
|
<div v-else-if="rules.length === 0" class="empty-state">
|
||||||
|
<p>No scan rules configured yet. Create your first rule to start automatic scanning.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="rules-grid">
|
||||||
|
<div v-for="rule in rules" :key="rule.id" class="rule-card">
|
||||||
|
<div class="rule-header">
|
||||||
|
<h3 class="rule-name">{{ rule.name }}</h3>
|
||||||
|
<div class="rule-actions">
|
||||||
|
<button
|
||||||
|
@click="toggleRule(rule)"
|
||||||
|
:class="['btn-toggle', rule.enabled ? 'enabled' : 'disabled']"
|
||||||
|
:title="rule.enabled ? 'Disable' : 'Enable'"
|
||||||
|
>
|
||||||
|
{{ rule.enabled ? '✓' : '✕' }}
|
||||||
|
</button>
|
||||||
|
<button @click="editRule(rule)" class="btn-edit" title="Edit">✎</button>
|
||||||
|
<button @click="deleteRule(rule.id)" class="btn-delete" title="Delete">🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rule-body">
|
||||||
|
<div class="rule-detail">
|
||||||
|
<span class="detail-label">Priority:</span>
|
||||||
|
<span class="detail-value">{{ rule.priority }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rule-detail">
|
||||||
|
<span class="detail-label">Audio:</span>
|
||||||
|
<span class="detail-value">{{ rule.conditions?.audio_language_is || 'Any' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rule-detail">
|
||||||
|
<span class="detail-label">Action:</span>
|
||||||
|
<span class="detail-value">{{ rule.action?.action_type }} → {{ rule.action?.target_language }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="rule.conditions?.missing_external_subtitle_lang" class="rule-detail">
|
||||||
|
<span class="detail-label">Check missing:</span>
|
||||||
|
<span class="detail-value">{{ rule.conditions.missing_external_subtitle_lang }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Rule Modal -->
|
||||||
|
<div v-if="showCreateModal || editingRule" class="modal-overlay" @click="closeModal">
|
||||||
|
<div class="modal-content" @click.stop>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{{ editingRule ? 'Edit Rule' : 'Create Rule' }}</h2>
|
||||||
|
<button @click="closeModal" class="btn-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Rule Name</label>
|
||||||
|
<input v-model="formData.name" type="text" class="form-input" placeholder="e.g., Japanese anime to Spanish" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Priority (higher = first)</label>
|
||||||
|
<input v-model.number="formData.priority" type="number" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Audio Language (empty = any)</label>
|
||||||
|
<input v-model="formData.audio_language_is" type="text" class="form-input" placeholder="ja, en, es..." />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Action Type</label>
|
||||||
|
<select v-model="formData.action_type" class="form-select" @change="onActionTypeChange">
|
||||||
|
<option value="transcribe">Transcribe (audio → English)</option>
|
||||||
|
<option value="translate">Translate (audio → English → target language)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
Target Language
|
||||||
|
<span v-if="formData.action_type === 'transcribe'" class="setting-description">
|
||||||
|
(Fixed: en - transcribe mode only creates English subtitles)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="formData.action_type === 'translate'"
|
||||||
|
v-model="formData.target_language"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="es, fr, de, it..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
value="en"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
disabled
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Check Missing Subtitle</label>
|
||||||
|
<input v-model="formData.missing_external_subtitle_lang" type="text" class="form-input" placeholder="es, en..." />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input v-model="formData.enabled" type="checkbox" />
|
||||||
|
<span>Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button @click="saveRule" class="btn btn-primary">{{ editingRule ? 'Update' : 'Create' }}</button>
|
||||||
|
<button @click="closeModal" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import api from '@/services/api'
|
||||||
|
|
||||||
|
interface Rule {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
priority: number
|
||||||
|
conditions: {
|
||||||
|
audio_language_is?: string | null
|
||||||
|
audio_language_not?: string | null
|
||||||
|
audio_track_count_min?: number | null
|
||||||
|
has_embedded_subtitle_lang?: string | null
|
||||||
|
missing_embedded_subtitle_lang?: string | null
|
||||||
|
missing_external_subtitle_lang?: string | null
|
||||||
|
file_extension?: string | null
|
||||||
|
}
|
||||||
|
action: {
|
||||||
|
action_type: string
|
||||||
|
target_language: string
|
||||||
|
quality_preset?: string
|
||||||
|
job_priority?: number
|
||||||
|
}
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = ref<Rule[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const editingRule = ref<Rule | null>(null)
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
name: '',
|
||||||
|
priority: 10,
|
||||||
|
audio_language_is: '',
|
||||||
|
target_language: 'en', // Default to 'en' for transcribe mode
|
||||||
|
action_type: 'transcribe',
|
||||||
|
missing_external_subtitle_lang: '',
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadRules() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await api.get('/scan-rules')
|
||||||
|
rules.value = response.data || []
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load rules:', error)
|
||||||
|
rules.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRule(rule: Rule) {
|
||||||
|
try {
|
||||||
|
await api.post(`/scan-rules/${rule.id}/toggle`)
|
||||||
|
await loadRules()
|
||||||
|
} catch (error: any) {
|
||||||
|
alert('Failed to toggle rule: ' + (error.response?.data?.detail || error.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onActionTypeChange() {
|
||||||
|
// When switching to transcribe mode, force target language to 'en'
|
||||||
|
if (formData.value.action_type === 'transcribe') {
|
||||||
|
formData.value.target_language = 'en'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRule(rule: Rule) {
|
||||||
|
editingRule.value = rule
|
||||||
|
formData.value = {
|
||||||
|
name: rule.name,
|
||||||
|
priority: rule.priority,
|
||||||
|
audio_language_is: rule.conditions?.audio_language_is || '',
|
||||||
|
target_language: rule.action?.target_language || 'en',
|
||||||
|
action_type: rule.action?.action_type || 'transcribe',
|
||||||
|
missing_external_subtitle_lang: rule.conditions?.missing_external_subtitle_lang || '',
|
||||||
|
enabled: rule.enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRule() {
|
||||||
|
try {
|
||||||
|
// Force target_language to 'en' if action_type is 'transcribe'
|
||||||
|
const targetLanguage = formData.value.action_type === 'transcribe'
|
||||||
|
? 'en'
|
||||||
|
: formData.value.target_language
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: formData.value.name,
|
||||||
|
enabled: formData.value.enabled,
|
||||||
|
priority: formData.value.priority,
|
||||||
|
conditions: {
|
||||||
|
audio_language_is: formData.value.audio_language_is || null,
|
||||||
|
missing_external_subtitle_lang: formData.value.missing_external_subtitle_lang || null
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
action_type: formData.value.action_type,
|
||||||
|
target_language: targetLanguage,
|
||||||
|
quality_preset: 'fast',
|
||||||
|
job_priority: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingRule.value) {
|
||||||
|
await api.put(`/scan-rules/${editingRule.value.id}`, payload)
|
||||||
|
} else {
|
||||||
|
await api.post('/scan-rules', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal()
|
||||||
|
await loadRules()
|
||||||
|
} catch (error: any) {
|
||||||
|
alert('Failed to save rule: ' + (error.response?.data?.detail || error.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRule(id: number) {
|
||||||
|
if (!confirm('Delete this rule?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/scan-rules/${id}`)
|
||||||
|
await loadRules()
|
||||||
|
} catch (error: any) {
|
||||||
|
alert('Failed to delete rule: ' + (error.response?.data?.detail || error.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showCreateModal.value = false
|
||||||
|
editingRule.value = null
|
||||||
|
formData.value = {
|
||||||
|
name: '',
|
||||||
|
priority: 10,
|
||||||
|
audio_language_is: '',
|
||||||
|
target_language: 'en', // Default to 'en' for transcribe mode
|
||||||
|
action_type: 'transcribe',
|
||||||
|
missing_external_subtitle_lang: '',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadRules()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card {
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
padding-bottom: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle, .btn-edit, .btn-delete {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle.enabled {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle.disabled {
|
||||||
|
background-color: var(--text-muted);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-detail {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input, .form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
803
frontend/src/views/ScannerView.vue
Normal file
803
frontend/src/views/ScannerView.vue
Normal file
@@ -0,0 +1,803 @@
|
|||||||
|
<template>
|
||||||
|
<div class="scanner-view">
|
||||||
|
<h1 class="page-title">Library Scanner</h1>
|
||||||
|
|
||||||
|
<!-- Notification Toast -->
|
||||||
|
<div v-if="notification.show" :class="['notification-toast', `notification-${notification.type}`]">
|
||||||
|
<span class="notification-icon">
|
||||||
|
<span v-if="notification.type === 'success'">✓</span>
|
||||||
|
<span v-else-if="notification.type === 'error'">✕</span>
|
||||||
|
<span v-else>ℹ</span>
|
||||||
|
</span>
|
||||||
|
<span class="notification-message">{{ notification.message }}</span>
|
||||||
|
<button @click="notification.show = false" class="notification-close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="spinner"></div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- Scanner Status Card -->
|
||||||
|
<div class="card status-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Scanner Status</h2>
|
||||||
|
<span :class="['badge', scannerStatus?.is_scanning ? 'badge-processing' : 'badge-queued']">
|
||||||
|
{{ scannerStatus?.is_scanning ? 'Scanning' : 'Idle' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="status-grid">
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Scheduler:</span>
|
||||||
|
<span :class="['badge', scannerStatus?.scheduler_running ? 'badge-completed' : 'badge-cancelled']">
|
||||||
|
{{ scannerStatus?.scheduler_running ? 'Running' : 'Stopped' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">File Watcher:</span>
|
||||||
|
<span :class="['badge', scannerStatus?.watcher_running ? 'badge-completed' : 'badge-cancelled']">
|
||||||
|
{{ scannerStatus?.watcher_running ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Last Scan:</span>
|
||||||
|
<span class="status-value">{{ formatDate(scannerStatus?.last_scan_time) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Files Scanned:</span>
|
||||||
|
<span class="status-value">{{ scannerStatus?.total_files_scanned || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scanner Controls -->
|
||||||
|
<div class="card controls-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Scanner Controls</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="controls-grid">
|
||||||
|
<!-- Scheduled Scanning -->
|
||||||
|
<div class="control-section">
|
||||||
|
<h3 class="control-title">Scheduled Scanning</h3>
|
||||||
|
<p class="control-description">Scan library periodically at set intervals</p>
|
||||||
|
<div class="control-actions">
|
||||||
|
<button
|
||||||
|
v-if="!scannerStatus?.scheduler_running"
|
||||||
|
@click="startScheduler"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="actionLoading"
|
||||||
|
>
|
||||||
|
Start Scheduler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
@click="stopScheduler"
|
||||||
|
class="btn btn-danger"
|
||||||
|
:disabled="actionLoading"
|
||||||
|
>
|
||||||
|
Stop Scheduler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Watcher -->
|
||||||
|
<div class="control-section">
|
||||||
|
<h3 class="control-title">Real-time File Watcher</h3>
|
||||||
|
<p class="control-description">Monitor filesystem for new files</p>
|
||||||
|
<div class="control-actions">
|
||||||
|
<button
|
||||||
|
v-if="!scannerStatus?.watcher_running"
|
||||||
|
@click="startWatcher"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="actionLoading"
|
||||||
|
>
|
||||||
|
Start Watcher
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
@click="stopWatcher"
|
||||||
|
class="btn btn-danger"
|
||||||
|
:disabled="actionLoading"
|
||||||
|
>
|
||||||
|
Stop Watcher
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Scan -->
|
||||||
|
<div class="control-section">
|
||||||
|
<h3 class="control-title">Manual Scan</h3>
|
||||||
|
<p class="control-description">Scan library immediately</p>
|
||||||
|
<div class="control-actions">
|
||||||
|
<button
|
||||||
|
@click="showManualScanModal = true"
|
||||||
|
class="btn btn-accent"
|
||||||
|
:disabled="actionLoading || scannerStatus?.is_scanning"
|
||||||
|
>
|
||||||
|
Run Manual Scan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Library Paths -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Library Paths</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-if="libraryPaths.length === 0" class="empty-state">
|
||||||
|
<p>No library paths configured. Add paths in Settings.</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="paths-list">
|
||||||
|
<div v-for="(path, index) in libraryPaths" :key="index" class="path-item">
|
||||||
|
<span class="path-icon">📁</span>
|
||||||
|
<span class="path-text">{{ path }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scan Results -->
|
||||||
|
<div v-if="scanResults.length > 0" class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Recent Scan Results</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="results-table-container">
|
||||||
|
<table class="results-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Files Scanned</th>
|
||||||
|
<th>Matched</th>
|
||||||
|
<th>Jobs Created</th>
|
||||||
|
<th>Skipped</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="result in scanResults" :key="result.id">
|
||||||
|
<td>{{ formatDate(result.timestamp) }}</td>
|
||||||
|
<td>{{ result.files_scanned }}</td>
|
||||||
|
<td class="text-success">{{ result.matched }}</td>
|
||||||
|
<td class="text-primary">{{ result.jobs_created }}</td>
|
||||||
|
<td class="text-muted">{{ result.skipped }}</td>
|
||||||
|
<td>{{ formatDuration(result.duration) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Scan Modal -->
|
||||||
|
<div v-if="showManualScanModal" class="modal-overlay" @click="showManualScanModal = false">
|
||||||
|
<div class="modal-content" @click.stop>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Manual Library Scan</h2>
|
||||||
|
<button @click="showManualScanModal = false" class="btn-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Start a manual scan of all configured library paths?</p>
|
||||||
|
<div v-if="libraryPaths.length > 0" class="paths-preview">
|
||||||
|
<p class="preview-label">Paths to scan:</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(path, index) in libraryPaths" :key="index">{{ path }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button @click="runManualScan" class="btn btn-primary" :disabled="actionLoading">
|
||||||
|
<span v-if="actionLoading">Scanning...</span>
|
||||||
|
<span v-else>Start Scan</span>
|
||||||
|
</button>
|
||||||
|
<button @click="showManualScanModal = false" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import api from '@/services/api'
|
||||||
|
|
||||||
|
interface ScannerStatus {
|
||||||
|
is_scanning: boolean
|
||||||
|
scheduler_running: boolean
|
||||||
|
watcher_running: boolean
|
||||||
|
last_scan_time: string | null
|
||||||
|
total_files_scanned: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanResult {
|
||||||
|
id: number
|
||||||
|
timestamp: string
|
||||||
|
files_scanned: number
|
||||||
|
matched: number
|
||||||
|
jobs_created: number
|
||||||
|
skipped: number
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const actionLoading = ref(false)
|
||||||
|
const scannerStatus = ref<ScannerStatus | null>(null)
|
||||||
|
const libraryPaths = ref<string[]>([])
|
||||||
|
const scanResults = ref<ScanResult[]>([])
|
||||||
|
const showManualScanModal = ref(false)
|
||||||
|
|
||||||
|
// Notification system
|
||||||
|
const notification = ref<{
|
||||||
|
show: boolean
|
||||||
|
type: 'success' | 'error' | 'info'
|
||||||
|
message: string
|
||||||
|
}>({
|
||||||
|
show: false,
|
||||||
|
type: 'info',
|
||||||
|
message: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function showNotification(message: string, type: 'success' | 'error' | 'info' = 'info') {
|
||||||
|
notification.value = { show: true, type, message }
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.value.show = false
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshInterval: number | null = null
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// Load scanner status
|
||||||
|
const statusRes = await api.get('/scanner/status')
|
||||||
|
scannerStatus.value = statusRes.data
|
||||||
|
|
||||||
|
// Load library paths from settings
|
||||||
|
try {
|
||||||
|
const settingsRes = await api.get('/settings/library_paths')
|
||||||
|
const pathsData = settingsRes.data.value
|
||||||
|
|
||||||
|
// Handle both string (comma-separated) and array types
|
||||||
|
if (Array.isArray(pathsData)) {
|
||||||
|
libraryPaths.value = pathsData.filter((p: string) => p && p.trim())
|
||||||
|
} else if (typeof pathsData === 'string' && pathsData.trim()) {
|
||||||
|
// Could be JSON array or comma-separated
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(pathsData)
|
||||||
|
libraryPaths.value = Array.isArray(parsed) ? parsed : pathsData.split(',').map((p: string) => p.trim()).filter((p: string) => p)
|
||||||
|
} catch {
|
||||||
|
// Not JSON, treat as comma-separated
|
||||||
|
libraryPaths.value = pathsData.split(',').map((p: string) => p.trim()).filter((p: string) => p)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
libraryPaths.value = []
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load library paths:', err)
|
||||||
|
libraryPaths.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load recent scan results (if available)
|
||||||
|
// TODO: Implement scan history endpoint
|
||||||
|
scanResults.value = []
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load scanner data:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScheduler() {
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
await api.post('/scanner/scheduler/start')
|
||||||
|
await loadData()
|
||||||
|
showNotification('Scheduler started successfully', 'success')
|
||||||
|
} catch (error: any) {
|
||||||
|
showNotification('Failed to start scheduler: ' + (error.response?.data?.detail || error.message), 'error')
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopScheduler() {
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
await api.post('/scanner/scheduler/stop')
|
||||||
|
await loadData()
|
||||||
|
showNotification('Scheduler stopped', 'success')
|
||||||
|
} catch (error: any) {
|
||||||
|
showNotification('Failed to stop scheduler: ' + (error.response?.data?.detail || error.message), 'error')
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWatcher() {
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
await api.post('/scanner/watcher/start')
|
||||||
|
await loadData()
|
||||||
|
showNotification('File watcher started successfully', 'success')
|
||||||
|
} catch (error: any) {
|
||||||
|
showNotification('Failed to start watcher: ' + (error.response?.data?.detail || error.message), 'error')
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopWatcher() {
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
await api.post('/scanner/watcher/stop')
|
||||||
|
await loadData()
|
||||||
|
showNotification('File watcher stopped', 'success')
|
||||||
|
} catch (error: any) {
|
||||||
|
showNotification('Failed to stop watcher: ' + (error.response?.data?.detail || error.message), 'error')
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runManualScan() {
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
await api.post('/scanner/scan')
|
||||||
|
showManualScanModal.value = false
|
||||||
|
await loadData()
|
||||||
|
showNotification('Manual scan started successfully!', 'success')
|
||||||
|
} catch (error: any) {
|
||||||
|
showNotification('Failed to start manual scan: ' + (error.response?.data?.detail || error.message), 'error')
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return 'Never'
|
||||||
|
|
||||||
|
// Parse the ISO string (backend sends timezone-aware UTC dates)
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
|
||||||
|
// Check if date is valid
|
||||||
|
if (isNaN(date.getTime())) return 'Invalid date'
|
||||||
|
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) return `${seconds}s`
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${mins}m ${secs}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
refreshInterval = window.setInterval(loadData, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-card {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-section {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schedule Configuration Styles */
|
||||||
|
.schedule-config {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-select,
|
||||||
|
.schedule-input {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-select:focus,
|
||||||
|
.schedule-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-interval {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border-left: 3px solid var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-indicator {
|
||||||
|
color: var(--success-color);
|
||||||
|
font-weight: 600;
|
||||||
|
animation: fadeIn 0.3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.paths-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-text {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table th,
|
||||||
|
.results-table td {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table th {
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paths-preview {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paths-preview ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paths-preview li {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-completed {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: var(--primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-cancelled {
|
||||||
|
background-color: var(--text-muted);
|
||||||
|
color: var(--primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-processing {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: var(--primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-queued {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: var(--primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notification Toast */
|
||||||
|
.notification-toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 80px;
|
||||||
|
right: var(--spacing-lg);
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 500px;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
z-index: 9999;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(400px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-success {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-info {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
787
frontend/src/views/SettingsView.vue
Normal file
787
frontend/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,787 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings-view">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Settings</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button @click="loadSettings" class="btn btn-secondary" :disabled="loading">
|
||||||
|
<span v-if="loading">Loading...</span>
|
||||||
|
<span v-else>↻ Refresh</span>
|
||||||
|
</button>
|
||||||
|
<button @click="saveSettings" class="btn btn-primary" :disabled="saving || !hasChanges">
|
||||||
|
<span v-if="saving">Saving...</span>
|
||||||
|
<span v-else>💾 Save Changes</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="spinner"></div>
|
||||||
|
|
||||||
|
<div v-else class="settings-container">
|
||||||
|
<!-- General Settings -->
|
||||||
|
<div class="card settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">🔧 General Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-item full-width">
|
||||||
|
<label class="setting-label">
|
||||||
|
Operation Mode
|
||||||
|
<span class="setting-description">Standalone or Bazarr provider mode (requires restart)</span>
|
||||||
|
</label>
|
||||||
|
<select v-model="settings.operation_mode" class="setting-input" @change="markChanged">
|
||||||
|
<option value="standalone">Standalone</option>
|
||||||
|
<option value="bazarr_slave">Bazarr Provider</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Library Paths - Solo en modo Standalone -->
|
||||||
|
<div v-if="isStandalone" class="setting-item full-width">
|
||||||
|
<label class="setting-label">
|
||||||
|
Library Paths
|
||||||
|
<span class="setting-description">Media library folders to scan</span>
|
||||||
|
</label>
|
||||||
|
<div class="paths-list">
|
||||||
|
<div v-for="(path, index) in libraryPaths" :key="index" class="path-display">
|
||||||
|
<code class="path-code">{{ path }}</code>
|
||||||
|
<button @click="removePath(index)" class="btn-icon">🗑️</button>
|
||||||
|
</div>
|
||||||
|
<button @click="showPathBrowser = true" class="btn btn-secondary btn-sm">
|
||||||
|
📁 Browse for Path
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<label class="setting-label">
|
||||||
|
Log Level
|
||||||
|
<span class="setting-description">Application logging level</span>
|
||||||
|
</label>
|
||||||
|
<select v-model="settings.log_level" class="setting-input" @change="markChanged">
|
||||||
|
<option value="DEBUG">DEBUG</option>
|
||||||
|
<option value="INFO">INFO</option>
|
||||||
|
<option value="WARNING">WARNING</option>
|
||||||
|
<option value="ERROR">ERROR</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Worker Settings -->
|
||||||
|
<div class="card settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">⚙️ Worker Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-item">
|
||||||
|
<label class="setting-label">
|
||||||
|
CPU Workers on Startup
|
||||||
|
<span class="setting-description">Number of CPU workers to start automatically</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="settings.worker_cpu_count"
|
||||||
|
class="setting-input"
|
||||||
|
min="0"
|
||||||
|
max="16"
|
||||||
|
@input="markChanged"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<label class="setting-label">
|
||||||
|
GPU Workers on Startup
|
||||||
|
<span class="setting-description">Number of GPU workers to start automatically</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="settings.worker_gpu_count"
|
||||||
|
class="setting-input"
|
||||||
|
min="0"
|
||||||
|
max="8"
|
||||||
|
:disabled="!hasGPU"
|
||||||
|
:placeholder="hasGPU ? '0' : 'No GPU detected'"
|
||||||
|
@input="markChanged"
|
||||||
|
/>
|
||||||
|
<span v-if="!hasGPU" class="warning-message">
|
||||||
|
⚠️ No GPU detected - GPU workers will not start
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<label class="setting-label">
|
||||||
|
Health Check Interval
|
||||||
|
<span class="setting-description">Worker health check interval (seconds)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="settings.worker_healthcheck_interval"
|
||||||
|
class="setting-input"
|
||||||
|
min="10"
|
||||||
|
max="300"
|
||||||
|
@input="markChanged"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<label class="setting-label">
|
||||||
|
Auto-Restart Failed Workers
|
||||||
|
<span class="setting-description">Automatically restart workers that crash</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="settings.worker_auto_restart"
|
||||||
|
@change="markChanged"
|
||||||
|
/>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transcription Settings -->
|
||||||
|
<div class="card settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">🎤 Transcription Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-item">
|
||||||
|
<label class="setting-label">
|
||||||
|
Whisper Model
|
||||||
|
<span class="setting-description">AI model size (larger = better quality, slower)</span>
|
||||||
|
</label>
|
||||||
|
<select v-model="settings.whisper_model" class="setting-input" @change="markChanged">
|
||||||
|
<option value="tiny">Tiny (fastest)</option>
|
||||||
|
<option value="base">Base</option>
|
||||||
|
<option value="small">Small</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="large">Large</option>
|
||||||
|
<option value="large-v2">Large v2</option>
|
||||||
|
<option value="large-v3">Large v3 (best)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<label class="setting-label">
|
||||||
|
CPU Compute Type
|
||||||
|
<span class="setting-description">Precision for CPU workers</span>
|
||||||
|
</label>
|
||||||
|
<select v-model="settings.cpu_compute_type" class="setting-input" @change="markChanged">
|
||||||
|
<option value="auto">Auto (recommended)</option>
|
||||||
|
<option value="int8">Int8 (faster, lower quality)</option>
|
||||||
|
<option value="float32">Float32 (slower, better quality)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item" v-if="hasGPU">
|
||||||
|
<label class="setting-label">
|
||||||
|
GPU Compute Type
|
||||||
|
<span class="setting-description">Precision for GPU workers</span>
|
||||||
|
</label>
|
||||||
|
<select v-model="settings.gpu_compute_type" class="setting-input" @change="markChanged">
|
||||||
|
<option value="auto">Auto (recommended)</option>
|
||||||
|
<option value="float16">Float16 (fast, recommended)</option>
|
||||||
|
<option value="float32">Float32 (slower, more precise)</option>
|
||||||
|
<option value="int8_float16">Int8 + Float16 (fastest, lower quality)</option>
|
||||||
|
<option value="int8">Int8 (very fast, lowest quality)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item full-width">
|
||||||
|
<label class="setting-label">
|
||||||
|
Skip if Subtitle Exists
|
||||||
|
<span class="setting-description">Skip transcription if subtitle file already exists</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="settings.skip_if_exists"
|
||||||
|
@change="markChanged"
|
||||||
|
/>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scanner Settings - Solo en modo Standalone -->
|
||||||
|
<div v-if="isStandalone" class="card settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">🔍 Scanner Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-item full-width">
|
||||||
|
<label class="setting-label">
|
||||||
|
Enable Library Scanner
|
||||||
|
<span class="setting-description">Automatically scan libraries for new media</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="settings.scanner_enabled"
|
||||||
|
@change="markChanged"
|
||||||
|
/>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item full-width">
|
||||||
|
<label class="setting-label">
|
||||||
|
Scan Interval
|
||||||
|
<span class="setting-description">How often should the scanner run automatically</span>
|
||||||
|
</label>
|
||||||
|
<div class="interval-config">
|
||||||
|
<select v-model="scanInterval" class="setting-input" @change="handleIntervalChange">
|
||||||
|
<option :value="15">Every 15 minutes</option>
|
||||||
|
<option :value="30">Every 30 minutes</option>
|
||||||
|
<option :value="60">Every hour</option>
|
||||||
|
<option :value="120">Every 2 hours</option>
|
||||||
|
<option :value="180">Every 3 hours</option>
|
||||||
|
<option :value="360">Every 6 hours (recommended)</option>
|
||||||
|
<option :value="720">Every 12 hours</option>
|
||||||
|
<option :value="1440">Every 24 hours (daily)</option>
|
||||||
|
<option value="custom">Custom...</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div v-if="scanInterval === 'custom'" class="custom-interval-input">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="customScanInterval"
|
||||||
|
class="setting-input"
|
||||||
|
min="1"
|
||||||
|
max="10080"
|
||||||
|
placeholder="Minutes"
|
||||||
|
@input="handleCustomIntervalChange"
|
||||||
|
/>
|
||||||
|
<span class="help-text">Between 1 minute and 7 days (10080 minutes)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="interval-preview">
|
||||||
|
<span class="preview-icon">📅</span>
|
||||||
|
<span class="preview-text">
|
||||||
|
Scans will run approximately every: <strong>{{ getScanIntervalText() }}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item full-width">
|
||||||
|
<label class="setting-label">
|
||||||
|
Enable File Watcher
|
||||||
|
<span class="setting-description">Watch for new files in real-time</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="settings.watcher_enabled"
|
||||||
|
@change="markChanged"
|
||||||
|
/>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bazarr Provider Settings - Solo en modo Bazarr -->
|
||||||
|
<div v-if="!isStandalone" class="card settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">🔌 Bazarr Provider Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-item full-width">
|
||||||
|
<label class="setting-label">
|
||||||
|
Provider Enabled
|
||||||
|
<span class="setting-description">Enable Bazarr provider API</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="settings.bazarr_provider_enabled"
|
||||||
|
@change="markChanged"
|
||||||
|
/>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="bazarrApiKey" class="setting-item full-width">
|
||||||
|
<label class="setting-label">
|
||||||
|
API Key
|
||||||
|
<span class="setting-description">Use this key to configure Bazarr</span>
|
||||||
|
</label>
|
||||||
|
<div class="copy-field">
|
||||||
|
<code>{{ bazarrApiKey }}</code>
|
||||||
|
<button @click="copyToClipboard(bazarrApiKey)" class="btn-icon">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Path Browser Modal -->
|
||||||
|
<div v-if="showPathBrowser" class="modal-overlay" @click.self="showPathBrowser = false">
|
||||||
|
<PathBrowser @select="addPath" @close="showPathBrowser = false" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useConfigStore } from '@/stores/config'
|
||||||
|
import PathBrowser from '@/components/PathBrowser.vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const hasChanges = ref(false)
|
||||||
|
const showPathBrowser = ref(false)
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
const settings = ref({
|
||||||
|
operation_mode: 'standalone',
|
||||||
|
log_level: 'INFO',
|
||||||
|
worker_cpu_count: 0,
|
||||||
|
worker_gpu_count: 0,
|
||||||
|
worker_healthcheck_interval: 30,
|
||||||
|
worker_auto_restart: true,
|
||||||
|
whisper_model: 'large-v3',
|
||||||
|
cpu_compute_type: 'auto',
|
||||||
|
gpu_compute_type: 'auto',
|
||||||
|
skip_if_exists: true,
|
||||||
|
scanner_enabled: false,
|
||||||
|
scanner_cron: '0 2 * * *',
|
||||||
|
watcher_enabled: false,
|
||||||
|
bazarr_provider_enabled: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const libraryPaths = ref<string[]>([])
|
||||||
|
const bazarrApiKey = ref('')
|
||||||
|
|
||||||
|
// Scanner interval configuration
|
||||||
|
const scanInterval = ref<number | 'custom'>(360) // Default: 6 hours
|
||||||
|
const customScanInterval = ref(90)
|
||||||
|
|
||||||
|
const hasGPU = computed(() => configStore.hasGPU)
|
||||||
|
const isStandalone = computed(() => settings.value.operation_mode === 'standalone')
|
||||||
|
|
||||||
|
function markChanged() {
|
||||||
|
hasChanges.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
loading.value = true
|
||||||
|
hasChanges.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/settings')
|
||||||
|
const settingsMap: Record<string, any> = {}
|
||||||
|
|
||||||
|
response.data.forEach((setting: any) => {
|
||||||
|
settingsMap[setting.key] = setting.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parse settings
|
||||||
|
settings.value.operation_mode = settingsMap['operation_mode'] || 'standalone'
|
||||||
|
settings.value.log_level = settingsMap['log_level'] || 'INFO'
|
||||||
|
settings.value.worker_cpu_count = parseInt(settingsMap['worker_cpu_count'] || '0')
|
||||||
|
// Force GPU worker count to 0 if no GPU detected
|
||||||
|
settings.value.worker_gpu_count = hasGPU.value ? parseInt(settingsMap['worker_gpu_count'] || '0') : 0
|
||||||
|
settings.value.worker_healthcheck_interval = parseInt(settingsMap['worker_healthcheck_interval'] || '30')
|
||||||
|
settings.value.worker_auto_restart = settingsMap['worker_auto_restart'] === 'true'
|
||||||
|
settings.value.whisper_model = settingsMap['whisper_model'] || 'large-v3'
|
||||||
|
settings.value.cpu_compute_type = settingsMap['cpu_compute_type'] || settingsMap['compute_type'] || 'auto'
|
||||||
|
settings.value.gpu_compute_type = settingsMap['gpu_compute_type'] || settingsMap['compute_type'] || 'auto'
|
||||||
|
settings.value.skip_if_exists = settingsMap['skip_if_exists'] !== 'false'
|
||||||
|
settings.value.scanner_enabled = settingsMap['scanner_enabled'] === 'true'
|
||||||
|
settings.value.scanner_cron = settingsMap['scanner_cron'] || '0 2 * * *'
|
||||||
|
settings.value.watcher_enabled = settingsMap['watcher_enabled'] === 'true'
|
||||||
|
settings.value.bazarr_provider_enabled = settingsMap['bazarr_provider_enabled'] === 'true'
|
||||||
|
|
||||||
|
// Parse library paths
|
||||||
|
const pathsStr = settingsMap['library_paths'] || ''
|
||||||
|
libraryPaths.value = pathsStr ? pathsStr.split(',').map((p: string) => p.trim()).filter((p: string) => p) : []
|
||||||
|
|
||||||
|
// Get Bazarr API key if exists
|
||||||
|
bazarrApiKey.value = settingsMap['bazarr_api_key'] || ''
|
||||||
|
|
||||||
|
// Load scanner interval
|
||||||
|
const interval = parseInt(settingsMap['scanner_schedule_interval_minutes'] || '360')
|
||||||
|
const presets = [15, 30, 60, 120, 180, 360, 720, 1440]
|
||||||
|
if (presets.includes(interval)) {
|
||||||
|
scanInterval.value = interval
|
||||||
|
} else {
|
||||||
|
scanInterval.value = 'custom'
|
||||||
|
customScanInterval.value = interval
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load settings:', error)
|
||||||
|
alert('Failed to load settings')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
saving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Calculate final scan interval
|
||||||
|
const finalScanInterval = scanInterval.value === 'custom' ? customScanInterval.value : scanInterval.value
|
||||||
|
|
||||||
|
// Force GPU worker count to 0 if no GPU detected
|
||||||
|
const gpuWorkerCount = hasGPU.value ? settings.value.worker_gpu_count : 0
|
||||||
|
|
||||||
|
const updates: Record<string, string> = {
|
||||||
|
operation_mode: settings.value.operation_mode,
|
||||||
|
log_level: settings.value.log_level,
|
||||||
|
worker_cpu_count: settings.value.worker_cpu_count.toString(),
|
||||||
|
worker_gpu_count: gpuWorkerCount.toString(),
|
||||||
|
worker_healthcheck_interval: settings.value.worker_healthcheck_interval.toString(),
|
||||||
|
worker_auto_restart: settings.value.worker_auto_restart.toString(),
|
||||||
|
whisper_model: settings.value.whisper_model,
|
||||||
|
cpu_compute_type: settings.value.cpu_compute_type,
|
||||||
|
gpu_compute_type: settings.value.gpu_compute_type,
|
||||||
|
skip_if_exists: settings.value.skip_if_exists.toString(),
|
||||||
|
scanner_enabled: settings.value.scanner_enabled.toString(),
|
||||||
|
scanner_schedule_interval_minutes: finalScanInterval.toString(),
|
||||||
|
watcher_enabled: settings.value.watcher_enabled.toString(),
|
||||||
|
bazarr_provider_enabled: settings.value.bazarr_provider_enabled.toString(),
|
||||||
|
library_paths: libraryPaths.value.join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.post('/api/settings/bulk-update', { settings: updates })
|
||||||
|
|
||||||
|
hasChanges.value = false
|
||||||
|
alert('Settings saved successfully! Some changes may require a restart.')
|
||||||
|
|
||||||
|
// Reload config
|
||||||
|
await configStore.fetchConfig()
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save settings:', error)
|
||||||
|
alert('Failed to save settings: ' + (error.response?.data?.detail || error.message))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPath(path: string) {
|
||||||
|
if (path && !libraryPaths.value.includes(path)) {
|
||||||
|
libraryPaths.value.push(path)
|
||||||
|
markChanged()
|
||||||
|
}
|
||||||
|
showPathBrowser.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePath(index: number) {
|
||||||
|
libraryPaths.value.splice(index, 1)
|
||||||
|
markChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(text: string) {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
alert('Copied to clipboard!')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIntervalChange() {
|
||||||
|
markChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCustomIntervalChange() {
|
||||||
|
markChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScanIntervalText(): string {
|
||||||
|
const interval = scanInterval.value === 'custom' ? customScanInterval.value : scanInterval.value
|
||||||
|
|
||||||
|
if (!interval || interval <= 0) return 'Invalid interval'
|
||||||
|
|
||||||
|
if (interval < 60) {
|
||||||
|
return `${interval} minutes`
|
||||||
|
} else if (interval < 1440) {
|
||||||
|
const hours = Math.floor(interval / 60)
|
||||||
|
const mins = interval % 60
|
||||||
|
return mins > 0 ? `${hours}h ${mins}m` : `${hours} hours`
|
||||||
|
} else {
|
||||||
|
const days = Math.floor(interval / 1440)
|
||||||
|
const hours = Math.floor((interval % 1440) / 60)
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${days} days ${hours}h`
|
||||||
|
}
|
||||||
|
return `${days} days`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Detect GPU first so we can properly handle GPU worker count
|
||||||
|
await configStore.detectGPU()
|
||||||
|
await loadSettings()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-description {
|
||||||
|
display: block;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-input {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: var(--tertiary-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: var(--secondary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-message {
|
||||||
|
color: var(--warning-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch */
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 50px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: var(--text-secondary);
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .toggle-slider {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .toggle-slider:before {
|
||||||
|
transform: translateX(24px);
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paths List */
|
||||||
|
.paths-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background: var(--tertiary-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-code {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--primary-bg);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--secondary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background: var(--tertiary-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-field code {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-family: monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scanner Interval Configuration */
|
||||||
|
.interval-config {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-interval-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: var(--tertiary-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border-left: 3px solid var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: var(--tertiary-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-text strong {
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
450
frontend/src/views/WorkersView.vue
Normal file
450
frontend/src/views/WorkersView.vue
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
<template>
|
||||||
|
<div class="workers-view">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Worker Management</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button @click="showAddWorkerModal = true" class="btn btn-success">
|
||||||
|
➕ Add Worker
|
||||||
|
</button>
|
||||||
|
<button @click="refreshWorkers" class="btn btn-secondary" :disabled="loading">
|
||||||
|
🔄 Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Worker Stats -->
|
||||||
|
<div v-if="workersStore.stats" class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Pool Statistics</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stats-grid-large">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">👷</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-number">{{ workersStore.stats.total_workers }}</div>
|
||||||
|
<div class="stat-label">Total Workers</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">💻</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-number">{{ workersStore.stats.cpu_workers }}</div>
|
||||||
|
<div class="stat-label">CPU Workers</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">🎮</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-number">{{ workersStore.stats.gpu_workers }}</div>
|
||||||
|
<div class="stat-label">GPU Workers</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">✅</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-number text-success">{{ workersStore.stats.total_jobs_completed }}</div>
|
||||||
|
<div class="stat-label">Jobs Completed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Workers List -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Active Workers</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-if="loading" class="spinner"></div>
|
||||||
|
|
||||||
|
<div v-else-if="workersStore.workers.length === 0" class="empty-state">
|
||||||
|
<p>No workers running</p>
|
||||||
|
<button @click="showAddWorkerModal = true" class="btn btn-primary">Add First Worker</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-else class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Worker ID</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Current Job</th>
|
||||||
|
<th>Progress</th>
|
||||||
|
<th>Completed</th>
|
||||||
|
<th>Failed</th>
|
||||||
|
<th>Uptime</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="worker in workersStore.workers" :key="worker.worker_id">
|
||||||
|
<td class="worker-id">{{ worker.worker_id }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="worker.worker_type === 'gpu' ? 'badge-processing' : 'badge-queued'">
|
||||||
|
{{ worker.worker_type.toUpperCase() }}
|
||||||
|
<span v-if="worker.device_id !== null">:{{ worker.device_id }}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="`badge-${worker.status}`">
|
||||||
|
{{ worker.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="worker.current_job_id" class="job-id">{{ worker.current_job_id.slice(0, 8) }}...</span>
|
||||||
|
<span v-else class="text-muted">—</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div v-if="worker.current_job_progress > 0" class="progress-container">
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar" :style="{ width: `${worker.current_job_progress}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-text">{{ worker.current_job_progress.toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-muted">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-success">{{ worker.jobs_completed }}</td>
|
||||||
|
<td class="text-danger">{{ worker.jobs_failed }}</td>
|
||||||
|
<td>{{ formatUptime(worker.uptime_seconds) }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
@click="removeWorker(worker.worker_id)"
|
||||||
|
class="btn btn-danger btn-sm"
|
||||||
|
:disabled="worker.status === 'busy'"
|
||||||
|
>
|
||||||
|
🗑️ Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Worker Modal -->
|
||||||
|
<div v-if="showAddWorkerModal" class="modal-overlay" @click.self="showAddWorkerModal = false">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Add Worker</h2>
|
||||||
|
<button @click="showAddWorkerModal = false" class="btn-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Worker Type</label>
|
||||||
|
<select v-model="newWorker.worker_type" class="form-control">
|
||||||
|
<option value="cpu">CPU</option>
|
||||||
|
<option value="gpu" :disabled="!configStore.hasGPU">
|
||||||
|
GPU {{ !configStore.hasGPU ? '(Not detected)' : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span v-if="!configStore.hasGPU" class="warning-text">
|
||||||
|
⚠️ No GPU detected on this system
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="newWorker.worker_type === 'gpu'" class="form-group">
|
||||||
|
<label>GPU Device ID</label>
|
||||||
|
<input v-model.number="newWorker.device_id" type="number" min="0" class="form-control" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button @click="showAddWorkerModal = false" class="btn btn-secondary">Cancel</button>
|
||||||
|
<button @click="addWorker" class="btn btn-success" :disabled="addingWorker">
|
||||||
|
{{ addingWorker ? 'Adding...' : 'Add Worker' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useWorkersStore } from '@/stores/workers'
|
||||||
|
import { useConfigStore } from '@/stores/config'
|
||||||
|
import type { AddWorkerRequest } from '@/types/api'
|
||||||
|
|
||||||
|
const workersStore = useWorkersStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const showAddWorkerModal = ref(false)
|
||||||
|
const addingWorker = ref(false)
|
||||||
|
const newWorker = ref<AddWorkerRequest>({
|
||||||
|
worker_type: 'cpu',
|
||||||
|
device_id: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
let refreshInterval: number | null = null
|
||||||
|
|
||||||
|
async function loadWorkers() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await workersStore.fetchWorkers()
|
||||||
|
await workersStore.fetchStats()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load workers:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshWorkers() {
|
||||||
|
await loadWorkers()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addWorker() {
|
||||||
|
addingWorker.value = true
|
||||||
|
try {
|
||||||
|
await workersStore.addWorker(newWorker.value)
|
||||||
|
showAddWorkerModal.value = false
|
||||||
|
// Reset form
|
||||||
|
newWorker.value = {
|
||||||
|
worker_type: 'cpu',
|
||||||
|
device_id: 0
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
alert('Failed to add worker: ' + (error.message || 'Unknown error'))
|
||||||
|
} finally {
|
||||||
|
addingWorker.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeWorker(workerId: string) {
|
||||||
|
if (!confirm(`Are you sure you want to remove worker ${workerId}?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await workersStore.removeWorker(workerId)
|
||||||
|
} catch (error: any) {
|
||||||
|
alert('Failed to remove worker: ' + (error.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`
|
||||||
|
return `${minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadWorkers()
|
||||||
|
// Auto-refresh every 3 seconds
|
||||||
|
refreshInterval = window.setInterval(loadWorkers, 3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid-large {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-id {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-id {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background-color: var(--tertiary-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-text {
|
||||||
|
color: var(--warning-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
14
frontend/tsconfig.json
Normal file
14
frontend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"types": ["vite/client"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
34
frontend/vite.config.ts
Normal file
34
frontend/vite.config.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/health': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
assetsDir: 'assets',
|
||||||
|
sourcemap: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
Reference in New Issue
Block a user