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