feat(frontend): add Vue 3 web application
Some checks failed
Build_Subgen_Dockerfile_CPU / docker (push) Failing after 27s
Build_Subgen_Dockerfile_GPU / docker (push) Has been cancelled

- 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:
2026-01-16 16:59:15 +01:00
parent a14d13c9d0
commit 4efdce8983
29 changed files with 11207 additions and 0 deletions

25
frontend/.gitignore vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

29
frontend/package.json Normal file
View 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
View 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
View 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>&copy; 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>

View 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;
}
}

View 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>

View 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>

File diff suppressed because it is too large Load Diff

13
frontend/src/main.ts Normal file
View 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')

View 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

View 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

View 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
View 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
}
})

View 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
}
})

View 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
View 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
}

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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
View 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"]
}
}

File diff suppressed because one or more lines are too long

34
frontend/vite.config.ts Normal file
View 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,
}
})