Initial commit
This commit is contained in:
8
frontend/.gitignore
vendored
Normal file
8
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.env.local
|
||||
backend/venv/
|
||||
backend/__pycache__/
|
||||
backend/.env
|
||||
backend/*.pyc
|
||||
.DS_Store
|
||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copier package.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Installer les dépendances
|
||||
RUN npm ci
|
||||
|
||||
# Copier le code
|
||||
COPY . .
|
||||
|
||||
# Exposer le port
|
||||
EXPOSE 3000
|
||||
|
||||
# Commande de démarrage
|
||||
CMD ["npm", "run", "dev"]
|
||||
22
frontend/README.md
Normal file
22
frontend/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# InnotexBoard - Frontend
|
||||
|
||||
Interface d'administration Debian avec Vue.js 3 et Tailwind CSS
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
,n
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>InnotexBoard - Debian Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2468
frontend/package-lock.json
generated
Normal file
2468
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "innotexboard-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.6",
|
||||
"axios": "^1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"vite": "^5.0.2",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"postcss": "^8.4.31",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"@tailwindcss/forms": "^0.5.6"
|
||||
}
|
||||
}
|
||||
9
frontend/postcss.config.js
Normal file
9
frontend/postcss.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import tailwindcss from 'tailwindcss'
|
||||
import autoprefixer from 'autoprefixer'
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
tailwindcss,
|
||||
autoprefixer,
|
||||
],
|
||||
}
|
||||
87
frontend/src/App.vue
Normal file
87
frontend/src/App.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div id="app" class="min-h-screen bg-gray-900">
|
||||
<nav class="bg-gray-800 border-b border-gray-700 sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<h1 class="text-2xl font-bold text-blue-500">InnotexBoard</h1>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span v-if="authStore.isAuthenticated" class="text-gray-300">
|
||||
{{ authStore.username }}
|
||||
</span>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
@click="handleLogout"
|
||||
class="btn btn-danger btn-small"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div v-if="authStore.isAuthenticated" class="flex">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-64 bg-gray-800 border-r border-gray-700 min-h-screen">
|
||||
<nav class="p-4 space-y-2">
|
||||
<router-link
|
||||
to="/dashboard"
|
||||
class="block px-4 py-2 rounded-lg hover:bg-gray-700 transition"
|
||||
:class="{ 'bg-gray-700': $route.path === '/dashboard' }"
|
||||
>
|
||||
📊 Dashboard
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/containers"
|
||||
class="block px-4 py-2 rounded-lg hover:bg-gray-700 transition"
|
||||
:class="{ 'bg-gray-700': $route.path === '/containers' }"
|
||||
>
|
||||
🐳 Conteneurs Docker
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/disks"
|
||||
class="block px-4 py-2 rounded-lg hover:bg-gray-700 transition"
|
||||
:class="{ 'bg-gray-700': $route.path === '/disks' }"
|
||||
>
|
||||
💾 Disques et Partitions
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/packages"
|
||||
class="block px-4 py-2 rounded-lg hover:bg-gray-700 transition"
|
||||
:class="{ 'bg-gray-700': $route.path === '/packages' }"
|
||||
>
|
||||
📦 App Store
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/shortcuts"
|
||||
class="block px-4 py-2 rounded-lg hover:bg-gray-700 transition"
|
||||
:class="{ 'bg-gray-700': $route.path === '/shortcuts' }"
|
||||
>
|
||||
🔗 Raccourcis Services
|
||||
</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
<router-view v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
35
frontend/src/api/index.js
Normal file
35
frontend/src/api/index.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
|
||||
// Interceptor pour ajouter le token JWT
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Interceptor pour gérer les erreurs d'authentification
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
const authStore = useAuthStore()
|
||||
authStore.logout()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
37
frontend/src/assets/styles.css
Normal file
37
frontend/src/assets/styles.css
Normal file
@@ -0,0 +1,37 @@
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
:root {
|
||||
--color-primary: #3b82f6;
|
||||
--color-secondary: #10b981;
|
||||
--color-danger: #ef4444;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-900 text-gray-100 font-sans;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-gray-800 rounded-lg p-6 shadow-lg border border-gray-700;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 hover:bg-blue-700 text-white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-green-600 hover:bg-green-700 text-white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 hover:bg-red-700 text-white;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
@apply px-3 py-1 text-sm;
|
||||
}
|
||||
12
frontend/src/main.js
Normal file
12
frontend/src/main.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/styles.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
73
frontend/src/router/index.js
Normal file
73
frontend/src/router/index.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import HomelabView from '../views/HomelabView.vue'
|
||||
import DashboardView from '../views/DashboardView.vue'
|
||||
import ContainersView from '../views/ContainersView.vue'
|
||||
import DisksView from '../views/DisksView.vue'
|
||||
import PackagesView from '../views/PackagesView.vue'
|
||||
import ShortcutsView from '../views/ShortcutsView.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Homelab',
|
||||
component: HomelabView,
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: LoginView,
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: DashboardView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/containers',
|
||||
name: 'Containers',
|
||||
component: ContainersView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/disks',
|
||||
name: 'Disks',
|
||||
component: DisksView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/packages',
|
||||
name: 'Packages',
|
||||
component: PackagesView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/shortcuts',
|
||||
name: 'Shortcuts',
|
||||
component: ShortcutsView,
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && authStore.isAuthenticated) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
51
frontend/src/stores/auth.js
Normal file
51
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '../api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('token') || null)
|
||||
const username = ref(localStorage.getItem('username') || null)
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
async function login(username_input, password) {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('username', username_input)
|
||||
params.append('password', password)
|
||||
|
||||
const response = await api.post('/auth/login', params, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
})
|
||||
console.log('Login response:', response)
|
||||
token.value = response.data.access_token
|
||||
username.value = response.data.username
|
||||
|
||||
localStorage.setItem('token', token.value)
|
||||
localStorage.setItem('username', username.value)
|
||||
|
||||
console.log('Token stored:', token.value)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Erreur de connexion:', error)
|
||||
console.error('Error response:', error.response)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null
|
||||
username.value = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('username')
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
username,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout
|
||||
}
|
||||
})
|
||||
215
frontend/src/views/ContainersView.vue
Normal file
215
frontend/src/views/ContainersView.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="p-8">
|
||||
<h1 class="text-3xl font-bold mb-8 text-gray-100">🐳 Gestion Docker</h1>
|
||||
|
||||
<!-- Statut Docker -->
|
||||
<div class="mb-6">
|
||||
<div v-if="dockerStatus.connected" class="p-4 bg-green-500/20 border border-green-500/50 rounded-lg text-green-400">
|
||||
✓ Docker est connecté et accessible
|
||||
</div>
|
||||
<div v-else class="p-4 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400">
|
||||
✗ Docker n'est pas accessible. Vérifiez la configuration.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Boutons d'action -->
|
||||
<div class="mb-6 flex space-x-3">
|
||||
<button
|
||||
@click="fetchContainers"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
🔄 Rafraîchir
|
||||
</button>
|
||||
<button
|
||||
@click="toggleShowAll"
|
||||
:class="showAll ? 'btn btn-secondary' : 'btn btn-primary'"
|
||||
>
|
||||
{{ showAll ? 'Afficher actifs' : 'Afficher tous' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste des conteneurs -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div
|
||||
v-for="container in containers"
|
||||
:key="container.id"
|
||||
class="card hover:border-blue-500/50 transition"
|
||||
>
|
||||
<!-- En-tête du conteneur -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-blue-400">{{ container.name }}</h3>
|
||||
<p class="text-gray-400 text-sm">{{ container.image }}</p>
|
||||
<p class="text-gray-500 text-xs mt-1">ID: {{ container.id }}</p>
|
||||
</div>
|
||||
<span :class="getStatusBadgeClass(container.state)" class="px-3 py-1 rounded-full text-xs font-medium">
|
||||
{{ container.state }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Ressources -->
|
||||
<div class="grid grid-cols-2 gap-3 mb-4 text-sm">
|
||||
<div class="bg-gray-700/50 p-2 rounded">
|
||||
<p class="text-gray-400 text-xs">CPU</p>
|
||||
<p class="text-blue-400 font-semibold">{{ container.cpu_percent }}%</p>
|
||||
</div>
|
||||
<div class="bg-gray-700/50 p-2 rounded">
|
||||
<p class="text-gray-400 text-xs">Mémoire</p>
|
||||
<p class="text-green-400 font-semibold">{{ container.memory_usage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ports -->
|
||||
<div v-if="container.ports.length > 0" class="mb-4">
|
||||
<p class="text-gray-400 text-xs mb-2">Ports:</p>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="(port, idx) in container.ports"
|
||||
:key="idx"
|
||||
class="text-xs text-gray-300 bg-gray-700/30 px-2 py-1 rounded"
|
||||
>
|
||||
{{ port.public_port || '-' }}:{{ port.private_port }}/{{ port.type }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Boutons d'action -->
|
||||
<div class="flex space-x-2 pt-4 border-t border-gray-700">
|
||||
<button
|
||||
v-if="container.state !== 'running'"
|
||||
@click="actionContainer(container.id, 'start')"
|
||||
:disabled="actionLoading"
|
||||
class="btn btn-secondary btn-small flex-1"
|
||||
>
|
||||
▶ Démarrer
|
||||
</button>
|
||||
<button
|
||||
v-if="container.state === 'running'"
|
||||
@click="actionContainer(container.id, 'stop')"
|
||||
:disabled="actionLoading"
|
||||
class="btn btn-danger btn-small flex-1"
|
||||
>
|
||||
⏹ Arrêter
|
||||
</button>
|
||||
<button
|
||||
@click="actionContainer(container.id, 'restart')"
|
||||
:disabled="actionLoading"
|
||||
class="btn btn-primary btn-small"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
<button
|
||||
@click="actionContainer(container.id, 'delete')"
|
||||
:disabled="actionLoading"
|
||||
class="btn btn-danger btn-small"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message si aucun conteneur -->
|
||||
<div v-if="containers.length === 0 && !loading" class="text-center py-12">
|
||||
<p class="text-gray-400">Aucun conteneur {{ showAll ? '' : 'actif' }} trouvé</p>
|
||||
</div>
|
||||
|
||||
<!-- Notification d'action -->
|
||||
<div
|
||||
v-if="notification.show"
|
||||
:class="notification.type === 'success' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'"
|
||||
class="fixed bottom-4 right-4 p-4 rounded-lg border transition"
|
||||
>
|
||||
{{ notification.message }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../api'
|
||||
|
||||
const containers = ref([])
|
||||
const dockerStatus = ref({ connected: false })
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const showAll = ref(true)
|
||||
const notification = ref({ show: false, message: '', type: 'success' })
|
||||
|
||||
const fetchContainers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/docker/containers', {
|
||||
params: { all: showAll.value }
|
||||
})
|
||||
containers.value = response.data
|
||||
} catch (error) {
|
||||
showNotification('Erreur lors du chargement des conteneurs', 'error')
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const checkDockerStatus = async () => {
|
||||
try {
|
||||
const response = await api.get('/docker/status')
|
||||
dockerStatus.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Erreur statut Docker:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const actionContainer = async (containerId, action) => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
if (action === 'start') {
|
||||
await api.post(`/docker/containers/${containerId}/start`)
|
||||
} else if (action === 'stop') {
|
||||
await api.post(`/docker/containers/${containerId}/stop`)
|
||||
} else if (action === 'restart') {
|
||||
await api.post(`/docker/containers/${containerId}/restart`)
|
||||
} else if (action === 'delete') {
|
||||
if (confirm('Êtes-vous sûr de vouloir supprimer ce conteneur ?')) {
|
||||
await api.delete(`/docker/containers/${containerId}`)
|
||||
} else {
|
||||
actionLoading.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
showNotification(`Conteneur ${action}é avec succès`, 'success')
|
||||
await fetchContainers()
|
||||
} catch (error) {
|
||||
showNotification(`Erreur lors du ${action} du conteneur`, 'error')
|
||||
console.error(error)
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleShowAll = () => {
|
||||
showAll.value = !showAll.value
|
||||
fetchContainers()
|
||||
}
|
||||
|
||||
const getStatusBadgeClass = (state) => {
|
||||
const baseClass = 'px-2 py-1 rounded-full text-xs font-medium'
|
||||
if (state === 'running') return baseClass + ' bg-green-500/30 text-green-400'
|
||||
if (state === 'exited') return baseClass + ' bg-red-500/30 text-red-400'
|
||||
return baseClass + ' bg-gray-600/30 text-gray-400'
|
||||
}
|
||||
|
||||
const showNotification = (message, type = 'success') => {
|
||||
notification.value = { show: true, message, type }
|
||||
setTimeout(() => {
|
||||
notification.value.show = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkDockerStatus()
|
||||
fetchContainers()
|
||||
})
|
||||
</script>
|
||||
281
frontend/src/views/DashboardView.vue
Normal file
281
frontend/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="bg-gray-900 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="bg-gray-800 border-b border-gray-700 p-6">
|
||||
<h1 class="text-4xl font-bold text-white">📊 Moniteur Système</h1>
|
||||
<p class="text-gray-400 mt-2">Suivi en temps réel des ressources</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats principales (grille 3 colonnes) -->
|
||||
<div class="p-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- CPU -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold text-white">💻 CPU</h3>
|
||||
<span class="text-sm text-gray-400">{{ stats.cpu?.cores }} cores</span>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span class="text-sm text-gray-300">Moyenne</span>
|
||||
<span class="text-2xl font-bold" :class="cpuColor">{{ Math.round(stats.cpu?.average || 0) }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
class="h-3 rounded-full progress-bar"
|
||||
:class="cpuColorBg"
|
||||
:style="{ width: (stats.cpu?.average || 0) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="stats.cpu?.freq" class="text-xs text-gray-400">
|
||||
📈 Fréquence: {{ stats.cpu.freq.toFixed(2) }} GHz
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mémoire -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold text-white">🧠 Mémoire</h3>
|
||||
<span class="text-sm text-gray-400">{{ formatBytes(stats.memory?.total) }}</span>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span class="text-sm text-gray-300">Utilisée</span>
|
||||
<span class="text-2xl font-bold" :class="memoryColor">{{ Math.round(stats.memory?.percent || 0) }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
class="h-3 rounded-full progress-bar"
|
||||
:class="memoryColorBg"
|
||||
:style="{ width: (stats.memory?.percent || 0) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
{{ formatBytes(stats.memory?.used) }} / {{ formatBytes(stats.memory?.total) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disque -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-bold text-white mb-4">💾 Disque</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-for="disk in stats.disk" :key="disk.device" class="space-y-1">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-300">{{ disk.device }}</span>
|
||||
<span :class="getDiskColor(disk.percent)">{{ Math.round(disk.percent) }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-700 rounded h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-2 progress-bar"
|
||||
:class="getDiskColorBg(disk.percent)"
|
||||
:style="{ width: disk.percent + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">{{ formatBytes(disk.used) }} / {{ formatBytes(disk.total) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cores détails et Processus (2 colonnes) -->
|
||||
<div class="p-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Cores individuels -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-bold text-white mb-4">⚙️ Coeurs CPU</h3>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<div v-for="(cpu, idx) in stats.cpu?.per_cpu" :key="idx" class="bg-gray-700 rounded p-3 text-center">
|
||||
<div class="text-xs text-gray-400 mb-1">CPU {{ idx + 1 }}</div>
|
||||
<div class="text-xl font-bold" :class="{
|
||||
'text-green-400': cpu < 50,
|
||||
'text-yellow-400': cpu >= 50 && cpu < 80,
|
||||
'text-red-400': cpu >= 80
|
||||
}">{{ Math.round(cpu) }}%</div>
|
||||
<div class="w-full bg-gray-600 rounded h-1 mt-2">
|
||||
<div
|
||||
class="h-1 rounded progress-bar"
|
||||
:class="{
|
||||
'bg-green-500': cpu < 50,
|
||||
'bg-yellow-500': cpu >= 50 && cpu < 80,
|
||||
'bg-red-500': cpu >= 80
|
||||
}"
|
||||
:style="{ width: cpu + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Processus -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-bold text-white mb-4">🔝 Top Processus</h3>
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<div v-for="proc in stats.processes" :key="proc.pid" class="bg-gray-700 rounded p-3 hover:bg-gray-600 transition">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold text-white truncate">{{ proc.name }}</div>
|
||||
<div class="text-xs text-gray-400">PID: {{ proc.pid }} | {{ proc.username }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-bold text-orange-400">{{ Math.round(proc.cpu_percent) }}%</div>
|
||||
<div class="text-xs text-gray-400">{{ Math.round(proc.memory_percent * 10) / 10 }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<div class="bg-gray-600 rounded h-1">
|
||||
<div class="bg-orange-500 h-1 progress-bar" :style="{ width: Math.min(proc.cpu_percent, 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="bg-gray-600 rounded h-1">
|
||||
<div class="bg-purple-500 h-1 progress-bar" :style="{ width: proc.memory_percent + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!stats.processes || stats.processes.length === 0" class="text-center text-gray-500 py-8">
|
||||
Aucun processus actif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Réseau (bottom) -->
|
||||
<div class="p-6">
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-bold text-white mb-4">🌐 Réseau</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="bg-gray-700 rounded p-4 text-center">
|
||||
<div class="text-xs text-gray-400 mb-2">📤 Envoyé</div>
|
||||
<div class="text-xl font-bold text-blue-400">{{ formatBytes(stats.network?.bytes_sent || 0) }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ formatBytes(stats.network?.packets_sent || 0) }} packets</div>
|
||||
</div>
|
||||
<div class="bg-gray-700 rounded p-4 text-center">
|
||||
<div class="text-xs text-gray-400 mb-2">📥 Reçu</div>
|
||||
<div class="text-xl font-bold text-green-400">{{ formatBytes(stats.network?.bytes_recv || 0) }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ formatBytes(stats.network?.packets_recv || 0) }} packets</div>
|
||||
</div>
|
||||
<div class="bg-gray-700 rounded p-4 text-center">
|
||||
<div class="text-xs text-gray-400 mb-2">💾 Mémoire Cache</div>
|
||||
<div class="text-xl font-bold text-cyan-400">{{ formatBytes(stats.memory?.cached || 0) }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-700 rounded p-4 text-center">
|
||||
<div class="text-xs text-gray-400 mb-2">📊 Disponible</div>
|
||||
<div class="text-xl font-bold text-emerald-400">{{ formatBytes(stats.memory?.available || 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const stats = ref({
|
||||
cpu: null,
|
||||
memory: null,
|
||||
disk: [],
|
||||
network: null,
|
||||
processes: []
|
||||
})
|
||||
|
||||
let ws = null
|
||||
|
||||
const connectWebSocket = () => {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${wsProtocol}//localhost:8000/api/v1/ws/system`
|
||||
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connecté')
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
stats.value = data
|
||||
} catch (error) {
|
||||
console.error('Erreur parsing:', error)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket erreur:', error)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('Reconnexion dans 2s...')
|
||||
setTimeout(connectWebSocket, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const cpuColor = computed(() => {
|
||||
const cpu = stats.value.cpu?.average || 0
|
||||
if (cpu < 50) return 'text-green-400'
|
||||
if (cpu < 80) return 'text-yellow-400'
|
||||
return 'text-red-400'
|
||||
})
|
||||
|
||||
const cpuColorBg = computed(() => {
|
||||
const cpu = stats.value.cpu?.average || 0
|
||||
if (cpu < 50) return 'bg-green-500'
|
||||
if (cpu < 80) return 'bg-yellow-500'
|
||||
return 'bg-red-500'
|
||||
})
|
||||
|
||||
const memoryColor = computed(() => {
|
||||
const mem = stats.value.memory?.percent || 0
|
||||
if (mem < 50) return 'text-green-400'
|
||||
if (mem < 80) return 'text-yellow-400'
|
||||
return 'text-red-400'
|
||||
})
|
||||
|
||||
const memoryColorBg = computed(() => {
|
||||
const mem = stats.value.memory?.percent || 0
|
||||
if (mem < 50) return 'bg-green-500'
|
||||
if (mem < 80) return 'bg-yellow-500'
|
||||
return 'bg-red-500'
|
||||
})
|
||||
|
||||
const getDiskColor = (percent) => {
|
||||
if (percent < 50) return 'text-green-400'
|
||||
if (percent < 80) return 'text-yellow-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
const getDiskColorBg = (percent) => {
|
||||
if (percent < 50) return 'bg-green-500'
|
||||
if (percent < 80) return 'bg-yellow-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return (Math.round(bytes / Math.pow(k, i) * 100) / 100) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connectWebSocket()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ws) ws.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
@apply bg-gray-800 border border-gray-700 rounded-lg p-4;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
transition: width 0.3s ease-out;
|
||||
will-change: width;
|
||||
}
|
||||
</style>
|
||||
206
frontend/src/views/DisksView.vue
Normal file
206
frontend/src/views/DisksView.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="bg-gray-900 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="bg-gray-800 border-b border-gray-700 p-6">
|
||||
<h1 class="text-4xl font-bold text-white">💾 Disques et Partitions</h1>
|
||||
<p class="text-gray-400 mt-2">Suivi des disques et volumes</p>
|
||||
</div>
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<div class="p-6">
|
||||
<!-- Statistiques globales -->
|
||||
<div v-if="disksData" class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Total Size -->
|
||||
<div class="card">
|
||||
<div class="flex items-center mb-4">
|
||||
<span class="text-3xl mr-3">📊</span>
|
||||
<h3 class="text-lg font-bold text-white">Taille Totale</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold text-blue-400">{{ disksData.total_size }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Total Used -->
|
||||
<div class="card">
|
||||
<div class="flex items-center mb-4">
|
||||
<span class="text-3xl mr-3">📉</span>
|
||||
<h3 class="text-lg font-bold text-white">Utilisé</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold text-red-400">{{ disksData.total_used }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Total Available -->
|
||||
<div class="card">
|
||||
<div class="flex items-center mb-4">
|
||||
<span class="text-3xl mr-3">📈</span>
|
||||
<h3 class="text-lg font-bold text-white">Disponible</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold text-green-400">{{ disksData.total_available }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disques et Partitions -->
|
||||
<div v-if="disksData && disksData.devices.length > 0" class="space-y-6">
|
||||
<div v-for="device in disksData.devices" :key="device.name" class="card">
|
||||
<!-- En-tête du disque -->
|
||||
<div class="flex items-center justify-between mb-6 pb-4 border-b border-gray-700">
|
||||
<div class="flex items-center">
|
||||
<span class="text-3xl mr-3">🖥️</span>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-white">/dev/{{ device.name }}</h3>
|
||||
<p class="text-sm text-gray-400">{{ device.type }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-bold text-gray-300">{{ device.size }}</p>
|
||||
<p v-if="device.mountpoint" class="text-sm text-gray-400">{{ device.mountpoint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression principale -->
|
||||
<div class="mb-6">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<span class="text-sm font-medium text-gray-300">Utilisation</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-lg font-bold" :class="getProgressColor(device.percent_used)">
|
||||
{{ Math.round(device.percent_used) }}%
|
||||
</span>
|
||||
<span class="text-sm text-gray-400">{{ device.used }} / {{ device.available }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression avec dégradé -->
|
||||
<div class="w-full bg-gray-700 rounded-full h-4 overflow-hidden relative">
|
||||
<div
|
||||
class="h-4 rounded-full transition-all duration-300"
|
||||
:class="getProgressBarClass(device.percent_used)"
|
||||
:style="{ width: Math.min(device.percent_used, 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Partitions -->
|
||||
<div v-if="device.partitions && device.partitions.length > 0" class="mt-6 pt-6 border-t border-gray-700">
|
||||
<h4 class="text-sm font-semibold text-gray-300 mb-4">Partitions:</h4>
|
||||
<div class="space-y-4 ml-4">
|
||||
<div v-for="partition in device.partitions" :key="partition.name" class="bg-gray-800 rounded-lg p-4">
|
||||
<!-- Header partition -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<span class="text-lg mr-2">📁</span>
|
||||
<div>
|
||||
<p class="font-medium text-gray-200">/dev/{{ partition.name }}</p>
|
||||
<p v-if="partition.mountpoint" class="text-xs text-gray-400">{{ partition.mountpoint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-bold text-gray-300">{{ partition.size }}</p>
|
||||
<p class="text-xs text-gray-400">{{ partition.type }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression partition -->
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-xs text-gray-400">Utilisé</span>
|
||||
<span class="text-sm font-semibold" :class="getProgressColor(partition.percent_used)">
|
||||
{{ Math.round(partition.percent_used) }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-gray-600 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
class="h-3 rounded-full transition-all duration-300"
|
||||
:class="getProgressBarClass(partition.percent_used)"
|
||||
:style="{ width: Math.min(partition.percent_used, 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Détails -->
|
||||
<div class="flex justify-between mt-2 text-xs text-gray-400">
|
||||
<span>{{ partition.used }} utilisé</span>
|
||||
<span>{{ partition.available }} disponible</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- État de chargement -->
|
||||
<div v-else-if="loading" class="card text-center py-12">
|
||||
<p class="text-gray-400">Chargement des disques...</p>
|
||||
</div>
|
||||
|
||||
<!-- Erreur -->
|
||||
<div v-else class="card text-center py-12">
|
||||
<p class="text-red-400">Impossible de charger les disques</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import api from '../api'
|
||||
|
||||
export default {
|
||||
name: 'DisksView',
|
||||
data() {
|
||||
return {
|
||||
disksData: null,
|
||||
loading: true,
|
||||
refreshInterval: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
authStore() {
|
||||
return useAuthStore()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchDisks() {
|
||||
try {
|
||||
const response = await api.get('/system/disks')
|
||||
this.disksData = response.data
|
||||
this.loading = false
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error)
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
getProgressColor(percent) {
|
||||
if (percent < 50) return 'text-green-400'
|
||||
if (percent < 75) return 'text-yellow-400'
|
||||
if (percent < 90) return 'text-orange-400'
|
||||
return 'text-red-400'
|
||||
},
|
||||
getProgressBarClass(percent) {
|
||||
if (percent < 50) return 'bg-gradient-to-r from-green-500 to-green-400'
|
||||
if (percent < 75) return 'bg-gradient-to-r from-yellow-500 to-yellow-400'
|
||||
if (percent < 90) return 'bg-gradient-to-r from-orange-500 to-orange-400'
|
||||
return 'bg-gradient-to-r from-red-500 to-red-400'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchDisks()
|
||||
// Rafraîchir chaque 30 secondes
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.fetchDisks()
|
||||
}, 30000)
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
@apply bg-gray-800 rounded-lg shadow-lg p-6 border border-gray-700;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@apply border-gray-600 shadow-xl transition-all duration-300;
|
||||
}
|
||||
</style>
|
||||
269
frontend/src/views/HomelabView.vue
Normal file
269
frontend/src/views/HomelabView.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
|
||||
<!-- Header -->
|
||||
<div class="sticky top-0 z-40 backdrop-blur-xl bg-gray-900/80 border-b border-gray-700/50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-white mb-2">
|
||||
<span class="bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
|
||||
InnotexBoard
|
||||
</span>
|
||||
</h1>
|
||||
<p class="text-gray-400">Votre Homelab Personnel</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span v-if="authStore.isAuthenticated" class="text-sm text-gray-300 bg-gray-800 px-3 py-1 rounded-full">
|
||||
{{ authStore.username }}
|
||||
</span>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
@click="goToAdmin"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition text-sm font-medium"
|
||||
>
|
||||
⚙️ Gérer
|
||||
</button>
|
||||
<button
|
||||
v-if="!authStore.isAuthenticated"
|
||||
@click="goToLogin"
|
||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition text-sm font-medium"
|
||||
>
|
||||
Connexion
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="handleLogout"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition text-sm font-medium"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<!-- Quick Stats (if authenticated) -->
|
||||
<div v-if="authStore.isAuthenticated" class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-12">
|
||||
<div class="bg-gray-800/50 backdrop-blur border border-gray-700/50 rounded-xl p-4">
|
||||
<div class="text-gray-400 text-sm mb-1">Total Services</div>
|
||||
<div class="text-3xl font-bold text-white">{{ shortcuts.length }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/50 backdrop-blur border border-gray-700/50 rounded-xl p-4">
|
||||
<div class="text-gray-400 text-sm mb-1">Catégories</div>
|
||||
<div class="text-3xl font-bold text-white">{{ categories.size }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/50 backdrop-blur border border-gray-700/50 rounded-xl p-4">
|
||||
<div class="text-gray-400 text-sm mb-1">État</div>
|
||||
<div class="text-3xl font-bold text-green-400">✓ Actif</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/50 backdrop-blur border border-gray-700/50 rounded-xl p-4">
|
||||
<div class="text-gray-400 text-sm mb-1">Dernier Update</div>
|
||||
<div class="text-lg font-bold text-white">{{ lastUpdate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shortcuts Grid by Category -->
|
||||
<div v-if="shortcuts.length > 0">
|
||||
<div v-for="(categoryShortcuts, category) in groupedShortcuts" :key="category" class="mb-12">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="h-1 w-8 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full mr-3"></div>
|
||||
<h2 class="text-2xl font-bold text-white capitalize">{{ getCategoryTitle(category) }}</h2>
|
||||
<span class="ml-3 px-3 py-1 bg-gray-700/50 text-gray-300 text-sm rounded-full">
|
||||
{{ categoryShortcuts.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<a
|
||||
v-for="shortcut in categoryShortcuts"
|
||||
:key="shortcut.id"
|
||||
:href="shortcut.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group relative bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700/50 hover:border-gray-600 rounded-xl p-6 transition-all duration-300 hover:shadow-2xl hover:shadow-blue-500/20 overflow-hidden"
|
||||
:style="{ borderLeftColor: shortcut.color || '#3b82f6', borderLeftWidth: '4px' }"
|
||||
>
|
||||
<!-- Background gradient on hover -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative z-10">
|
||||
<!-- Icon -->
|
||||
<div class="text-4xl mb-4 inline-block p-3 bg-gray-700/50 group-hover:bg-gray-600/50 rounded-lg transition">
|
||||
{{ shortcut.icon }}
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h3 class="font-bold text-white text-lg mb-2 group-hover:text-blue-400 transition truncate">
|
||||
{{ shortcut.name }}
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<p v-if="shortcut.description" class="text-gray-400 text-sm mb-4 line-clamp-2">
|
||||
{{ shortcut.description }}
|
||||
</p>
|
||||
|
||||
<!-- URL -->
|
||||
<div class="flex items-center text-gray-500 text-xs group-hover:text-gray-300 transition">
|
||||
<span class="truncate">{{ getHostname(shortcut.url) }}</span>
|
||||
<svg class="w-4 h-4 ml-2 opacity-0 group-hover:opacity-100 transition transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-13.5-2.5H21m0 0l-3-3m3 3l-3 3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete button (if authenticated) -->
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
@click.prevent.stop="deleteShortcut(shortcut.id)"
|
||||
class="absolute top-2 right-2 p-2 bg-red-500/0 hover:bg-red-500 text-red-400 hover:text-white rounded-lg transition opacity-0 group-hover:opacity-100 duration-300"
|
||||
title="Supprimer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="flex flex-col items-center justify-center py-20">
|
||||
<div class="text-6xl mb-4">🔗</div>
|
||||
<h3 class="text-2xl font-bold text-white mb-2">Aucun Service Configuré</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
<span v-if="authStore.isAuthenticated">
|
||||
Ajoutez vos premiers services pour les afficher ici
|
||||
</span>
|
||||
<span v-else>
|
||||
Connectez-vous pour gérer vos services
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
@click="goToAdmin"
|
||||
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition font-medium"
|
||||
>
|
||||
Ajouter un Service
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="border-t border-gray-800 mt-20 py-8">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-gray-500 text-sm">
|
||||
<p>InnotexBoard v1.0 • Votre centre de contrôle homelab personnel</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import api from '../api'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const shortcuts = ref([])
|
||||
const loading = ref(true)
|
||||
const lastUpdate = ref('À l\'instant')
|
||||
|
||||
const categories = computed(() => {
|
||||
return new Set(shortcuts.value.map(s => s.category || 'Autres'))
|
||||
})
|
||||
|
||||
const groupedShortcuts = computed(() => {
|
||||
const grouped = {}
|
||||
shortcuts.value.forEach(shortcut => {
|
||||
const cat = shortcut.category || 'Autres'
|
||||
if (!grouped[cat]) {
|
||||
grouped[cat] = []
|
||||
}
|
||||
grouped[cat].push(shortcut)
|
||||
})
|
||||
return grouped
|
||||
})
|
||||
|
||||
const fetchShortcuts = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await api.get('/shortcuts/')
|
||||
shortcuts.value = response.data || []
|
||||
lastUpdate.value = new Date().toLocaleTimeString('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des raccourcis:', error)
|
||||
shortcuts.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteShortcut = async (shortcutId) => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce raccourci ?')) return
|
||||
|
||||
try {
|
||||
await api.delete(`/shortcuts/${shortcutId}`)
|
||||
shortcuts.value = shortcuts.value.filter(s => s.id !== shortcutId)
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression:', error)
|
||||
alert('Erreur lors de la suppression du raccourci')
|
||||
}
|
||||
}
|
||||
|
||||
const getHostname = (url) => {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return u.hostname
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryTitle = (category) => {
|
||||
const titles = {
|
||||
'media': '🎬 Médias',
|
||||
'storage': '💾 Stockage',
|
||||
'tools': '🔧 Outils',
|
||||
'monitoring': '📊 Monitoring',
|
||||
'security': '🔒 Sécurité',
|
||||
'other': '📌 Autres'
|
||||
}
|
||||
return titles[category.toLowerCase()] || category
|
||||
}
|
||||
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const goToAdmin = () => {
|
||||
router.push('/shortcuts')
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// Auto-refresh shortcuts every 30 seconds
|
||||
onMounted(() => {
|
||||
fetchShortcuts()
|
||||
const interval = setInterval(fetchShortcuts, 30000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
90
frontend/src/views/LoginView.vue
Normal file
90
frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="card border-blue-600/50">
|
||||
<h2 class="text-3xl font-bold text-center mb-8 text-blue-400">InnotexBoard</h2>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Nom d'utilisateur
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="credentials.username"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
|
||||
placeholder="votre_utilisateur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="credentials.password"
|
||||
type="password"
|
||||
required
|
||||
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full btn btn-primary py-2 font-medium"
|
||||
>
|
||||
<span v-if="loading">Connexion en cours...</span>
|
||||
<span v-else>Se connecter</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-gray-400 text-sm mt-6">
|
||||
⚙️ Authentification via PAM du système Debian
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const credentials = ref({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const handleLogin = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
const success = await authStore.login(credentials.value.username, credentials.value.password)
|
||||
console.log('Login success result:', success)
|
||||
|
||||
if (success) {
|
||||
console.log('Navigating to dashboard')
|
||||
await router.push('/')
|
||||
} else {
|
||||
error.value = 'Identifiants incorrects. Veuillez réessayer.'
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
||||
365
frontend/src/views/PackagesView.vue
Normal file
365
frontend/src/views/PackagesView.vue
Normal file
@@ -0,0 +1,365 @@
|
||||
<template>
|
||||
<div class="bg-gray-900 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="bg-gray-800 border-b border-gray-700 p-6">
|
||||
<h1 class="text-4xl font-bold text-white">📦 App Store</h1>
|
||||
<p class="text-gray-400 mt-2">Gérer les paquets système</p>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="p-6">
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Total</p>
|
||||
<p class="text-3xl font-bold text-blue-400">{{ stats.total_packages || 0 }}</p>
|
||||
</div>
|
||||
<span class="text-4xl">📊</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Installés</p>
|
||||
<p class="text-3xl font-bold text-green-400">{{ stats.installed || 0 }}</p>
|
||||
</div>
|
||||
<span class="text-4xl">✅</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Mises à jour</p>
|
||||
<p class="text-3xl font-bold text-yellow-400">{{ stats.upgradable || 0 }}</p>
|
||||
</div>
|
||||
<span class="text-4xl">⬆️</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Disponibles</p>
|
||||
<p class="text-3xl font-bold text-purple-400">{{ stats.available || 0 }}</p>
|
||||
</div>
|
||||
<span class="text-4xl">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recherche et filtres -->
|
||||
<div class="card mb-6">
|
||||
<div class="flex gap-4 flex-col md:flex-row">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Rechercher un paquet..."
|
||||
class="flex-1 bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white placeholder-gray-400"
|
||||
@keyup="performSearch"
|
||||
/>
|
||||
<button @click="performSearch" class="btn btn-primary">
|
||||
🔍 Rechercher
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 mb-6 border-b border-gray-700">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab"
|
||||
@click="currentTab = tab"
|
||||
:class="[
|
||||
'px-4 py-2 font-medium transition',
|
||||
currentTab === tab
|
||||
? 'text-blue-400 border-b-2 border-blue-400'
|
||||
: 'text-gray-400 hover:text-gray-300'
|
||||
]"
|
||||
>
|
||||
{{ tab === 'installed' ? '✅ Installés' : '🔍 Résultats' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste des paquets -->
|
||||
<div v-if="!loading" class="space-y-4">
|
||||
<div v-for="pkg in displayedPackages" :key="pkg.name" class="card">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-bold text-white">{{ pkg.name }}</h3>
|
||||
<p class="text-sm text-gray-400 mt-1">{{ pkg.description }}</p>
|
||||
<div class="flex gap-4 mt-3 text-xs text-gray-500">
|
||||
<span>Version: <span class="text-gray-300 font-mono">{{ pkg.version }}</span></span>
|
||||
<span v-if="pkg.installed_version">Installée: <span class="text-gray-300 font-mono">{{ pkg.installed_version }}</span></span>
|
||||
<span>Taille: {{ formatBytes(pkg.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Boutons d'action -->
|
||||
<div class="flex gap-2 ml-4">
|
||||
<button
|
||||
v-if="pkg.status === 'installed' && pkg.version !== pkg.installed_version"
|
||||
@click="upgradePackage(pkg.name)"
|
||||
:disabled="operatingPackages.includes(pkg.name)"
|
||||
class="btn btn-warning text-xs"
|
||||
>
|
||||
{{ operatingPackages.includes(pkg.name) ? '⏳' : '⬆️' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.status === 'installed'"
|
||||
@click="removePackage(pkg.name)"
|
||||
:disabled="operatingPackages.includes(pkg.name)"
|
||||
class="btn btn-danger text-xs"
|
||||
>
|
||||
{{ operatingPackages.includes(pkg.name) ? '⏳' : '❌' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.status !== 'installed'"
|
||||
@click="installPackage(pkg.name)"
|
||||
:disabled="operatingPackages.includes(pkg.name)"
|
||||
class="btn btn-success text-xs"
|
||||
>
|
||||
{{ operatingPackages.includes(pkg.name) ? '⏳' : '✅' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="currentTab === 'installed'" class="flex justify-between items-center mt-6">
|
||||
<button
|
||||
@click="previousPage"
|
||||
:disabled="offset === 0"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
← Précédent
|
||||
</button>
|
||||
<span class="text-gray-400">
|
||||
Page {{ Math.floor(offset / limit) + 1 }} / {{ Math.ceil(totalPackages / limit) }}
|
||||
</span>
|
||||
<button
|
||||
@click="nextPage"
|
||||
:disabled="offset + limit >= totalPackages"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Suivant →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else class="card text-center py-12">
|
||||
<p class="text-gray-400">⏳ Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import api from '../api'
|
||||
|
||||
export default {
|
||||
name: 'PackagesView',
|
||||
data() {
|
||||
return {
|
||||
stats: {
|
||||
total_packages: 0,
|
||||
installed: 0,
|
||||
upgradable: 0,
|
||||
available: 0
|
||||
},
|
||||
packages: [],
|
||||
searchResults: [],
|
||||
loading: true,
|
||||
searchQuery: '',
|
||||
currentTab: 'installed',
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
totalPackages: 0,
|
||||
operatingPackages: [],
|
||||
tabs: ['installed', 'search']
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
authStore() {
|
||||
return useAuthStore()
|
||||
},
|
||||
displayedPackages() {
|
||||
if (this.currentTab === 'installed') {
|
||||
return this.packages
|
||||
}
|
||||
return this.searchResults
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchStats() {
|
||||
try {
|
||||
const response = await api.get('/packages/info')
|
||||
this.stats = response.data
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error)
|
||||
}
|
||||
},
|
||||
async fetchInstalledPackages() {
|
||||
try {
|
||||
this.loading = true
|
||||
const response = await api.get('/packages/installed', {
|
||||
params: {
|
||||
search: this.searchQuery || undefined,
|
||||
limit: this.limit,
|
||||
offset: this.offset
|
||||
}
|
||||
})
|
||||
this.packages = response.data.packages
|
||||
this.totalPackages = response.data.total
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async performSearch() {
|
||||
if (!this.searchQuery) {
|
||||
this.searchResults = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.loading = true
|
||||
const response = await api.get('/packages/search', {
|
||||
params: {
|
||||
q: this.searchQuery,
|
||||
limit: 50
|
||||
}
|
||||
})
|
||||
this.searchResults = response.data
|
||||
this.currentTab = 'search'
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async installPackage(packageName) {
|
||||
this.operatingPackages.push(packageName)
|
||||
try {
|
||||
const response = await api.post('/packages/install', null, {
|
||||
params: { package: packageName }
|
||||
})
|
||||
if (response.data.success) {
|
||||
alert(`✅ ${packageName} installé`)
|
||||
this.fetchStats()
|
||||
this.fetchInstalledPackages()
|
||||
} else {
|
||||
alert(`❌ Erreur: ${response.data.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error)
|
||||
alert('Erreur lors de l\'installation')
|
||||
} finally {
|
||||
this.operatingPackages = this.operatingPackages.filter(p => p !== packageName)
|
||||
}
|
||||
},
|
||||
async removePackage(packageName) {
|
||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer ${packageName} ?`)) return
|
||||
|
||||
this.operatingPackages.push(packageName)
|
||||
try {
|
||||
const response = await api.post('/packages/remove', null, {
|
||||
params: { package: packageName }
|
||||
})
|
||||
if (response.data.success) {
|
||||
alert(`✅ ${packageName} supprimé`)
|
||||
this.fetchStats()
|
||||
this.fetchInstalledPackages()
|
||||
} else {
|
||||
alert(`❌ Erreur: ${response.data.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error)
|
||||
alert('Erreur lors de la suppression')
|
||||
} finally {
|
||||
this.operatingPackages = this.operatingPackages.filter(p => p !== packageName)
|
||||
}
|
||||
},
|
||||
async upgradePackage(packageName) {
|
||||
this.operatingPackages.push(packageName)
|
||||
try {
|
||||
const response = await api.post('/packages/upgrade', null, {
|
||||
params: { package: packageName }
|
||||
})
|
||||
if (response.data.success) {
|
||||
alert(`✅ ${packageName} mis à jour`)
|
||||
this.fetchStats()
|
||||
this.fetchInstalledPackages()
|
||||
} else {
|
||||
alert(`❌ Erreur: ${response.data.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error)
|
||||
alert('Erreur lors de la mise à jour')
|
||||
} finally {
|
||||
this.operatingPackages = this.operatingPackages.filter(p => p !== packageName)
|
||||
}
|
||||
},
|
||||
formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||
},
|
||||
nextPage() {
|
||||
this.offset += this.limit
|
||||
this.fetchInstalledPackages()
|
||||
},
|
||||
previousPage() {
|
||||
this.offset = Math.max(0, this.offset - this.limit)
|
||||
this.fetchInstalledPackages()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchStats()
|
||||
this.fetchInstalledPackages()
|
||||
// Rafraîchir les stats toutes les 60s
|
||||
setInterval(() => this.fetchStats(), 60000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
@apply bg-gray-800 rounded-lg shadow-lg p-6 border border-gray-700;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@apply border-gray-600 shadow-xl transition-all duration-300;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-3 py-1 rounded font-medium transition duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-green-600 hover:bg-green-700 text-white disabled:opacity-50;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 hover:bg-red-700 text-white disabled:opacity-50;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply bg-yellow-600 hover:bg-yellow-700 text-white disabled:opacity-50;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-700 hover:bg-gray-600 text-white disabled:opacity-50;
|
||||
}
|
||||
</style>
|
||||
373
frontend/src/views/ShortcutsView.vue
Normal file
373
frontend/src/views/ShortcutsView.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<div class="bg-gray-900 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="bg-gray-800 border-b border-gray-700 p-6">
|
||||
<h1 class="text-4xl font-bold text-white">🔗 Raccourcis Services</h1>
|
||||
<p class="text-gray-400 mt-2">Accès rapide à vos services self-hosted</p>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="p-6">
|
||||
<!-- Bouton Ajouter -->
|
||||
<div class="mb-6">
|
||||
<button @click="showAddForm = !showAddForm" class="btn btn-primary px-6 py-2">
|
||||
{{ showAddForm ? '❌ Annuler' : '➕ Ajouter un raccourci' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'ajout -->
|
||||
<div v-if="showAddForm" class="card mb-6">
|
||||
<h3 class="text-xl font-bold text-white mb-4">Nouveau raccourci</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Nom</label>
|
||||
<input
|
||||
v-model="newShortcut.name"
|
||||
type="text"
|
||||
placeholder="ex: Nextcloud"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">URL</label>
|
||||
<input
|
||||
v-model="newShortcut.url"
|
||||
type="url"
|
||||
placeholder="https://nextcloud.example.com"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Icône (emoji)</label>
|
||||
<input
|
||||
v-model="newShortcut.icon"
|
||||
type="text"
|
||||
placeholder="☁️"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white text-2xl"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Catégorie</label>
|
||||
<input
|
||||
v-model="newShortcut.category"
|
||||
type="text"
|
||||
placeholder="cloud"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Couleur</label>
|
||||
<input
|
||||
v-model="newShortcut.color"
|
||||
type="color"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 h-10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Description</label>
|
||||
<input
|
||||
v-model="newShortcut.description"
|
||||
type="text"
|
||||
placeholder="Description optionnelle"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="addShortcut" class="btn btn-success px-6 py-2 w-full">
|
||||
✅ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grouper par catégorie -->
|
||||
<div v-if="!loading && groupedShortcuts.length > 0" class="space-y-8">
|
||||
<div v-for="(group, groupIdx) in groupedShortcuts" :key="groupIdx">
|
||||
<!-- Titre de catégorie -->
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ getCategoryTitle(group.category) }}</h2>
|
||||
|
||||
<!-- Grille de raccourcis -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="shortcut in group.shortcuts"
|
||||
:key="shortcut.id"
|
||||
class="card p-6 cursor-pointer hover:scale-105 transition transform"
|
||||
:style="{ borderLeftWidth: '4px', borderLeftColor: shortcut.color }"
|
||||
>
|
||||
<!-- Contenu du raccourci -->
|
||||
<a :href="shortcut.url" target="_blank" class="block">
|
||||
<div class="text-5xl mb-3">{{ shortcut.icon }}</div>
|
||||
<h3 class="text-xl font-bold text-white mb-1">{{ shortcut.name }}</h3>
|
||||
<p v-if="shortcut.description" class="text-sm text-gray-400 mb-2">
|
||||
{{ shortcut.description }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 truncate">{{ shortcut.url }}</p>
|
||||
</a>
|
||||
|
||||
<!-- Boutons d'action -->
|
||||
<div class="flex gap-2 mt-4 pt-4 border-t border-gray-700">
|
||||
<button
|
||||
@click="editShortcut(shortcut)"
|
||||
class="btn btn-primary text-xs flex-1"
|
||||
>
|
||||
✏️ Éditer
|
||||
</button>
|
||||
<button
|
||||
@click="deleteShortcut(shortcut.id)"
|
||||
class="btn btn-danger text-xs flex-1"
|
||||
>
|
||||
🗑️ Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- État vide -->
|
||||
<div v-else-if="!loading" class="card text-center py-12">
|
||||
<p class="text-gray-400 text-lg">Aucun raccourci trouvé</p>
|
||||
<p class="text-gray-500 text-sm mt-2">Cliquez sur "Ajouter un raccourci" pour commencer</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else class="card text-center py-12">
|
||||
<p class="text-gray-400">⏳ Chargement...</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal d'édition -->
|
||||
<div
|
||||
v-if="editingShortcut"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
@click="editingShortcut = null"
|
||||
>
|
||||
<div class="card p-8 max-w-md w-full mx-4" @click.stop>
|
||||
<h3 class="text-2xl font-bold text-white mb-4">Éditer le raccourci</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Nom</label>
|
||||
<input
|
||||
v-model="editingShortcut.name"
|
||||
type="text"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">URL</label>
|
||||
<input
|
||||
v-model="editingShortcut.url"
|
||||
type="url"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Icône</label>
|
||||
<input
|
||||
v-model="editingShortcut.icon"
|
||||
type="text"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white text-2xl"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Catégorie</label>
|
||||
<input
|
||||
v-model="editingShortcut.category"
|
||||
type="text"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Couleur</label>
|
||||
<input
|
||||
v-model="editingShortcut.color"
|
||||
type="color"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 h-10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Description</label>
|
||||
<input
|
||||
v-model="editingShortcut.description"
|
||||
type="text"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button @click="saveEdit" class="btn btn-success flex-1">
|
||||
✅ Sauvegarder
|
||||
</button>
|
||||
<button @click="editingShortcut = null" class="btn btn-secondary flex-1">
|
||||
❌ Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import api from '../api'
|
||||
|
||||
export default {
|
||||
name: 'ShortcutsView',
|
||||
data() {
|
||||
return {
|
||||
shortcuts: [],
|
||||
loading: true,
|
||||
showAddForm: false,
|
||||
editingShortcut: null,
|
||||
newShortcut: {
|
||||
id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
icon: '🔗',
|
||||
description: '',
|
||||
category: 'other',
|
||||
color: '#3B82F6',
|
||||
order: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
authStore() {
|
||||
return useAuthStore()
|
||||
},
|
||||
groupedShortcuts() {
|
||||
const grouped = {}
|
||||
this.shortcuts.forEach(shortcut => {
|
||||
if (!grouped[shortcut.category]) {
|
||||
grouped[shortcut.category] = {
|
||||
category: shortcut.category,
|
||||
shortcuts: []
|
||||
}
|
||||
}
|
||||
grouped[shortcut.category].shortcuts.push(shortcut)
|
||||
})
|
||||
return Object.values(grouped)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchShortcuts() {
|
||||
try {
|
||||
this.loading = true
|
||||
const response = await api.get('/shortcuts/')
|
||||
this.shortcuts = response.data
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async addShortcut() {
|
||||
if (!this.newShortcut.name || !this.newShortcut.url) {
|
||||
alert('Veuillez remplir le nom et l\'URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.post('/shortcuts/', {
|
||||
...this.newShortcut,
|
||||
id: this.newShortcut.id || `shortcut_${Date.now()}`
|
||||
})
|
||||
this.shortcuts.push(response.data)
|
||||
this.resetForm()
|
||||
this.showAddForm = false
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error)
|
||||
alert('Erreur lors de l\'ajout du raccourci')
|
||||
}
|
||||
},
|
||||
editShortcut(shortcut) {
|
||||
this.editingShortcut = { ...shortcut }
|
||||
},
|
||||
async saveEdit() {
|
||||
if (!this.editingShortcut.name || !this.editingShortcut.url) {
|
||||
alert('Veuillez remplir le nom et l\'URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.put(`/shortcuts/${this.editingShortcut.id}`, this.editingShortcut)
|
||||
const idx = this.shortcuts.findIndex(s => s.id === this.editingShortcut.id)
|
||||
if (idx >= 0) {
|
||||
this.shortcuts[idx] = response.data
|
||||
}
|
||||
this.editingShortcut = null
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error)
|
||||
alert('Erreur lors de la sauvegarde')
|
||||
}
|
||||
},
|
||||
async deleteShortcut(id) {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce raccourci ?')) return
|
||||
|
||||
try {
|
||||
await api.delete(`/shortcuts/${id}`)
|
||||
this.shortcuts = this.shortcuts.filter(s => s.id !== id)
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error)
|
||||
alert('Erreur lors de la suppression')
|
||||
}
|
||||
},
|
||||
resetForm() {
|
||||
this.newShortcut = {
|
||||
id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
icon: '🔗',
|
||||
description: '',
|
||||
category: 'other',
|
||||
color: '#3B82F6',
|
||||
order: 0
|
||||
}
|
||||
},
|
||||
getCategoryTitle(category) {
|
||||
const titles = {
|
||||
cloud: '☁️ Services Cloud',
|
||||
media: '🎬 Médias',
|
||||
productivity: '📝 Productivité',
|
||||
monitoring: '📊 Monitoring',
|
||||
development: '👨💻 Développement',
|
||||
other: '🔗 Autres'
|
||||
}
|
||||
return titles[category] || category
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchShortcuts()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
@apply bg-gray-800 rounded-lg shadow-lg p-6 border border-gray-700;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@apply border-gray-600 shadow-xl transition-all duration-300;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-3 py-2 rounded font-medium transition duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 hover:bg-blue-700 text-white;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-green-600 hover:bg-green-700 text-white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 hover:bg-red-700 text-white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-700 hover:bg-gray-600 text-white;
|
||||
}
|
||||
</style>
|
||||
26
frontend/tailwind.config.js
Normal file
26
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
gray: {
|
||||
900: '#0f172a',
|
||||
800: '#1e293b',
|
||||
700: '#334155',
|
||||
600: '#475569',
|
||||
500: '#64748b',
|
||||
400: '#94a3b8',
|
||||
300: '#cbd5e1',
|
||||
200: '#e2e8f0',
|
||||
100: '#f1f5f9',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
}
|
||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 3010,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user