feat: renforcer inventaire et configuration dashboard
This commit is contained in:
10
app.json
Normal file
10
app.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "manus-dashboard",
|
||||||
|
"name": "Dashboard Production",
|
||||||
|
"category": "INFRA",
|
||||||
|
"urls": {"recette": "https://dashboard.recette.santinova-soft.org", "prod": "https://dashboard.santinova-soft.org"},
|
||||||
|
"containerName": "manus-dashboard",
|
||||||
|
"image": "images/dashboard.png",
|
||||||
|
"giteaRepo": "manus-dashboard",
|
||||||
|
"giteaOwner": "manus-admin"
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
- web
|
- web
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.recette.santinova-soft.org`)"
|
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.recette.santinova-soft.org`) || Host(`dashboard.santinova-soft.org`)"
|
||||||
- "traefik.http.routers.manus-dashboard.entrypoints=websecure"
|
- "traefik.http.routers.manus-dashboard.entrypoints=websecure"
|
||||||
- "traefik.http.routers.manus-dashboard.tls=true"
|
- "traefik.http.routers.manus-dashboard.tls=true"
|
||||||
- "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt"
|
||||||
|
|||||||
@@ -434,6 +434,7 @@ function addDynamicApp(appDef) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initDynamicApps() {
|
function initDynamicApps() {
|
||||||
|
// 1. Charger les apps depuis .dashboard-apps.json (legacy)
|
||||||
const dynamicApps = loadDynamicApps();
|
const dynamicApps = loadDynamicApps();
|
||||||
for (const app of dynamicApps) {
|
for (const app of dynamicApps) {
|
||||||
const existingInConfig = config.apps.findIndex((a) => a.id === app.id);
|
const existingInConfig = config.apps.findIndex((a) => a.id === app.id);
|
||||||
@@ -441,7 +442,32 @@ function initDynamicApps() {
|
|||||||
config.apps.push(app);
|
config.apps.push(app);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`Apps dynamiques chargées: ${dynamicApps.length}`);
|
// 2. Scanner les dossiers app.json (découverte automatique)
|
||||||
|
try {
|
||||||
|
const appsBasePath = config.appsBasePath || process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps';
|
||||||
|
const entries = fs.readdirSync(appsBasePath, { withFileTypes: true });
|
||||||
|
let scanned = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const appJsonPath = path.join(appsBasePath, entry.name, 'app.json');
|
||||||
|
if (!fs.existsSync(appJsonPath)) continue;
|
||||||
|
try {
|
||||||
|
const appDef = JSON.parse(fs.readFileSync(appJsonPath, 'utf-8'));
|
||||||
|
if (!appDef.id) continue;
|
||||||
|
const existingInConfig = config.apps.findIndex((a) => a.id === appDef.id);
|
||||||
|
if (existingInConfig < 0) {
|
||||||
|
config.apps.push(appDef);
|
||||||
|
scanned++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Erreur lecture app.json dans ${entry.name}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Apps découvertes via app.json: ${scanned}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur scan app.json:', err.message);
|
||||||
|
}
|
||||||
|
console.log(`Total apps chargées: ${config.apps.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ CREATION D'APPLICATION ============
|
// ============ CREATION D'APPLICATION ============
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ module.exports = {
|
|||||||
containerName: 'demat-facturation-app',
|
containerName: 'demat-facturation-app',
|
||||||
healthCheckUrl: 'https://demat-facturation.recette.santinova-soft.org',
|
healthCheckUrl: 'https://demat-facturation.recette.santinova-soft.org',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
image: 'images/demat-facturation-dsi.jpg',
|
||||||
category: 'SANTINOVA',
|
category: 'SANTINOVA',
|
||||||
status: 'production',
|
status: 'production',
|
||||||
},
|
},
|
||||||
@@ -188,7 +189,7 @@ module.exports = {
|
|||||||
containerName: 'portail-santinova',
|
containerName: 'portail-santinova',
|
||||||
healthCheckUrl: 'https://portail.recette.santinova-soft.org',
|
healthCheckUrl: 'https://portail.recette.santinova-soft.org',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
category: 'SANTINOVA',
|
category: 'INFRA',
|
||||||
status: 'production',
|
status: 'production',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -225,22 +226,5 @@ module.exports = {
|
|||||||
category: 'SANTINOVA',
|
category: 'SANTINOVA',
|
||||||
status: 'recette',
|
status: 'recette',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'falc-generator',
|
|
||||||
name: 'FALC Generator',
|
|
||||||
description: 'Générateur de documents FALC (Facile À Lire et à Comprendre)',
|
|
||||||
directory: 'falc-generator',
|
|
||||||
giteaRepo: 'falc-generator',
|
|
||||||
giteaOwner: 'manus-admin',
|
|
||||||
urls: {
|
|
||||||
recette: 'https://falc.recette.santinova-soft.org',
|
|
||||||
prod: 'https://falc.santinova-soft.org',
|
|
||||||
},
|
|
||||||
containerName: 'falc-generator',
|
|
||||||
healthCheckUrl: 'https://falc.santinova-soft.org',
|
|
||||||
port: 3000,
|
|
||||||
category: 'ITINOVA',
|
|
||||||
status: 'production',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -417,6 +417,36 @@ router.get('/public/status', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ PUBLIC APPS ROUTE (sans authentification) ============
|
||||||
|
// Utilisé par le portail applicatif pour construire les tuiles dynamiquement
|
||||||
|
// Ne bloque jamais : retourne les apps avec status 'unknown' si les health checks ne sont pas encore disponibles
|
||||||
|
router.get('/public/apps', (req, res) => {
|
||||||
|
try {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
// getAllStatuses() retourne un tableau vide si aucun check n'a encore été effectué
|
||||||
|
// On ne déclenche PAS checkAllApps() ici pour éviter de bloquer la réponse
|
||||||
|
const statuses = getAllStatuses();
|
||||||
|
const publicApps = config.apps
|
||||||
|
.filter((a) => a.category !== 'INFRA') // Exclure les apps infra (portail, dashboard)
|
||||||
|
.map((a) => {
|
||||||
|
const status = statuses.find((s) => s.id === a.id);
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
category: a.category || 'SANTINOVA',
|
||||||
|
image: a.image || ('images/' + a.id + '.png'),
|
||||||
|
urls: a.urls || {},
|
||||||
|
status: status ? status.status : 'unknown',
|
||||||
|
containerRunning: status && status.container ? status.container.running : false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
res.json(publicApps);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============ DOCKER ROUTES ============
|
// ============ DOCKER ROUTES ============
|
||||||
|
|
||||||
router.get('/docker/containers', authMiddleware, async (req, res) => {
|
router.get('/docker/containers', authMiddleware, async (req, res) => {
|
||||||
@@ -452,7 +482,7 @@ router.post('/apps/:id/start', authMiddleware, async (req, res) => {
|
|||||||
const app = config.apps.find((a) => a.id === req.params.id);
|
const app = config.apps.find((a) => a.id === req.params.id);
|
||||||
if (!app) return res.status(404).json({ error: 'Application non trouvée' });
|
if (!app) return res.status(404).json({ error: 'Application non trouvée' });
|
||||||
const containerName = app.containerName || app.id;
|
const containerName = app.containerName || app.id;
|
||||||
const result = await startContainer(containerName);
|
const result = await startContainer(containerName, app.id);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@@ -505,21 +535,14 @@ const GITEA_PASS_REC = process.env.GITEA_PASSWORD || 'Itinova69!';
|
|||||||
const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!';
|
const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!';
|
||||||
const GITEA_TOKEN_REC = process.env.GITEA_RECETTE_TOKEN || process.env.GITEA_TOKEN || null;
|
const GITEA_TOKEN_REC = process.env.GITEA_RECETTE_TOKEN || process.env.GITEA_TOKEN || null;
|
||||||
|
|
||||||
const INVENTORY_APPS = [
|
// INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json)
|
||||||
{ id: 'itinova-contacts', name: 'Itinova Contacts', repoName: 'itinova-contacts' },
|
function getInventoryApps() {
|
||||||
{ id: 'itinova-podcasts', name: 'Itinova Podcasts', repoName: 'itinova-podcasts' },
|
return config.apps.map((a) => ({
|
||||||
{ id: 'veille-reglementaire', name: 'Veille Réglementaire', repoName: 'veille-reglementaire' },
|
id: a.id,
|
||||||
{ id: 'itinova-vehicle-exchange', name: 'Itinova Gestion de Flotte', repoName: 'itinova-vehicle-exchange' },
|
name: a.name,
|
||||||
{ id: 'sonum', name: 'SONUM', repoName: 'sonum' },
|
repoName: a.giteaRepo || a.id,
|
||||||
{ id: 'demat-facturation-dsi', name: 'Démat. Facturation DSI', repoName: 'demat-facturation-dsi' },
|
}));
|
||||||
{ id: 'facturation-santinova', name: 'Facturation Santinova', repoName: 'facturation-santinova' },
|
}
|
||||||
{ id: 'formation-manager-itinova', name: 'Formation Manager', repoName: 'formation-manager-itinova' },
|
|
||||||
{ id: 'pilotage-masse-salariale', name: 'Pilotage Masse Salariale', repoName: 'pilotage-masse-salariale' },
|
|
||||||
{ id: 'itinova-budget-si', name: 'Gestion Budget Informatique', repoName: 'itinova-budget-si' },
|
|
||||||
{ id: 'falc-generator', name: 'FALC Generator', repoName: 'falc-generator' },
|
|
||||||
{ id: 'portail-santinova', name: 'Portail Applicatif', repoName: 'portail-santinova' },
|
|
||||||
{ id: 'manus-dashboard', name: 'Dashboard Manus', repoName: 'manus-dashboard' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
|
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -559,6 +582,7 @@ function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
|
|||||||
router.get('/inventory', authMiddleware, async (req, res) => {
|
router.get('/inventory', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const owner = GITEA_USER_INV;
|
const owner = GITEA_USER_INV;
|
||||||
|
const INVENTORY_APPS = getInventoryApps();
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
INVENTORY_APPS.map(async (app) => {
|
INVENTORY_APPS.map(async (app) => {
|
||||||
const [repoRecette, repoProd] = await Promise.all([
|
const [repoRecette, repoProd] = await Promise.all([
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Dashboard Recette - Gestion des Applications</title>
|
<title>Dashboard - Gestion des Applications</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-dark-900 text-white">
|
<body class="bg-dark-900 text-white">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-white">Dashboard</h1>
|
<h1 className="text-lg font-bold text-white">Dashboard</h1>
|
||||||
<p className="text-xs text-gray-500">Recette</p>
|
<p className="text-xs text-gray-500">{window.location.hostname.includes('recette') ? 'Recette' : 'Production'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const APPS_CONFIG = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'manus-dashboard',
|
id: 'manus-dashboard',
|
||||||
name: 'Dashboard Recette',
|
name: 'Dashboard',
|
||||||
repoName: 'manus-dashboard',
|
repoName: 'manus-dashboard',
|
||||||
urlRecette: 'https://dashboard.recette.santinova-soft.org',
|
urlRecette: 'https://dashboard.recette.santinova-soft.org',
|
||||||
urlProd: 'https://dashboard.santinova-soft.org',
|
urlProd: 'https://dashboard.santinova-soft.org',
|
||||||
@@ -107,9 +107,9 @@ function SyncBadge({ versionRecette, versionProd }) {
|
|||||||
|
|
||||||
if (!shaRec && !shaProd) {
|
if (!shaRec && !shaProd) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500 italic">
|
||||||
<Minus className="w-3 h-3" />
|
<Minus className="w-3 h-3" />
|
||||||
N/A
|
Non déployé
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -123,9 +123,9 @@ function SyncBadge({ versionRecette, versionProd }) {
|
|||||||
}
|
}
|
||||||
if (!shaProd) {
|
if (!shaProd) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-orange-900/40 text-orange-300 border border-orange-700/40" title="Uniquement en recette">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-orange-900/40 text-orange-300 border border-orange-700/40" title="Recette déployée, production vide">
|
||||||
<ArrowUp className="w-3 h-3" />
|
<ArrowUp className="w-3 h-3" />
|
||||||
Recette en avance
|
Non déployé en prod
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -175,6 +175,7 @@ function SyncBadge({ versionRecette, versionProd }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function VersionBadge({ version, loading }) {
|
function VersionBadge({ version, loading }) {
|
||||||
|
if (!version) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400 animate-pulse">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400 animate-pulse">
|
||||||
@@ -183,11 +184,10 @@ function VersionBadge({ version, loading }) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!version) {
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500 italic">
|
||||||
<AlertCircle className="w-3 h-3" />
|
<AlertCircle className="w-3 h-3" />
|
||||||
N/A
|
Non déployé
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export default function LoginPage({ onLogin }) {
|
|||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-600 mb-4">
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-600 mb-4">
|
||||||
<Server className="w-8 h-8 text-white" />
|
<Server className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-white">Dashboard Recette</h1>
|
<h1 className="text-2xl font-bold text-white">
|
||||||
|
Dashboard {window.location.hostname.includes('recette') ? 'Recette' : 'Production'}
|
||||||
|
</h1>
|
||||||
<p className="text-gray-400 mt-2">Gestion des applications</p>
|
<p className="text-gray-400 mt-2">Gestion des applications</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user