Initial commit

This commit is contained in:
innotex
2026-01-16 18:40:39 +01:00
commit 9ec63a8aa2
76 changed files with 13235 additions and 0 deletions

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

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View 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"
}
}

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

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

View 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

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

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

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

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

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

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

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

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

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