SOGOMS v1.0.3 - Admin UI, Cron, Config reload
Phase 13 : sogoms-cron
- Jobs planifiés avec schedule cron standard
- Types: query_email, http, service
- Actions: list, trigger, status
Phase 16 : Réorganisation config/apps/{app}/
- Tous les fichiers d'une app dans un seul dossier
- Migration prokov vers nouvelle structure
Phase 17 : sogoms-admin
- Interface web d'administration (Go templates + htmx)
- Auth sessions cookies signées HMAC-SHA256
- Rôles super_admin / app_admin avec permissions
Phase 19 : Création d'app via Admin UI
- Formulaire création app avec config DB/auth
- Bouton "Scanner la base" : introspection + schema.yaml
- Rechargement automatique sogoway via SIGHUP
Infrastructure :
- sogoctl : socket de contrôle /run/sogoctl.sock
- sogoway : reload config sur SIGHUP sans restart
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
622
cmd/sogoms/admin/handlers.go
Normal file
622
cmd/sogoms/admin/handlers.go
Normal file
@@ -0,0 +1,622 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"sogoms.com/internal/admin"
|
||||
"sogoms.com/internal/auth"
|
||||
"sogoms.com/internal/config"
|
||||
)
|
||||
|
||||
// AdminServer contient les dépendances des handlers.
|
||||
type AdminServer struct {
|
||||
adminCfg *admin.AdminConfig
|
||||
registry *config.Registry
|
||||
sessions *SessionStore
|
||||
rateLimiter *RateLimiter
|
||||
perms *admin.PermissionChecker
|
||||
audit *admin.AuditLogger
|
||||
services *ServicePool
|
||||
templates *template.Template
|
||||
templatesDir string
|
||||
devMode bool
|
||||
}
|
||||
|
||||
// getTemplates retourne les templates, en les rechargeant si devMode est activé.
|
||||
func (s *AdminServer) getTemplates() *template.Template {
|
||||
if s.devMode && s.templatesDir != "" {
|
||||
tmpl, err := loadTemplates(s.templatesDir)
|
||||
if err != nil {
|
||||
log.Printf("[admin] reload templates error: %v", err)
|
||||
return s.templates
|
||||
}
|
||||
return tmpl
|
||||
}
|
||||
return s.templates
|
||||
}
|
||||
|
||||
// HandleLoginPage affiche la page de login.
|
||||
func (s *AdminServer) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
// Si déjà authentifié, rediriger vers dashboard
|
||||
if session, _ := s.sessions.GetSessionFromRequest(r); session != nil {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Générer un CSRF token pour le formulaire
|
||||
csrfToken, _ := generateSecureToken(32)
|
||||
|
||||
data := map[string]any{
|
||||
"Title": "Connexion",
|
||||
"CSRFToken": csrfToken,
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
}
|
||||
|
||||
s.render(w, "login.html", data)
|
||||
}
|
||||
|
||||
// HandleLogin traite la soumission du formulaire de login.
|
||||
func (s *AdminServer) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
ip := getClientIP(r)
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
// Rate limiting
|
||||
if !s.rateLimiter.Allow(ip) {
|
||||
s.audit.LogLogin(false, "", ip, userAgent, "rate_limited")
|
||||
http.Redirect(w, r, "/admin/login?error=Trop+de+tentatives", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Parser le formulaire
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/admin/login?error=Formulaire+invalide", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
// Enregistrer la tentative
|
||||
s.rateLimiter.Record(ip)
|
||||
|
||||
// Vérifier les credentials
|
||||
user := s.adminCfg.GetUser(username)
|
||||
if user == nil {
|
||||
s.audit.LogLogin(false, username, ip, userAgent, "user_not_found")
|
||||
http.Redirect(w, r, "/admin/login?error=Identifiants+incorrects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if !auth.VerifyPassword(user.PasswordHash, password) {
|
||||
s.audit.LogLogin(false, username, ip, userAgent, "wrong_password")
|
||||
http.Redirect(w, r, "/admin/login?error=Identifiants+incorrects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Créer la session
|
||||
session, err := s.sessions.Create(username, user.Role, ip, userAgent)
|
||||
if err != nil {
|
||||
log.Printf("[admin] failed to create session: %v", err)
|
||||
http.Redirect(w, r, "/admin/login?error=Erreur+serveur", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Définir le cookie
|
||||
s.sessions.SetCookie(w, session)
|
||||
|
||||
// Log succès
|
||||
s.audit.LogLogin(true, username, ip, userAgent, "")
|
||||
|
||||
// Rediriger vers dashboard
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleLogout déconnecte l'utilisateur.
|
||||
func (s *AdminServer) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
if session != nil {
|
||||
s.audit.LogLogout(session.Username, getClientIP(r))
|
||||
s.sessions.Delete(session.ID)
|
||||
}
|
||||
|
||||
s.sessions.ClearCookie(w)
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleDashboard affiche le dashboard principal.
|
||||
func (s *AdminServer) HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les apps accessibles
|
||||
allApps := s.registry.Apps()
|
||||
accessibleApps := s.perms.GetAccessibleApps(user, allApps)
|
||||
|
||||
data := map[string]any{
|
||||
"Title": "Dashboard",
|
||||
"User": user,
|
||||
"Session": session,
|
||||
"CSRFToken": session.CSRFToken,
|
||||
"IsSuperAdmin": user.IsSuperAdmin(),
|
||||
"Apps": accessibleApps,
|
||||
"Permissions": s.perms.GetUserPermissions(user),
|
||||
}
|
||||
|
||||
s.render(w, "dashboard.html", data)
|
||||
}
|
||||
|
||||
// HandleAPIApps retourne la liste des apps (partial htmx).
|
||||
func (s *AdminServer) HandleAPIApps(w http.ResponseWriter, r *http.Request) {
|
||||
user := GetUserFromContext(r.Context())
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
allApps := s.registry.Apps()
|
||||
accessibleApps := s.perms.GetAccessibleApps(user, allApps)
|
||||
|
||||
// Construire les infos des apps
|
||||
type AppInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
apps := make([]AppInfo, 0, len(accessibleApps))
|
||||
for _, appID := range accessibleApps {
|
||||
apps = append(apps, AppInfo{
|
||||
ID: appID,
|
||||
Name: appID, // On pourrait charger le nom depuis la config
|
||||
})
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Apps": apps,
|
||||
}
|
||||
|
||||
s.render(w, "partials/apps_list.html", data)
|
||||
}
|
||||
|
||||
// HandleAPIServicesHealth retourne le statut des services (partial htmx).
|
||||
func (s *AdminServer) HandleAPIServicesHealth(w http.ResponseWriter, r *http.Request) {
|
||||
user := GetUserFromContext(r.Context())
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Seul super_admin peut voir le statut des services
|
||||
if !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
statuses := s.services.HealthCheck()
|
||||
|
||||
data := map[string]any{
|
||||
"Services": statuses,
|
||||
}
|
||||
|
||||
s.render(w, "partials/services_status.html", data)
|
||||
}
|
||||
|
||||
// AppInfo contient les informations d'une app pour le template.
|
||||
type AppInfo struct {
|
||||
App string
|
||||
Version string
|
||||
BasePath string
|
||||
Hosts []string
|
||||
Database DatabaseInfo
|
||||
Schema bool
|
||||
SchemaTableCount int
|
||||
Queries bool
|
||||
QueriesCount int
|
||||
RoutesCount int
|
||||
}
|
||||
|
||||
// DatabaseInfo contient les infos de connexion DB.
|
||||
type DatabaseInfo struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Name string
|
||||
}
|
||||
|
||||
// TableInfo contient les infos d'une table pour le template.
|
||||
type TableInfo struct {
|
||||
Name string
|
||||
ColumnCount int
|
||||
PrimaryKey string
|
||||
}
|
||||
|
||||
// RouteInfo contient les infos d'une route pour le template.
|
||||
type RouteInfo struct {
|
||||
Method string
|
||||
Path string
|
||||
Handler string
|
||||
}
|
||||
|
||||
// HandleAppsPage affiche la liste des applications.
|
||||
func (s *AdminServer) HandleAppsPage(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les apps accessibles
|
||||
allApps := s.registry.Apps()
|
||||
accessibleApps := s.perms.GetAccessibleApps(user, allApps)
|
||||
|
||||
// Construire les infos détaillées
|
||||
apps := make([]AppInfo, 0, len(accessibleApps))
|
||||
for _, appID := range accessibleApps {
|
||||
cfg, ok := s.registry.GetByApp(appID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
info := AppInfo{
|
||||
App: cfg.App,
|
||||
Version: cfg.Version,
|
||||
BasePath: cfg.BasePath,
|
||||
Hosts: cfg.Hosts,
|
||||
Database: DatabaseInfo{
|
||||
Host: cfg.Database.Host,
|
||||
Port: cfg.Database.Port,
|
||||
User: cfg.Database.User,
|
||||
Name: cfg.Database.Name,
|
||||
},
|
||||
}
|
||||
|
||||
if cfg.Schema != nil {
|
||||
info.Schema = true
|
||||
info.SchemaTableCount = len(cfg.Schema.Tables)
|
||||
}
|
||||
|
||||
if cfg.Queries != nil {
|
||||
info.Queries = true
|
||||
info.QueriesCount = cfg.Queries.FileCount()
|
||||
}
|
||||
|
||||
info.RoutesCount = len(cfg.Routes)
|
||||
|
||||
apps = append(apps, info)
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Title": "Applications",
|
||||
"User": user,
|
||||
"Session": session,
|
||||
"CSRFToken": session.CSRFToken,
|
||||
"IsSuperAdmin": user.IsSuperAdmin(),
|
||||
"Apps": apps,
|
||||
}
|
||||
|
||||
s.render(w, "apps.html", data)
|
||||
}
|
||||
|
||||
// HandleAppDetailPage affiche les détails d'une application.
|
||||
func (s *AdminServer) HandleAppDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer l'app ID depuis l'URL
|
||||
appID := r.PathValue("appID")
|
||||
if appID == "" {
|
||||
http.Redirect(w, r, "/admin/apps", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que l'utilisateur a accès à cette app
|
||||
allApps := s.registry.Apps()
|
||||
accessibleApps := s.perms.GetAccessibleApps(user, allApps)
|
||||
hasAccess := false
|
||||
for _, a := range accessibleApps {
|
||||
if a == appID {
|
||||
hasAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAccess {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer la config de l'app
|
||||
cfg, ok := s.registry.GetByApp(appID)
|
||||
if !ok {
|
||||
http.Error(w, "App not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Construire les infos de l'app
|
||||
appInfo := AppInfo{
|
||||
App: cfg.App,
|
||||
Version: cfg.Version,
|
||||
BasePath: cfg.BasePath,
|
||||
Hosts: cfg.Hosts,
|
||||
Database: DatabaseInfo{
|
||||
Host: cfg.Database.Host,
|
||||
Port: cfg.Database.Port,
|
||||
User: cfg.Database.User,
|
||||
Name: cfg.Database.Name,
|
||||
},
|
||||
RoutesCount: len(cfg.Routes),
|
||||
}
|
||||
|
||||
if cfg.Schema != nil {
|
||||
appInfo.Schema = true
|
||||
appInfo.SchemaTableCount = len(cfg.Schema.Tables)
|
||||
}
|
||||
|
||||
if cfg.Queries != nil {
|
||||
appInfo.Queries = true
|
||||
appInfo.QueriesCount = cfg.Queries.FileCount()
|
||||
}
|
||||
|
||||
// Construire les infos des tables
|
||||
var tables []TableInfo
|
||||
if cfg.Schema != nil {
|
||||
for name, table := range cfg.Schema.Tables {
|
||||
pk := ""
|
||||
if len(table.Primary) > 0 {
|
||||
pk = strings.Join(table.Primary, ", ")
|
||||
}
|
||||
tables = append(tables, TableInfo{
|
||||
Name: name,
|
||||
ColumnCount: len(table.Columns),
|
||||
PrimaryKey: pk,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Construire les infos des routes
|
||||
var routes []RouteInfo
|
||||
for _, route := range cfg.Routes {
|
||||
routes = append(routes, RouteInfo{
|
||||
Method: route.Method,
|
||||
Path: route.Path,
|
||||
Handler: route.Scenario,
|
||||
})
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Title": cfg.App,
|
||||
"User": user,
|
||||
"Session": session,
|
||||
"CSRFToken": session.CSRFToken,
|
||||
"IsSuperAdmin": user.IsSuperAdmin(),
|
||||
"App": appInfo,
|
||||
"Tables": tables,
|
||||
"Routes": routes,
|
||||
}
|
||||
|
||||
// Flash message depuis URL
|
||||
if flash := r.URL.Query().Get("flash"); flash != "" {
|
||||
data["FlashType"] = flash
|
||||
data["FlashMessage"] = r.URL.Query().Get("msg")
|
||||
}
|
||||
|
||||
s.render(w, "app_detail.html", data)
|
||||
}
|
||||
|
||||
// HandleAPICronJobs retourne la liste des jobs cron (partial htmx).
|
||||
func (s *AdminServer) HandleAPICronJobs(w http.ResponseWriter, r *http.Request) {
|
||||
user := GetUserFromContext(r.Context())
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Seul super_admin peut voir les jobs cron
|
||||
if !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
jobs, err := s.services.GetCronJobs()
|
||||
if err != nil {
|
||||
log.Printf("[admin] cron jobs error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Jobs": jobs,
|
||||
}
|
||||
|
||||
s.render(w, "partials/cron_jobs.html", data)
|
||||
}
|
||||
|
||||
// HandleAppNewPage affiche le formulaire de création d'app.
|
||||
func (s *AdminServer) HandleAppNewPage(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Seul super_admin peut créer des apps
|
||||
if !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Title": "Nouvelle Application",
|
||||
"User": user,
|
||||
"Session": session,
|
||||
"CSRFToken": session.CSRFToken,
|
||||
"IsSuperAdmin": user.IsSuperAdmin(),
|
||||
}
|
||||
|
||||
s.render(w, "apps_new.html", data)
|
||||
}
|
||||
|
||||
// HandleAppCreate traite la création d'une nouvelle app.
|
||||
func (s *AdminServer) HandleAppCreate(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Seul super_admin peut créer des apps
|
||||
if !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les valeurs du formulaire
|
||||
appName := strings.TrimSpace(r.FormValue("app_name"))
|
||||
version := strings.TrimSpace(r.FormValue("version"))
|
||||
basePath := strings.TrimSpace(r.FormValue("base_path"))
|
||||
hostsRaw := strings.TrimSpace(r.FormValue("hosts"))
|
||||
dbHost := strings.TrimSpace(r.FormValue("db_host"))
|
||||
dbPort := strings.TrimSpace(r.FormValue("db_port"))
|
||||
dbUser := strings.TrimSpace(r.FormValue("db_user"))
|
||||
dbName := strings.TrimSpace(r.FormValue("db_name"))
|
||||
dbPassword := r.FormValue("db_password")
|
||||
jwtExpiry := strings.TrimSpace(r.FormValue("jwt_expiry"))
|
||||
|
||||
// Validation basique
|
||||
if appName == "" || basePath == "" || hostsRaw == "" {
|
||||
http.Error(w, "Champs obligatoires manquants", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if dbHost == "" || dbPort == "" || dbUser == "" || dbName == "" || dbPassword == "" {
|
||||
http.Error(w, "Configuration base de données incomplète", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Parser les hosts (un par ligne)
|
||||
var hosts []string
|
||||
for _, h := range strings.Split(hostsRaw, "\n") {
|
||||
h = strings.TrimSpace(h)
|
||||
if h != "" {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
}
|
||||
|
||||
if len(hosts) == 0 {
|
||||
http.Error(w, "Au moins un host requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Créer l'app via le service
|
||||
err := s.services.CreateApp(appName, version, basePath, hosts, dbHost, dbPort, dbUser, dbName, dbPassword, jwtExpiry)
|
||||
if err != nil {
|
||||
log.Printf("[admin] create app error: %v", err)
|
||||
http.Error(w, "Erreur création app: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Log l'action
|
||||
s.audit.LogAction(user.Username, "create_app", appName, map[string]any{
|
||||
"ip": getClientIP(r),
|
||||
})
|
||||
|
||||
// Rediriger vers la page de l'app
|
||||
http.Redirect(w, r, "/admin/apps/"+appName, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleAppScanDB scanne la base de données et génère le schema.yaml.
|
||||
func (s *AdminServer) HandleAppScanDB(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Seul super_admin peut scanner les bases
|
||||
if !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
appID := r.PathValue("appID")
|
||||
if appID == "" {
|
||||
http.Error(w, "App ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que l'app existe
|
||||
if _, ok := s.registry.GetByApp(appID); !ok {
|
||||
http.Error(w, "App not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Scanner la base et générer le schema
|
||||
tableCount, err := s.services.ScanAndGenerateSchema(appID)
|
||||
if err != nil {
|
||||
log.Printf("[admin] scan db error: %v", err)
|
||||
redirectURL := fmt.Sprintf("/admin/apps/%s?flash=error&msg=%s", appID, url.QueryEscape("Erreur scan: "+err.Error()))
|
||||
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Recharger le registry local
|
||||
if err := s.registry.Load(); err != nil {
|
||||
log.Printf("[admin] reload registry error: %v", err)
|
||||
}
|
||||
|
||||
// Demander à sogoway de recharger sa config
|
||||
if err := s.services.ReloadGateway(); err != nil {
|
||||
log.Printf("[admin] reload gateway error: %v", err)
|
||||
// On ne bloque pas, on continue avec le message de succès
|
||||
} else {
|
||||
log.Printf("[admin] gateway reloaded successfully")
|
||||
}
|
||||
|
||||
// Log l'action
|
||||
s.audit.LogAction(user.Username, "scan_db", appID, map[string]any{
|
||||
"ip": getClientIP(r),
|
||||
"tables": tableCount,
|
||||
})
|
||||
|
||||
// Rediriger vers la page de l'app avec message de succès
|
||||
msg := fmt.Sprintf("Scan terminé : %d tables détectées", tableCount)
|
||||
redirectURL := fmt.Sprintf("/admin/apps/%s?flash=success&msg=%s", appID, url.QueryEscape(msg))
|
||||
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// render rend un template.
|
||||
func (s *AdminServer) render(w http.ResponseWriter, name string, data map[string]any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
tmpl := s.getTemplates()
|
||||
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("[admin] template error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
198
cmd/sogoms/admin/main.go
Normal file
198
cmd/sogoms/admin/main.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// sogoms-admin : Interface web d'administration pour SOGOMS.
|
||||
// Gestion des apps, schemas, queries, emails, crons et logs.
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"sogoms.com/internal/admin"
|
||||
"sogoms.com/internal/config"
|
||||
"sogoms.com/internal/protocol"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html templates/partials/*.html
|
||||
var templatesFS embed.FS
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
var (
|
||||
port = flag.Int("port", 9000, "HTTP server port")
|
||||
configDir = flag.String("config", "/config", "Configuration directory")
|
||||
secretsDir = flag.String("secrets", "/secrets", "Secrets directory")
|
||||
templatesDir = flag.String("templates", "", "Templates directory (empty = use embedded)")
|
||||
devMode = flag.Bool("dev", false, "Dev mode: reload templates on each request")
|
||||
dbSocket = flag.String("db-socket", "/run/sogoms-db.1.sock", "DB service socket")
|
||||
logsSocket = flag.String("logs-socket", "/run/sogoms-logs.1.sock", "Logs service socket")
|
||||
cronSocket = flag.String("cron-socket", "/run/sogoms-cron.1.sock", "Cron service socket")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
log.SetFlags(log.Ltime | log.Lshortfile)
|
||||
|
||||
// Charger la config admin
|
||||
adminConfigPath := *secretsDir + "/admin_users.yaml"
|
||||
adminCfg, err := admin.LoadAdminConfig(adminConfigPath)
|
||||
if err != nil {
|
||||
log.Fatalf("load admin config: %v", err)
|
||||
}
|
||||
log.Printf("[admin] loaded %d admin users", len(adminCfg.Users))
|
||||
|
||||
// Charger le registry des apps
|
||||
registry := config.NewRegistry(*configDir)
|
||||
if err := registry.Load(); err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
log.Printf("[admin] loaded apps: %v", registry.Apps())
|
||||
|
||||
// Pools de connexion aux services
|
||||
services := &ServicePool{}
|
||||
if *dbSocket != "" {
|
||||
services.DB = protocol.NewPool(*dbSocket, 2)
|
||||
}
|
||||
if *logsSocket != "" {
|
||||
services.Logs = protocol.NewPool(*logsSocket, 2)
|
||||
}
|
||||
if *cronSocket != "" {
|
||||
services.Cron = protocol.NewPool(*cronSocket, 2)
|
||||
}
|
||||
|
||||
// Session store
|
||||
sessions := NewSessionStore(&adminCfg.Session)
|
||||
go sessions.Cleanup()
|
||||
|
||||
// Rate limiter
|
||||
rateLimiter := NewRateLimiter(&adminCfg.RateLimit)
|
||||
|
||||
// Permission checker
|
||||
perms := admin.NewPermissionChecker(adminCfg)
|
||||
|
||||
// Audit logger
|
||||
audit := admin.NewAuditLogger(services.Logs)
|
||||
|
||||
// Charger les templates
|
||||
templates, err := loadTemplates(*templatesDir)
|
||||
if err != nil {
|
||||
log.Fatalf("load templates: %v", err)
|
||||
}
|
||||
if *templatesDir != "" {
|
||||
log.Printf("[admin] templates loaded from filesystem: %s", *templatesDir)
|
||||
if *devMode {
|
||||
log.Printf("[admin] dev mode: templates will reload on each request")
|
||||
}
|
||||
} else {
|
||||
log.Printf("[admin] templates loaded from embedded")
|
||||
}
|
||||
|
||||
// Créer le serveur
|
||||
server := &AdminServer{
|
||||
adminCfg: adminCfg,
|
||||
registry: registry,
|
||||
sessions: sessions,
|
||||
rateLimiter: rateLimiter,
|
||||
perms: perms,
|
||||
audit: audit,
|
||||
services: services,
|
||||
templates: templates,
|
||||
templatesDir: *templatesDir,
|
||||
devMode: *devMode,
|
||||
}
|
||||
|
||||
// Router
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Fichiers statiques (CSS, JS)
|
||||
staticSubFS, _ := fs.Sub(staticFS, "static")
|
||||
staticHandler := http.FileServerFS(staticSubFS)
|
||||
mux.Handle("GET /admin/static/", http.StripPrefix("/admin/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000")
|
||||
staticHandler.ServeHTTP(w, r)
|
||||
})))
|
||||
|
||||
// Routes publiques
|
||||
mux.HandleFunc("GET /admin/login", server.HandleLoginPage)
|
||||
mux.HandleFunc("POST /admin/login", server.HandleLogin)
|
||||
|
||||
// Routes protégées
|
||||
mux.HandleFunc("GET /admin/{$}", AuthMiddleware(sessions, adminCfg, server.HandleDashboard))
|
||||
mux.HandleFunc("GET /admin/apps", AuthMiddleware(sessions, adminCfg, server.HandleAppsPage))
|
||||
mux.HandleFunc("GET /admin/apps/new", AuthMiddleware(sessions, adminCfg, server.HandleAppNewPage))
|
||||
mux.HandleFunc("POST /admin/apps/new", AuthMiddleware(sessions, adminCfg,
|
||||
CSRFMiddleware(sessions, server.HandleAppCreate)))
|
||||
mux.HandleFunc("GET /admin/apps/{appID}", AuthMiddleware(sessions, adminCfg, server.HandleAppDetailPage))
|
||||
mux.HandleFunc("POST /admin/apps/{appID}/scan", AuthMiddleware(sessions, adminCfg,
|
||||
CSRFMiddleware(sessions, server.HandleAppScanDB)))
|
||||
mux.HandleFunc("POST /admin/logout", AuthMiddleware(sessions, adminCfg,
|
||||
CSRFMiddleware(sessions, server.HandleLogout)))
|
||||
|
||||
// API htmx (protégées)
|
||||
mux.HandleFunc("GET /admin/api/apps", AuthMiddleware(sessions, adminCfg, server.HandleAPIApps))
|
||||
mux.HandleFunc("GET /admin/api/services/health", AuthMiddleware(sessions, adminCfg, server.HandleAPIServicesHealth))
|
||||
mux.HandleFunc("GET /admin/api/cron/jobs", AuthMiddleware(sessions, adminCfg, server.HandleAPICronJobs))
|
||||
|
||||
// Handler avec logging
|
||||
handler := LoggingMiddleware(mux)
|
||||
|
||||
// Démarrer le serveur
|
||||
addr := fmt.Sprintf(":%d", *port)
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("[admin] sogoms-admin started on %s", addr)
|
||||
if err := httpServer.ListenAndServe(); err != http.ErrServerClosed {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Attendre signal d'arrêt
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
log.Printf("[admin] shutting down...")
|
||||
httpServer.Close()
|
||||
}
|
||||
|
||||
// loadTemplates charge les templates depuis le filesystem ou embedded.
|
||||
func loadTemplates(dir string) (*template.Template, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"safe": func(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
},
|
||||
}
|
||||
|
||||
if dir != "" {
|
||||
// Charger depuis le filesystem
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseGlob(dir + "/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Charger les partials
|
||||
tmpl, err = tmpl.ParseGlob(dir + "/partials/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
// Charger depuis embedded
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html", "templates/partials/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tmpl, nil
|
||||
}
|
||||
235
cmd/sogoms/admin/middleware.go
Normal file
235
cmd/sogoms/admin/middleware.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"sogoms.com/internal/admin"
|
||||
)
|
||||
|
||||
// contextKey est une clé pour le contexte.
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
ctxSession contextKey = "session"
|
||||
ctxUser contextKey = "user"
|
||||
)
|
||||
|
||||
// GetSessionFromContext récupère la session depuis le contexte.
|
||||
func GetSessionFromContext(ctx context.Context) *Session {
|
||||
if session, ok := ctx.Value(ctxSession).(*Session); ok {
|
||||
return session
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserFromContext récupère l'utilisateur depuis le contexte.
|
||||
func GetUserFromContext(ctx context.Context) *admin.AdminUser {
|
||||
if user, ok := ctx.Value(ctxUser).(*admin.AdminUser); ok {
|
||||
return user
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthMiddleware vérifie que l'utilisateur est authentifié.
|
||||
func AuthMiddleware(sessions *SessionStore, adminCfg *admin.AdminConfig, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := sessions.GetSessionFromRequest(r)
|
||||
if err != nil {
|
||||
log.Printf("[admin] auth failed: %v", err)
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur
|
||||
user := adminCfg.GetUser(session.Username)
|
||||
if user == nil {
|
||||
log.Printf("[admin] user not found: %s", session.Username)
|
||||
sessions.Delete(session.ID)
|
||||
sessions.ClearCookie(w)
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Prolonger la session (sliding expiration)
|
||||
sessions.Refresh(session.ID)
|
||||
|
||||
// Injecter dans le contexte
|
||||
ctx := context.WithValue(r.Context(), ctxSession, session)
|
||||
ctx = context.WithValue(ctx, ctxUser, user)
|
||||
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
// CSRFMiddleware vérifie le token CSRF pour les requêtes POST/PUT/DELETE.
|
||||
func CSRFMiddleware(sessions *SessionStore, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Seules les requêtes de modification nécessitent CSRF
|
||||
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := sessions.GetSessionFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid session", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer le token CSRF (header ou form)
|
||||
csrfToken := r.Header.Get("X-CSRF-Token")
|
||||
if csrfToken == "" {
|
||||
csrfToken = r.FormValue("csrf_token")
|
||||
}
|
||||
|
||||
if csrfToken == "" || csrfToken != session.CSRFToken {
|
||||
log.Printf("[admin] CSRF validation failed for %s", session.Username)
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimiter limite les tentatives de login.
|
||||
type RateLimiter struct {
|
||||
attempts map[string][]time.Time // IP -> timestamps
|
||||
config *admin.RateLimitConfig
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewRateLimiter crée un nouveau rate limiter.
|
||||
func NewRateLimiter(config *admin.RateLimitConfig) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
attempts: make(map[string][]time.Time),
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow vérifie si une IP peut tenter un login.
|
||||
func (r *RateLimiter) Allow(ip string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
window := time.Duration(r.config.LoginWindow) * time.Second
|
||||
cutoff := now.Add(-window)
|
||||
|
||||
// Nettoyer les anciennes tentatives
|
||||
var recent []time.Time
|
||||
for _, t := range r.attempts[ip] {
|
||||
if t.After(cutoff) {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
r.attempts[ip] = recent
|
||||
|
||||
return len(recent) < r.config.LoginMax
|
||||
}
|
||||
|
||||
// Record enregistre une tentative.
|
||||
func (r *RateLimiter) Record(ip string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.attempts[ip] = append(r.attempts[ip], time.Now())
|
||||
}
|
||||
|
||||
// Remaining retourne le nombre de tentatives restantes.
|
||||
func (r *RateLimiter) Remaining(ip string) int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
window := time.Duration(r.config.LoginWindow) * time.Second
|
||||
cutoff := now.Add(-window)
|
||||
|
||||
var count int
|
||||
for _, t := range r.attempts[ip] {
|
||||
if t.After(cutoff) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
remaining := r.config.LoginMax - count
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
// RateLimitMiddleware applique le rate limiting.
|
||||
func RateLimitMiddleware(rateLimiter *RateLimiter, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := getClientIP(r)
|
||||
|
||||
if !rateLimiter.Allow(ip) {
|
||||
log.Printf("[admin] rate limit exceeded for IP: %s", ip)
|
||||
http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// LoggingMiddleware log toutes les requêtes.
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Wrapper pour capturer le status code
|
||||
lw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(lw, r)
|
||||
|
||||
log.Printf("[admin] %s %s %d %s", r.Method, r.URL.Path, lw.statusCode, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
// loggingResponseWriter capture le status code.
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (lw *loggingResponseWriter) WriteHeader(code int) {
|
||||
lw.statusCode = code
|
||||
lw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// getClientIP récupère l'IP du client.
|
||||
func getClientIP(r *http.Request) string {
|
||||
// Vérifier X-Forwarded-For (proxy/load balancer)
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
// Prendre la première IP
|
||||
if idx := len(xff); idx > 0 {
|
||||
for i, c := range xff {
|
||||
if c == ',' {
|
||||
return xff[:i]
|
||||
}
|
||||
}
|
||||
return xff
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier X-Real-IP
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
// Fallback sur RemoteAddr
|
||||
ip := r.RemoteAddr
|
||||
// Enlever le port
|
||||
for i := len(ip) - 1; i >= 0; i-- {
|
||||
if ip[i] == ':' {
|
||||
return ip[:i]
|
||||
}
|
||||
}
|
||||
return ip
|
||||
}
|
||||
448
cmd/sogoms/admin/services.go
Normal file
448
cmd/sogoms/admin/services.go
Normal file
@@ -0,0 +1,448 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"sogoms.com/internal/protocol"
|
||||
)
|
||||
|
||||
// ServicePool centralise les connexions vers les microservices.
|
||||
type ServicePool struct {
|
||||
DB *protocol.Pool
|
||||
Logs *protocol.Pool
|
||||
Cron *protocol.Pool
|
||||
}
|
||||
|
||||
// ServiceStatus représente le statut d'un service.
|
||||
type ServiceStatus struct {
|
||||
Name string `json:"name"`
|
||||
Available bool `json:"available"`
|
||||
LatencyMs int64 `json:"latency_ms"`
|
||||
}
|
||||
|
||||
// HealthCheck vérifie l'état de tous les services.
|
||||
func (sp *ServicePool) HealthCheck() []ServiceStatus {
|
||||
statuses := make([]ServiceStatus, 0, 3)
|
||||
|
||||
// Check sogoms-db
|
||||
if sp.DB != nil {
|
||||
statuses = append(statuses, sp.checkService("sogoms-db", sp.DB))
|
||||
}
|
||||
|
||||
// Check sogoms-logs
|
||||
if sp.Logs != nil {
|
||||
statuses = append(statuses, sp.checkService("sogoms-logs", sp.Logs))
|
||||
}
|
||||
|
||||
// Check sogoms-cron
|
||||
if sp.Cron != nil {
|
||||
statuses = append(statuses, sp.checkService("sogoms-cron", sp.Cron))
|
||||
}
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
// checkService vérifie un service individuel.
|
||||
func (sp *ServicePool) checkService(name string, pool *protocol.Pool) ServiceStatus {
|
||||
status := ServiceStatus{
|
||||
Name: name,
|
||||
Available: false,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
req := protocol.NewRequest("health", nil)
|
||||
resp, err := pool.Call(ctx, req)
|
||||
|
||||
status.LatencyMs = time.Since(start).Milliseconds()
|
||||
|
||||
if err == nil && resp != nil && resp.Status == "success" {
|
||||
status.Available = true
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// GetCronJobs récupère la liste des jobs cron.
|
||||
func (sp *ServicePool) GetCronJobs() ([]map[string]any, error) {
|
||||
if sp.Cron == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := protocol.NewRequest("list", nil)
|
||||
resp, err := sp.Cron.Call(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.Status != "success" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Extraire les jobs
|
||||
if result, ok := resp.Result.(map[string]any); ok {
|
||||
if jobs, ok := result["jobs"].([]any); ok {
|
||||
jobList := make([]map[string]any, 0, len(jobs))
|
||||
for _, j := range jobs {
|
||||
if job, ok := j.(map[string]any); ok {
|
||||
jobList = append(jobList, job)
|
||||
}
|
||||
}
|
||||
return jobList, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TriggerCronJob déclenche un job cron manuellement.
|
||||
func (sp *ServicePool) TriggerCronJob(appID, jobName string) error {
|
||||
if sp.Cron == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := protocol.NewRequest("trigger", map[string]any{
|
||||
"app_id": appID,
|
||||
"job": jobName,
|
||||
})
|
||||
_, err := sp.Cron.Call(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCronHistory récupère l'historique des exécutions cron.
|
||||
func (sp *ServicePool) GetCronHistory(appID string, limit int) ([]map[string]any, error) {
|
||||
if sp.Cron == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
params := map[string]any{"limit": limit}
|
||||
if appID != "" {
|
||||
params["app_id"] = appID
|
||||
}
|
||||
|
||||
req := protocol.NewRequest("status", params)
|
||||
resp, err := sp.Cron.Call(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.Status != "success" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Extraire les exécutions
|
||||
if result, ok := resp.Result.(map[string]any); ok {
|
||||
if execs, ok := result["executions"].([]any); ok {
|
||||
execList := make([]map[string]any, 0, len(execs))
|
||||
for _, e := range execs {
|
||||
if exec, ok := e.(map[string]any); ok {
|
||||
execList = append(execList, exec)
|
||||
}
|
||||
}
|
||||
return execList, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// CreateApp crée une nouvelle application avec sa configuration.
|
||||
func (sp *ServicePool) CreateApp(appName, version, basePath string, hosts []string, dbHost, dbPort, dbUser, dbName, dbPassword, jwtExpiry string) error {
|
||||
configDir := "/config"
|
||||
secretsDir := "/secrets"
|
||||
|
||||
// Créer le dossier de l'app
|
||||
appDir := filepath.Join(configDir, "apps", appName)
|
||||
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir app: %w", err)
|
||||
}
|
||||
|
||||
// Générer le JWT secret
|
||||
jwtSecret := make([]byte, 32)
|
||||
if _, err := rand.Read(jwtSecret); err != nil {
|
||||
return fmt.Errorf("generate jwt secret: %w", err)
|
||||
}
|
||||
jwtSecretB64 := base64.StdEncoding.EncodeToString(jwtSecret)
|
||||
|
||||
// Créer les fichiers secrets
|
||||
dbPassFile := filepath.Join(secretsDir, appName+"_db_pass")
|
||||
if err := os.WriteFile(dbPassFile, []byte(dbPassword), 0600); err != nil {
|
||||
return fmt.Errorf("write db password: %w", err)
|
||||
}
|
||||
|
||||
jwtSecretFile := filepath.Join(secretsDir, appName+"_jwt_secret")
|
||||
if err := os.WriteFile(jwtSecretFile, []byte(jwtSecretB64), 0600); err != nil {
|
||||
return fmt.Errorf("write jwt secret: %w", err)
|
||||
}
|
||||
|
||||
// Générer app.yaml
|
||||
hostsYAML := ""
|
||||
for _, h := range hosts {
|
||||
hostsYAML += fmt.Sprintf(" - %s\n", h)
|
||||
}
|
||||
|
||||
appYAML := fmt.Sprintf(`# Application %s
|
||||
# Générée automatiquement par sogoms-admin
|
||||
|
||||
app: %s
|
||||
version: "%s"
|
||||
base_path: %s
|
||||
|
||||
hosts:
|
||||
%s
|
||||
database:
|
||||
host: %s
|
||||
port: %s
|
||||
user: %s
|
||||
password_file: %s
|
||||
name: %s
|
||||
|
||||
auth:
|
||||
jwt_secret_file: %s
|
||||
jwt_expiry: %s
|
||||
|
||||
logs:
|
||||
retention_days: 30
|
||||
|
||||
routes: []
|
||||
`, appName, appName, version, basePath, hostsYAML, dbHost, dbPort, dbUser, dbPassFile, dbName, jwtSecretFile, jwtExpiry)
|
||||
|
||||
appYAMLFile := filepath.Join(appDir, "app.yaml")
|
||||
if err := os.WriteFile(appYAMLFile, []byte(appYAML), 0644); err != nil {
|
||||
return fmt.Errorf("write app.yaml: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScanAndGenerateSchema introspect la DB et génère schema.yaml.
|
||||
// Retourne le nombre de tables détectées.
|
||||
func (sp *ServicePool) ScanAndGenerateSchema(appID string) (int, error) {
|
||||
if sp.DB == nil {
|
||||
return 0, fmt.Errorf("db service not available")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Appeler l'introspection
|
||||
req := protocol.NewRequest("introspect", map[string]any{
|
||||
"app_id": appID,
|
||||
})
|
||||
resp, err := sp.DB.Call(ctx, req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("introspect call: %w", err)
|
||||
}
|
||||
|
||||
if resp.Status != "success" {
|
||||
if resp.Error != nil {
|
||||
return 0, fmt.Errorf("introspect failed: %s", resp.Error.Message)
|
||||
}
|
||||
return 0, fmt.Errorf("introspect failed")
|
||||
}
|
||||
|
||||
// Extraire les tables du résultat
|
||||
result, ok := resp.Result.(map[string]any)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid introspect result")
|
||||
}
|
||||
|
||||
tablesRaw, ok := result["tables"].(map[string]any)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("no tables in result")
|
||||
}
|
||||
|
||||
tableCount := len(tablesRaw)
|
||||
|
||||
// Construire le schema
|
||||
schema := map[string]any{
|
||||
"app": appID,
|
||||
"tables": convertTablesToSchema(tablesRaw),
|
||||
}
|
||||
|
||||
// Sérialiser en YAML
|
||||
yamlData, err := yaml.Marshal(schema)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("yaml marshal: %w", err)
|
||||
}
|
||||
|
||||
// Écrire le fichier
|
||||
schemaFile := filepath.Join("/config", "apps", appID, "schema.yaml")
|
||||
if err := os.WriteFile(schemaFile, yamlData, 0644); err != nil {
|
||||
return 0, fmt.Errorf("write schema.yaml: %w", err)
|
||||
}
|
||||
|
||||
return tableCount, nil
|
||||
}
|
||||
|
||||
// convertTablesToSchema convertit les données d'introspection en format schema.
|
||||
func convertTablesToSchema(tablesRaw map[string]any) map[string]any {
|
||||
tables := make(map[string]any)
|
||||
|
||||
// Trier les tables par nom pour un output cohérent
|
||||
tableNames := make([]string, 0, len(tablesRaw))
|
||||
for name := range tablesRaw {
|
||||
tableNames = append(tableNames, name)
|
||||
}
|
||||
sort.Strings(tableNames)
|
||||
|
||||
for _, tableName := range tableNames {
|
||||
tableData, ok := tablesRaw[tableName].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
table := make(map[string]any)
|
||||
|
||||
// Colonnes
|
||||
if columnsRaw, ok := tableData["columns"].(map[string]any); ok {
|
||||
columns := make(map[string]any)
|
||||
|
||||
// Trier les colonnes
|
||||
colNames := make([]string, 0, len(columnsRaw))
|
||||
for name := range columnsRaw {
|
||||
colNames = append(colNames, name)
|
||||
}
|
||||
sort.Strings(colNames)
|
||||
|
||||
for _, colName := range colNames {
|
||||
colData, ok := columnsRaw[colName].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
col := make(map[string]any)
|
||||
|
||||
// Type
|
||||
if t, ok := colData["type"].(string); ok {
|
||||
col["type"] = t
|
||||
}
|
||||
|
||||
// Longueur
|
||||
if l, ok := colData["length"].(float64); ok && l > 0 {
|
||||
col["length"] = int(l)
|
||||
}
|
||||
|
||||
// Primary
|
||||
if p, ok := colData["primary"].(bool); ok && p {
|
||||
col["primary"] = true
|
||||
}
|
||||
|
||||
// Auto increment
|
||||
if a, ok := colData["auto"].(bool); ok && a {
|
||||
col["auto"] = true
|
||||
}
|
||||
|
||||
// Required (NOT NULL)
|
||||
if r, ok := colData["required"].(bool); ok && r {
|
||||
col["required"] = true
|
||||
}
|
||||
|
||||
// Default
|
||||
if d, ok := colData["default"].(string); ok && d != "" {
|
||||
col["default"] = d
|
||||
}
|
||||
|
||||
// Unique
|
||||
if u, ok := colData["unique"].(bool); ok && u {
|
||||
col["unique"] = true
|
||||
}
|
||||
|
||||
// Foreign key
|
||||
if fk, ok := colData["foreign"].(string); ok && fk != "" {
|
||||
col["foreign"] = fk
|
||||
}
|
||||
|
||||
// Détecter filter: owner pour les colonnes user_id
|
||||
if colName == "user_id" {
|
||||
col["filter"] = "owner"
|
||||
}
|
||||
|
||||
columns[colName] = col
|
||||
}
|
||||
table["columns"] = columns
|
||||
}
|
||||
|
||||
// Primary keys
|
||||
if pk, ok := tableData["primary"].([]any); ok && len(pk) > 0 {
|
||||
pkStrings := make([]string, 0, len(pk))
|
||||
for _, p := range pk {
|
||||
if s, ok := p.(string); ok {
|
||||
pkStrings = append(pkStrings, s)
|
||||
}
|
||||
}
|
||||
table["primary"] = pkStrings
|
||||
}
|
||||
|
||||
// CRUD par défaut (sauf tables de liaison)
|
||||
if hasUserID(tableData) {
|
||||
table["crud"] = []string{"list", "show", "create", "update", "delete"}
|
||||
} else {
|
||||
table["crud"] = []string{}
|
||||
}
|
||||
|
||||
tables[tableName] = table
|
||||
}
|
||||
|
||||
return tables
|
||||
}
|
||||
|
||||
// hasUserID vérifie si une table a une colonne user_id.
|
||||
func hasUserID(tableData map[string]any) bool {
|
||||
if columnsRaw, ok := tableData["columns"].(map[string]any); ok {
|
||||
_, hasIt := columnsRaw["user_id"]
|
||||
return hasIt
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReloadGateway demande à sogoctl de recharger sogoway.
|
||||
func (sp *ServicePool) ReloadGateway() error {
|
||||
conn, err := net.DialTimeout("unix", "/run/sogoctl.sock", 2*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to sogoctl: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Envoyer la commande reload
|
||||
_, err = conn.Write([]byte("reload sogoway\n"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("send command: %w", err)
|
||||
}
|
||||
|
||||
// Lire la réponse
|
||||
buf := make([]byte, 256)
|
||||
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
response := string(buf[:n])
|
||||
if strings.HasPrefix(response, "error:") {
|
||||
return fmt.Errorf("%s", strings.TrimPrefix(response, "error: "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
214
cmd/sogoms/admin/session.go
Normal file
214
cmd/sogoms/admin/session.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"sogoms.com/internal/admin"
|
||||
)
|
||||
|
||||
// Session représente une session utilisateur.
|
||||
type Session struct {
|
||||
ID string
|
||||
Username string
|
||||
Role string
|
||||
CSRFToken string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// IsExpired vérifie si la session a expiré.
|
||||
func (s *Session) IsExpired() bool {
|
||||
return time.Now().After(s.ExpiresAt)
|
||||
}
|
||||
|
||||
// SessionStore gère les sessions en mémoire.
|
||||
type SessionStore struct {
|
||||
sessions map[string]*Session
|
||||
config *admin.SessionConfig
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewSessionStore crée un nouveau store de sessions.
|
||||
func NewSessionStore(config *admin.SessionConfig) *SessionStore {
|
||||
return &SessionStore{
|
||||
sessions: make(map[string]*Session),
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Create crée une nouvelle session.
|
||||
func (s *SessionStore) Create(username, role, ip, userAgent string) (*Session, error) {
|
||||
sessionID, err := generateSecureToken(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
csrfToken, err := generateSecureToken(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
session := &Session{
|
||||
ID: sessionID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
CSRFToken: csrfToken,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(time.Duration(s.config.MaxAge) * time.Second),
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.sessions[sessionID] = session
|
||||
s.mu.Unlock()
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// Get récupère une session par son ID.
|
||||
func (s *SessionStore) Get(sessionID string) (*Session, bool) {
|
||||
s.mu.RLock()
|
||||
session, ok := s.sessions[sessionID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok || session.IsExpired() {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return session, true
|
||||
}
|
||||
|
||||
// Delete supprime une session.
|
||||
func (s *SessionStore) Delete(sessionID string) {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, sessionID)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Refresh prolonge la durée de vie d'une session (sliding expiration).
|
||||
func (s *SessionStore) Refresh(sessionID string) {
|
||||
s.mu.Lock()
|
||||
if session, ok := s.sessions[sessionID]; ok {
|
||||
session.ExpiresAt = time.Now().Add(time.Duration(s.config.MaxAge) * time.Second)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Cleanup supprime les sessions expirées.
|
||||
func (s *SessionStore) Cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
s.mu.Lock()
|
||||
now := time.Now()
|
||||
for id, session := range s.sessions {
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Count retourne le nombre de sessions actives.
|
||||
func (s *SessionStore) Count() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.sessions)
|
||||
}
|
||||
|
||||
// SetCookie définit le cookie de session dans la réponse.
|
||||
func (s *SessionStore) SetCookie(w http.ResponseWriter, session *Session) {
|
||||
// Signer le session ID
|
||||
signature := s.sign(session.ID)
|
||||
value := session.ID + "." + signature
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.config.CookieName,
|
||||
Value: value,
|
||||
Path: "/admin",
|
||||
MaxAge: s.config.MaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearCookie supprime le cookie de session.
|
||||
func (s *SessionStore) ClearCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.config.CookieName,
|
||||
Value: "",
|
||||
Path: "/admin",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSessionFromRequest extrait et valide la session depuis le cookie.
|
||||
func (s *SessionStore) GetSessionFromRequest(r *http.Request) (*Session, error) {
|
||||
cookie, err := r.Cookie(s.config.CookieName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no session cookie")
|
||||
}
|
||||
|
||||
// Séparer ID et signature
|
||||
parts := strings.SplitN(cookie.Value, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid cookie format")
|
||||
}
|
||||
|
||||
sessionID := parts[0]
|
||||
signature := parts[1]
|
||||
|
||||
// Vérifier la signature
|
||||
if !s.verify(sessionID, signature) {
|
||||
return nil, fmt.Errorf("invalid cookie signature")
|
||||
}
|
||||
|
||||
// Récupérer la session
|
||||
session, ok := s.Get(sessionID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("session not found or expired")
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// sign signe une donnée avec HMAC-SHA256.
|
||||
func (s *SessionStore) sign(data string) string {
|
||||
h := hmac.New(sha256.New, []byte(s.config.Secret))
|
||||
h.Write([]byte(data))
|
||||
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// verify vérifie une signature HMAC.
|
||||
func (s *SessionStore) verify(data, signature string) bool {
|
||||
expected := s.sign(data)
|
||||
return hmac.Equal([]byte(expected), []byte(signature))
|
||||
}
|
||||
|
||||
// generateSecureToken génère un token aléatoire sécurisé.
|
||||
func generateSecureToken(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
1
cmd/sogoms/admin/static/htmx.min.js
vendored
Normal file
1
cmd/sogoms/admin/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/sogoms/admin/static/pico.min.css
vendored
Normal file
4
cmd/sogoms/admin/static/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
116
cmd/sogoms/admin/templates/app_detail.html
Normal file
116
cmd/sogoms/admin/templates/app_detail.html
Normal file
@@ -0,0 +1,116 @@
|
||||
{{define "app_detail.html"}}
|
||||
{{template "partials/header.html" .}}
|
||||
|
||||
<h1>{{.App.App}}</h1>
|
||||
|
||||
<div class="card-grid">
|
||||
<!-- Infos générales -->
|
||||
<article>
|
||||
<header><strong>Informations</strong></header>
|
||||
<dl>
|
||||
<dt>Version</dt>
|
||||
<dd>{{if .App.Version}}{{.App.Version}}{{else}}<em>Non définie</em>{{end}}</dd>
|
||||
|
||||
<dt>Base Path</dt>
|
||||
<dd><code>{{.App.BasePath}}</code></dd>
|
||||
|
||||
<dt>Hosts</dt>
|
||||
<dd>
|
||||
{{range .App.Hosts}}
|
||||
<code>{{.}}</code><br>
|
||||
{{end}}
|
||||
</dd>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<!-- Database -->
|
||||
<article>
|
||||
<header><strong>Base de données</strong></header>
|
||||
<dl>
|
||||
<dt>Host</dt>
|
||||
<dd><code>{{.App.Database.Host}}:{{.App.Database.Port}}</code></dd>
|
||||
|
||||
<dt>Database</dt>
|
||||
<dd><code>{{.App.Database.Name}}</code></dd>
|
||||
|
||||
<dt>User</dt>
|
||||
<dd><code>{{.App.Database.User}}</code></dd>
|
||||
</dl>
|
||||
<form method="post" action="/admin/apps/{{.App.App}}/scan" style="margin-top: 1rem;">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="outline">Scanner la base</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<!-- Stats -->
|
||||
<article>
|
||||
<header><strong>Configuration</strong></header>
|
||||
<dl>
|
||||
{{if .App.Schema}}
|
||||
<dt>Tables (schema)</dt>
|
||||
<dd>{{.App.SchemaTableCount}} tables</dd>
|
||||
{{end}}
|
||||
|
||||
{{if .App.Queries}}
|
||||
<dt>Fichiers queries</dt>
|
||||
<dd>{{.App.QueriesCount}} fichiers</dd>
|
||||
{{end}}
|
||||
|
||||
<dt>Routes</dt>
|
||||
<dd>{{.App.RoutesCount}} routes</dd>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{{if .App.Schema}}
|
||||
<!-- Schema / Tables -->
|
||||
<article>
|
||||
<header><strong>Schema - Tables</strong></header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Table</th>
|
||||
<th>Colonnes</th>
|
||||
<th>Clé primaire</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Tables}}
|
||||
<tr>
|
||||
<td><strong>{{.Name}}</strong></td>
|
||||
<td>{{.ColumnCount}}</td>
|
||||
<td><code>{{.PrimaryKey}}</code></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
{{end}}
|
||||
|
||||
{{if .Routes}}
|
||||
<!-- Routes -->
|
||||
<article>
|
||||
<header><strong>Routes API</strong></header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Méthode</th>
|
||||
<th>Path</th>
|
||||
<th>Handler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Routes}}
|
||||
<tr>
|
||||
<td><code>{{.Method}}</code></td>
|
||||
<td><code>{{.Path}}</code></td>
|
||||
<td>{{.Handler}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
{{end}}
|
||||
|
||||
{{template "partials/footer.html" .}}
|
||||
{{end}}
|
||||
58
cmd/sogoms/admin/templates/apps.html
Normal file
58
cmd/sogoms/admin/templates/apps.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{{define "apps.html"}}
|
||||
{{template "partials/header.html" .}}
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h1>Applications</h1>
|
||||
{{if .IsSuperAdmin}}
|
||||
<a href="/admin/apps/new" role="button">+ Nouvelle App</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Apps}}
|
||||
<div class="apps-list">
|
||||
{{range .Apps}}
|
||||
<article>
|
||||
<header>
|
||||
<strong>{{.App}}</strong>
|
||||
{{if .Version}}<small>v{{.Version}}</small>{{end}}
|
||||
</header>
|
||||
|
||||
<dl>
|
||||
<dt>Hosts</dt>
|
||||
<dd>
|
||||
{{range .Hosts}}
|
||||
<code>{{.}}</code><br>
|
||||
{{end}}
|
||||
</dd>
|
||||
|
||||
<dt>Base Path</dt>
|
||||
<dd><code>{{.BasePath}}</code></dd>
|
||||
|
||||
<dt>Database</dt>
|
||||
<dd>
|
||||
<code>{{.Database.User}}@{{.Database.Host}}:{{.Database.Port}}/{{.Database.Name}}</code>
|
||||
</dd>
|
||||
|
||||
{{if .Schema}}
|
||||
<dt>Tables (schema)</dt>
|
||||
<dd>{{.SchemaTableCount}} tables</dd>
|
||||
{{end}}
|
||||
|
||||
{{if .Queries}}
|
||||
<dt>Queries</dt>
|
||||
<dd>{{.QueriesCount}} fichiers</dd>
|
||||
{{end}}
|
||||
</dl>
|
||||
|
||||
<footer>
|
||||
<a href="/admin/apps/{{.App}}" role="button" class="outline">Détails</a>
|
||||
</footer>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p>Aucune application configurée.</p>
|
||||
{{end}}
|
||||
|
||||
{{template "partials/footer.html" .}}
|
||||
{{end}}
|
||||
88
cmd/sogoms/admin/templates/apps_new.html
Normal file
88
cmd/sogoms/admin/templates/apps_new.html
Normal file
@@ -0,0 +1,88 @@
|
||||
{{define "apps_new.html"}}
|
||||
{{template "partials/header.html" .}}
|
||||
|
||||
<h1>Nouvelle Application</h1>
|
||||
|
||||
<form method="post" action="/admin/apps/new">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
<!-- Informations générales -->
|
||||
<article>
|
||||
<header><strong>Informations</strong></header>
|
||||
|
||||
<label for="app_name">Nom de l'application *</label>
|
||||
<input type="text" id="app_name" name="app_name" required
|
||||
pattern="[a-z][a-z0-9_]*" placeholder="monapp"
|
||||
aria-describedby="app_name_help">
|
||||
<small id="app_name_help">Lettres minuscules, chiffres et underscore uniquement</small>
|
||||
|
||||
<label for="version">Version</label>
|
||||
<input type="text" id="version" name="version" value="1.0" placeholder="1.0">
|
||||
|
||||
<label for="base_path">Base Path *</label>
|
||||
<input type="text" id="base_path" name="base_path" value="/api" required placeholder="/api">
|
||||
</article>
|
||||
|
||||
<!-- Hosts -->
|
||||
<article>
|
||||
<header><strong>Hosts</strong></header>
|
||||
|
||||
<label for="hosts">Domaines (un par ligne) *</label>
|
||||
<textarea id="hosts" name="hosts" rows="3" required
|
||||
placeholder="monapp.example.com monapp.sogoms.com"></textarea>
|
||||
</article>
|
||||
|
||||
<!-- Database -->
|
||||
<article>
|
||||
<header><strong>Base de données</strong></header>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="db_host">Host *</label>
|
||||
<input type="text" id="db_host" name="db_host" required placeholder="127.0.0.1">
|
||||
</div>
|
||||
<div>
|
||||
<label for="db_port">Port *</label>
|
||||
<input type="number" id="db_port" name="db_port" value="3306" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="db_user">Utilisateur *</label>
|
||||
<input type="text" id="db_user" name="db_user" required placeholder="monapp_user">
|
||||
</div>
|
||||
<div>
|
||||
<label for="db_name">Base *</label>
|
||||
<input type="text" id="db_name" name="db_name" required placeholder="monapp">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="db_password">Mot de passe *</label>
|
||||
<input type="password" id="db_password" name="db_password" required>
|
||||
</article>
|
||||
|
||||
<!-- Auth -->
|
||||
<article>
|
||||
<header><strong>Authentification JWT</strong></header>
|
||||
|
||||
<label for="jwt_expiry">Durée du token</label>
|
||||
<select id="jwt_expiry" name="jwt_expiry">
|
||||
<option value="1h">1 heure</option>
|
||||
<option value="12h">12 heures</option>
|
||||
<option value="24h" selected>24 heures</option>
|
||||
<option value="168h">7 jours</option>
|
||||
<option value="720h">30 jours</option>
|
||||
</select>
|
||||
<small>Le secret JWT sera généré automatiquement</small>
|
||||
</article>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="grid">
|
||||
<a href="/admin/apps" role="button" class="secondary outline">Annuler</a>
|
||||
<button type="submit">Créer l'application</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{template "partials/footer.html" .}}
|
||||
{{end}}
|
||||
81
cmd/sogoms/admin/templates/dashboard.html
Normal file
81
cmd/sogoms/admin/templates/dashboard.html
Normal file
@@ -0,0 +1,81 @@
|
||||
{{define "dashboard.html"}}
|
||||
{{template "partials/header.html" .}}
|
||||
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<p class="user-info">
|
||||
Connecté en tant que <strong>{{.User.Username}}</strong>
|
||||
{{if .IsSuperAdmin}}(Super Admin){{else}}(App Admin){{end}}
|
||||
</p>
|
||||
|
||||
<div class="card-grid">
|
||||
{{if .IsSuperAdmin}}
|
||||
<!-- Services Status (super admin only) -->
|
||||
<article>
|
||||
<header>
|
||||
<strong>Services</strong>
|
||||
<span class="htmx-indicator" aria-busy="true"></span>
|
||||
</header>
|
||||
<div hx-get="/admin/api/services/health"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-indicator=".htmx-indicator">
|
||||
Chargement...
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
|
||||
<!-- Applications -->
|
||||
<article>
|
||||
<header>
|
||||
<strong>Applications</strong>
|
||||
{{if .IsSuperAdmin}}
|
||||
<small>({{len .Apps}} apps)</small>
|
||||
{{end}}
|
||||
</header>
|
||||
<div hx-get="/admin/api/apps"
|
||||
hx-trigger="load">
|
||||
Chargement...
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{if .IsSuperAdmin}}
|
||||
<!-- Quick Stats -->
|
||||
<article>
|
||||
<header><strong>Statistiques</strong></header>
|
||||
<dl>
|
||||
<dt>Applications</dt>
|
||||
<dd>{{len .Apps}}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .IsSuperAdmin}}
|
||||
<!-- Jobs Cron (super admin only) -->
|
||||
<article>
|
||||
<header>
|
||||
<strong>Jobs Cron</strong>
|
||||
<span class="htmx-indicator" aria-busy="true"></span>
|
||||
</header>
|
||||
<div hx-get="/admin/api/cron/jobs"
|
||||
hx-trigger="load"
|
||||
hx-indicator=".htmx-indicator">
|
||||
Chargement...
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
|
||||
{{if not .IsSuperAdmin}}
|
||||
<!-- Permissions de l'utilisateur -->
|
||||
<article>
|
||||
<header><strong>Vos permissions</strong></header>
|
||||
<ul>
|
||||
{{range .Permissions}}
|
||||
<li><code>{{.}}</code></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</article>
|
||||
{{end}}
|
||||
|
||||
{{template "partials/footer.html" .}}
|
||||
{{end}}
|
||||
69
cmd/sogoms/admin/templates/login.html
Normal file
69
cmd/sogoms/admin/templates/login.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{{define "login.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Connexion - SOGOMS Admin</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.logo {
|
||||
text-align: center;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.logo span {
|
||||
color: var(--pico-primary);
|
||||
}
|
||||
.error-message {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<article class="login-card">
|
||||
<div class="logo">SOGO<span>MS</span> Admin</div>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="error-message">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="/admin/login" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
<label for="username">
|
||||
Nom d'utilisateur
|
||||
<input type="text" id="username" name="username"
|
||||
placeholder="Votre identifiant" required autofocus>
|
||||
</label>
|
||||
|
||||
<label for="password">
|
||||
Mot de passe
|
||||
<input type="password" id="password" name="password"
|
||||
placeholder="Votre mot de passe" required>
|
||||
</label>
|
||||
|
||||
<button type="submit">Se connecter</button>
|
||||
</form>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
16
cmd/sogoms/admin/templates/partials/apps_list.html
Normal file
16
cmd/sogoms/admin/templates/partials/apps_list.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{define "partials/apps_list.html"}}
|
||||
{{if .Apps}}
|
||||
<ul>
|
||||
{{range .Apps}}
|
||||
<li>
|
||||
<a href="/admin/apps/{{.ID}}">
|
||||
<strong>{{.Name}}</strong>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<p><a href="/admin/apps">Voir toutes les apps →</a></p>
|
||||
{{else}}
|
||||
<p><em>Aucune application accessible</em></p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
36
cmd/sogoms/admin/templates/partials/cron_jobs.html
Normal file
36
cmd/sogoms/admin/templates/partials/cron_jobs.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{{define "partials/cron_jobs.html"}}
|
||||
{{if .Jobs}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>App</th>
|
||||
<th>Job</th>
|
||||
<th>Type</th>
|
||||
<th>Schedule</th>
|
||||
<th>Prochain run</th>
|
||||
<th>Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Jobs}}
|
||||
<tr>
|
||||
<td><code>{{index . "app_id"}}</code></td>
|
||||
<td><strong>{{index . "name"}}</strong></td>
|
||||
<td>{{index . "type"}}</td>
|
||||
<td><code>{{index . "schedule"}}</code></td>
|
||||
<td>{{index . "next_run"}}</td>
|
||||
<td>
|
||||
{{if index . "enabled"}}
|
||||
<span class="status-badge status-ok">Actif</span>
|
||||
{{else}}
|
||||
<span class="status-badge status-error">Inactif</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p>Aucun job cron configuré.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
61
cmd/sogoms/admin/templates/partials/flash.html
Normal file
61
cmd/sogoms/admin/templates/partials/flash.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{{define "partials/flash.html"}}
|
||||
{{if .FlashMessage}}
|
||||
<div id="flash-message" class="flash flash-{{.FlashType}}" role="alert">
|
||||
{{.FlashMessage}}
|
||||
</div>
|
||||
<style>
|
||||
.flash {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
.flash.fade-out {
|
||||
animation: fadeOut 0.5s ease-out forwards;
|
||||
}
|
||||
.flash-success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 1px solid #6ee7b7;
|
||||
}
|
||||
.flash-info {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
border: 1px solid #93c5fd;
|
||||
}
|
||||
.flash-warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
.flash-error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fca5a5;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-20px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
var flash = document.getElementById('flash-message');
|
||||
if (flash) {
|
||||
flash.classList.add('fade-out');
|
||||
setTimeout(function() { flash.remove(); }, 500);
|
||||
}
|
||||
}, 4000);
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
9
cmd/sogoms/admin/templates/partials/footer.html
Normal file
9
cmd/sogoms/admin/templates/partials/footer.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{{define "partials/footer.html"}}
|
||||
</main>
|
||||
<footer class="container">
|
||||
<hr>
|
||||
<small>SOGOMS Admin © 2025</small>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
50
cmd/sogoms/admin/templates/partials/header.html
Normal file
50
cmd/sogoms/admin/templates/partials/header.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{{define "partials/header.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} - SOGOMS Admin</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%231095c1' d='M0,3v8H11V0H3A3,3,0,0,0,0,3Z'/%3E%3Cpath fill='%231095c1' d='M21,0H13V11H24V3A3,3,0,0,0,21,0Z'/%3E%3Cpath fill='%231095c1' d='M0,21a3,3,0,0,0,3,3h8V13H0Z'/%3E%3Cpath fill='%231095c1' d='M13,24h8a3,3,0,0,0,3-3V13H13Z'/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/admin/static/pico.min.css">
|
||||
<script src="/admin/static/htmx.min.js"></script>
|
||||
<style>
|
||||
.logo { font-weight: bold; font-size: 1.2rem; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.logo svg { width: 24px; height: 24px; }
|
||||
.logo span { color: var(--pico-primary); }
|
||||
.status-badge { display: inline-block; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.875rem; font-weight: 500; }
|
||||
.status-ok { background: #10b981; color: white; }
|
||||
.status-error { background: #ef4444; color: white; }
|
||||
.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; }
|
||||
.user-info { font-size: 0.875rem; color: var(--pico-muted-color); }
|
||||
.htmx-indicator { opacity: 0; transition: opacity 200ms ease-in; }
|
||||
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="container">
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="/admin/" class="logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1095c1" d="M0,3v8H11V0H3A3,3,0,0,0,0,3Z"/><path fill="#1095c1" d="M21,0H13V11H24V3A3,3,0,0,0,21,0Z"/><path fill="#1095c1" d="M0,21a3,3,0,0,0,3,3h8V13H0Z"/><path fill="#1095c1" d="M13,24h8a3,3,0,0,0,3-3V13H13Z"/></svg>SOGO<span>MS</span></a></li>
|
||||
</ul>
|
||||
<ul>
|
||||
{{if .User}}
|
||||
<li><a href="/admin/"{{if eq .Title "Dashboard"}} aria-current="page"{{end}}>Dashboard</a></li>
|
||||
{{if .IsSuperAdmin}}
|
||||
<li><a href="/admin/apps"{{if eq .Title "Applications"}} aria-current="page"{{end}}>Apps</a></li>
|
||||
{{end}}
|
||||
<li>
|
||||
<form action="/admin/logout" method="post" style="margin:0">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="outline secondary" style="margin:0;padding:0.5rem 1rem">
|
||||
{{.User.Username}} - Logout
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="container">
|
||||
{{template "partials/flash.html" .}}
|
||||
{{end}}
|
||||
17
cmd/sogoms/admin/templates/partials/services_status.html
Normal file
17
cmd/sogoms/admin/templates/partials/services_status.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{{define "partials/services_status.html"}}
|
||||
<ul>
|
||||
{{range .Services}}
|
||||
<li>
|
||||
<strong>{{.Name}}</strong>
|
||||
{{if .Available}}
|
||||
<span class="status-badge status-ok">OK</span>
|
||||
{{else}}
|
||||
<span class="status-badge status-error">Erreur</span>
|
||||
{{end}}
|
||||
{{if .LatencyMs}}
|
||||
<small>({{.LatencyMs}}ms)</small>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
743
cmd/sogoms/cron/main.go
Normal file
743
cmd/sogoms/cron/main.go
Normal file
@@ -0,0 +1,743 @@
|
||||
// sogoms-cron : Microservice de tâches planifiées.
|
||||
// Exécute des jobs périodiques définis dans config/apps/{app}/cron.yaml.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"sogoms.com/internal/config"
|
||||
"sogoms.com/internal/cron"
|
||||
"sogoms.com/internal/protocol"
|
||||
)
|
||||
|
||||
var (
|
||||
socketPath = flag.String("socket", "/run/sogoms-cron.1.sock", "Unix socket path")
|
||||
configDir = flag.String("config", "/config", "Configuration directory")
|
||||
dbSocket = flag.String("db-socket", "/run/sogoms-db.1.sock", "DB service socket")
|
||||
smtpSocket = flag.String("smtp-socket", "/run/sogoms-smtp.1.sock", "SMTP service socket")
|
||||
logsSocket = flag.String("logs-socket", "/run/sogoms-logs.1.sock", "Logs service socket")
|
||||
)
|
||||
|
||||
// CronConfig représente la configuration cron d'une application.
|
||||
type CronConfig struct {
|
||||
Timezone string `yaml:"timezone"`
|
||||
Retry RetryConfig `yaml:"retry"`
|
||||
HistoryDays int `yaml:"history_days"`
|
||||
Jobs map[string]*JobConfig `yaml:"jobs"`
|
||||
location *time.Location
|
||||
}
|
||||
|
||||
// RetryConfig configure les tentatives en cas d'échec.
|
||||
type RetryConfig struct {
|
||||
MaxAttempts int `yaml:"max_attempts"`
|
||||
Delay string `yaml:"delay"`
|
||||
delayDur time.Duration
|
||||
}
|
||||
|
||||
// JobConfig représente un job planifié.
|
||||
type JobConfig struct {
|
||||
Schedule string `yaml:"schedule"`
|
||||
Type string `yaml:"type"` // query_email, http, service
|
||||
Enabled bool `yaml:"enabled"`
|
||||
|
||||
// Pour query_email
|
||||
Query string `yaml:"query"`
|
||||
GroupBy string `yaml:"group_by"`
|
||||
Template string `yaml:"template"`
|
||||
|
||||
// Pour http
|
||||
Method string `yaml:"method"`
|
||||
URL string `yaml:"url"`
|
||||
Headers map[string]string `yaml:"headers"`
|
||||
Body string `yaml:"body"`
|
||||
|
||||
// Pour service
|
||||
Service string `yaml:"service"`
|
||||
Action string `yaml:"action"`
|
||||
Params map[string]any `yaml:"params"`
|
||||
|
||||
// Runtime
|
||||
schedule *cron.Schedule
|
||||
nextRun time.Time
|
||||
}
|
||||
|
||||
// JobExecution représente une exécution de job (historique).
|
||||
type JobExecution struct {
|
||||
JobName string `json:"job_name"`
|
||||
AppID string `json:"app_id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
Success bool `json:"success"`
|
||||
Attempt int `json:"attempt"`
|
||||
Result string `json:"result"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CronManager gère les jobs cron pour toutes les applications.
|
||||
type CronManager struct {
|
||||
registry *config.Registry
|
||||
configDir string
|
||||
configs map[string]*CronConfig // appID -> config
|
||||
executions []*JobExecution
|
||||
historyDays int
|
||||
dbPool *protocol.Pool
|
||||
smtpPool *protocol.Pool
|
||||
logsPool *protocol.Pool
|
||||
stopCh chan struct{}
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewCronManager crée un nouveau gestionnaire cron.
|
||||
func NewCronManager(registry *config.Registry, configDir string, dbPool, smtpPool, logsPool *protocol.Pool) *CronManager {
|
||||
return &CronManager{
|
||||
registry: registry,
|
||||
configDir: configDir,
|
||||
configs: make(map[string]*CronConfig),
|
||||
executions: make([]*JobExecution, 0),
|
||||
historyDays: 7,
|
||||
dbPool: dbPool,
|
||||
smtpPool: smtpPool,
|
||||
logsPool: logsPool,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Load charge les configurations cron pour toutes les applications.
|
||||
func (m *CronManager) Load() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for _, appID := range m.registry.Apps() {
|
||||
cronPath := filepath.Join(m.configDir, "apps", appID, "cron.yaml")
|
||||
if _, err := os.Stat(cronPath); os.IsNotExist(err) {
|
||||
continue // Pas de config cron pour cette app
|
||||
}
|
||||
|
||||
cfg, err := m.loadCronConfig(cronPath)
|
||||
if err != nil {
|
||||
log.Printf("[cron] warning: cannot load %s: %v", appID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Parser les schedules des jobs
|
||||
for name, job := range cfg.Jobs {
|
||||
if !job.Enabled {
|
||||
continue
|
||||
}
|
||||
sched, err := cron.ParseSchedule(job.Schedule, cfg.location)
|
||||
if err != nil {
|
||||
log.Printf("[cron] warning: %s/%s invalid schedule: %v", appID, name, err)
|
||||
job.Enabled = false
|
||||
continue
|
||||
}
|
||||
job.schedule = sched
|
||||
job.nextRun = sched.Next(time.Now())
|
||||
}
|
||||
|
||||
m.configs[appID] = cfg
|
||||
log.Printf("[cron] loaded %s: %d jobs", appID, len(cfg.Jobs))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadCronConfig charge une configuration cron depuis un fichier YAML.
|
||||
func (m *CronManager) loadCronConfig(path string) (*CronConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg CronConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Timezone par défaut
|
||||
if cfg.Timezone == "" {
|
||||
cfg.Timezone = "UTC"
|
||||
}
|
||||
loc, err := time.LoadLocation(cfg.Timezone)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid timezone %s: %w", cfg.Timezone, err)
|
||||
}
|
||||
cfg.location = loc
|
||||
|
||||
// Retry par défaut
|
||||
if cfg.Retry.MaxAttempts == 0 {
|
||||
cfg.Retry.MaxAttempts = 3
|
||||
}
|
||||
if cfg.Retry.Delay == "" {
|
||||
cfg.Retry.Delay = "5m"
|
||||
}
|
||||
cfg.Retry.delayDur, err = time.ParseDuration(cfg.Retry.Delay)
|
||||
if err != nil {
|
||||
cfg.Retry.delayDur = 5 * time.Minute
|
||||
}
|
||||
|
||||
// History par défaut
|
||||
if cfg.HistoryDays == 0 {
|
||||
cfg.HistoryDays = 7
|
||||
}
|
||||
if cfg.HistoryDays > m.historyDays {
|
||||
m.historyDays = cfg.HistoryDays
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Start démarre le scheduler.
|
||||
func (m *CronManager) Start() {
|
||||
go m.run()
|
||||
log.Printf("[cron] scheduler started")
|
||||
}
|
||||
|
||||
// Stop arrête le scheduler.
|
||||
func (m *CronManager) Stop() {
|
||||
close(m.stopCh)
|
||||
}
|
||||
|
||||
// run est la boucle principale du scheduler.
|
||||
func (m *CronManager) run() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Vérification initiale
|
||||
m.checkJobs()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
m.checkJobs()
|
||||
m.cleanHistory()
|
||||
case <-m.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkJobs vérifie et exécute les jobs dont l'heure est passée.
|
||||
func (m *CronManager) checkJobs() {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for appID, cfg := range m.configs {
|
||||
for jobName, job := range cfg.Jobs {
|
||||
if !job.Enabled || job.schedule == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if now.After(job.nextRun) || now.Equal(job.nextRun) {
|
||||
// Exécuter le job
|
||||
go m.executeJob(appID, jobName, job, cfg)
|
||||
|
||||
// Calculer le prochain run
|
||||
job.nextRun = job.schedule.Next(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeJob exécute un job avec retry.
|
||||
func (m *CronManager) executeJob(appID, jobName string, job *JobConfig, cfg *CronConfig) {
|
||||
var lastErr error
|
||||
var result string
|
||||
|
||||
for attempt := 1; attempt <= cfg.Retry.MaxAttempts; attempt++ {
|
||||
exec := &JobExecution{
|
||||
JobName: jobName,
|
||||
AppID: appID,
|
||||
StartTime: time.Now(),
|
||||
Attempt: attempt,
|
||||
}
|
||||
|
||||
result, lastErr = m.runJob(appID, jobName, job)
|
||||
|
||||
exec.EndTime = time.Now()
|
||||
exec.Success = lastErr == nil
|
||||
exec.Result = result
|
||||
if lastErr != nil {
|
||||
exec.Error = lastErr.Error()
|
||||
}
|
||||
|
||||
m.addExecution(exec)
|
||||
|
||||
if lastErr == nil {
|
||||
m.logEvent(appID, "job_success", map[string]any{
|
||||
"job": jobName,
|
||||
"attempt": attempt,
|
||||
"result": result,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
m.logEvent(appID, "job_failed", map[string]any{
|
||||
"job": jobName,
|
||||
"attempt": attempt,
|
||||
"error": lastErr.Error(),
|
||||
})
|
||||
|
||||
if attempt < cfg.Retry.MaxAttempts {
|
||||
time.Sleep(cfg.Retry.delayDur)
|
||||
}
|
||||
}
|
||||
|
||||
// Échec après tous les retries
|
||||
m.logEvent(appID, "job_exhausted", map[string]any{
|
||||
"job": jobName,
|
||||
"attempts": cfg.Retry.MaxAttempts,
|
||||
"error": lastErr.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// runJob exécute un job selon son type.
|
||||
func (m *CronManager) runJob(appID, jobName string, job *JobConfig) (string, error) {
|
||||
switch job.Type {
|
||||
case "query_email":
|
||||
return m.runQueryEmail(appID, job)
|
||||
case "http":
|
||||
return m.runHTTP(job)
|
||||
case "service":
|
||||
return m.runService(appID, job)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown job type: %s", job.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// runQueryEmail exécute une requête DB et envoie des emails groupés.
|
||||
func (m *CronManager) runQueryEmail(appID string, job *JobConfig) (string, error) {
|
||||
if m.dbPool == nil {
|
||||
return "", fmt.Errorf("db service not available")
|
||||
}
|
||||
if m.smtpPool == nil {
|
||||
return "", fmt.Errorf("smtp service not available")
|
||||
}
|
||||
|
||||
// Exécuter la requête
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := protocol.NewRequest("query", map[string]any{
|
||||
"app_id": appID,
|
||||
"query": job.Query,
|
||||
"args": []any{},
|
||||
})
|
||||
|
||||
resp, err := m.dbPool.Call(ctx, req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db query: %w", err)
|
||||
}
|
||||
if resp.Status != "success" {
|
||||
return "", fmt.Errorf("db query failed: %s", resp.Error.Message)
|
||||
}
|
||||
|
||||
// Extraire les résultats
|
||||
resultMap, ok := resp.Result.(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid result format")
|
||||
}
|
||||
rows, ok := resultMap["rows"].([]any)
|
||||
if !ok || len(rows) == 0 {
|
||||
return "no data", nil
|
||||
}
|
||||
|
||||
// Grouper par user si demandé
|
||||
grouped := m.groupRows(rows, job.GroupBy)
|
||||
|
||||
// Envoyer un email par groupe
|
||||
sent := 0
|
||||
for key, groupRows := range grouped {
|
||||
if err := m.sendGroupEmail(appID, job, key, groupRows); err != nil {
|
||||
log.Printf("[cron] %s: email error for %s: %v", appID, key, err)
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
}
|
||||
|
||||
return fmt.Sprintf("sent %d emails", sent), nil
|
||||
}
|
||||
|
||||
// groupRows groupe les lignes par une clé.
|
||||
func (m *CronManager) groupRows(rows []any, groupBy string) map[string][]map[string]any {
|
||||
grouped := make(map[string][]map[string]any)
|
||||
|
||||
for _, row := range rows {
|
||||
rowMap, ok := row.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
key := "default"
|
||||
if groupBy != "" {
|
||||
if v, ok := rowMap[groupBy]; ok {
|
||||
key = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
grouped[key] = append(grouped[key], rowMap)
|
||||
}
|
||||
|
||||
return grouped
|
||||
}
|
||||
|
||||
// sendGroupEmail envoie un email pour un groupe de lignes.
|
||||
func (m *CronManager) sendGroupEmail(appID string, job *JobConfig, key string, rows []map[string]any) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extraire l'email du premier row (doit contenir "email")
|
||||
email, ok := rows[0]["email"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("no email field in row")
|
||||
}
|
||||
|
||||
// Extraire le nom (optionnel)
|
||||
name, _ := rows[0]["user_name"].(string)
|
||||
if name == "" {
|
||||
name, _ = rows[0]["name"].(string)
|
||||
}
|
||||
|
||||
// Préparer les données du template
|
||||
now := time.Now()
|
||||
tasks := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
task := map[string]any{
|
||||
"Name": row["title"],
|
||||
"Project": row["project_name"],
|
||||
"Status": row["status_name"],
|
||||
"StatusColor": row["status_color"],
|
||||
"DueTime": "",
|
||||
}
|
||||
if dt, ok := row["due_date"].(time.Time); ok {
|
||||
task["DueTime"] = dt.Format("15:04")
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Name": name,
|
||||
"Date": now.Format("02/01/2006"),
|
||||
"Tasks": tasks,
|
||||
"TaskCount": len(tasks),
|
||||
}
|
||||
|
||||
// Envoyer via smtp service
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := protocol.NewRequest("send_template", map[string]any{
|
||||
"app_id": appID,
|
||||
"to": email,
|
||||
"template": job.Template,
|
||||
"data": data,
|
||||
})
|
||||
|
||||
resp, err := m.smtpPool.Call(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Status != "success" {
|
||||
return fmt.Errorf("smtp: %s", resp.Error.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runHTTP exécute une requête HTTP.
|
||||
func (m *CronManager) runHTTP(job *JobConfig) (string, error) {
|
||||
method := job.Method
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
var body *bytes.Reader
|
||||
if job.Body != "" {
|
||||
body = bytes.NewReader([]byte(job.Body))
|
||||
} else {
|
||||
body = bytes.NewReader(nil)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, job.URL, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for k, v := range job.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("HTTP %d", resp.StatusCode), nil
|
||||
}
|
||||
|
||||
// runService appelle un service interne.
|
||||
func (m *CronManager) runService(appID string, job *JobConfig) (string, error) {
|
||||
var pool *protocol.Pool
|
||||
switch job.Service {
|
||||
case "db", "sogoms-db":
|
||||
pool = m.dbPool
|
||||
case "smtp", "sogoms-smtp":
|
||||
pool = m.smtpPool
|
||||
case "logs", "sogoms-logs":
|
||||
pool = m.logsPool
|
||||
default:
|
||||
return "", fmt.Errorf("unknown service: %s", job.Service)
|
||||
}
|
||||
|
||||
if pool == nil {
|
||||
return "", fmt.Errorf("service %s not available", job.Service)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
params := make(map[string]any)
|
||||
for k, v := range job.Params {
|
||||
params[k] = v
|
||||
}
|
||||
params["app_id"] = appID
|
||||
|
||||
req := protocol.NewRequest(job.Action, params)
|
||||
resp, err := pool.Call(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.Status != "success" {
|
||||
return "", fmt.Errorf("%s: %s", resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(resp.Result)
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
// addExecution ajoute une exécution à l'historique.
|
||||
func (m *CronManager) addExecution(exec *JobExecution) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.executions = append(m.executions, exec)
|
||||
}
|
||||
|
||||
// cleanHistory supprime les exécutions plus vieilles que historyDays.
|
||||
func (m *CronManager) cleanHistory() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
cutoff := time.Now().AddDate(0, 0, -m.historyDays)
|
||||
var kept []*JobExecution
|
||||
for _, exec := range m.executions {
|
||||
if exec.StartTime.After(cutoff) {
|
||||
kept = append(kept, exec)
|
||||
}
|
||||
}
|
||||
m.executions = kept
|
||||
}
|
||||
|
||||
// logEvent envoie un log au service logs.
|
||||
func (m *CronManager) logEvent(appID, eventType string, data map[string]any) {
|
||||
if m.logsPool == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := protocol.NewRequest("log_event", map[string]any{
|
||||
"app_id": appID,
|
||||
"event_type": "cron_" + eventType,
|
||||
"data": data,
|
||||
})
|
||||
m.logsPool.Call(ctx, req)
|
||||
}()
|
||||
}
|
||||
|
||||
// ListJobs retourne la liste des jobs avec leur prochain run.
|
||||
func (m *CronManager) ListJobs() []map[string]any {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var jobs []map[string]any
|
||||
for appID, cfg := range m.configs {
|
||||
for name, job := range cfg.Jobs {
|
||||
jobs = append(jobs, map[string]any{
|
||||
"app_id": appID,
|
||||
"name": name,
|
||||
"type": job.Type,
|
||||
"schedule": job.Schedule,
|
||||
"enabled": job.Enabled,
|
||||
"next_run": job.nextRun.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
// GetHistory retourne l'historique des exécutions.
|
||||
func (m *CronManager) GetHistory(appID, jobName string, limit int) []*JobExecution {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var result []*JobExecution
|
||||
for i := len(m.executions) - 1; i >= 0 && len(result) < limit; i-- {
|
||||
exec := m.executions[i]
|
||||
if appID != "" && exec.AppID != appID {
|
||||
continue
|
||||
}
|
||||
if jobName != "" && exec.JobName != jobName {
|
||||
continue
|
||||
}
|
||||
result = append(result, exec)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// TriggerJob déclenche un job manuellement.
|
||||
func (m *CronManager) TriggerJob(appID, jobName string) error {
|
||||
m.mu.RLock()
|
||||
cfg, ok := m.configs[appID]
|
||||
if !ok {
|
||||
m.mu.RUnlock()
|
||||
return fmt.Errorf("app not found: %s", appID)
|
||||
}
|
||||
job, ok := cfg.Jobs[jobName]
|
||||
if !ok {
|
||||
m.mu.RUnlock()
|
||||
return fmt.Errorf("job not found: %s", jobName)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
go m.executeJob(appID, jobName, job, cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
log.SetFlags(log.Ltime | log.Lshortfile)
|
||||
|
||||
// Charger les configurations des apps
|
||||
registry := config.NewRegistry(*configDir)
|
||||
if err := registry.Load(); err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
log.Printf("[cron] loaded apps: %v", registry.Apps())
|
||||
|
||||
// Pools de connexion aux services
|
||||
var dbPool, smtpPool, logsPool *protocol.Pool
|
||||
if *dbSocket != "" {
|
||||
dbPool = protocol.NewPool(*dbSocket, 2)
|
||||
}
|
||||
if *smtpSocket != "" {
|
||||
smtpPool = protocol.NewPool(*smtpSocket, 2)
|
||||
}
|
||||
if *logsSocket != "" {
|
||||
logsPool = protocol.NewPool(*logsSocket, 2)
|
||||
}
|
||||
|
||||
// Manager cron
|
||||
manager := NewCronManager(registry, *configDir, dbPool, smtpPool, logsPool)
|
||||
if err := manager.Load(); err != nil {
|
||||
log.Fatalf("load cron config: %v", err)
|
||||
}
|
||||
manager.Start()
|
||||
defer manager.Stop()
|
||||
|
||||
// Handler des requêtes IPC
|
||||
handler := func(ctx context.Context, req *protocol.Request) *protocol.Response {
|
||||
return handleRequest(ctx, req, manager)
|
||||
}
|
||||
|
||||
// Démarrer le serveur
|
||||
server := protocol.NewServer(*socketPath, handler)
|
||||
if err := server.Start(); err != nil {
|
||||
log.Fatalf("start server: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[cron] sogoms-cron started on %s", *socketPath)
|
||||
|
||||
// Attendre signal d'arrêt
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
log.Printf("[cron] shutting down...")
|
||||
server.Stop()
|
||||
}
|
||||
|
||||
func handleRequest(ctx context.Context, req *protocol.Request, manager *CronManager) *protocol.Response {
|
||||
switch req.Action {
|
||||
case "health":
|
||||
return protocol.Success(req.ID, map[string]any{"status": "ok"})
|
||||
case "list":
|
||||
return handleList(req, manager)
|
||||
case "trigger":
|
||||
return handleTrigger(req, manager)
|
||||
case "status":
|
||||
return handleStatus(req, manager)
|
||||
default:
|
||||
return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// handleList retourne la liste des jobs.
|
||||
func handleList(req *protocol.Request, manager *CronManager) *protocol.Response {
|
||||
jobs := manager.ListJobs()
|
||||
return protocol.Success(req.ID, map[string]any{"jobs": jobs})
|
||||
}
|
||||
|
||||
// handleTrigger déclenche un job manuellement.
|
||||
// Params: app_id, job
|
||||
func handleTrigger(req *protocol.Request, manager *CronManager) *protocol.Response {
|
||||
appID, _ := req.Params["app_id"].(string)
|
||||
jobName, _ := req.Params["job"].(string)
|
||||
|
||||
if appID == "" || jobName == "" {
|
||||
return protocol.Failure(req.ID, "MISSING_PARAMS", "app_id and job are required")
|
||||
}
|
||||
|
||||
if err := manager.TriggerJob(appID, jobName); err != nil {
|
||||
return protocol.Failure(req.ID, "TRIGGER_ERROR", err.Error())
|
||||
}
|
||||
|
||||
return protocol.Success(req.ID, map[string]any{"triggered": true})
|
||||
}
|
||||
|
||||
// handleStatus retourne l'historique des exécutions.
|
||||
// Params: app_id (optionnel), job (optionnel), limit (optionnel, défaut 50)
|
||||
func handleStatus(req *protocol.Request, manager *CronManager) *protocol.Response {
|
||||
appID, _ := req.Params["app_id"].(string)
|
||||
jobName, _ := req.Params["job"].(string)
|
||||
limit := 50
|
||||
if l, ok := req.Params["limit"].(float64); ok {
|
||||
limit = int(l)
|
||||
}
|
||||
|
||||
history := manager.GetHistory(appID, jobName, limit)
|
||||
return protocol.Success(req.ID, map[string]any{"executions": history})
|
||||
}
|
||||
|
||||
@@ -157,7 +157,22 @@ func main() {
|
||||
}
|
||||
|
||||
func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *protocol.Response {
|
||||
// L'app_id doit être fourni
|
||||
// Health check sans app_id (vérifie juste que le service tourne)
|
||||
if req.Action == "health" {
|
||||
if appID, ok := req.Params["app_id"].(string); ok && appID != "" {
|
||||
// Health check avec app_id : vérifie la connexion DB
|
||||
if db, err := dbPool.GetDB(appID); err == nil {
|
||||
if err := db.Ping(); err != nil {
|
||||
return protocol.Failure(req.ID, "UNHEALTHY", err.Error())
|
||||
}
|
||||
return protocol.Success(req.ID, map[string]any{"status": "ok", "app_id": appID})
|
||||
}
|
||||
}
|
||||
// Health check simple : le service tourne
|
||||
return protocol.Success(req.ID, map[string]any{"status": "ok"})
|
||||
}
|
||||
|
||||
// L'app_id doit être fourni pour les autres actions
|
||||
appID, ok := req.Params["app_id"].(string)
|
||||
if !ok || appID == "" {
|
||||
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
|
||||
@@ -179,8 +194,8 @@ func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *
|
||||
return handleUpdate(req, db, appID)
|
||||
case "delete":
|
||||
return handleDelete(req, db, appID)
|
||||
case "health":
|
||||
return handleHealth(req, db)
|
||||
case "introspect":
|
||||
return handleIntrospect(req, db, appID)
|
||||
default:
|
||||
return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action)
|
||||
}
|
||||
@@ -363,14 +378,6 @@ func handleDelete(req *protocol.Request, db *sql.DB, appID string) *protocol.Res
|
||||
})
|
||||
}
|
||||
|
||||
// handleHealth vérifie la connexion à la DB.
|
||||
func handleHealth(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
if err := db.Ping(); err != nil {
|
||||
return protocol.Failure(req.ID, "UNHEALTHY", err.Error())
|
||||
}
|
||||
return protocol.Success(req.ID, map[string]any{"status": "ok"})
|
||||
}
|
||||
|
||||
// extractQueryParams extrait query et args des paramètres.
|
||||
func extractQueryParams(params map[string]any) (string, []any, error) {
|
||||
query, ok := params["query"].(string)
|
||||
@@ -434,3 +441,214 @@ func scanRows(rows *sql.Rows) ([]map[string]any, error) {
|
||||
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// handleIntrospect analyse la structure de la base de données.
|
||||
// Retourne tables, colonnes, clés primaires et étrangères.
|
||||
func handleIntrospect(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
|
||||
// Récupérer le nom de la base
|
||||
var dbName string
|
||||
if err := db.QueryRow("SELECT DATABASE()").Scan(&dbName); err != nil {
|
||||
return protocol.Failure(req.ID, "DB_ERROR", "cannot get database name: "+err.Error())
|
||||
}
|
||||
|
||||
// 1. Récupérer les tables
|
||||
tablesQuery := `
|
||||
SELECT TABLE_NAME
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
|
||||
ORDER BY TABLE_NAME`
|
||||
|
||||
tableRows, err := db.Query(tablesQuery, dbName)
|
||||
if err != nil {
|
||||
return protocol.Failure(req.ID, "QUERY_ERROR", err.Error())
|
||||
}
|
||||
defer tableRows.Close()
|
||||
|
||||
var tableNames []string
|
||||
for tableRows.Next() {
|
||||
var name string
|
||||
if err := tableRows.Scan(&name); err != nil {
|
||||
return protocol.Failure(req.ID, "SCAN_ERROR", err.Error())
|
||||
}
|
||||
tableNames = append(tableNames, name)
|
||||
}
|
||||
|
||||
// 2. Pour chaque table, récupérer les colonnes
|
||||
columnsQuery := `
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
DATA_TYPE,
|
||||
CHARACTER_MAXIMUM_LENGTH,
|
||||
IS_NULLABLE,
|
||||
COLUMN_DEFAULT,
|
||||
EXTRA,
|
||||
COLUMN_KEY
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION`
|
||||
|
||||
// 3. Récupérer les clés étrangères
|
||||
fkQuery := `
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
REFERENCED_TABLE_NAME,
|
||||
REFERENCED_COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
AND REFERENCED_TABLE_NAME IS NOT NULL`
|
||||
|
||||
// 4. Récupérer les contraintes UNIQUE
|
||||
uniqueQuery := `
|
||||
SELECT COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
|
||||
ON kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
|
||||
AND kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA
|
||||
WHERE tc.TABLE_SCHEMA = ? AND tc.TABLE_NAME = ?
|
||||
AND tc.CONSTRAINT_TYPE = 'UNIQUE'`
|
||||
|
||||
tables := make(map[string]any)
|
||||
|
||||
for _, tableName := range tableNames {
|
||||
// Colonnes
|
||||
colRows, err := db.Query(columnsQuery, dbName, tableName)
|
||||
if err != nil {
|
||||
logError(appID, "error", "introspect_columns_failed", map[string]any{"table": tableName, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
|
||||
columns := make(map[string]any)
|
||||
var primaryKeys []string
|
||||
|
||||
for colRows.Next() {
|
||||
var (
|
||||
colName string
|
||||
dataType string
|
||||
maxLength sql.NullInt64
|
||||
nullable string
|
||||
colDefault sql.NullString
|
||||
extra string
|
||||
colKey string
|
||||
)
|
||||
if err := colRows.Scan(&colName, &dataType, &maxLength, &nullable, &colDefault, &extra, &colKey); err != nil {
|
||||
colRows.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
col := map[string]any{
|
||||
"type": mapMySQLType(dataType),
|
||||
}
|
||||
|
||||
// Longueur pour varchar/char
|
||||
if maxLength.Valid && maxLength.Int64 > 0 {
|
||||
col["length"] = maxLength.Int64
|
||||
}
|
||||
|
||||
// Nullable
|
||||
if nullable == "NO" {
|
||||
col["required"] = true
|
||||
}
|
||||
|
||||
// Default
|
||||
if colDefault.Valid {
|
||||
col["default"] = colDefault.String
|
||||
}
|
||||
|
||||
// Auto increment
|
||||
if strings.Contains(extra, "auto_increment") {
|
||||
col["auto"] = true
|
||||
}
|
||||
|
||||
// Primary key
|
||||
if colKey == "PRI" {
|
||||
col["primary"] = true
|
||||
primaryKeys = append(primaryKeys, colName)
|
||||
}
|
||||
|
||||
columns[colName] = col
|
||||
}
|
||||
colRows.Close()
|
||||
|
||||
// Clés étrangères
|
||||
fkRows, err := db.Query(fkQuery, dbName, tableName)
|
||||
if err == nil {
|
||||
for fkRows.Next() {
|
||||
var colName, refTable, refCol string
|
||||
if err := fkRows.Scan(&colName, &refTable, &refCol); err != nil {
|
||||
continue
|
||||
}
|
||||
if col, ok := columns[colName].(map[string]any); ok {
|
||||
col["foreign"] = refTable + "." + refCol
|
||||
// Détecter le pattern owner (user_id -> users.id)
|
||||
if colName == "user_id" && refTable == "users" {
|
||||
col["filter"] = "owner"
|
||||
}
|
||||
}
|
||||
}
|
||||
fkRows.Close()
|
||||
}
|
||||
|
||||
// Contraintes UNIQUE
|
||||
uqRows, err := db.Query(uniqueQuery, dbName, tableName)
|
||||
if err == nil {
|
||||
for uqRows.Next() {
|
||||
var colName string
|
||||
if err := uqRows.Scan(&colName); err != nil {
|
||||
continue
|
||||
}
|
||||
if col, ok := columns[colName].(map[string]any); ok {
|
||||
col["unique"] = true
|
||||
}
|
||||
}
|
||||
uqRows.Close()
|
||||
}
|
||||
|
||||
table := map[string]any{
|
||||
"columns": columns,
|
||||
}
|
||||
|
||||
// Clé primaire composite
|
||||
if len(primaryKeys) > 1 {
|
||||
table["primary"] = primaryKeys
|
||||
}
|
||||
|
||||
// CRUD par défaut (à affiner manuellement)
|
||||
table["crud"] = []string{"list", "show", "create", "update", "delete"}
|
||||
|
||||
tables[tableName] = table
|
||||
}
|
||||
|
||||
return protocol.Success(req.ID, map[string]any{
|
||||
"app": appID,
|
||||
"database": dbName,
|
||||
"tables": tables,
|
||||
})
|
||||
}
|
||||
|
||||
// mapMySQLType convertit un type MySQL en type schema simplifié.
|
||||
func mapMySQLType(mysqlType string) string {
|
||||
switch strings.ToLower(mysqlType) {
|
||||
case "tinyint", "smallint", "mediumint", "int", "bigint":
|
||||
return "int"
|
||||
case "float", "double", "decimal":
|
||||
return "float"
|
||||
case "varchar", "char":
|
||||
return "string"
|
||||
case "text", "mediumtext", "longtext":
|
||||
return "text"
|
||||
case "tinyint(1)", "boolean", "bool":
|
||||
return "bool"
|
||||
case "date":
|
||||
return "date"
|
||||
case "datetime", "timestamp":
|
||||
return "datetime"
|
||||
case "time":
|
||||
return "time"
|
||||
case "json":
|
||||
return "json"
|
||||
case "blob", "mediumblob", "longblob":
|
||||
return "blob"
|
||||
default:
|
||||
return mysqlType
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user