feat(automations): moteur de regles if-this-then-that (v5.1.0 roadmap, bump v5.2.0)
Moteur d'automatisation complet : declencheurs (evenement / cron / bouton),
conditions combinables (eq, neq, contains, is_empty, is_not_empty, changed)
et actions (webhook, set_property, create_page, notify).
- Migration v5 : tables `automations` + `automation_runs` (avec index).
- Service `app/services/automations.py` : fire_event, cron_due (`*/N`,
minute fixe, @hourly/@daily), scheduler de fond dans le lifespan.
- Router `app/routers/automations.py` : CRUD `/workspace/automations`,
historique des executions, run manuel + run bouton `/api/automations/{id}/run`
(exempt CSRF, comme `/api/agent`).
- Hooks d'evenements dans collections.py / board.py / workspace.py
(collection.* et page.* : created/updated/deleted/moved).
- Bloc `button` dans l'editeur (menu slash) : declenche une regle au clic,
picker d'automation inline, serialisation automations_id/name.
- Panneau Automations dans le Settings (creer/editer/activer/desactiver/
lancer/supprimer + historique), collection scope + JSON conditions/actions.
- 11 tests `tests/test_automations.py` ; suite complete pytest 289 verte.
- Version bump 5.2.0 (VERSION, app/main.py, CHANGELOG).
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
{id:'to_do',name:'To-do',icon:'☐'},{id:'toggle',name:'Toggle',icon:'▶'},{id:'quote',name:'Quote',icon:'❝'},
|
||||
{id:'callout',name:'Callout',icon:'💡'},{id:'table_of_contents',name:'Table of contents',icon:'☰'},{id:'math',name:'Math equation',icon:'∑'},
|
||||
{id:'columns',name:'Column list',icon:'▮'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
|
||||
{id:'table',name:'Table',icon:'⊞'},
|
||||
{id:'table',name:'Table',icon:'⊞'},{id:'button',name:'Button / Automation',icon:'⚡'},
|
||||
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼️'}]},
|
||||
{name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]}];
|
||||
|
||||
@@ -358,6 +358,7 @@
|
||||
if(b.type==='meeting')return renderMeetingBlock(b,idx);
|
||||
if(b.type==='table')return renderTableBlock(b,idx);
|
||||
if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></div>`;
|
||||
if(b.type==='button')return renderAutomationButton(b,idx);
|
||||
if(b.type==='image')return `<div class="block-wrapper"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`;
|
||||
if(b.type==='table_of_contents')return renderTOC();
|
||||
if(b.type==='math')return `<div class="block-wrapper"><div class="block-content block-math">${renderMathBlock(b)}</div></div>`;
|
||||
@@ -414,6 +415,18 @@
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderAutomationButton(b,idx){
|
||||
var label=(b.content&&b.content.trim())?b.content:((b.automation_name||'Run automation'));
|
||||
var bound=b.automation_id>0;
|
||||
var badge=bound?'<span style="opacity:.65;font-weight:400;margin-left:8px;font-size:11px;">⚡ '+esc(b.automation_name||'')+'</span>':'<span style="opacity:.65;font-weight:400;margin-left:8px;font-size:11px;">Choose…</span>';
|
||||
var actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
|
||||
return `<div class="block-wrapper">
|
||||
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
|
||||
${actions}
|
||||
<div class="block-content"><button type="button" class="fd-automation-btn" onclick="E.runAutomationButton('${b.id}')">⚡ ${esc(label)}${badge}</button></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderColumnsBlock(b){
|
||||
var cols=(b.children&&b.children.length)?b.children:[{id:genId(),type:'paragraph',content:''}];
|
||||
var cells=cols.map(function(c){
|
||||
@@ -742,14 +755,14 @@
|
||||
}
|
||||
this.blocks=[this.mkB('embed','',{src:url,embed_type:'download',file_name:name,file_size:data.file_size||0,file_mime:mime})];this.render();
|
||||
},
|
||||
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='toggle')b.children=[];return b;},
|
||||
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='toggle')b.children=[];if(type==='button'){b.automation_id=0;b.automation_name='';}return b;},
|
||||
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
|
||||
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);},
|
||||
getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};},
|
||||
focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}},
|
||||
onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];if(b&&(b.type==='table'||b.type==='divider'||b.type==='image'||b.type==='embed')){this.addAt(this.blocks.length,'paragraph');}else{const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}}},
|
||||
onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];if(b&&(b.type==='table'||b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='button')){this.addAt(this.blocks.length,'paragraph');}else{const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}}},
|
||||
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;}
|
||||
if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents')continue;
|
||||
if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents'||b.type==='button')continue;
|
||||
if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;}
|
||||
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gt(el)||'';else if(el)b.content=gt(el)||'';
|
||||
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';});
|
||||
@@ -772,7 +785,7 @@
|
||||
if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='<span style="color:#e5484d;">Invalid LaTeX</span>';}}
|
||||
else{el.innerHTML='<span>'+esc(tex)+'</span>';}
|
||||
},
|
||||
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
|
||||
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}if(type==='button'){b.automation_id=0;b.automation_name='';}this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
|
||||
addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
|
||||
addAfter(idx,type){return this.addAt(idx+1,type);},
|
||||
removeBlock(idx){this.sync();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||
@@ -1164,7 +1177,7 @@ applyAIBlocks(text){
|
||||
if(id==='database'){ this.openDbTemplatePicker(idx); return; }
|
||||
const block=this.blocks[idx];this.sync();block.content='';
|
||||
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
|
||||
else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';if(id==='table'){block.rows=_tblNew(3,3);block.content='';}}
|
||||
else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';if(id==='table'){block.rows=_tblNew(3,3);block.content='';}if(id==='button'){block.automation_id=0;block.automation_name='';}}
|
||||
this.render();this.dirty=true;this.autoSave();
|
||||
if(id==='table'){setTimeout(()=>_tblFocus(block.id,1,0),60);}
|
||||
else if(id==='math'){setTimeout(()=>{const ne=document.querySelector(`[data-math-bid="${block.id}"]`);if(ne)ne.focus();},60);}
|
||||
@@ -1379,12 +1392,56 @@ applyAIBlocks(text){
|
||||
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
|
||||
this.saving=true;
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW','meeting'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
|
||||
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
|
||||
if(cb)cb();
|
||||
}).catch(()=>{this.saving=false;});
|
||||
},
|
||||
runAutomationButton(bid){
|
||||
const idx=this.getIdx(bid);if(idx<0)return;
|
||||
const b=this.blocks[idx];
|
||||
if(!b.automation_id){this.pickAutomation(bid);return;}
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const self=this;
|
||||
this.showToast('⚡ '+ (b.automation_name||'Automation') + '…');
|
||||
fetch('/api/automations/'+b.automation_id+'/run',{method:'POST',
|
||||
headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},
|
||||
body:JSON.stringify({page_id:this.pid})})
|
||||
.then(r=>r.json())
|
||||
.then(d=>{
|
||||
if(d.status==='fired'){self.showToast('✅ Automation exécutée');}
|
||||
else if(d.status==='skipped'){self.showToast('⏭️ Condition non remplie (skippée)');}
|
||||
else{self.showToast((d.detail||'Erreur')+'','error');}
|
||||
})
|
||||
.catch(()=>{self.showToast('Échec de l\u2019exécution','error');});
|
||||
},
|
||||
pickAutomation(bid){
|
||||
const self=this;
|
||||
fetch('/workspace/automations',{credentials:'same-origin'})
|
||||
.then(r=>r.json())
|
||||
.then(d=>{
|
||||
const list=d.automations||[];
|
||||
if(!list.length){self.showToast('Aucune automation — créez-en dans Paramètres → Automations','error');return;}
|
||||
const ov=document.createElement('div');ov.id='fdAutomationOverlay';
|
||||
ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;';
|
||||
const box=document.createElement('div');box.style.cssText='background:var(--bg-secondary,#1f1f1f);border:1px solid var(--border,#333);border-radius:12px;padding:20px;width:420px;max-width:92vw;max-height:80vh;overflow:auto;';
|
||||
box.innerHTML='<div style="font-weight:600;font-size:14px;margin-bottom:12px;">Choisir une automation</div>'+
|
||||
list.filter(a=>a.enabled).map(function(a){
|
||||
return `<button type="button" data-id="${a.id}" style="display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:9px 10px;background:var(--bg-hover,transparent);border:1px solid var(--border,#333);color:var(--text-primary);border-radius:8px;margin-bottom:8px;cursor:pointer;"><span>⚡</span><span style="flex:1;font-size:13px;">${esc(a.name||'Automation')}</span><span style="font-size:11px;color:var(--text-tertiary);">${esc(a.trigger_type||'event')}</span></button>`;
|
||||
}).join('')+
|
||||
'<button type="button" data-cancel="1" style="width:100%;text-align:center;padding:8px;border:none;background:transparent;color:var(--text-secondary);cursor:pointer;font-size:12px;">Annuler</button>';
|
||||
ov.appendChild(box);
|
||||
document.body.appendChild(ov);
|
||||
box.addEventListener('click',function(ev){
|
||||
const btn=ev.target.closest('[data-id]');if(btn){
|
||||
const idx=self.getIdx(bid);if(idx>=0){self.blocks[idx].automation_id=parseInt(btn.getAttribute('data-id'),10);const a=list.find(function(x){return x.id===parseInt(btn.getAttribute('data-id'),10);});self.blocks[idx].automation_name=a?a.name:'';self.dirty=true;self.autoSave();self.render();}
|
||||
ov.remove();
|
||||
}else if(ev.target.closest('[data-cancel]')){ov.remove();}
|
||||
});
|
||||
})
|
||||
.catch(()=>{self.showToast('Erreur de chargement','error');});
|
||||
},
|
||||
md2b(md){const bl=[];const lines=String(md).split('\n');let i=0;for(;i<lines.length;i++){const raw=lines[i];const t=raw.trim();if(!t)continue;
|
||||
if(t.startsWith('```')){const lang=t.substring(3).trim()||'Plain Text';let code='';i++;for(;i<lines.length;i++){if(lines[i].trim()==='```')break;code+=(code?'\n':'')+lines[i];}bl.push(this.mkB('code',code,{language:lang}));continue;}
|
||||
if(_tblIsData(t)&&i+1<lines.length&&_tblIsSep(lines[i+1])){const header=_tblSplitLine(t);const rows=[header];let j=i+2;while(j<lines.length&&_tblIsData(lines[j])){rows.push(_tblSplitLine(lines[j]));j++;}let w=0;rows.forEach(function(r){if(r.length>w)w=r.length;});if(w<1)w=1;const rows2=rows.map(function(r){const a=r.slice();while(a.length<w)a.push('');return a;});bl.push(this.mkB('table','',{rows:rows2}));i=j-1;continue;}
|
||||
|
||||
+217
-1
@@ -115,6 +115,7 @@
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
||||
<div class="settings-nav-header">Features</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="activeSection='automations'; loadAutomations()">{{ fd_icon("zap",14) }} Automations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent & IA</div>
|
||||
<!-- Admin nav: only visible to admins -->
|
||||
<template x-if="userIsAdmin">
|
||||
@@ -388,6 +389,116 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Automations -->
|
||||
<div x-show="activeSection==='automations'">
|
||||
<h2>Automations</h2>
|
||||
<p class="section-desc">Règles if-this-then-that : déclencheur + condition + action. Utilisables aussi via le bloc bouton dans l'éditeur de page.</p>
|
||||
|
||||
<!-- New / Edit form -->
|
||||
<div class="setting-group">
|
||||
<h3 x-text="editAutomationId ? 'Modifier' : 'Nouvelle automation'"></h3>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Nom</div>
|
||||
<input class="settings-input" style="width:100%;" type="text" x-model="af.name" placeholder="Ex : Notifier le statut Done">
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" style="flex-wrap:wrap;gap:10px;">
|
||||
<div>
|
||||
<div class="setting-label">Déclencheur</div>
|
||||
<select class="settings-input" style="min-width:180px;" x-model="af.trigger_type" @change="af.event='page.created'">
|
||||
<option value="event">Événement (base de données)</option>
|
||||
<option value="cron">Planifié (cron)</option>
|
||||
<option value="button">Bouton cliquable</option>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="af.trigger_type==='event'">
|
||||
<div class="setting-label">Événement</div>
|
||||
<select class="settings-input" style="min-width:200px;" x-model="af.event">
|
||||
<option value="page.created">Page créée</option>
|
||||
<option value="page.updated">Page modifiée</option>
|
||||
<option value="page.deleted">Page supprimée</option>
|
||||
<option value="collection.created">Base créée</option>
|
||||
<option value="collection.updated">Base modifiée</option>
|
||||
<option value="collection.deleted">Base supprimée</option>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="af.trigger_type==='cron'">
|
||||
<div>
|
||||
<div class="setting-label">Expression cron</div>
|
||||
<input class="settings-input" style="min-width:180px;" type="text" x-model="af.cron_expression" placeholder="*/15 * * * * | @hourly | @daily">
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="af.trigger_type==='event'">
|
||||
<div>
|
||||
<div class="setting-label">Base (optionnel)</div>
|
||||
<select class="settings-input" style="min-width:180px;" x-model="af.collection_id">
|
||||
<option value="">Toutes les bases</option>
|
||||
<template x-for="c in globalCollections" :key="c.id">
|
||||
<option x-bind:value="c.id" x-text="c.icon + ' ' + c.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-label">Conditions (JSON — toutes AND)</div>
|
||||
<textarea class="settings-input" style="width:100%;min-height:60px;font-family:monospace;font-size:12px;" x-model="af.condition_json" placeholder='[{"property":"Status","op":"eq","value":"Done"}]'></textarea>
|
||||
<div class="setting-desc">Ops : eq, neq, contains, not_contains, is_empty, is_not_empty, changed</div>
|
||||
</div>
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-label">Actions (JSON)</div>
|
||||
<textarea class="settings-input" style="width:100%;min-height:80px;font-family:monospace;font-size:12px;" x-model="af.actions_json" placeholder='[{"type":"webhook","url":"https://..."}, {"type":"set_property","property":"Status","value":"Done"}, {"type":"create_page","collection_id":1,"title":"New task"}, {"type":"notify","message":"Automation fired"}]'></textarea>
|
||||
<div class="setting-desc">Types : webhook, set_property, create_page, notify</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button class="btn-sm" @click="saveAutomation()" x-text="editAutomationId ? 'Enregistrer' : 'Créer'"></button>
|
||||
<button class="btn-sm" style="color:var(--text-secondary);" x-show="editAutomationId" @click="cancelEditAutomation()">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="setting-group">
|
||||
<h3>Mes automatisations <span style="font-size:11px;color:var(--text-tertiary);" x-text="automations.length + ' règle(s)'"></span></h3>
|
||||
<div x-show="!automations.length" class="setting-desc" style="padding:8px 0;">Aucune automation pour l'instant.</div>
|
||||
<template x-for="a in automations" :key="a.id">
|
||||
<div class="setting-row" style="border-bottom:1px solid var(--border);">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="setting-label">
|
||||
<span x-text="'⚡ '+a.name"></span>
|
||||
<span x-show="!a.enabled" style="font-size:10px;background:#666;color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">OFF</span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:8px;" x-text="'('+a.trigger_type+' · '+(a.event||a.cron_expression||'')+')'"></span>
|
||||
</div>
|
||||
<div class="setting-desc" style="font-size:11px;">
|
||||
<span x-text="a.run_count + ' exécution(s)'"></span>
|
||||
<span x-show="a.last_run_at" x-text="' · dernière : '+a.last_run_at"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-control" style="gap:6px;">
|
||||
<button class="btn-sm" @click="runAutomation(a.id)">▶ Lancer</button>
|
||||
<button class="btn-sm" @click="toggleAutomation(a.id, a.enabled)"><span x-text="a.enabled ? 'Désactiver' : 'Activer'"></span></button>
|
||||
<button class="btn-sm" @click="editAutomation(a)">✎</button>
|
||||
<button class="btn-sm" style="color:var(--danger);" @click="deleteAutomation(a.id)">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Runs history -->
|
||||
<div class="setting-group" x-show="autoRuns.length">
|
||||
<h3>Historique d'exécutions</h3>
|
||||
<template x-for="r in autoRuns" :key="r.id">
|
||||
<div class="setting-row" style="border-bottom:1px solid var(--border);">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<span style="font-size:12px;" x-text="r.created_at"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:8px;" x-text="'['+r.trigger_source+']'"></span>
|
||||
</div>
|
||||
<span style="font-size:11px;" x-text="r.detail" :style="r.status==='error' ? 'color:var(--danger);' : 'color:var(--text-secondary);'"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin: Users & Roles -->
|
||||
<div x-show="activeSection==='admin-users'">
|
||||
<h2>Users & Roles</h2>
|
||||
@@ -734,6 +845,13 @@ document.addEventListener('alpine:init', function() {
|
||||
// Per-user provider keys (multi-providers)
|
||||
keyForms: {},
|
||||
|
||||
// Automations (v5.1.0)
|
||||
automations: [],
|
||||
autoRuns: [],
|
||||
editAutomationId: null,
|
||||
globalCollections: [],
|
||||
af: { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' },
|
||||
|
||||
async init() {
|
||||
await this.loadTags();
|
||||
await this.loadGiteaStatus();
|
||||
@@ -1327,8 +1445,106 @@ document.addEventListener('alpine:init', function() {
|
||||
if (r.ok) { this.githubLinked = false; }
|
||||
} catch(e) {}
|
||||
},
|
||||
async loadAutomations() {
|
||||
try { await this.loadCollectionsForForm(); } catch(e) {}
|
||||
try {
|
||||
var r = await fetch('/workspace/automations', {credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.automations = d.automations || [];
|
||||
} catch(e) { this.automations = []; }
|
||||
},
|
||||
async loadCollectionsForForm() {
|
||||
try {
|
||||
var r = await fetch('/db/api', {credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.globalCollections = d.collections || [];
|
||||
} catch(e) { this.globalCollections = []; }
|
||||
},
|
||||
async saveAutomation() {
|
||||
if (!this.af.name.trim()) { window.showToast && window.showToast('Nom requis', 'error'); return; }
|
||||
try { JSON.parse(this.af.condition_json || '[]'); } catch(e) { window.showToast && window.showToast('Conditions JSON invalide', 'error'); return; }
|
||||
try { JSON.parse(this.af.actions_json || '[]'); } catch(e) { window.showToast && window.showToast('Actions JSON invalide', 'error'); return; }
|
||||
var payload = {
|
||||
name: this.af.name,
|
||||
trigger_type: this.af.trigger_type,
|
||||
event: this.af.event,
|
||||
cron_expression: this.af.cron_expression,
|
||||
collection_id: this.af.collection_id ? parseInt(this.af.collection_id, 10) : null,
|
||||
condition_json: this.af.condition_json,
|
||||
actions_json: this.af.actions_json,
|
||||
enabled: true
|
||||
};
|
||||
var url = '/workspace/automations' + (this.editAutomationId ? '/' + this.editAutomationId : '');
|
||||
var method = this.editAutomationId ? 'PUT' : 'POST';
|
||||
try {
|
||||
var r = await fetch(url, { method: method, credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify(payload) });
|
||||
var d = await r.json();
|
||||
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Échec', 'error'); return; }
|
||||
window.showToast && window.showToast(this.editAutomationId ? 'Automation mise à jour' : 'Automation créée');
|
||||
this.cancelEditAutomation();
|
||||
await this.loadAutomations();
|
||||
} catch(e) { window.showToast && window.showToast('Erreur réseau', 'error'); }
|
||||
},
|
||||
editAutomation(a) {
|
||||
this.editAutomationId = a.id;
|
||||
this.af = {
|
||||
name: a.name || '',
|
||||
trigger_type: a.trigger_type || 'event',
|
||||
event: a.event || 'page.created',
|
||||
cron_expression: a.cron_expression || '*/15 * * * *',
|
||||
collection_id: a.collection_id ? String(a.collection_id) : '',
|
||||
condition_json: a.condition_json || '[]',
|
||||
actions_json: a.actions_json || '[]'
|
||||
};
|
||||
},
|
||||
cancelEditAutomation() {
|
||||
this.editAutomationId = null;
|
||||
this.af = { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' };
|
||||
},
|
||||
async toggleAutomation(id, currentlyEnabled) {
|
||||
try {
|
||||
var row = this.automations.find(function(x){ return x.id === id; });
|
||||
if (!row) return;
|
||||
var payload = {
|
||||
name: row.name,
|
||||
trigger_type: row.trigger_type || 'event',
|
||||
event: row.event || 'page.created',
|
||||
cron_expression: row.cron_expression || '',
|
||||
collection_id: row.collection_id || null,
|
||||
condition_json: row.condition_json || '[]',
|
||||
actions_json: row.actions_json || '[]',
|
||||
enabled: !currentlyEnabled
|
||||
};
|
||||
var r = await fetch('/workspace/automations/' + id, { method:'PUT', credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify(payload) });
|
||||
if (r.ok) await this.loadAutomations();
|
||||
} catch(e) {}
|
||||
},
|
||||
async runAutomation(id) {
|
||||
try {
|
||||
this.autoRuns = [];
|
||||
this.autoRuns.push({ created_at: new Date().toLocaleString(), trigger_source: 'manual', status: 'running', detail: '…' });
|
||||
var r = await fetch('/workspace/automations/' + id + '/run', { method:'POST', credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()}, body: '{}' });
|
||||
var d = await r.json();
|
||||
this.autoRuns = [];
|
||||
this.autoRuns.push({ created_at: new Date().toLocaleString(), trigger_source: 'manual', status: d.status || 'fired', detail: d.detail || '' });
|
||||
window.showToast && window.showToast('Résultat : ' + (d.status || '') + ' — ' + (d.detail || ''));
|
||||
await this.loadAutomations();
|
||||
} catch(e) {}
|
||||
},
|
||||
async deleteAutomation(id) {
|
||||
if (!confirm('Supprimer cette automation ?')) return;
|
||||
try {
|
||||
var r = await fetch('/workspace/automations/' + id, { method:'DELETE', credentials:'same-origin',
|
||||
headers: {'X-CSRF-Token': this.getCsrfToken()} });
|
||||
if (r.ok) await this.loadAutomations();
|
||||
} catch(e) {}
|
||||
},
|
||||
closeSettings() {
|
||||
// Refresh parent page after settings changes, then navigate back
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.location.reload();
|
||||
window.close();
|
||||
|
||||
Reference in New Issue
Block a user