init
This commit is contained in:
commit
d05799fe65
60 changed files with 7078 additions and 0 deletions
14
frontend/.eslintrc.cjs
Normal file
14
frontend/.eslintrc.cjs
Normal file
|
@ -0,0 +1,14 @@
|
|||
/* eslint-env node */
|
||||
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/eslint-config-typescript',
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
},
|
||||
}
|
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
16
frontend/env.d.ts
vendored
Normal file
16
frontend/env.d.ts
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
readonly VITE_WS_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
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>Étoile Polaire</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
13
frontend/install.sh
Executable file
13
frontend/install.sh
Executable file
|
@ -0,0 +1,13 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Installation des dépendances
|
||||
npm install
|
||||
|
||||
# Création du fichier .env
|
||||
cat > .env << EOL
|
||||
VITE_API_URL=http://localhost:8000
|
||||
VITE_WS_URL=ws://localhost:8000
|
||||
EOL
|
||||
|
||||
echo "Installation terminée !"
|
||||
echo "Pour lancer le serveur de développement : npm run dev"
|
4013
frontend/package-lock.json
generated
Normal file
4013
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "etoilepolaire-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/motion": "^3.0.3",
|
||||
"axios": "^1.6.2",
|
||||
"vue": "^3.3.8",
|
||||
"vue-router": "^4.2.5",
|
||||
"vue-toastification": "^2.0.0-rc.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^1.8.22"
|
||||
}
|
||||
}
|
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
86
frontend/src/App.vue
Normal file
86
frontend/src/App.vue
Normal file
|
@ -0,0 +1,86 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-white shadow-sm">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<h1 class="text-xl font-bold text-gray-900">Étoile Polaire</h1>
|
||||
</div>
|
||||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
<router-link
|
||||
to="/"
|
||||
class="inline-flex items-center px-1 pt-1 border-b-2"
|
||||
:class="[
|
||||
$route.path === '/'
|
||||
? 'border-blue-500 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
]"
|
||||
>
|
||||
Conteneurs
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/agents"
|
||||
class="inline-flex items-center px-1 pt-1 border-b-2"
|
||||
:class="[
|
||||
$route.path === '/agents'
|
||||
? 'border-blue-500 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
]"
|
||||
>
|
||||
Agents
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/logs"
|
||||
class="inline-flex items-center px-1 pt-1 border-b-2"
|
||||
:class="[
|
||||
$route.path === '/logs'
|
||||
? 'border-blue-500 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
]"
|
||||
>
|
||||
Logs
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<router-view></router-view>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Le composant App ne nécessite pas de logique supplémentaire
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-white text-gray-700 border-gray-300 hover:bg-gray-50 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow p-4;
|
||||
}
|
||||
</style>
|
27
frontend/src/assets/main.css
Normal file
27
frontend/src/assets/main.css
Normal file
|
@ -0,0 +1,27 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-md font-medium transition-colors duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 text-white hover:bg-blue-700;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 text-gray-800 hover:bg-gray-300;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm p-6;
|
||||
}
|
||||
}
|
264
frontend/src/components/AgentList.vue
Normal file
264
frontend/src/components/AgentList.vue
Normal file
|
@ -0,0 +1,264 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Agents</h2>
|
||||
<div class="flex space-x-4">
|
||||
<button
|
||||
@click="refreshAgents"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Rafraîchir
|
||||
</button>
|
||||
<button
|
||||
@click="showAddAgentModal = true"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Ajouter un agent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="agent in agents"
|
||||
:key="agent.id"
|
||||
class="card hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
{{ agent.name }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">{{ agent.host }}</p>
|
||||
</div>
|
||||
<span
|
||||
:class="{
|
||||
'px-2 py-1 rounded-full text-xs font-medium': true,
|
||||
'bg-green-100 text-green-800': agent.status === 'online',
|
||||
'bg-red-100 text-red-800': agent.status === 'offline',
|
||||
'bg-yellow-100 text-yellow-800': agent.status === 'connecting'
|
||||
}"
|
||||
>
|
||||
{{ agent.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm">
|
||||
<h4 class="font-medium text-gray-700">Informations :</h4>
|
||||
<ul class="list-disc list-inside">
|
||||
<li>Version : {{ agent.version }}</li>
|
||||
<li>Dernière mise à jour : {{ formatDate(agent.last_seen) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="editAgent(agent)"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
@click="deleteAgent(agent.id)"
|
||||
class="btn btn-danger"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal d'ajout/modification d'agent -->
|
||||
<div
|
||||
v-if="showAddAgentModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="bg-white rounded-lg w-full max-w-md">
|
||||
<div class="flex justify-between items-center p-4 border-b">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ editingAgent ? 'Modifier l\'agent' : 'Ajouter un agent' }}
|
||||
</h3>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<span class="sr-only">Fermer</span>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="submitAgent" class="p-4">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">
|
||||
Nom
|
||||
</label>
|
||||
<input
|
||||
v-model="agentForm.name"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">
|
||||
Hôte
|
||||
</label>
|
||||
<input
|
||||
v-model="agentForm.host"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">
|
||||
Port
|
||||
</label>
|
||||
<input
|
||||
v-model="agentForm.port"
|
||||
type="number"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">
|
||||
Token d'authentification
|
||||
</label>
|
||||
<input
|
||||
v-model="agentForm.token"
|
||||
type="password"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ editingAgent ? 'Modifier' : 'Ajouter' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
const toast = useToast()
|
||||
const agents = ref([])
|
||||
const showAddAgentModal = ref(false)
|
||||
const editingAgent = ref(null)
|
||||
const agentForm = ref({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 8080,
|
||||
token: ''
|
||||
})
|
||||
|
||||
// Récupération des agents
|
||||
const fetchAgents = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/agents')
|
||||
agents.value = response.data
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la récupération des agents')
|
||||
}
|
||||
}
|
||||
|
||||
// Formatage de la date
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleString()
|
||||
}
|
||||
|
||||
// Fermeture du modal
|
||||
const closeModal = () => {
|
||||
showAddAgentModal.value = false
|
||||
editingAgent.value = null
|
||||
agentForm.value = {
|
||||
name: '',
|
||||
host: '',
|
||||
port: 8080,
|
||||
token: ''
|
||||
}
|
||||
}
|
||||
|
||||
// Édition d'un agent
|
||||
const editAgent = (agent) => {
|
||||
editingAgent.value = agent
|
||||
agentForm.value = { ...agent }
|
||||
showAddAgentModal.value = true
|
||||
}
|
||||
|
||||
// Suppression d'un agent
|
||||
const deleteAgent = async (agentId: string) => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cet agent ?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/agents/${agentId}`)
|
||||
toast.success('Agent supprimé avec succès')
|
||||
await fetchAgents()
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression de l\'agent')
|
||||
}
|
||||
}
|
||||
|
||||
// Soumission du formulaire
|
||||
const submitAgent = async () => {
|
||||
try {
|
||||
if (editingAgent.value) {
|
||||
await axios.put(`/api/agents/${editingAgent.value.id}`, agentForm.value)
|
||||
toast.success('Agent modifié avec succès')
|
||||
} else {
|
||||
await axios.post('/api/agents', agentForm.value)
|
||||
toast.success('Agent ajouté avec succès')
|
||||
}
|
||||
closeModal()
|
||||
await fetchAgents()
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de l\'enregistrement de l\'agent')
|
||||
}
|
||||
}
|
||||
|
||||
// Rafraîchissement des agents
|
||||
const refreshAgents = () => {
|
||||
fetchAgents()
|
||||
}
|
||||
|
||||
// Initialisation
|
||||
onMounted(() => {
|
||||
fetchAgents()
|
||||
// Mise à jour automatique toutes les 30 secondes
|
||||
setInterval(fetchAgents, 30000)
|
||||
})
|
||||
</script>
|
205
frontend/src/components/ContainerDetails.vue
Normal file
205
frontend/src/components/ContainerDetails.vue
Normal file
|
@ -0,0 +1,205 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div v-if="container" class="bg-white rounded-lg shadow-lg p-6">
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">{{ container.name }}</h2>
|
||||
<p class="text-gray-600">{{ container.image }}</p>
|
||||
</div>
|
||||
<span :class="getStatusClass(container.status)"
|
||||
class="px-3 py-1 rounded-full text-sm font-medium">
|
||||
{{ container.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold">Informations</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<p><span class="font-medium">ID:</span> {{ container.id }}</p>
|
||||
<p><span class="font-medium">Créé:</span> {{ formatDate(container.created) }}</p>
|
||||
<p><span class="font-medium">Politique de redémarrage:</span> {{ container.restart_policy }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold">Réseau</h3>
|
||||
<div v-if="container.ports && container.ports.length" class="space-y-2 text-sm">
|
||||
<p class="font-medium">Ports:</p>
|
||||
<ul class="list-disc list-inside">
|
||||
<li v-for="port in container.ports" :key="port.container_port">
|
||||
{{ port.container_port }}/{{ port.protocol }} → {{ port.host_ip }}:{{ port.host_port }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-else class="text-sm text-gray-500">Aucun port exposé</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Statistiques</h3>
|
||||
<div v-if="stats" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-600">CPU</p>
|
||||
<p class="text-xl font-bold">{{ stats.cpu_percent.toFixed(1) }}%</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-600">Mémoire</p>
|
||||
<p class="text-xl font-bold">{{ formatBytes(stats.memory_usage) }}</p>
|
||||
<p class="text-sm text-gray-500">sur {{ formatBytes(stats.memory_limit) }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-600">Réseau</p>
|
||||
<p class="text-sm">↓ {{ formatBytes(stats.network.eth0.rx_bytes) }}</p>
|
||||
<p class="text-sm">↑ {{ formatBytes(stats.network.eth0.tx_bytes) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4">Logs</h3>
|
||||
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg font-mono text-sm h-96 overflow-y-auto">
|
||||
<div v-for="(log, index) in logs" :key="index" class="mb-1">
|
||||
<span :class="{ 'text-green-400': log.stream === 'stdout', 'text-red-400': log.stream === 'stderr' }">
|
||||
{{ formatDate(log.timestamp) }} [{{ log.stream }}]
|
||||
</span>
|
||||
{{ log.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
|
||||
interface Container {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
image: string
|
||||
created: string
|
||||
ports: Array<{
|
||||
host_ip: string
|
||||
host_port: number
|
||||
container_port: number
|
||||
protocol: string
|
||||
}>
|
||||
restart_policy: string
|
||||
}
|
||||
|
||||
interface NetworkStats {
|
||||
rx_bytes: number
|
||||
rx_packets: number
|
||||
rx_errors: number
|
||||
rx_dropped: number
|
||||
tx_bytes: number
|
||||
tx_packets: number
|
||||
tx_errors: number
|
||||
tx_dropped: number
|
||||
}
|
||||
|
||||
interface ContainerStats {
|
||||
cpu_percent: number
|
||||
memory_usage: number
|
||||
memory_limit: number
|
||||
network: {
|
||||
eth0: NetworkStats
|
||||
}
|
||||
}
|
||||
|
||||
interface Log {
|
||||
timestamp: string
|
||||
message: string
|
||||
stream: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const container = ref<Container | null>(null)
|
||||
const stats = ref<ContainerStats | null>(null)
|
||||
const logs = ref<Log[]>([])
|
||||
let ws: WebSocket | null = null
|
||||
|
||||
const fetchContainer = async () => {
|
||||
try {
|
||||
const response = await axios.get(`http://localhost:8000/api/containers/${route.params.id}`)
|
||||
container.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération du conteneur:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await axios.get(`http://localhost:8000/api/containers/${route.params.id}/stats`)
|
||||
stats.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des statistiques:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const connectWebSocket = () => {
|
||||
ws = new WebSocket(`ws://localhost:8000/api/containers/${route.params.id}/logs/ws`)
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const log = JSON.parse(event.data)
|
||||
logs.value.push(log)
|
||||
// Garder seulement les 1000 derniers logs
|
||||
if (logs.value.length > 1000) {
|
||||
logs.value = logs.value.slice(-1000)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('Erreur WebSocket:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'exited':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'paused':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = bytes
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchContainer()
|
||||
fetchStats()
|
||||
connectWebSocket()
|
||||
|
||||
// Mettre à jour les statistiques toutes les 5 secondes
|
||||
setInterval(fetchStats, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
}
|
||||
})
|
||||
</script>
|
144
frontend/src/components/ContainerList.vue
Normal file
144
frontend/src/components/ContainerList.vue
Normal file
|
@ -0,0 +1,144 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h2 class="text-2xl font-bold mb-6" v-motion-slide-visible-once-bottom>Conteneurs Docker</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div v-for="(container, index) in containers"
|
||||
:key="container.id"
|
||||
class="bg-white rounded-lg shadow-md p-4 hover:shadow-lg transition-all duration-300"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 50 }"
|
||||
:enter="{ opacity: 1, y: 0, transition: { delay: index * 100 } }">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<router-link :to="'/containers/' + container.id"
|
||||
class="text-lg font-semibold text-blue-600 hover:text-blue-800 transition-colors duration-200"
|
||||
v-motion-hover="{ scale: 1.05 }">
|
||||
{{ container.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
<span :class="getStatusClass(container.status)"
|
||||
class="px-2 py-1 rounded text-sm transition-colors duration-300"
|
||||
v-motion
|
||||
:initial="{ scale: 0.8 }"
|
||||
:enter="{ scale: 1 }">
|
||||
{{ container.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm text-gray-600">
|
||||
<p><span class="font-medium">Image:</span> {{ container.image }}</p>
|
||||
<p><span class="font-medium">ID:</span> {{ container.id.slice(0, 12) }}</p>
|
||||
<p><span class="font-medium">Créé:</span> {{ formatDate(container.created) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex space-x-2">
|
||||
<button v-if="container.status !== 'running'"
|
||||
@click="startContainer(container.id)"
|
||||
class="btn btn-primary transition-all duration-300"
|
||||
v-motion-hover="{ scale: 1.05 }"
|
||||
v-motion-tap="{ scale: 0.95 }">
|
||||
Démarrer
|
||||
</button>
|
||||
<button v-if="container.status === 'running'"
|
||||
@click="stopContainer(container.id)"
|
||||
class="btn btn-danger transition-all duration-300"
|
||||
v-motion-hover="{ scale: 1.05 }"
|
||||
v-motion-tap="{ scale: 0.95 }">
|
||||
Arrêter
|
||||
</button>
|
||||
<button @click="restartContainer(container.id)"
|
||||
class="btn btn-secondary transition-all duration-300"
|
||||
v-motion-hover="{ scale: 1.05 }"
|
||||
v-motion-tap="{ scale: 0.95 }">
|
||||
Redémarrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
interface Container {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
image: string
|
||||
created: string
|
||||
}
|
||||
|
||||
const containers = ref<Container[]>([])
|
||||
|
||||
const fetchContainers = async () => {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:8000/api/containers')
|
||||
containers.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des conteneurs:', error)
|
||||
toast.error('Erreur lors de la récupération des conteneurs')
|
||||
}
|
||||
}
|
||||
|
||||
const startContainer = async (id: string) => {
|
||||
try {
|
||||
await axios.post(`http://localhost:8000/api/containers/${id}/start`)
|
||||
await fetchContainers()
|
||||
toast.success('Conteneur démarré avec succès')
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du démarrage du conteneur:', error)
|
||||
toast.error('Erreur lors du démarrage du conteneur')
|
||||
}
|
||||
}
|
||||
|
||||
const stopContainer = async (id: string) => {
|
||||
try {
|
||||
await axios.post(`http://localhost:8000/api/containers/${id}/stop`)
|
||||
await fetchContainers()
|
||||
toast.success('Conteneur arrêté avec succès')
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'arrêt du conteneur:', error)
|
||||
toast.error('Erreur lors de l\'arrêt du conteneur')
|
||||
}
|
||||
}
|
||||
|
||||
const restartContainer = async (id: string) => {
|
||||
try {
|
||||
await axios.post(`http://localhost:8000/api/containers/${id}/restart`)
|
||||
await fetchContainers()
|
||||
toast.success('Conteneur redémarré avec succès')
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du redémarrage du conteneur:', error)
|
||||
toast.error('Erreur lors du redémarrage du conteneur')
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'exited':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'paused':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchContainers()
|
||||
// Rafraîchir la liste toutes les 5 secondes
|
||||
setInterval(fetchContainers, 5000)
|
||||
})
|
||||
</script>
|
139
frontend/src/components/LogsView.vue
Normal file
139
frontend/src/components/LogsView.vue
Normal file
|
@ -0,0 +1,139 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="bg-white rounded-lg shadow-lg p-6" v-motion-slide-visible-once-bottom>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold">Logs en Temps Réel</h2>
|
||||
<div class="flex space-x-4">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Rechercher dans les logs..."
|
||||
class="px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedContainer"
|
||||
class="px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Tous les conteneurs</option>
|
||||
<option v-for="container in containers" :key="container.id" :value="container.id">
|
||||
{{ container.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-sm text-gray-600">Auto-scroll</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="autoScroll"
|
||||
class="form-checkbox h-5 w-5 text-blue-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg font-mono text-sm h-[calc(100vh-250px)] overflow-y-auto">
|
||||
<div v-for="(log, index) in filteredLogs"
|
||||
:key="index"
|
||||
class="mb-1"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, x: -20 }"
|
||||
:enter="{ opacity: 1, x: 0, transition: { delay: index * 50 } }">
|
||||
<span class="text-gray-400">{{ formatDate(log.timestamp) }}</span>
|
||||
<span class="mx-2 text-blue-400">[{{ log.container_name }}]</span>
|
||||
<span :class="{
|
||||
'text-green-400': log.stream === 'stdout',
|
||||
'text-red-400': log.stream === 'stderr'
|
||||
}">[{{ log.stream }}]</span>
|
||||
<span class="ml-2">{{ log.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
interface Container {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Log {
|
||||
timestamp: string
|
||||
container_id: string
|
||||
container_name: string
|
||||
message: string
|
||||
stream: string
|
||||
}
|
||||
|
||||
const containers = ref<Container[]>([])
|
||||
const logs = ref<Log[]>([])
|
||||
const searchQuery = ref('')
|
||||
const selectedContainer = ref('')
|
||||
const autoScroll = ref(true)
|
||||
let ws: WebSocket | null = null
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
return logs.value.filter(log => {
|
||||
const matchesSearch = searchQuery.value === '' ||
|
||||
log.message.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
const matchesContainer = selectedContainer.value === '' ||
|
||||
log.container_id === selectedContainer.value
|
||||
return matchesSearch && matchesContainer
|
||||
})
|
||||
})
|
||||
|
||||
const fetchContainers = async () => {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:8000/api/containers')
|
||||
containers.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des conteneurs:', error)
|
||||
toast.error('Erreur lors de la récupération des conteneurs')
|
||||
}
|
||||
}
|
||||
|
||||
const connectWebSocket = () => {
|
||||
ws = new WebSocket('ws://localhost:8000/api/logs/ws')
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const log = JSON.parse(event.data)
|
||||
logs.value.push(log)
|
||||
// Garder seulement les 1000 derniers logs
|
||||
if (logs.value.length > 1000) {
|
||||
logs.value = logs.value.slice(-1000)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('Erreur WebSocket:', error)
|
||||
toast.error('Erreur de connexion aux logs en temps réel')
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('Connexion WebSocket fermée')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchContainers()
|
||||
connectWebSocket()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
}
|
||||
})
|
||||
</script>
|
7
frontend/src/env.d.ts
vendored
Normal file
7
frontend/src/env.d.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
42
frontend/src/main.ts
Normal file
42
frontend/src/main.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import Toast from 'vue-toastification'
|
||||
import { MotionPlugin } from '@vueuse/motion'
|
||||
import 'vue-toastification/dist/index.css'
|
||||
import './assets/main.css'
|
||||
|
||||
interface Toast {
|
||||
type: string
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
app.use(Toast, {
|
||||
position: 'top-right',
|
||||
timeout: 3000,
|
||||
closeOnClick: true,
|
||||
pauseOnFocusLoss: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
draggablePercent: 0.6,
|
||||
showCloseButtonOnHover: false,
|
||||
hideProgressBar: false,
|
||||
closeButton: 'button',
|
||||
icon: true,
|
||||
rtl: false,
|
||||
transition: 'Vue-Toastification__bounce',
|
||||
maxToasts: 5,
|
||||
newestOnTop: true,
|
||||
filterBeforeCreate: (toast: Toast, toasts: Toast[]) => {
|
||||
if (toasts.filter((t: Toast) => t.type === toast.type).length !== 0) {
|
||||
return false
|
||||
}
|
||||
return toast
|
||||
}
|
||||
})
|
||||
|
||||
app.use(MotionPlugin)
|
||||
|
||||
app.mount('#app')
|
27
frontend/src/router/index.ts
Normal file
27
frontend/src/router/index.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import ContainerList from '../components/ContainerList.vue'
|
||||
import ContainerDetails from '../components/ContainerDetails.vue'
|
||||
import LogsView from '../components/LogsView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'containers',
|
||||
component: ContainerList
|
||||
},
|
||||
{
|
||||
path: '/containers/:id',
|
||||
name: 'container-details',
|
||||
component: ContainerDetails
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
name: 'logs',
|
||||
component: LogsView
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
26
frontend/vite.config.ts
Normal file
26
frontend/vite.config.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue