This commit is contained in:
el 2025-04-01 16:33:54 +02:00
commit d05799fe65
60 changed files with 7078 additions and 0 deletions

38
README.md Normal file
View file

@ -0,0 +1,38 @@
# Étoile Polaire
Une application de gestion de conteneurs Docker avec support multi-machines.
## Description
Étoile Polaire est une application web permettant de :
- Visualiser les logs des conteneurs Docker en temps réel
- Gérer les conteneurs (démarrer, arrêter, redémarrer)
- Mettre à jour les conteneurs en un clic
- Gérer des conteneurs sur des machines distantes via des agents
## Architecture
Le projet est composé de trois parties principales :
- `server/` : Backend FastAPI (Python)
- `frontend/` : Interface utilisateur Vue.js
- `agent/` : Agent Go pour la gestion des conteneurs
## Prérequis
- Python 3.8+
- Node.js 16+
- Go 1.16+
- Docker
## Installation
Instructions d'installation à venir...
## Développement
Instructions de développement à venir...
## Licence
MIT

53
agent/cmd/main.go Normal file
View file

@ -0,0 +1,53 @@
package main
import (
"flag"
"log"
"os"
"os/signal"
"syscall"
"github.com/etoilepolaire/agent/internal/agent"
"github.com/etoilepolaire/agent/internal/config"
)
func main() {
// Configuration des flags
serverURL := flag.String("server", "http://localhost:8000", "URL du serveur central")
agentName := flag.String("name", "", "Nom de l'agent")
flag.Parse()
if *agentName == "" {
log.Fatal("Le nom de l'agent est requis")
}
// Chargement de la configuration
cfg := config.Config{
ServerURL: *serverURL,
Name: *agentName,
}
// Création de l'agent
a, err := agent.New(cfg)
if err != nil {
log.Fatalf("Erreur lors de la création de l'agent: %v", err)
}
// Gestion des signaux pour un arrêt propre
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Démarrage de l'agent
go func() {
if err := a.Start(); err != nil {
log.Printf("Erreur lors du démarrage de l'agent: %v", err)
}
}()
// Attente du signal d'arrêt
<-sigChan
log.Println("Arrêt de l'agent...")
if err := a.Stop(); err != nil {
log.Printf("Erreur lors de l'arrêt de l'agent: %v", err)
}
}

11
agent/go.mod Normal file
View file

@ -0,0 +1,11 @@
module github.com/etoilepolaire/agent
go 1.21
require (
github.com/docker/docker v24.0.7+incompatible
github.com/gorilla/websocket v1.5.1
github.com/spf13/cobra v1.7.0
github.com/spf13/viper v1.16.0
golang.org/x/crypto v0.14.0
)

View file

@ -0,0 +1,157 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/docker/docker/client"
"github.com/etoilepolaire/agent/internal/config"
)
// Agent représente l'agent Docker
type Agent struct {
config config.Config
client *client.Client
httpClient *http.Client
agentID string
ctx context.Context
cancel context.CancelFunc
}
// New crée une nouvelle instance de l'agent
func New(cfg config.Config) (*Agent, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
// Création du client Docker
dockerClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("erreur lors de la création du client Docker: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
return &Agent{
config: cfg,
client: dockerClient,
httpClient: &http.Client{Timeout: 10 * time.Second},
ctx: ctx,
cancel: cancel,
}, nil
}
// Start démarre l'agent
func (a *Agent) Start() error {
// Enregistrement de l'agent
if err := a.register(); err != nil {
return fmt.Errorf("erreur lors de l'enregistrement: %v", err)
}
// Démarrage des routines
go a.heartbeat()
go a.watchContainers()
go a.watchLogs()
return nil
}
// Stop arrête l'agent
func (a *Agent) Stop() error {
a.cancel()
return nil
}
// register enregistre l'agent auprès du serveur
func (a *Agent) register() error {
registration := struct {
Name string `json:"name"`
Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"`
DockerVersion string `json:"docker_version"`
}{
Name: a.config.Name,
Hostname: a.config.Hostname,
IPAddress: a.config.IPAddress,
DockerVersion: a.config.DockerVersion,
}
data, err := json.Marshal(registration)
if err != nil {
return err
}
resp, err := a.httpClient.Post(
fmt.Sprintf("%s/api/agents/register", a.config.ServerURL),
"application/json",
bytes.NewBuffer(data),
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("erreur lors de l'enregistrement: %s", resp.Status)
}
var result struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
a.agentID = result.ID
return nil
}
// heartbeat envoie régulièrement un heartbeat au serveur
func (a *Agent) heartbeat() {
ticker := time.NewTicker(a.config.HeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
if err := a.sendHeartbeat(); err != nil {
fmt.Printf("Erreur lors de l'envoi du heartbeat: %v\n", err)
}
}
}
}
// sendHeartbeat envoie un heartbeat au serveur
func (a *Agent) sendHeartbeat() error {
resp, err := a.httpClient.Post(
fmt.Sprintf("%s/api/agents/%s/heartbeat", a.config.ServerURL, a.agentID),
"application/json",
nil,
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("erreur lors de l'envoi du heartbeat: %s", resp.Status)
}
return nil
}
// watchContainers surveille les changements dans les conteneurs
func (a *Agent) watchContainers() {
// TODO: Implémenter la surveillance des conteneurs
}
// watchLogs surveille les logs des conteneurs
func (a *Agent) watchLogs() {
// TODO: Implémenter la surveillance des logs
}

View file

@ -0,0 +1,121 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/events"
)
// Container représente un conteneur Docker
type Container struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
Status string `json:"status"`
Created time.Time `json:"created"`
Ports map[string][]Port `json:"ports"`
Labels map[string]string `json:"labels"`
}
// Port représente un port exposé
type Port struct {
HostIP string `json:"host_ip"`
HostPort string `json:"host_port"`
}
// watchContainers surveille les changements dans les conteneurs
func (a *Agent) watchContainers() {
events, errs := a.client.Events(a.ctx, types.EventsOptions{})
for {
select {
case <-a.ctx.Done():
return
case err := <-errs:
fmt.Printf("Erreur lors de la surveillance des conteneurs: %v\n", err)
case event := <-events:
if event.Type == events.ContainerEventType {
if err := a.handleContainerEvent(event); err != nil {
fmt.Printf("Erreur lors du traitement de l'événement: %v\n", err)
}
}
}
}
}
// handleContainerEvent traite un événement de conteneur
func (a *Agent) handleContainerEvent(event events.Message) error {
// Mise à jour de la liste des conteneurs
containers, err := a.listContainers()
if err != nil {
return err
}
// Envoi de la mise à jour au serveur
data, err := json.Marshal(containers)
if err != nil {
return err
}
resp, err := a.httpClient.Post(
fmt.Sprintf("%s/api/agents/%s/containers/update", a.config.ServerURL, a.agentID),
"application/json",
bytes.NewBuffer(data),
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("erreur lors de la mise à jour des conteneurs: %s", resp.Status)
}
return nil
}
// listContainers liste tous les conteneurs
func (a *Agent) listContainers() ([]Container, error) {
containers, err := a.client.ContainerList(a.ctx, types.ContainerListOptions{All: true})
if err != nil {
return nil, err
}
var result []Container
for _, c := range containers {
container, err := a.client.ContainerInspect(a.ctx, c.ID)
if err != nil {
continue
}
ports := make(map[string][]Port)
for containerPort, bindings := range container.NetworkSettings.Ports {
var portBindings []Port
for _, binding := range bindings {
portBindings = append(portBindings, Port{
HostIP: binding.HostIP,
HostPort: binding.HostPort,
})
}
ports[string(containerPort)] = portBindings
}
result = append(result, Container{
ID: container.ID,
Name: container.Name,
Image: container.Config.Image,
Status: container.State.Status,
Created: time.Unix(container.Created, 0),
Ports: ports,
Labels: container.Config.Labels,
})
}
return result, nil
}

View file

@ -0,0 +1,120 @@
package agent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/docker/docker/api/types"
)
// LogEntry représente une entrée de log
type LogEntry struct {
ContainerID string `json:"container_id"`
Timestamp time.Time `json:"timestamp"`
Message string `json:"message"`
Stream string `json:"stream"`
}
// watchLogs surveille les logs des conteneurs
func (a *Agent) watchLogs() {
containers, err := a.client.ContainerList(a.ctx, types.ContainerListOptions{})
if err != nil {
fmt.Printf("Erreur lors de la récupération des conteneurs: %v\n", err)
return
}
for _, container := range containers {
go a.watchContainerLogs(container.ID)
}
}
// watchContainerLogs surveille les logs d'un conteneur spécifique
func (a *Agent) watchContainerLogs(containerID string) {
options := types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: true,
Timestamps: true,
}
reader, err := a.client.ContainerLogs(a.ctx, containerID, options)
if err != nil {
fmt.Printf("Erreur lors de la récupération des logs: %v\n", err)
return
}
defer reader.Close()
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
select {
case <-a.ctx.Done():
return
default:
line := scanner.Text()
if err := a.handleLogLine(containerID, line); err != nil {
fmt.Printf("Erreur lors du traitement du log: %v\n", err)
}
}
}
}
// handleLogLine traite une ligne de log
func (a *Agent) handleLogLine(containerID, line string) error {
// Le format des logs Docker est : [timestamp] stream message
// Exemple : [2024-03-31T22:30:00.000000000Z] stdout Hello World
var stream string
var message string
var timestamp time.Time
// Parse le timestamp
if len(line) >= 32 {
timestampStr := line[1:31]
var err error
timestamp, err = time.Parse(time.RFC3339Nano, timestampStr)
if err != nil {
return err
}
// Parse le stream et le message
if len(line) >= 35 {
stream = line[33:35]
message = line[36:]
}
}
logEntry := LogEntry{
ContainerID: containerID,
Timestamp: timestamp,
Message: message,
Stream: stream,
}
// Envoi du log au serveur
data, err := json.Marshal(logEntry)
if err != nil {
return err
}
resp, err := a.httpClient.Post(
fmt.Sprintf("%s/api/containers/%s/logs", a.config.ServerURL, containerID),
"application/json",
bytes.NewBuffer(data),
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("erreur lors de l'envoi du log: %s", resp.Status)
}
return nil
}

View file

@ -0,0 +1,40 @@
package config
import (
"os"
"time"
)
// Config représente la configuration de l'agent
type Config struct {
ServerURL string
Name string
Hostname string
IPAddress string
DockerVersion string
HeartbeatInterval time.Duration
}
// DefaultConfig retourne une configuration par défaut
func DefaultConfig() Config {
hostname, _ := os.Hostname()
return Config{
ServerURL: "http://localhost:8000",
Name: "",
Hostname: hostname,
IPAddress: "127.0.0.1", // À remplacer par l'adresse IP réelle
DockerVersion: "unknown",
HeartbeatInterval: 30 * time.Second,
}
}
// Validate vérifie que la configuration est valide
func (c *Config) Validate() error {
if c.Name == "" {
return ErrNameRequired
}
if c.ServerURL == "" {
return ErrServerURLRequired
}
return nil
}

View file

@ -0,0 +1,8 @@
package config
import "errors"
var (
ErrNameRequired = errors.New("le nom de l'agent est requis")
ErrServerURLRequired = errors.New("l'URL du serveur est requise")
)

14
frontend/.eslintrc.cjs Normal file
View 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
View 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
View 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
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>É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
View 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

File diff suppressed because it is too large Load diff

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

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

86
frontend/src/App.vue Normal file
View 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>

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

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

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

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

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

View 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

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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

7
server/app/api/api.py Normal file
View file

@ -0,0 +1,7 @@
from fastapi import APIRouter
from app.api.endpoints import containers, logs
api_router = APIRouter()
api_router.include_router(containers.router, prefix="/containers", tags=["containers"])
api_router.include_router(logs.router, prefix="/logs", tags=["logs"])

View file

@ -0,0 +1,31 @@
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.services.logs_service import logs_service
import asyncio
router = APIRouter()
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""Endpoint WebSocket pour recevoir les logs en temps réel."""
await logs_service.connect(websocket)
try:
while True:
data = await websocket.receive_text()
# Ici, nous pourrions ajouter la logique pour filtrer les logs
# en fonction des préférences du client
await logs_service.broadcast_log(data)
except WebSocketDisconnect:
logs_service.disconnect(websocket)
@router.websocket("/ws/{container_id}")
async def container_logs_websocket(websocket: WebSocket, container_id: str):
"""Endpoint WebSocket pour recevoir les logs d'un conteneur spécifique."""
await logs_service.connect(websocket, container_id)
try:
async for log in logs_service.get_container_logs(container_id):
await websocket.send_text(log)
except WebSocketDisconnect:
logs_service.disconnect(websocket, container_id)
except Exception as e:
await websocket.send_text(f"Error: {str(e)}")
logs_service.disconnect(websocket, container_id)

37
server/app/auth.py Normal file
View file

@ -0,0 +1,37 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from datetime import datetime, timedelta
from typing import Optional
# TODO: Déplacer dans un fichier de configuration
SECRET_KEY = "votre_clé_secrète_ici"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Impossible de valider les identifiants",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
return username

77
server/app/main.py Normal file
View file

@ -0,0 +1,77 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.websockets import WebSocket
import uvicorn
import logging
import asyncio
from app.routes import containers, agents
from app.services.agent import AgentService
# Configuration du logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(
title="Étoile Polaire",
description="API de gestion des conteneurs Docker et des agents",
version="1.0.0"
)
# Configuration CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"], # Frontend URL
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Inclusion des routes
app.include_router(containers.router)
app.include_router(agents.router)
# Service d'agents
agent_service = AgentService()
@app.get("/")
async def root():
return {"message": "Bienvenue sur l'API Étoile Polaire"}
@app.get("/health")
async def health_check():
return {"status": "healthy"}
@app.on_event("startup")
async def startup_event():
"""Tâches à exécuter au démarrage de l'application."""
# Démarrer la tâche de nettoyage des agents inactifs
asyncio.create_task(cleanup_inactive_agents())
async def cleanup_inactive_agents():
"""Nettoie périodiquement les agents inactifs."""
while True:
try:
inactive_agents = await agent_service.cleanup_inactive_agents()
if inactive_agents:
logger.info(f"Agents inactifs supprimés : {inactive_agents}")
except Exception as e:
logger.error(f"Erreur lors du nettoyage des agents : {e}")
await asyncio.sleep(300) # Vérifier toutes les 5 minutes
# WebSocket pour les logs en temps réel
@app.websocket("/ws/logs")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
try:
while True:
# TODO: Implémenter la logique de streaming des logs
data = await websocket.receive_text()
await websocket.send_text(f"Message reçu: {data}")
except Exception as e:
logger.error(f"Erreur WebSocket: {e}")
finally:
await websocket.close()
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

56
server/app/models.py Normal file
View file

@ -0,0 +1,56 @@
from pydantic import BaseModel, Field
from typing import List, Optional, Dict
from datetime import datetime
from enum import Enum
class ContainerStatus(str, Enum):
RUNNING = "running"
STOPPED = "stopped"
PAUSED = "paused"
RESTARTING = "restarting"
REMOVED = "removed"
DEAD = "dead"
CREATED = "created"
class Container(BaseModel):
id: str
name: str
image: str
status: ContainerStatus
created: datetime
ports: Dict[str, List[Dict[str, str]]] = Field(default_factory=dict)
labels: Dict[str, str] = Field(default_factory=dict)
networks: List[str] = Field(default_factory=list)
volumes: List[str] = Field(default_factory=list)
env_vars: Dict[str, str] = Field(default_factory=dict)
restart_policy: Optional[str] = None
cpu_usage: Optional[float] = None
memory_usage: Optional[int] = None
network_usage: Optional[Dict[str, int]] = None
class Agent(BaseModel):
id: str
name: str
hostname: str
ip_address: str
docker_version: str
status: str
last_seen: datetime
containers: List[Container] = Field(default_factory=list)
class LogEntry(BaseModel):
container_id: str
timestamp: datetime
message: str
stream: str # stdout ou stderr
class ContainerUpdate(BaseModel):
container_id: str
action: str # start, stop, restart, update
agent_id: Optional[str] = None
class AgentRegistration(BaseModel):
name: str
hostname: str
ip_address: str
docker_version: str

View file

@ -0,0 +1,7 @@
from .container import Container, ContainerLog, ContainerUpdate, ContainerStats
from .agent import Agent, AgentRegistration
__all__ = [
'Container', 'ContainerLog', 'ContainerUpdate', 'ContainerStats',
'Agent', 'AgentRegistration'
]

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,54 @@
from pydantic import BaseModel
from typing import Optional, Dict, List
from datetime import datetime
from .container import Container
class AgentBase(BaseModel):
name: str
host: str
port: int
token: str
class AgentCreate(AgentBase):
pass
class AgentUpdate(BaseModel):
name: Optional[str] = None
host: Optional[str] = None
port: Optional[int] = None
token: Optional[str] = None
class Agent(AgentBase):
id: str
status: str
version: str
last_seen: datetime
created_at: datetime
updated_at: datetime
containers: List[Container]
class Config:
from_attributes = True
class AgentStatus(BaseModel):
status: str
version: str
last_seen: datetime
containers_count: int
system_info: Dict[str, str]
class AgentRegistration(BaseModel):
name: str
hostname: str
ip_address: str
docker_version: str
class Agent(BaseModel):
id: str
name: str
hostname: str
ip_address: str
docker_version: str
status: str
last_seen: datetime
containers: List[Container]

View file

@ -0,0 +1,54 @@
from pydantic import BaseModel
from typing import Dict, List, Optional, Any
from datetime import datetime
class PortBinding(BaseModel):
host_ip: str = "0.0.0.0"
host_port: int
container_port: int
protocol: str = "tcp"
class NetworkStats(BaseModel):
rx_bytes: int
rx_packets: int
rx_errors: int
rx_dropped: int
tx_bytes: int
tx_packets: int
tx_errors: int
tx_dropped: int
class ContainerStats(BaseModel):
cpu_percent: float
memory_usage: int
memory_limit: int
network: Dict[str, NetworkStats]
block_read: int
block_write: int
class Container(BaseModel):
id: str
name: str
status: str
image: str
created: str
ports: Optional[List[PortBinding]] = None
volumes: Optional[List[str]] = None
environment: Optional[Dict[str, str]] = None
networks: Optional[List[str]] = None
health_status: Optional[str] = None
restart_policy: Optional[str] = None
command: Optional[str] = None
class ContainerUpdate(BaseModel):
image: Optional[str] = None
command: Optional[str] = None
environment: Optional[Dict[str, str]] = None
ports: Optional[List[PortBinding]] = None
volumes: Optional[List[str]] = None
restart_policy: Optional[str] = None
class ContainerLog(BaseModel):
timestamp: datetime
message: str
stream: str = "stdout"

View file

@ -0,0 +1,4 @@
from . import containers
from . import agents
__all__ = ['containers', 'agents']

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,75 @@
from fastapi import APIRouter, HTTPException, Depends
from typing import List
from ..models.agent import Agent, AgentCreate, AgentUpdate
from ..services.agent_service import AgentService
from ..auth import get_current_user
router = APIRouter(prefix="/api/agents", tags=["agents"])
@router.get("/", response_model=List[Agent])
async def list_agents(current_user: str = Depends(get_current_user)):
"""Liste tous les agents."""
try:
agent_service = AgentService()
agents = await agent_service.list_agents()
return agents
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/", response_model=Agent)
async def create_agent(agent: AgentCreate, current_user: str = Depends(get_current_user)):
"""Crée un nouvel agent."""
try:
agent_service = AgentService()
new_agent = await agent_service.create_agent(agent)
return new_agent
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{agent_id}", response_model=Agent)
async def get_agent(agent_id: str, current_user: str = Depends(get_current_user)):
"""Récupère les informations d'un agent spécifique."""
try:
agent_service = AgentService()
agent = await agent_service.get_agent(agent_id)
if not agent:
raise HTTPException(status_code=404, detail="Agent non trouvé")
return agent
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{agent_id}", response_model=Agent)
async def update_agent(agent_id: str, agent: AgentUpdate, current_user: str = Depends(get_current_user)):
"""Met à jour un agent."""
try:
agent_service = AgentService()
updated_agent = await agent_service.update_agent(agent_id, agent)
if not updated_agent:
raise HTTPException(status_code=404, detail="Agent non trouvé")
return updated_agent
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{agent_id}")
async def delete_agent(agent_id: str, current_user: str = Depends(get_current_user)):
"""Supprime un agent."""
try:
agent_service = AgentService()
success = await agent_service.delete_agent(agent_id)
if not success:
raise HTTPException(status_code=404, detail="Agent non trouvé")
return {"message": "Agent supprimé avec succès"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{agent_id}/status")
async def get_agent_status(agent_id: str, current_user: str = Depends(get_current_user)):
"""Récupère le statut d'un agent."""
try:
agent_service = AgentService()
status = await agent_service.get_agent_status(agent_id)
if not status:
raise HTTPException(status_code=404, detail="Agent non trouvé")
return status
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1,270 @@
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from typing import List, Dict, Any, Set
from ..models.container import Container, ContainerUpdate, ContainerStats
from ..services.docker import DockerService
from ..services.redis import RedisService
import asyncio
import logging
import docker
from weakref import WeakSet
from contextlib import asynccontextmanager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/containers", tags=["containers"])
docker_service = DockerService()
redis_service = RedisService()
# Garder une trace des connexions WebSocket actives avec leurs tâches associées
active_connections: Dict[str, Dict[WebSocket, asyncio.Task]] = {}
async def cleanup_connection(container_id: str, websocket: WebSocket):
"""Nettoie proprement une connexion WebSocket."""
try:
if container_id in active_connections and websocket in active_connections[container_id]:
# Annuler la tâche associée si elle existe
task = active_connections[container_id][websocket]
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# Supprimer la connexion
del active_connections[container_id][websocket]
# Si plus de connexions pour ce conteneur, nettoyer le dictionnaire
if not active_connections[container_id]:
del active_connections[container_id]
# Fermer la connexion WebSocket
await websocket.close()
except Exception as e:
logger.error(f"Erreur lors du nettoyage de la connexion pour {container_id}: {e}")
@asynccontextmanager
async def manage_websocket_connection(websocket: WebSocket, container_id: str):
"""Gère le cycle de vie d'une connexion WebSocket."""
try:
await websocket.accept()
# Initialiser le dictionnaire pour ce conteneur si nécessaire
if container_id not in active_connections:
active_connections[container_id] = {}
yield
finally:
await cleanup_connection(container_id, websocket)
@router.get("/", response_model=List[Container])
async def list_containers():
"""Liste tous les conteneurs Docker."""
try:
logger.info("Début de la récupération de la liste des conteneurs")
containers = await docker_service.list_containers()
if not containers:
logger.warning("Aucun conteneur trouvé")
return []
logger.info(f"Conteneurs trouvés avec succès : {len(containers)}")
return containers
except docker.errors.APIError as e:
logger.error(f"Erreur API Docker lors de la récupération des conteneurs : {e}")
raise HTTPException(status_code=500, detail=f"Erreur Docker: {str(e)}")
except Exception as e:
logger.error(f"Erreur inattendue lors de la récupération des conteneurs : {e}")
raise HTTPException(status_code=500, detail=str(e))
def validate_container_id(container_id: str) -> None:
"""Valide l'ID d'un conteneur."""
if not container_id or container_id == "undefined":
logger.error("ID de conteneur invalide ou undefined")
raise HTTPException(status_code=400, detail="ID de conteneur invalide")
@router.get("/{container_id}", response_model=Container)
async def get_container(container_id: str):
"""Récupère les informations d'un conteneur spécifique."""
try:
validate_container_id(container_id)
container = await docker_service.get_container(container_id)
return container
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lors de la récupération du conteneur {container_id} : {e}")
raise HTTPException(status_code=404, detail="Conteneur non trouvé")
@router.get("/{container_id}/logs")
async def get_container_logs(container_id: str, tail: int = 100):
"""Récupère les logs d'un conteneur."""
try:
validate_container_id(container_id)
logger.info(f"Requête de logs pour le conteneur {container_id}")
# Récupérer les logs
logs = await docker_service.get_container_logs(container_id, tail=tail)
if not logs:
logger.warning(f"Aucun log disponible pour le conteneur {container_id}")
return {"message": "Aucun log disponible pour ce conteneur"}
logger.info(f"Logs récupérés avec succès pour le conteneur {container_id}")
return {"logs": logs}
except HTTPException:
raise
except docker.errors.NotFound:
logger.error(f"Conteneur {container_id} non trouvé")
raise HTTPException(status_code=404, detail="Conteneur non trouvé")
except docker.errors.APIError as e:
logger.error(f"Erreur API Docker pour le conteneur {container_id}: {e}")
raise HTTPException(status_code=500, detail=f"Erreur Docker: {str(e)}")
except Exception as e:
logger.error(f"Erreur lors de la récupération des logs du conteneur {container_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{container_id}/stats")
async def get_container_stats(container_id: str):
"""Récupère les statistiques d'un conteneur."""
try:
validate_container_id(container_id)
logger.info(f"Récupération des statistiques pour le conteneur {container_id}")
stats = await docker_service.get_container_stats(container_id)
return stats
except HTTPException:
raise
except docker.errors.NotFound:
logger.error(f"Conteneur {container_id} non trouvé")
raise HTTPException(status_code=404, detail=f"Conteneur {container_id} non trouvé")
except Exception as e:
logger.error(f"Erreur lors de la récupération des stats du conteneur {container_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{container_id}/start")
async def start_container(container_id: str):
"""Démarre un conteneur."""
try:
validate_container_id(container_id)
await docker_service.start_container(container_id)
return {"message": "Conteneur démarré avec succès"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lors du démarrage du conteneur {container_id} : {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{container_id}/stop")
async def stop_container(container_id: str):
"""Arrête un conteneur."""
try:
validate_container_id(container_id)
await docker_service.stop_container(container_id)
return {"message": "Conteneur arrêté avec succès"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lors de l'arrêt du conteneur {container_id} : {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{container_id}/restart")
async def restart_container(container_id: str):
"""Redémarre un conteneur."""
try:
validate_container_id(container_id)
await docker_service.restart_container(container_id)
return {"message": "Conteneur redémarré avec succès"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lors du redémarrage du conteneur {container_id} : {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.websocket("/{container_id}/logs/ws")
async def websocket_logs(websocket: WebSocket, container_id: str):
"""WebSocket pour les logs en temps réel d'un conteneur."""
if not container_id or container_id == "undefined":
logger.error("ID de conteneur invalide ou undefined")
try:
await websocket.send_json({"error": True, "message": "ID de conteneur invalide"})
except:
pass
return
async with manage_websocket_connection(websocket, container_id):
try:
# Créer une tâche pour suivre les logs
task = asyncio.create_task(handle_container_logs(websocket, container_id))
active_connections[container_id][websocket] = task
# Attendre que la tâche se termine ou que le client se déconnecte
try:
await task
except asyncio.CancelledError:
logger.info(f"Tâche de suivi des logs annulée pour {container_id}")
except WebSocketDisconnect:
logger.info(f"Client WebSocket déconnecté pour le conteneur {container_id}")
except Exception as e:
logger.error(f"Erreur WebSocket pour le conteneur {container_id}: {e}")
try:
await websocket.send_json({"error": True, "message": str(e)})
except:
pass
async def handle_container_logs(websocket: WebSocket, container_id: str):
"""Gère l'envoi des logs pour un conteneur."""
try:
# Buffer pour accumuler les logs
log_buffer = []
last_send_time = asyncio.get_event_loop().time()
BUFFER_SIZE = 100 # Nombre maximum de logs dans le buffer
FLUSH_INTERVAL = 0.1 # Intervalle minimum entre les envois (100ms)
logger.info(f"Démarrage du suivi des logs pour le conteneur {container_id}")
async for log in docker_service.follow_container_logs(container_id):
# Vérifier si la connexion est toujours active
if container_id not in active_connections or websocket not in active_connections[container_id]:
logger.info(f"Connexion WebSocket fermée pour le conteneur {container_id}")
break
# Ajouter le log au buffer
log_buffer.append(log)
current_time = asyncio.get_event_loop().time()
# Envoyer les logs si le buffer est plein ou si assez de temps s'est écoulé
if len(log_buffer) >= BUFFER_SIZE or (current_time - last_send_time) >= FLUSH_INTERVAL:
if log_buffer:
try:
await websocket.send_json({"logs": log_buffer})
logger.debug(f"Envoi de {len(log_buffer)} logs pour le conteneur {container_id}")
except WebSocketDisconnect:
logger.info(f"Client WebSocket déconnecté pendant l'envoi des logs pour {container_id}")
break
except Exception as e:
logger.error(f"Erreur lors de l'envoi des logs pour {container_id}: {e}")
break
log_buffer = []
last_send_time = current_time
# Envoyer les logs restants
if log_buffer:
try:
await websocket.send_json({"logs": log_buffer})
logger.debug(f"Envoi des {len(log_buffer)} logs restants pour le conteneur {container_id}")
except WebSocketDisconnect:
logger.info(f"Client WebSocket déconnecté pendant l'envoi des logs restants pour {container_id}")
except Exception as e:
logger.error(f"Erreur lors de l'envoi des logs restants pour {container_id}: {e}")
except asyncio.CancelledError:
logger.info(f"Suivi des logs annulé pour le conteneur {container_id}")
raise
except Exception as e:
logger.error(f"Erreur lors du suivi des logs pour {container_id}: {e}")
raise

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,74 @@
import uuid
from typing import List, Optional, Dict
from datetime import datetime, timedelta
from ..models import Agent, AgentRegistration
from .docker import DockerService
class AgentService:
def __init__(self):
self.agents: Dict[str, Agent] = {}
self.docker_service = DockerService()
async def register_agent(self, registration: AgentRegistration) -> Agent:
"""Enregistre un nouvel agent."""
agent_id = str(uuid.uuid4())
agent = Agent(
id=agent_id,
name=registration.name,
hostname=registration.hostname,
ip_address=registration.ip_address,
docker_version=registration.docker_version,
status="active",
last_seen=datetime.utcnow(),
containers=[]
)
self.agents[agent_id] = agent
return agent
async def update_agent_status(self, agent_id: str) -> Optional[Agent]:
"""Met à jour le statut d'un agent."""
if agent_id not in self.agents:
return None
agent = self.agents[agent_id]
agent.last_seen = datetime.utcnow()
agent.status = "active"
return agent
async def get_agent(self, agent_id: str) -> Optional[Agent]:
"""Récupère un agent par son ID."""
return self.agents.get(agent_id)
async def list_agents(self) -> List[Agent]:
"""Liste tous les agents enregistrés."""
return list(self.agents.values())
async def remove_agent(self, agent_id: str) -> bool:
"""Supprime un agent."""
if agent_id in self.agents:
del self.agents[agent_id]
return True
return False
async def cleanup_inactive_agents(self, timeout_minutes: int = 5) -> List[str]:
"""Nettoie les agents inactifs."""
now = datetime.utcnow()
inactive_agents = [
agent_id for agent_id, agent in self.agents.items()
if (now - agent.last_seen) > timedelta(minutes=timeout_minutes)
]
for agent_id in inactive_agents:
await self.remove_agent(agent_id)
return inactive_agents
async def update_agent_containers(self, agent_id: str) -> Optional[Agent]:
"""Met à jour la liste des conteneurs d'un agent."""
if agent_id not in self.agents:
return None
agent = self.agents[agent_id]
agent.containers = await self.docker_service.list_containers()
agent.last_seen = datetime.utcnow()
return agent

View file

@ -0,0 +1,75 @@
import uuid
from typing import List, Optional
from datetime import datetime
from ..models.agent import Agent, AgentCreate, AgentUpdate, AgentStatus
class AgentService:
def __init__(self):
# TODO: Remplacer par une vraie base de données
self.agents: List[Agent] = []
async def list_agents(self) -> List[Agent]:
"""Liste tous les agents."""
return self.agents
async def create_agent(self, agent: AgentCreate) -> Agent:
"""Crée un nouvel agent."""
new_agent = Agent(
id=str(uuid.uuid4()),
name=agent.name,
host=agent.host,
port=agent.port,
token=agent.token,
status="offline",
version="1.0.0",
last_seen=datetime.now(),
created_at=datetime.now(),
updated_at=datetime.now()
)
self.agents.append(new_agent)
return new_agent
async def get_agent(self, agent_id: str) -> Optional[Agent]:
"""Récupère un agent par son ID."""
return next((agent for agent in self.agents if agent.id == agent_id), None)
async def update_agent(self, agent_id: str, agent_update: AgentUpdate) -> Optional[Agent]:
"""Met à jour un agent."""
agent = await self.get_agent(agent_id)
if not agent:
return None
update_data = agent_update.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(agent, key, value)
agent.updated_at = datetime.now()
return agent
async def delete_agent(self, agent_id: str) -> bool:
"""Supprime un agent."""
agent = await self.get_agent(agent_id)
if not agent:
return False
self.agents.remove(agent)
return True
async def get_agent_status(self, agent_id: str) -> Optional[AgentStatus]:
"""Récupère le statut d'un agent."""
agent = await self.get_agent(agent_id)
if not agent:
return None
# TODO: Implémenter la vérification réelle du statut
return AgentStatus(
status=agent.status,
version=agent.version,
last_seen=agent.last_seen,
containers_count=0,
system_info={
"os": "Linux",
"arch": "x86_64",
"docker_version": "20.10.0"
}
)

View file

@ -0,0 +1,387 @@
import docker
from typing import List, Dict, Any, Optional, AsyncGenerator
import asyncio
import logging
from ..models.container import Container, ContainerStats, NetworkStats, PortBinding
logger = logging.getLogger(__name__)
class DockerService:
def __init__(self):
"""Initialise le service Docker."""
self.client = None
self.init_client()
def init_client(self):
"""Initialise ou réinitialise le client Docker."""
try:
if self.client:
try:
# Tester si le client existant est toujours valide
self.client.ping()
logger.info("Client Docker existant est valide")
return
except:
logger.warning("Client Docker existant n'est plus valide")
self.client = None
self.client = docker.from_env()
self.client.ping() # Vérifier que la connexion fonctionne
logger.info("Nouveau client Docker initialisé avec succès")
except Exception as e:
logger.error(f"Erreur lors de l'initialisation du client Docker: {e}")
raise
def ensure_client(self):
"""S'assure que le client Docker est initialisé et valide."""
try:
if not self.client:
logger.warning("Client Docker non initialisé, tentative de réinitialisation")
self.init_client()
return
# Test simple pour vérifier que le client est fonctionnel
self.client.ping()
except Exception as e:
logger.warning(f"Client Docker invalide, tentative de réinitialisation: {e}")
self.init_client()
def _parse_port_bindings(self, ports: Dict) -> List[PortBinding]:
"""Convertit les ports Docker en modèle PortBinding."""
bindings = []
if not ports:
return bindings
for container_port, host_bindings in ports.items():
if not host_bindings:
continue
# Format: "8080/tcp"
port, proto = container_port.split("/")
for binding in host_bindings:
bindings.append(PortBinding(
host_ip=binding.get("HostIp", "0.0.0.0"),
host_port=int(binding.get("HostPort", port)),
container_port=int(port),
protocol=proto
))
return bindings
def _parse_environment(self, env: List[str]) -> Dict[str, str]:
"""Convertit les variables d'environnement en dictionnaire."""
result = {}
if not env:
return result
for var in env:
if "=" in var:
key, value = var.split("=", 1)
result[key] = value
return result
async def list_containers(self) -> List[Container]:
"""Liste tous les conteneurs Docker."""
try:
self.ensure_client()
logger.info("Début de la récupération de la liste des conteneurs")
containers = self.client.containers.list(all=True)
logger.info(f"Nombre de conteneurs trouvés: {len(containers)}")
result = []
for container in containers:
try:
attrs = container.attrs
container_model = Container(
id=container.id,
name=container.name,
status=container.status,
image=container.image.tags[0] if container.image.tags else "none",
created=attrs['Created'],
ports=self._parse_port_bindings(attrs['NetworkSettings']['Ports']),
volumes=[v['Source'] for v in attrs['Mounts']],
environment=self._parse_environment(attrs['Config']['Env']),
networks=list(attrs['NetworkSettings']['Networks'].keys()),
health_status=attrs.get('State', {}).get('Health', {}).get('Status'),
restart_policy=attrs['HostConfig']['RestartPolicy']['Name'],
command=attrs['Config']['Cmd'][0] if attrs['Config']['Cmd'] else None
)
result.append(container_model)
logger.debug(f"Conteneur ajouté: {container.name}")
except Exception as e:
logger.error(f"Erreur lors de la conversion du conteneur {container.name}: {e}")
continue
return result
except Exception as e:
logger.error(f"Erreur lors de la liste des conteneurs: {e}")
raise
async def get_container(self, container_id: str) -> Container:
"""Récupère les informations d'un conteneur spécifique."""
self.ensure_client()
container = self.client.containers.get(container_id)
attrs = container.attrs
return Container(
id=container.id,
name=container.name,
status=container.status,
image=container.image.tags[0] if container.image.tags else "none",
created=attrs['Created'],
ports=self._parse_port_bindings(attrs['NetworkSettings']['Ports']),
volumes=[v['Source'] for v in attrs['Mounts']],
environment=self._parse_environment(attrs['Config']['Env']),
networks=list(attrs['NetworkSettings']['Networks'].keys()),
health_status=attrs.get('State', {}).get('Health', {}).get('Status'),
restart_policy=attrs['HostConfig']['RestartPolicy']['Name'],
command=attrs['Config']['Cmd'][0] if attrs['Config']['Cmd'] else None
)
async def get_container_logs(self, container_id: str, tail: int = 100) -> List[Dict[str, str]]:
"""Récupère les logs d'un conteneur."""
try:
self.ensure_client()
container = self.client.containers.get(container_id)
if not container:
logger.error(f"Conteneur {container_id} non trouvé")
return []
logger.info(f"Récupération des logs pour le conteneur {container_id}")
# Utilisation de l'API Docker pour obtenir les logs
logs = container.logs(
tail=tail,
timestamps=True,
stdout=True,
stderr=True
)
if not logs:
logger.warning(f"Aucun log trouvé pour le conteneur {container_id}")
return []
decoded_logs = logs.decode('utf-8')
log_lines = []
for line in decoded_logs.split('\n'):
if not line.strip():
continue
# Format attendu: "2024-04-01T11:10:27.000000000Z message..."
try:
timestamp, message = line.split(' ', 1)
log_lines.append({
"timestamp": timestamp,
"message": message.strip(),
"stream": "stdout" # Par défaut stdout
})
except ValueError:
# Si le format n'est pas celui attendu, on garde la ligne complète
log_lines.append({
"timestamp": "",
"message": line.strip(),
"stream": "stdout"
})
logger.info(f"Nombre de lignes de logs trouvées : {len(log_lines)}")
return log_lines
except docker.errors.NotFound:
logger.error(f"Conteneur {container_id} non trouvé")
return []
except Exception as e:
logger.error(f"Erreur lors de la récupération des logs du conteneur {container_id}: {e}")
raise
async def get_container_stats(self, container_id: str) -> ContainerStats:
"""Récupère les statistiques d'un conteneur."""
try:
self.ensure_client()
container = self.client.containers.get(container_id)
stats = container.stats(stream=False)
logger.debug(f"Stats brutes reçues pour {container_id}: {stats}")
# Vérifier la présence des clés nécessaires
if not all(key in stats for key in ['cpu_stats', 'precpu_stats', 'memory_stats']):
logger.error(f"Données de stats incomplètes pour le conteneur {container_id}")
raise ValueError("Données de statistiques incomplètes")
# Calculer le pourcentage CPU avec vérification des valeurs
try:
cpu_delta = stats['cpu_stats']['cpu_usage']['total_usage'] - stats['precpu_stats']['cpu_usage']['total_usage']
system_delta = stats['cpu_stats']['system_cpu_usage'] - stats['precpu_stats']['system_cpu_usage']
online_cpus = stats['cpu_stats'].get('online_cpus', 1) # Utiliser 1 comme valeur par défaut
if system_delta > 0:
cpu_percent = (cpu_delta / system_delta) * 100.0 * online_cpus
else:
cpu_percent = 0.0
except (KeyError, TypeError) as e:
logger.warning(f"Erreur lors du calcul du CPU pour {container_id}: {e}")
cpu_percent = 0.0
# Convertir les statistiques réseau avec gestion des erreurs
networks = {}
for net_name, net_stats in stats.get('networks', {}).items():
try:
networks[net_name] = NetworkStats(
rx_bytes=net_stats.get('rx_bytes', 0),
rx_packets=net_stats.get('rx_packets', 0),
rx_errors=net_stats.get('rx_errors', 0),
rx_dropped=net_stats.get('rx_dropped', 0),
tx_bytes=net_stats.get('tx_bytes', 0),
tx_packets=net_stats.get('tx_packets', 0),
tx_errors=net_stats.get('tx_errors', 0),
tx_dropped=net_stats.get('tx_dropped', 0)
)
except Exception as e:
logger.warning(f"Erreur lors de la conversion des stats réseau pour {net_name}: {e}")
continue
# Récupérer les statistiques de bloc avec gestion des erreurs
try:
io_stats = stats.get('blkio_stats', {}).get('io_service_bytes_recursive', [])
block_read = io_stats[0].get('value', 0) if len(io_stats) > 0 else 0
block_write = io_stats[1].get('value', 0) if len(io_stats) > 1 else 0
except (IndexError, KeyError, AttributeError) as e:
logger.warning(f"Erreur lors de la récupération des stats de bloc pour {container_id}: {e}")
block_read = 0
block_write = 0
return ContainerStats(
cpu_percent=cpu_percent,
memory_usage=stats.get('memory_stats', {}).get('usage', 0),
memory_limit=stats.get('memory_stats', {}).get('limit', 0),
network=networks,
block_read=block_read,
block_write=block_write
)
except docker.errors.NotFound:
logger.error(f"Conteneur {container_id} non trouvé")
raise
except Exception as e:
logger.error(f"Erreur lors de la récupération des stats du conteneur {container_id}: {e}")
raise
async def start_container(self, container_id: str) -> None:
"""Démarre un conteneur."""
self.ensure_client()
container = self.client.containers.get(container_id)
container.start()
async def stop_container(self, container_id: str) -> None:
"""Arrête un conteneur."""
self.ensure_client()
container = self.client.containers.get(container_id)
container.stop()
async def restart_container(self, container_id: str) -> None:
"""Redémarre un conteneur."""
self.ensure_client()
container = self.client.containers.get(container_id)
container.restart()
async def update_container(self, container_id: str) -> None:
"""Met à jour un conteneur."""
self.ensure_client()
container = self.client.containers.get(container_id)
container.restart()
async def follow_container_logs(self, container_id: str) -> AsyncGenerator[Dict[str, str], None]:
"""Suit les logs d'un conteneur en temps réel en utilisant l'API Docker Python."""
try:
self.ensure_client()
container = self.client.containers.get(container_id)
logger.info(f"Démarrage du suivi des logs pour le conteneur {container_id}")
# Utiliser l'API Docker Python pour obtenir un flux de logs continu
log_stream = container.logs(
stream=True,
follow=True,
timestamps=True,
stdout=True,
stderr=True,
tail=100 # Commencer avec les 100 derniers logs
)
for log in log_stream:
try:
# Décoder la ligne de log
line = log.decode('utf-8').strip()
if not line:
continue
logger.debug(f"Ligne de log brute reçue: {line}")
# Format attendu: "2024-04-01T11:10:27.000000000Z message..."
try:
# Trouver le premier espace qui sépare la date du message
space_index = line.find(' ')
if space_index > 0:
timestamp = line[:space_index]
message = line[space_index + 1:].strip()
logger.debug(f"Timestamp extrait: {timestamp}")
logger.debug(f"Message extrait: {message}")
# Vérifier si le timestamp est valide (format ISO 8601)
if timestamp.endswith('Z'):
# Convertir le timestamp en format ISO 8601 standard
timestamp = timestamp.replace('Z', '+00:00')
# Vérifier que le format est valide
if not timestamp.replace('.', '').replace(':', '').replace('-', '').replace('T', '').replace('+', '').isdigit():
logger.warning(f"Format de timestamp invalide: {timestamp}")
timestamp = ""
else:
logger.warning(f"Timestamp ne se termine pas par Z: {timestamp}")
timestamp = ""
yield {
"timestamp": timestamp,
"message": message,
"stream": "stdout"
}
else:
# Si pas de timestamp valide, envoyer le message sans timestamp
logger.warning(f"Pas d'espace trouvé dans la ligne: {line}")
yield {
"timestamp": "",
"message": line,
"stream": "stdout"
}
except ValueError as e:
logger.error(f"Erreur lors du parsing de la ligne: {e}")
yield {
"timestamp": "",
"message": line,
"stream": "stdout"
}
except UnicodeDecodeError:
# Fallback sur latin-1 si UTF-8 échoue
line = log.decode('latin-1').strip()
yield {
"timestamp": "",
"message": line,
"stream": "stdout"
}
except Exception as e:
logger.error(f"Erreur lors du décodage d'un log : {e}")
continue
except docker.errors.NotFound:
logger.error(f"Conteneur {container_id} non trouvé")
except Exception as e:
logger.error(f"Erreur lors du suivi des logs du conteneur {container_id}: {e}")
raise
finally:
logger.info(f"Arrêt du suivi des logs pour le conteneur {container_id}")
if 'log_stream' in locals():
try:
log_stream.close()
except:
pass

View file

@ -0,0 +1,77 @@
import docker
from typing import List, Dict, Any
from datetime import datetime
from ..models.container import Container, ContainerLog, ContainerStats
class DockerService:
def __init__(self):
self.client = docker.from_env()
async def list_containers(self) -> List[Container]:
"""Liste tous les conteneurs Docker."""
containers = self.client.containers.list(all=True)
return [
Container(
id=container.id,
name=container.name,
image=container.image.tags[0] if container.image.tags else container.image.id,
status=container.status,
ports=container.ports,
created_at=datetime.fromtimestamp(container.attrs['Created']),
updated_at=datetime.now()
)
for container in containers
]
async def get_container_logs(self, container_id: str) -> List[ContainerLog]:
"""Récupère les logs d'un conteneur."""
container = self.client.containers.get(container_id)
logs = container.logs(tail=100, timestamps=True).decode('utf-8').split('\n')
return [
ContainerLog(
timestamp=datetime.fromisoformat(line.split(' ')[0].replace('T', ' ').replace('Z', '')),
stream=line.split(' ')[1].strip('[]'),
message=' '.join(line.split(' ')[2:])
)
for line in logs if line
]
async def update_container(self, container_id: str) -> bool:
"""Met à jour un conteneur avec la dernière version de son image."""
try:
container = self.client.containers.get(container_id)
image = container.image
container.stop()
container.remove()
new_container = self.client.containers.run(
image=image.id,
name=container.name,
ports=container.ports,
environment=container.attrs['Config']['Env'],
restart_policy={"Name": container.attrs['HostConfig']['RestartPolicy']['Name']}
)
return True
except Exception as e:
print(f"Erreur lors de la mise à jour du conteneur : {e}")
return False
async def get_container_stats(self, container_id: str) -> ContainerStats:
"""Récupère les statistiques d'un conteneur."""
container = self.client.containers.get(container_id)
stats = container.stats(stream=False)
# Calcul du pourcentage CPU
cpu_delta = stats['cpu_stats']['cpu_usage']['total_usage'] - stats['precpu_stats']['cpu_usage']['total_usage']
system_delta = stats['cpu_stats']['system_cpu_usage'] - stats['precpu_stats']['system_cpu_usage']
cpu_percent = (cpu_delta / system_delta) * 100.0 if system_delta > 0 else 0.0
return ContainerStats(
cpu_percent=cpu_percent,
memory_usage=stats['memory_stats']['usage'],
memory_limit=stats['memory_stats']['limit'],
network_rx=stats['networks']['eth0']['rx_bytes'],
network_tx=stats['networks']['eth0']['tx_bytes'],
block_read=stats['blkio_stats']['io_service_bytes_recursive'][0]['value'],
block_write=stats['blkio_stats']['io_service_bytes_recursive'][1]['value'],
timestamp=datetime.now()
)

View file

@ -0,0 +1,58 @@
from typing import AsyncGenerator, Dict, List
import docker
import json
from datetime import datetime
from fastapi import WebSocket
from app.core.config import settings
class LogsService:
def __init__(self):
self.client = docker.from_env()
self.active_connections: Dict[str, List[WebSocket]] = {}
async def get_container_logs(self, container_id: str, follow: bool = True) -> AsyncGenerator[str, None]:
"""Récupère les logs d'un conteneur en temps réel."""
try:
container = self.client.containers.get(container_id)
for log in container.logs(stream=True, follow=follow, timestamps=True):
log_entry = {
"timestamp": datetime.fromisoformat(log.decode().split()[0].decode()),
"container_id": container_id,
"container_name": container.name,
"message": log.decode().split(b' ', 1)[1].decode(),
"stream": "stdout" if log.decode().split(b' ', 1)[1].decode().startswith("stdout") else "stderr"
}
yield json.dumps(log_entry)
except docker.errors.NotFound:
yield json.dumps({
"error": f"Conteneur {container_id} non trouvé"
})
except Exception as e:
yield json.dumps({
"error": str(e)
})
async def connect(self, websocket: WebSocket, container_id: str = None):
"""Connecte un client WebSocket pour recevoir les logs."""
await websocket.accept()
if container_id not in self.active_connections:
self.active_connections[container_id] = []
self.active_connections[container_id].append(websocket)
def disconnect(self, websocket: WebSocket, container_id: str = None):
"""Déconnecte un client WebSocket."""
if container_id in self.active_connections:
self.active_connections[container_id].remove(websocket)
if not self.active_connections[container_id]:
del self.active_connections[container_id]
async def broadcast_log(self, log: str, container_id: str = None):
"""Diffuse un log à tous les clients connectés."""
if container_id in self.active_connections:
for connection in self.active_connections[container_id]:
try:
await connection.send_text(log)
except Exception:
self.disconnect(connection, container_id)
logs_service = LogsService()

View file

@ -0,0 +1,33 @@
import redis
import json
from typing import List, Dict, Any
from datetime import datetime
from ..models.container import ContainerLog
class RedisService:
def __init__(self):
self.redis = redis.Redis(host='localhost', port=6379, db=0)
self.logs_key_prefix = "container_logs:"
async def get_logs(self, container_id: str, limit: int = 100) -> List[ContainerLog]:
"""Récupère les logs d'un conteneur depuis Redis."""
key = f"{self.logs_key_prefix}{container_id}"
logs = self.redis.lrange(key, 0, limit - 1)
return [ContainerLog(**json.loads(log)) for log in logs]
async def add_log(self, container_id: str, log: ContainerLog) -> None:
"""Ajoute un log pour un conteneur dans Redis."""
key = f"{self.logs_key_prefix}{container_id}"
self.redis.lpush(key, log.model_dump_json())
# Garder seulement les 1000 derniers logs
self.redis.ltrim(key, 0, 999)
async def clear_logs(self, container_id: str) -> None:
"""Supprime tous les logs d'un conteneur."""
key = f"{self.logs_key_prefix}{container_id}"
self.redis.delete(key)
async def get_container_ids(self) -> List[str]:
"""Récupère la liste des IDs de conteneurs ayant des logs."""
keys = self.redis.keys(f"{self.logs_key_prefix}*")
return [key.replace(self.logs_key_prefix, "") for key in keys]

14
server/requirements.txt Normal file
View file

@ -0,0 +1,14 @@
fastapi>=0.109.0
uvicorn>=0.27.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
docker>=6.1.3
pydantic>=2.6.0
python-dotenv>=1.0.0
redis>=5.0.1
sqlalchemy>=2.0.23
websockets>=12.0
aiohttp>=3.9.1
pytest>=7.4.3
httpx>=0.25.2