Mise à jour automatique (Gitea + Watchtower) et conteneurisation Docker

- Détection de version via l'API Gitea, application via Watchtower
- Router système, UI admin (intégrations), bandeau de mise à jour
- Dockerfile multi-étapes, docker-compose, scripts/release.sh
- Version centralisée dans app/version.py (pyproject ou OHM_VERSION au build)
This commit is contained in:
Roman
2026-09-22 11:58:03 +00:00
parent 41566ab5fb
commit 9148b5fb6a
21 changed files with 1092 additions and 49 deletions
+140 -12
View File
@@ -26,12 +26,23 @@
<template x-for="s in sources" :key="s.name">
<tr>
<td><strong x-text="s.label"></strong> <span style="color:var(--text-dim);font-size:0.8rem" x-text="'(' + s.name + ')'"></span></td>
<td style="font-size:0.82rem;color:var(--text-dim)" x-text="s.base_url"></td>
<td>
<div style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap">
<input class="input" x-model="s._url" :placeholder="s.default_base_url"
style="min-width:230px;font-size:0.8rem;padding:0.3rem 0.5rem"
@change="s._urlDirty = s._url.trim() !== s.base_url">
<button class="btn btn-sm btn-ghost" @click="saveUrl(s)" :disabled="!s._urlDirty || s._saving"
x-text="s._saving ? '…' : 'Enregistrer'"></button>
<button class="btn btn-sm btn-ghost" x-show="s.overridden" @click="resetUrl(s)"
:disabled="s._saving" title="Restaurer l'URL par défaut">↺</button>
</div>
</td>
<td><div class="toggle" :class="s.enabled && 'on'" @click="toggle(s)"></div></td>
<td>
<span x-show="s.health == null" style="color:var(--text-dim)">—</span>
<span x-show="s.health === true" style="color:var(--success)">✔ OK</span>
<span x-show="s.health === false" style="color:var(--danger)" :title="s.healthDetail">✖ KO</span>
<span x-show="!s.health" style="color:var(--text-dim)">—</span>
<span x-show="s.health" :style="s.health?.healthy ? 'color:var(--success)' : 'color:var(--danger)'"
:title="s.health ? s.health.detail + (s.health.checked_at ? ' — vérifié le ' + new Date(s.health.checked_at).toLocaleString('fr-FR') : '') : ''"
x-text="s.health?.healthy ? '✔ OK' : '✖ KO'"></span>
</td>
<td><button class="btn btn-sm btn-ghost" @click="healthCheck(s)" :disabled="s._checking" x-text="s._checking ? '…' : 'Tester'"></button></td>
</tr>
@@ -84,6 +95,38 @@
:style="integrations.testResult?.ok ? 'color:var(--success)' : 'color:var(--danger)'"
x-text="integrations.testResult?.detail"></p>
</div>
<div class="panel">
<h2>🔄 Mise à jour</h2>
<p style="color:var(--text-dim);font-size:0.85rem;margin:0 0 0.6rem">
Version installée : <strong x-text="upd.current || '—'"></strong>
<span x-show="upd.update_available" class="badge" style="background:var(--accent);color:#fff"
x-text="(upd.latest || '?') + ' disponible'"></span>
<span x-show="upd.latest && !upd.update_available" style="color:var(--success)">✔ à jour</span>
</p>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
<input class="input" x-model="upd.config.gitea_url" placeholder="https://git.lanro.eu"
style="flex:2;min-width:220px" @change="upd._dirty = true">
<input class="input" x-model="upd.config.repo" placeholder="Roman/ohm_streaming"
style="flex:1;min-width:180px" @change="upd._dirty = true">
<input class="input" x-model="upd.config.token" type="password" placeholder="Jeton d'accès Gitea"
style="flex:1;min-width:180px" @change="upd._dirty = true">
<button class="btn btn-sm" @click="saveUpdate()" :disabled="!upd._dirty">Enregistrer</button>
<button class="btn btn-sm btn-ghost" @click="checkUpdate()" :disabled="upd._checking"
x-text="upd._checking ? '…' : 'Vérifier'"></button>
<button class="btn btn-sm btn-accent" x-show="upd.update_available" @click="applyUpdate()"
:disabled="upd._applying" x-text="upd._applying ? 'Mise à jour…' : '⬆ Mettre à jour maintenant'"></button>
</div>
<p x-show="!upd.configured" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
Renseignez le dépôt Gitea pour activer la détection des mises à jour.
Jeton d'accès (droit « lecture ») nécessaire uniquement si le dépôt est privé.
</p>
<p x-show="upd.configured && !upd.docker" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
⚠ Mise à jour automatique disponible uniquement en déploiement Docker
(<code>docker compose pull &amp;&amp; docker compose up -d</code> sinon).
</p>
</div>
+
<div class="panel">
<h2>👥 Utilisateurs</h2>
@@ -121,6 +164,11 @@
function adminPage() {
return {
users: [], sources: [], stats: {}, forbidden: false,
upd: {
current: '', latest: null, update_available: false, configured: false, docker: false,
config: { gitea_url: '', repo: '', token: '' },
_dirty: false, _checking: false, _applying: false,
},
integrations: {
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
_regen: false, _testing: false, testResult: null,
@@ -132,7 +180,7 @@ function adminPage() {
]);
if (u.status === 403) { this.forbidden = true; return; }
this.users = await u.json();
this.sources = await s.json();
this.sources = (await s.json()).map((x) => ({ ...x, _url: x.base_url, _urlDirty: false, _saving: false }));
this.stats = await st.json();
const itg = await fetch('/api/admin/integrations');
if (itg.ok) {
@@ -140,8 +188,10 @@ function adminPage() {
this.integrations.torznab = data.torznab;
this.integrations.sonarr = { ...data.sonarr, _dirty: false };
}
const upd = await fetch('/api/admin/update');
if (upd.ok) this.applyUpdateData(await upd.json());
this.checkAllSources();
},
copy(value) {
navigator.clipboard.writeText(value).then(() => toast('✔ Copié'));
},
@@ -170,13 +220,53 @@ function adminPage() {
if (res.ok) s.enabled = !s.enabled;
},
async healthCheck(s) {
async saveUrl(s) {
s._saving = true;
try {
const res = await fetch(`/api/admin/sources/${s.name}/url`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: s._url }),
});
if (res.ok) {
const data = await res.json();
s.base_url = data.base_url;
s.overridden = data.overridden;
s._url = data.base_url;
s._urlDirty = false;
toast('✔ URL enregistrée — test de la source…');
this.healthCheck(s);
} else {
toast('✖ ' + ((await res.json()).detail || 'Erreur'));
}
} catch {
toast('✖ Erreur réseau');
} finally {
s._saving = false;
}
},
async resetUrl(s) {
s._url = '';
await this.saveUrl(s);
},
async healthCheck(s, notify = true) {
s._checking = true;
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
const data = await res.json();
s.health = data.healthy; s.healthDetail = data.detail;
s._checking = false;
toast(data.healthy ? `✔ ${s.label} : ${data.detail}` : `✖ ${s.label} : ${data.detail}`);
try {
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
const data = res.ok ? await res.json() : { healthy: false, detail: `Erreur serveur (${res.status})` };
s.health = { healthy: data.healthy, detail: data.detail, checked_at: data.checked_at };
if (notify) toast(`${data.healthy ? '✔' : '✖'} ${s.label} : ${data.detail}`);
} catch {
s.health = { healthy: false, detail: 'Erreur réseau', checked_at: null };
if (notify) toast(`✖ ${s.label} : erreur réseau`);
} finally {
s._checking = false;
}
},
checkAllSources() {
return Promise.allSettled(this.sources.filter(s => s.enabled).map(s => this.healthCheck(s, false)));
},
async post(url) { await fetch(url, { method: 'POST' }); await this.load(); },
@@ -188,6 +278,44 @@ function adminPage() {
this.integrations._testing = false;
},
applyUpdateData(data) {
this.upd.current = data.current;
this.upd.latest = data.latest;
this.upd.update_available = data.update_available;
this.upd.configured = data.configured;
this.upd.docker = data.docker;
this.upd.config = { ...data.config, _dirty: false };
},
async saveUpdate() {
const res = await fetch('/api/admin/update', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.upd.config),
});
if (res.ok) { this.applyUpdateData(await res.json()); toast('✔ Configuration enregistrée'); }
},
async checkUpdate() {
this.upd._checking = true;
const res = await fetch('/api/admin/update/check', { method: 'POST' });
if (res.ok) this.applyUpdateData(await res.json());
this.upd._checking = false;
if (this.upd.update_available) toast('⬆ ' + this.upd.latest + ' disponible');
},
async applyUpdate() {
if (!confirm('Mettre à jour maintenant ? Le service sera indisponible quelques secondes.')) return;
this.upd._applying = true;
sessionStorage.setItem('ohm-updating', '1');
const res = await fetch('/api/admin/update/apply', { method: 'POST' });
if (!res.ok) {
sessionStorage.removeItem('ohm-updating');
const detail = (await res.json()).detail || 'Erreur';
toast('✖ ' + detail); this.upd._applying = false;
}
// sinon : le conteneur est recréé, update-watcher.js affiche le bandeau et recharge la page
},
async del(u) {
if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return;
await fetch('/api/admin/users/' + u.id, { method: 'DELETE' });
+1
View File
@@ -32,6 +32,7 @@
{% block content %}{% endblock %}
</main>
<script src="/static/js/app.js"></script>
<script src="/static/js/update-watcher.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>