feat: application n8n Pilot complète (Expo managed workflow)
- Stack : Expo Router, Axios, Zustand, React Native Paper (thème sombre), date-fns - Sécurité : secrets dans Android Keystore via expo-secure-store, TLS obligatoire, headers X-N8N-API-KEY + X-App-Token injectés par intercepteur Axios - API : client.ts centralisé + workflows.ts + executions.ts (TypeScript strict) - Store : Zustand appStore avec chargement depuis secure store au démarrage - Hooks : usePolling (générique), useWorkflows, useExecutions - Composants : StatusBadge, WorkflowCard, ExecutionCard, SkeletonLoader - Screens : Dashboard, Workflows, Executions, Logs (détail exécution), Settings - Navigation Expo Router : 4 tabs + stack Logs + écran Setup initial - Docs : INSTALL.md, UPDATE.md, BACKUP.md, HAPROXY.md, SECURITY.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# Valeurs de développement local UNIQUEMENT
|
||||
# NE JAMAIS committer .env avec de vraies valeurs
|
||||
# En production, tous les secrets transitent par expo-secure-store (Android Keystore)
|
||||
|
||||
# URL de l'instance n8n — doit utiliser HTTPS en production
|
||||
N8N_BASE_URL=https://n8n.votre-domaine.com
|
||||
|
||||
# Clé API n8n (Settings > API Keys dans l'interface n8n)
|
||||
# Scope minimal recommandé : lecture workflows + exécutions uniquement
|
||||
N8N_API_KEY=votre-cle-api-n8n
|
||||
|
||||
# Token custom injecté par HAProxy (header X-App-Token)
|
||||
# Tourner régulièrement selon la procédure dans docs/HAPROXY.md
|
||||
N8N_APP_TOKEN=votre-token-haproxy
|
||||
+6
-1
@@ -30,9 +30,14 @@ yarn-error.*
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
# local env files — NE JAMAIS committer les vraies valeurs
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
|
||||
# EAS credentials — keystore critique, sauvegarder hors git
|
||||
credentials.json
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Open up App.js to start working on your app!</Text>
|
||||
<StatusBar style="auto" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
@@ -1,29 +1,41 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "n8n",
|
||||
"slug": "n8n",
|
||||
"name": "n8n Pilot",
|
||||
"slug": "n8n-pilot",
|
||||
"scheme": "n8npilot",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"userInterfaceStyle": "dark",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
"backgroundColor": "#121212"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"supportsTablet": false
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
"backgroundColor": "#121212"
|
||||
},
|
||||
"edgeToEdgeEnabled": true
|
||||
"edgeToEdgeEnabled": true,
|
||||
"package": "com.n8npilot.app"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-local-authentication",
|
||||
{
|
||||
"faceIDPermission": "Autoriser n8n Pilot à utiliser Face ID"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { Tabs } from 'expo-router';
|
||||
import { useTheme } from 'react-native-paper';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
/**
|
||||
* Props pour les icônes de tab — typage strict MaterialCommunityIcons.
|
||||
*/
|
||||
interface TabIconProps {
|
||||
name: React.ComponentProps<typeof MaterialCommunityIcons>['name'];
|
||||
color: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** Composant d'icône de tab — évite la répétition du rendu inline */
|
||||
const TabIcon: React.FC<TabIconProps> = ({ name, color, size }) => (
|
||||
<MaterialCommunityIcons name={name} size={size} color={color} />
|
||||
);
|
||||
|
||||
/**
|
||||
* Layout de navigation par onglets (4 tabs principaux).
|
||||
* Les logs d'exécution sont accessibles via le Stack navigator (pas de tab dédié)
|
||||
* pour garder la nav propre et hiérarchiquement correcte.
|
||||
*/
|
||||
export default function TabsLayout() {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderTopColor: theme.colors.surfaceVariant,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
tabBarActiveTintColor: theme.colors.primary,
|
||||
tabBarInactiveTintColor: theme.colors.onSurfaceVariant,
|
||||
headerStyle: { backgroundColor: theme.colors.surface },
|
||||
headerTintColor: theme.colors.onSurface,
|
||||
headerTitleStyle: { color: theme.colors.onSurface },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Dashboard',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<TabIcon name="view-dashboard-outline" color={color} size={size} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="workflows"
|
||||
options={{
|
||||
title: 'Workflows',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<TabIcon name="sitemap-outline" color={color} size={size} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="executions"
|
||||
options={{
|
||||
title: 'Exécutions',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<TabIcon name="history" color={color} size={size} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Paramètres',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<TabIcon name="cog-outline" color={color} size={size} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import ExecutionsScreen from '../../src/screens/ExecutionsScreen';
|
||||
|
||||
export default ExecutionsScreen;
|
||||
@@ -0,0 +1,3 @@
|
||||
import DashboardScreen from '../../src/screens/DashboardScreen';
|
||||
|
||||
export default DashboardScreen;
|
||||
@@ -0,0 +1,3 @@
|
||||
import SettingsScreen from '../../src/screens/SettingsScreen';
|
||||
|
||||
export default SettingsScreen;
|
||||
@@ -0,0 +1,3 @@
|
||||
import WorkflowsScreen from '../../src/screens/WorkflowsScreen';
|
||||
|
||||
export default WorkflowsScreen;
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||
import { PaperProvider, MD3DarkTheme, ActivityIndicator, Snackbar } from 'react-native-paper';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { useAppStore } from '../src/store/appStore';
|
||||
import { registerToastCallback } from '../src/utils/errorHandler';
|
||||
|
||||
/**
|
||||
* Thème Material Design 3 sombre personnalisé pour n8n Pilot.
|
||||
* L'orange (#FF6D3E) est la couleur d'accent principale — cohérente avec l'identité n8n.
|
||||
*/
|
||||
const APP_THEME = {
|
||||
...MD3DarkTheme,
|
||||
colors: {
|
||||
...MD3DarkTheme.colors,
|
||||
primary: '#FF6D3E',
|
||||
primaryContainer: '#5C1C00',
|
||||
secondary: '#FF9E7D',
|
||||
background: '#121212',
|
||||
surface: '#1E1E1E',
|
||||
surfaceVariant: '#2C2C2C',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Layout racine de l'application.
|
||||
* Responsabilités :
|
||||
* 1. Fournit les providers globaux (GestureHandler, SafeArea, Paper)
|
||||
* 2. Charge la configuration au démarrage depuis le secure store
|
||||
* 3. Redirige vers /setup si aucune configuration n'existe
|
||||
* 4. Enregistre le callback de toast pour l'errorHandler global
|
||||
* 5. Affiche le toast d'erreur global en overlay
|
||||
*/
|
||||
export default function RootLayout() {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { config, loadConfig } = useAppStore();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
const [toastVisible, setToastVisible] = useState(false);
|
||||
|
||||
/**
|
||||
* Enregistre le callback de toast dans l'errorHandler au premier montage.
|
||||
* Permet à toute la couche API de remonter des erreurs vers l'UI sans couplage direct.
|
||||
*/
|
||||
useEffect(() => {
|
||||
registerToastCallback((message) => {
|
||||
setToastMessage(message);
|
||||
setToastVisible(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Charge la configuration depuis le secure store puis marque l'app comme prête */
|
||||
useEffect(() => {
|
||||
loadConfig().finally(() => setIsReady(true));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Gère la redirection initiale selon l'état de configuration.
|
||||
* - Pas configuré et hors /setup → redirige vers /setup
|
||||
* - Configuré et dans /setup → redirige vers les tabs
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
const inSetup = segments[0] === 'setup';
|
||||
if (!config.isConfigured && !inSetup) {
|
||||
router.replace('/setup');
|
||||
} else if (config.isConfigured && inSetup) {
|
||||
router.replace('/(tabs)');
|
||||
}
|
||||
}, [isReady, config.isConfigured, segments]);
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#121212' }}>
|
||||
<ActivityIndicator color="#FF6D3E" size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<PaperProvider theme={APP_THEME}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen
|
||||
name="setup"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTitle: 'Configuration',
|
||||
headerStyle: { backgroundColor: '#1E1E1E' },
|
||||
headerTintColor: '#FF6D3E',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="execution/[id]"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTitle: "Logs d'exécution",
|
||||
headerStyle: { backgroundColor: '#1E1E1E' },
|
||||
headerTintColor: '#FF6D3E',
|
||||
presentation: 'card',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Toast global pour toutes les erreurs remontées par l'errorHandler */}
|
||||
<Snackbar
|
||||
visible={toastVisible}
|
||||
onDismiss={() => setToastVisible(false)}
|
||||
duration={4000}
|
||||
style={{ backgroundColor: '#4A0000' }}
|
||||
>
|
||||
{toastMessage}
|
||||
</Snackbar>
|
||||
</PaperProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import LogsScreen from '../../src/screens/LogsScreen';
|
||||
|
||||
export default LogsScreen;
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Text, useTheme } from 'react-native-paper';
|
||||
import SettingsScreen from '../src/screens/SettingsScreen';
|
||||
|
||||
/**
|
||||
* Écran d'installation initiale — affiché au premier lancement.
|
||||
* Partage le composant SettingsScreen avec un en-tête de bienvenue.
|
||||
* Après sauvegarde, le root layout redirige automatiquement vers les tabs.
|
||||
*/
|
||||
export default function SetupScreen() {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<Text
|
||||
variant="headlineMedium"
|
||||
style={[styles.title, { color: theme.colors.primary }]}
|
||||
>
|
||||
n8n Pilot
|
||||
</Text>
|
||||
<Text
|
||||
variant="bodyMedium"
|
||||
style={{ color: theme.colors.onSurfaceVariant, textAlign: 'center' }}
|
||||
>
|
||||
Configurez votre instance n8n pour commencer
|
||||
</Text>
|
||||
</View>
|
||||
<SettingsScreen />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 8,
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ['react-native-reanimated/plugin'],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
# Sauvegarde de n8n Pilot
|
||||
|
||||
## Ce qu'il faut sauvegarder
|
||||
|
||||
### 1. Credentials de signature EAS (`credentials.json`)
|
||||
|
||||
**Où** : généré par EAS lors du premier build dans `~/.eas/` ou téléchargeable depuis le dashboard Expo.
|
||||
|
||||
**Pourquoi** : sans le keystore de signature, il est **impossible** de mettre à jour l'APK sur un appareil
|
||||
qui a déjà la version signée avec ce keystore. Android refuse les mises à jour avec un keystore différent.
|
||||
|
||||
**Comment sauvegarder** :
|
||||
|
||||
```bash
|
||||
# Télécharger les credentials depuis EAS
|
||||
eas credentials
|
||||
|
||||
# Choisir : Android > Keystore > Download
|
||||
# Sauvegarder le fichier .jks et les mots de passe associés
|
||||
```
|
||||
|
||||
> **Criticalité : MAXIMALE** — perte du keystore = obligation de désinstaller l'app sur tous les appareils
|
||||
> avant de pouvoir installer une nouvelle version.
|
||||
|
||||
---
|
||||
|
||||
### 2. Fichier `.env` de développement
|
||||
|
||||
**Où** : racine du projet (jamais dans git)
|
||||
|
||||
**Pourquoi** : contient l'URL de l'instance, la clé API et le token HAProxy pour l'environnement de dev.
|
||||
Sans ce fichier, les développeurs doivent reconfigurer manuellement l'environnement.
|
||||
|
||||
**Comment sauvegarder** : gestionnaire de mots de passe (Bitwarden, 1Password, Vault) avec les champs :
|
||||
- `N8N_BASE_URL`
|
||||
- `N8N_API_KEY`
|
||||
- `N8N_APP_TOKEN`
|
||||
|
||||
---
|
||||
|
||||
### 3. Configuration EAS (`eas.json`)
|
||||
|
||||
**Où** : racine du projet, **committé dans git**
|
||||
|
||||
**Pourquoi** : définit les profils de build (development, preview, production).
|
||||
|
||||
**Statut** : déjà sauvegardé via git — aucune action supplémentaire requise.
|
||||
|
||||
---
|
||||
|
||||
### 4. Données utilisateur sur l'appareil
|
||||
|
||||
Les credentials (URL, clé API, token) sont chiffrés dans l'**Android Keystore** de l'appareil.
|
||||
Ils ne sont **pas** sauvegardés dans les sauvegardes Android standard (par conception de sécurité).
|
||||
|
||||
**Conséquence** : après réinitialisation usine ou changement d'appareil, l'utilisateur doit
|
||||
reconfigurer l'app depuis l'écran Paramètres.
|
||||
|
||||
---
|
||||
|
||||
## Fréquence recommandée
|
||||
|
||||
| Élément | Fréquence | Stockage |
|
||||
|---------|-----------|----------|
|
||||
| Keystore EAS | À chaque rotation + après génération | Coffre chiffré hors-ligne |
|
||||
| `.env` dev | À chaque rotation des tokens | Gestionnaire de mots de passe |
|
||||
| Code source | En continu via git push | Gitea self-hosted |
|
||||
|
||||
---
|
||||
|
||||
## Procédure de restauration
|
||||
|
||||
### Restaurer le keystore
|
||||
|
||||
```bash
|
||||
# Uploader le keystore sauvegardé vers EAS
|
||||
eas credentials
|
||||
# Choisir : Android > Keystore > Upload existing keystore
|
||||
```
|
||||
|
||||
### Restaurer l'environnement de développement
|
||||
|
||||
```bash
|
||||
git clone https://homegit.gyozamancave.fr/billisdead/n8n-mobile.git
|
||||
cd n8n-mobile
|
||||
cp .env.example .env
|
||||
# Remplir .env depuis le gestionnaire de mots de passe
|
||||
npm install --legacy-peer-deps
|
||||
```
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
# Configuration HAProxy pour n8n Pilot
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Android App → HTTPS → HAProxy (443) → HTTP → n8n (5678)
|
||||
↓
|
||||
Validation X-App-Token
|
||||
Rate limiting
|
||||
TLS termination
|
||||
```
|
||||
|
||||
HAProxy agit comme reverse proxy devant n8n. Il :
|
||||
1. Termine TLS (certificat Let's Encrypt ou autosigné)
|
||||
2. Valide le header `X-App-Token` (token secret partagé)
|
||||
3. Applique du rate limiting pour protéger l'API
|
||||
4. Forward les requêtes vers n8n en HTTP interne
|
||||
|
||||
---
|
||||
|
||||
## Configuration HAProxy complète
|
||||
|
||||
```haproxy
|
||||
#---------------------------------------------------------------------
|
||||
# global : paramètres du processus HAProxy
|
||||
#---------------------------------------------------------------------
|
||||
global
|
||||
log /dev/log local0
|
||||
log /dev/log local1 notice
|
||||
maxconn 2000
|
||||
daemon
|
||||
|
||||
#---------------------------------------------------------------------
|
||||
# defaults : valeurs par défaut pour tous les frontends/backends
|
||||
#---------------------------------------------------------------------
|
||||
defaults
|
||||
log global
|
||||
mode http
|
||||
option httplog
|
||||
option dontlognull
|
||||
timeout connect 5s
|
||||
timeout client 30s
|
||||
timeout server 30s
|
||||
# Timeout plus long pour les webhooks n8n qui peuvent prendre du temps
|
||||
timeout tunnel 3600s
|
||||
|
||||
#---------------------------------------------------------------------
|
||||
# frontend : point d'entrée HTTPS sur le port 443
|
||||
#---------------------------------------------------------------------
|
||||
frontend n8n_pilot_frontend
|
||||
bind *:443 ssl crt /etc/ssl/n8n/fullchain.pem alpn h2,http/1.1
|
||||
|
||||
# Sécurité TLS : désactiver les versions obsolètes
|
||||
ssl-min-ver TLSv1.2
|
||||
|
||||
# Headers de sécurité injectés sur toutes les réponses
|
||||
http-response set-header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
|
||||
http-response set-header X-Content-Type-Options "nosniff"
|
||||
http-response set-header X-Frame-Options "DENY"
|
||||
|
||||
# ACL : vérification du X-App-Token
|
||||
# La valeur doit correspondre exactement au token configuré dans l'app
|
||||
acl valid_app_token req.hdr(X-App-Token) -m str "VOTRE_TOKEN_ICI"
|
||||
|
||||
# Refuser les requêtes sans token valide avec 403
|
||||
http-request deny deny_status 403 if !valid_app_token
|
||||
|
||||
# Rate limiting : max 60 requêtes par minute par IP
|
||||
# Protège contre les scripts de scan et les abus
|
||||
stick-table type ip size 100k expire 60s store http_req_rate(60s)
|
||||
http-request track-sc0 src
|
||||
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 60 }
|
||||
|
||||
# Redirection HTTP → HTTPS (optionnel si port 80 ouvert)
|
||||
# redirect scheme https if !{ ssl_fc }
|
||||
|
||||
default_backend n8n_backend
|
||||
|
||||
#---------------------------------------------------------------------
|
||||
# backend : instance n8n locale
|
||||
#---------------------------------------------------------------------
|
||||
backend n8n_backend
|
||||
balance roundrobin
|
||||
|
||||
# Supprimer le X-App-Token avant de forwarder à n8n (ne doit pas atteindre n8n)
|
||||
http-request del-header X-App-Token
|
||||
|
||||
# Health check : n8n répond sur /healthz
|
||||
option httpchk GET /healthz
|
||||
http-check expect status 200
|
||||
|
||||
server n8n_local 127.0.0.1:5678 check inter 10s fall 3 rise 2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rotation du X-App-Token
|
||||
|
||||
Le token doit être tourné régulièrement (recommandé : tous les 90 jours minimum).
|
||||
|
||||
### Procédure de rotation sans downtime
|
||||
|
||||
1. **Générer un nouveau token** :
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
# Exemple : a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1
|
||||
```
|
||||
|
||||
2. **Phase de transition — accepter les deux tokens** :
|
||||
```haproxy
|
||||
# Accepter l'ancien ET le nouveau pendant la transition
|
||||
acl valid_app_token req.hdr(X-App-Token) -m str "ANCIEN_TOKEN"
|
||||
acl valid_app_token_new req.hdr(X-App-Token) -m str "NOUVEAU_TOKEN"
|
||||
http-request deny deny_status 403 if !valid_app_token !valid_app_token_new
|
||||
```
|
||||
|
||||
3. **Mettre à jour l'app** sur tous les appareils (Paramètres > Token HAProxy > Sauvegarder)
|
||||
|
||||
4. **Retirer l'ancien token** de HAProxy une fois tous les appareils mis à jour :
|
||||
```haproxy
|
||||
acl valid_app_token req.hdr(X-App-Token) -m str "NOUVEAU_TOKEN"
|
||||
http-request deny deny_status 403 if !valid_app_token
|
||||
```
|
||||
|
||||
5. **Recharger HAProxy** sans coupure :
|
||||
```bash
|
||||
haproxy -f /etc/haproxy/haproxy.cfg -c # Valider la config
|
||||
systemctl reload haproxy # Rechargement gracieux
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Erreur 403 Forbidden
|
||||
|
||||
**Cause** : `X-App-Token` absent ou incorrect.
|
||||
|
||||
**Diagnostic** :
|
||||
```bash
|
||||
# Tester manuellement avec curl
|
||||
curl -v -H "X-App-Token: VOTRE_TOKEN" https://n8n.votre-domaine.com/api/v1/workflows
|
||||
```
|
||||
|
||||
**Solutions** :
|
||||
- Vérifier que le token dans l'app (Paramètres) correspond exactement à la config HAProxy
|
||||
- Contrôler les espaces ou caractères invisibles dans le token
|
||||
- Vérifier les logs HAProxy : `journalctl -u haproxy -f`
|
||||
|
||||
---
|
||||
|
||||
### Erreur 429 Too Many Requests
|
||||
|
||||
**Cause** : rate limit dépassé (> 60 req/min).
|
||||
|
||||
**Solutions** :
|
||||
- Augmenter l'intervalle de polling dans l'app (Paramètres > Intervalle)
|
||||
- Ajuster la limite dans HAProxy si l'usage légitime est plus élevé
|
||||
- Vérifier qu'aucun processus tiers ne spam l'endpoint
|
||||
|
||||
---
|
||||
|
||||
### Timeout / connexion refusée
|
||||
|
||||
**Cause** : n8n inaccessible sur le port 5678, ou HAProxy mal configuré.
|
||||
|
||||
**Diagnostic** :
|
||||
```bash
|
||||
# Vérifier que n8n tourne
|
||||
curl http://127.0.0.1:5678/healthz
|
||||
|
||||
# Vérifier le statut HAProxy
|
||||
systemctl status haproxy
|
||||
haproxy -f /etc/haproxy/haproxy.cfg -c
|
||||
|
||||
# Logs HAProxy en temps réel
|
||||
journalctl -u haproxy -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Certificat TLS expiré
|
||||
|
||||
```bash
|
||||
# Renouveler avec Certbot
|
||||
certbot renew --quiet
|
||||
# Recharger HAProxy pour prendre en compte le nouveau certificat
|
||||
systemctl reload haproxy
|
||||
```
|
||||
|
||||
Automatiser le renouvellement :
|
||||
```bash
|
||||
# Ajouter dans crontab (root)
|
||||
0 3 * * 1 certbot renew --quiet && systemctl reload haproxy
|
||||
```
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
# Installation de n8n Pilot
|
||||
|
||||
## Prérequis
|
||||
|
||||
| Outil | Version minimale | Rôle |
|
||||
|-------|-----------------|------|
|
||||
| Node.js | 20 LTS | Runtime JS |
|
||||
| npm | 10+ | Gestionnaire de paquets |
|
||||
| EAS CLI | 3.0+ | Build cloud Expo |
|
||||
| Android | 8.0 (API 26) | Cible minimum |
|
||||
| Compte Expo | gratuit | Build EAS |
|
||||
|
||||
```bash
|
||||
# Vérifier les prérequis
|
||||
node --version # >= 20
|
||||
npm --version # >= 10
|
||||
eas --version # >= 3.0 (après install)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Cloner le dépôt
|
||||
|
||||
```bash
|
||||
git clone https://homegit.gyozamancave.fr/billisdead/n8n-mobile.git
|
||||
cd n8n-mobile
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Installer les dépendances
|
||||
|
||||
```bash
|
||||
npm install --legacy-peer-deps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configurer l'environnement de développement
|
||||
|
||||
Copiez le fichier exemple et remplissez vos valeurs **localement uniquement** :
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Éditez .env avec votre URL, clé API et token HAProxy
|
||||
```
|
||||
|
||||
> ⚠️ Le fichier `.env` est dans `.gitignore` — ne jamais le committer.
|
||||
> En production, les secrets transitent **uniquement** par `expo-secure-store`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Lancer en développement
|
||||
|
||||
```bash
|
||||
# Démarrer le serveur Metro
|
||||
npm start
|
||||
|
||||
# Sur appareil Android (USB ou WiFi) ou émulateur
|
||||
npm run android
|
||||
```
|
||||
|
||||
Pour le développement, l'app vous demandera de saisir l'URL et la clé API
|
||||
directement dans l'écran Paramètres — elles seront stockées dans le Keystore Android.
|
||||
|
||||
---
|
||||
|
||||
## 5. Installer EAS CLI
|
||||
|
||||
```bash
|
||||
npm install -g eas-cli
|
||||
eas login # Connectez-vous avec votre compte Expo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Configurer le projet EAS
|
||||
|
||||
```bash
|
||||
# Initialise l'ID de projet Expo (première fois uniquement)
|
||||
eas init
|
||||
|
||||
# Vérifie la configuration
|
||||
eas build:configure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Builder l'APK (profil preview)
|
||||
|
||||
```bash
|
||||
# Build Android APK — exécuté dans le cloud Expo
|
||||
eas build --platform android --profile preview
|
||||
```
|
||||
|
||||
Le build prend environ 10–15 minutes. EAS envoie un email avec le lien de téléchargement.
|
||||
|
||||
Pour un build local (nécessite Android Studio + JDK 17) :
|
||||
|
||||
```bash
|
||||
eas build --platform android --profile preview --local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Sideloader l'APK sur Android
|
||||
|
||||
### Via ADB
|
||||
|
||||
```bash
|
||||
# Connecter l'appareil en USB avec le débogage USB activé
|
||||
adb devices
|
||||
|
||||
# Installer l'APK
|
||||
adb install chemin/vers/n8n-pilot.apk
|
||||
```
|
||||
|
||||
### Manuellement
|
||||
|
||||
1. Transférez l'APK sur l'appareil (câble, Google Drive, etc.)
|
||||
2. Ouvrez l'APK depuis le gestionnaire de fichiers
|
||||
3. Autorisez l'installation depuis des sources inconnues si demandé
|
||||
4. Suivez l'assistant d'installation
|
||||
|
||||
---
|
||||
|
||||
## 9. Premier lancement
|
||||
|
||||
Au premier démarrage, l'écran de configuration apparaît automatiquement :
|
||||
|
||||
1. Saisissez l'URL HTTPS de votre instance n8n
|
||||
2. Collez votre clé API n8n (Settings > API Keys dans n8n)
|
||||
3. Saisissez le token HAProxy si applicable
|
||||
4. Appuyez sur **Sauvegarder**
|
||||
|
||||
Les credentials sont chiffrés dans l'Android Keystore — ils ne quittent jamais l'appareil.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Sécurité — n8n Pilot
|
||||
|
||||
## Modèle de menaces
|
||||
|
||||
### Acteurs et vecteurs
|
||||
|
||||
| Menace | Impact | Probabilité | Mitigation |
|
||||
|--------|--------|-------------|-----------|
|
||||
| Vol de l'appareil Android | Accès aux credentials n8n | Moyen | Android Keystore chiffré, biométrie optionnelle |
|
||||
| Interception réseau (MITM) | Exposition de la clé API | Faible (TLS) | TLS obligatoire, refus HTTP |
|
||||
| Fuite du token HAProxy | Accès non autorisé à n8n | Moyen | Rotation régulière, token hors bundle |
|
||||
| Compromission du build APK | Backdoor dans l'app | Faible | Keystore signé, builds EAS reproductibles |
|
||||
| Brute force API | Épuisement quotas n8n | Faible | Rate limiting HAProxy (429) |
|
||||
| Logs applicatifs exposés | Fuite de secrets | Faible | Jamais de log de secrets, même en dev |
|
||||
|
||||
---
|
||||
|
||||
## Principes appliqués
|
||||
|
||||
### Least Privilege (moindre privilège)
|
||||
|
||||
La clé API n8n doit être créée avec le **scope minimal** nécessaire :
|
||||
|
||||
```
|
||||
Recommandé :
|
||||
✓ workflows:list — lister les workflows
|
||||
✓ workflows:read — lire les détails
|
||||
✓ workflows:update — activer/désactiver (toggle)
|
||||
✓ executions:list — voir l'historique
|
||||
✓ executions:read — lire les logs
|
||||
✓ executions:delete — supprimer des exécutions
|
||||
✓ workflows:run — déclencher manuellement
|
||||
|
||||
Non recommandé :
|
||||
✗ credentials:* — inutile pour cette app
|
||||
✗ users:* — inutile pour cette app
|
||||
✗ admin:* — jamais
|
||||
```
|
||||
|
||||
**Comment créer la clé** dans n8n :
|
||||
1. Settings > n8n API
|
||||
2. Create API Key
|
||||
3. Nommer la clé "n8n-pilot-mobile"
|
||||
4. Définir les scopes minimaux ci-dessus
|
||||
5. Copier la clé — elle n'est affichée qu'une seule fois
|
||||
|
||||
---
|
||||
|
||||
### Defense in Depth (défense en profondeur)
|
||||
|
||||
L'accès à l'API n8n passe par **deux barrières indépendantes** :
|
||||
|
||||
```
|
||||
Requête → [TLS] → [X-App-Token HAProxy] → [X-N8N-API-KEY n8n] → Données
|
||||
```
|
||||
|
||||
Si un token est compromis :
|
||||
- Compromission du `X-App-Token` seul → HAProxy bloque, n8n jamais atteint
|
||||
- Compromission de la clé API seul → sans le `X-App-Token`, HAProxy bloque à 403
|
||||
- Les deux compromis → rotation immédiate des deux (procédures dans HAPROXY.md et BACKUP.md)
|
||||
|
||||
---
|
||||
|
||||
### Zero Trust (réseau local inclus)
|
||||
|
||||
n8n ne doit **pas** être accessible directement sur le réseau, même en local.
|
||||
Toutes les requêtes passent par HAProxy qui valide le token.
|
||||
|
||||
```
|
||||
# Bloquer l'accès direct au port n8n (iptables)
|
||||
iptables -A INPUT -p tcp --dport 5678 -s 127.0.0.1 -j ACCEPT
|
||||
iptables -A INPUT -p tcp --dport 5678 -j DROP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gestion des secrets — cycle de vie
|
||||
|
||||
### Stockage
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Appareil Android │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Android Keystore (expo-secure-store) │ │
|
||||
│ │ n8n_base_url → https://n8n.example.com │ │
|
||||
│ │ n8n_api_key → clé API (chiffrée) │ │
|
||||
│ │ n8n_app_token → token HAProxy (chiffré) │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ État React (Zustand) — en mémoire uniquement : │
|
||||
│ config.baseUrl → copie non sensible │
|
||||
│ config.isConfigured → booléen │
|
||||
│ JAMAIS : apiKey, appToken en mémoire JS │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Transit
|
||||
|
||||
- Les secrets sont lus depuis le Keystore **à chaque requête** HTTP dans `client.ts`
|
||||
- Ils sont injectés dans les headers HTTP et **ne sont pas loggués**
|
||||
- Ils ne sont **jamais** :
|
||||
- Sérialisés dans AsyncStorage
|
||||
- Inclus dans des logs (même en dev)
|
||||
- Embarqués dans le bundle JS (`app.json`, `package.json`, code source)
|
||||
- Envoyés à des services tiers (analytics, crash reporters)
|
||||
|
||||
### Rotation
|
||||
|
||||
| Secret | Fréquence recommandée | Procédure |
|
||||
|--------|-----------------------|-----------|
|
||||
| Clé API n8n | Tous les 180 jours, ou en cas de compromission | Créer nouvelle clé dans n8n, mettre à jour dans l'app |
|
||||
| X-App-Token | Tous les 90 jours | Voir docs/HAPROXY.md |
|
||||
| Keystore EAS | Jamais (sauf compromission) | Voir docs/BACKUP.md |
|
||||
|
||||
---
|
||||
|
||||
## Checklist avant mise en production
|
||||
|
||||
### Application
|
||||
|
||||
- [ ] `app.json` : `userInterfaceStyle` = `"dark"` (pas d'info sensible en clair)
|
||||
- [ ] `.env` non commité dans git (`git status` ne montre pas `.env`)
|
||||
- [ ] Aucun `console.log` de valeurs sensibles dans le code
|
||||
- [ ] `__DEV__` gate sur tous les logs dans `errorHandler.ts`
|
||||
- [ ] Expo SDK à jour — vérifier les CVE : `npx expo-doctor`
|
||||
- [ ] Dépendances à jour : `npm audit`
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- [ ] HAProxy : TLS 1.2 minimum, TLS 1.0/1.1 désactivés
|
||||
- [ ] Certificat TLS valide et non expiré
|
||||
- [ ] n8n inaccessible directement sur le port 5678 depuis l'extérieur
|
||||
- [ ] Rate limiting HAProxy activé (429 configuré)
|
||||
- [ ] Logs HAProxy ne contiennent pas les valeurs des headers secrets
|
||||
|
||||
### n8n
|
||||
|
||||
- [ ] Clé API avec scope minimal (voir § Least Privilege ci-dessus)
|
||||
- [ ] n8n mis à jour vers la dernière version stable
|
||||
- [ ] Sauvegardes n8n actives (workflows, credentials n8n)
|
||||
|
||||
### Appareil
|
||||
|
||||
- [ ] Verrouillage d'écran activé (PIN, pattern ou biométrie)
|
||||
- [ ] Biométrie configurée dans l'app si disponible
|
||||
- [ ] Appareil Android 8+ (API 26+) pour garantir la robustesse du Keystore
|
||||
@@ -0,0 +1,78 @@
|
||||
# Mise à jour de n8n Pilot
|
||||
|
||||
## Processus de mise à jour
|
||||
|
||||
### 1. Récupérer les changements
|
||||
|
||||
```bash
|
||||
git pull origin main
|
||||
npm install --legacy-peer-deps
|
||||
```
|
||||
|
||||
### 2. Bumper la version
|
||||
|
||||
Éditez `app.json` et incrémentez `version` :
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"version": "1.1.0",
|
||||
"android": {
|
||||
"versionCode": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Règle** : `versionCode` doit être strictement supérieur à la version précédente.
|
||||
> `version` suit [semver](https://semver.org/) : MAJEUR.MINEUR.CORRECTIF
|
||||
|
||||
### 3. Rebuilder l'APK
|
||||
|
||||
```bash
|
||||
eas build --platform android --profile preview
|
||||
```
|
||||
|
||||
### 4. Distribuer la mise à jour
|
||||
|
||||
#### Option A — Sideload manuel
|
||||
|
||||
Répétez la procédure de sideload (voir INSTALL.md § 8) avec le nouvel APK.
|
||||
L'Android Package Manager gère la mise à jour en place si le `versionCode` est supérieur.
|
||||
|
||||
#### Option B — OTA (Over The Air) via Expo Updates
|
||||
|
||||
Pour les mises à jour mineures (JS uniquement, pas de changements natifs) :
|
||||
|
||||
```bash
|
||||
eas update --branch production --message "Fix: correction du polling"
|
||||
```
|
||||
|
||||
L'app télécharge la mise à jour au prochain démarrage si elle est connectée.
|
||||
|
||||
> **Important** : les changements dans `app.json`, l'ajout de plugins natifs
|
||||
> ou la modification des permissions nécessitent un rebuild APK complet.
|
||||
|
||||
---
|
||||
|
||||
## Vérifications avant release
|
||||
|
||||
- [ ] Tests sur appareil physique (pas seulement émulateur)
|
||||
- [ ] Vérifier que la connexion à l8n fonctionne avec les nouvelles données
|
||||
- [ ] Valider le polling sur WiFi et données mobiles
|
||||
- [ ] Vérifier que les secrets restent dans le Keystore après mise à jour
|
||||
- [ ] Contrôler les logs Android (`adb logcat`) pour des erreurs inattendues
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
En cas de régression critique :
|
||||
|
||||
```bash
|
||||
# Revenir au commit précédent
|
||||
git revert HEAD
|
||||
|
||||
# Rebuilder et redéployer la version précédente
|
||||
eas build --platform android --profile preview
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 3.0.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"gradleCommand": ":app:assembleRelease"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
Generated
+1629
-17
File diff suppressed because it is too large
Load Diff
+20
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "n8n",
|
||||
"name": "n8n-pilot",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
@@ -9,10 +9,26 @@
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.16.1",
|
||||
"date-fns": "^4.2.1",
|
||||
"expo": "~54.0.33",
|
||||
"expo-local-authentication": "~17.0.8",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5"
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-paper": "^5.15.2",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"private": true
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-native": "^0.72.8",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import axios, {
|
||||
AxiosInstance,
|
||||
InternalAxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
AxiosError,
|
||||
} from 'axios';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { handleError } from '../utils/errorHandler';
|
||||
|
||||
/**
|
||||
* Clés de stockage sécurisé Android Keystore via expo-secure-store.
|
||||
* Ces constantes sont les seuls points de référence aux secrets dans toute l'app.
|
||||
*/
|
||||
export const SECURE_STORE_KEYS = {
|
||||
BASE_URL: 'n8n_base_url',
|
||||
API_KEY: 'n8n_api_key',
|
||||
APP_TOKEN: 'n8n_app_token',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Crée et configure l'instance Axios centrale de l'application.
|
||||
*
|
||||
* Architecture de sécurité :
|
||||
* - Les credentials (API key, app token) ne sont JAMAIS embarqués dans le bundle.
|
||||
* - Ils sont récupérés dynamiquement depuis le secure store à chaque requête.
|
||||
* - La base URL est également stockée dans le secure store, pas en dur.
|
||||
*/
|
||||
const createApiClient = (): AxiosInstance => {
|
||||
const client = axios.create({
|
||||
timeout: 15_000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Intercepteur de requête sortante.
|
||||
* Lit baseURL, X-N8N-API-KEY et X-App-Token depuis le secure store
|
||||
* et les injecte dans chaque requête avant envoi.
|
||||
* Aucune valeur sensible ne transite par les variables d'environnement du bundle.
|
||||
*/
|
||||
client.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig): Promise<InternalAxiosRequestConfig> => {
|
||||
const [baseUrl, apiKey, appToken] = await Promise.all([
|
||||
SecureStore.getItemAsync(SECURE_STORE_KEYS.BASE_URL),
|
||||
SecureStore.getItemAsync(SECURE_STORE_KEYS.API_KEY),
|
||||
SecureStore.getItemAsync(SECURE_STORE_KEYS.APP_TOKEN),
|
||||
]);
|
||||
|
||||
if (baseUrl) config.baseURL = baseUrl;
|
||||
if (apiKey) config.headers.set('X-N8N-API-KEY', apiKey);
|
||||
if (appToken) config.headers.set('X-App-Token', appToken);
|
||||
|
||||
return config;
|
||||
},
|
||||
(error: AxiosError) => Promise.reject(error)
|
||||
);
|
||||
|
||||
/**
|
||||
* Intercepteur de réponse.
|
||||
* Les erreurs HTTP sont transmises à errorHandler qui affiche un toast.
|
||||
* L'erreur est re-rejetée pour que les appelants puissent aussi réagir si besoin.
|
||||
* Aucun secret n'est loggué ici.
|
||||
*/
|
||||
client.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
(error: AxiosError) => {
|
||||
handleError(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
/** Instance Axios unique partagée dans toute l'application */
|
||||
export const apiClient = createApiClient();
|
||||
@@ -0,0 +1,107 @@
|
||||
import { apiClient } from './client';
|
||||
import { ApiListResponse } from './workflows';
|
||||
|
||||
/** Ensemble des statuts possibles pour une exécution n8n */
|
||||
export type ExecutionStatus = 'success' | 'error' | 'waiting' | 'running' | 'canceled';
|
||||
|
||||
/** Modes de déclenchement d'une exécution */
|
||||
export type ExecutionMode = 'manual' | 'trigger' | 'webhook' | 'internal' | 'retry';
|
||||
|
||||
/**
|
||||
* Données d'exécution d'un nœud individuel.
|
||||
* Disponibles uniquement dans la réponse détaillée (GET /executions/:id).
|
||||
*/
|
||||
export interface NodeExecutionData {
|
||||
startTime: number;
|
||||
executionTime: number;
|
||||
source: unknown[];
|
||||
data?: Record<string, unknown>;
|
||||
error?: {
|
||||
message: string;
|
||||
name?: string;
|
||||
stack?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Données complètes d'un résultat d'exécution */
|
||||
export interface ExecutionResultData {
|
||||
resultData: {
|
||||
/** Map nœud → tableau des runs de ce nœud */
|
||||
runData: Record<string, NodeExecutionData[]>;
|
||||
lastNodeExecuted?: string;
|
||||
/** Erreur globale de l'exécution (distinct des erreurs par nœud) */
|
||||
error?: {
|
||||
message: string;
|
||||
name: string;
|
||||
stack?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Représente une exécution telle que retournée par l'API n8n v1.
|
||||
* Le champ `data` n'est disponible que dans la réponse de GET /executions/:id.
|
||||
*/
|
||||
export interface Execution {
|
||||
id: string;
|
||||
finished: boolean;
|
||||
mode: ExecutionMode;
|
||||
/** ID de l'exécution originale si c'est un retry */
|
||||
retryOf?: string;
|
||||
retrySuccessId?: string;
|
||||
startedAt: string;
|
||||
stoppedAt?: string;
|
||||
workflowId: string;
|
||||
workflowName?: string;
|
||||
status: ExecutionStatus;
|
||||
/** Présent uniquement via GET /executions/:id */
|
||||
data?: ExecutionResultData;
|
||||
}
|
||||
|
||||
/** Paramètres de filtrage acceptés par l'endpoint GET /executions */
|
||||
export interface FetchExecutionsParams {
|
||||
/** Nombre max de résultats à retourner */
|
||||
limit?: number;
|
||||
/** Filtre par statut d'exécution */
|
||||
status?: ExecutionStatus;
|
||||
/** Filtre par identifiant de workflow */
|
||||
workflowId?: string;
|
||||
/** Curseur opaque de pagination (valeur nextCursor de la réponse précédente) */
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'historique des exécutions avec filtres optionnels.
|
||||
*
|
||||
* @param params - Filtres : limit, status, workflowId, cursor
|
||||
* @returns Réponse paginée avec les exécutions et le curseur suivant
|
||||
*/
|
||||
export const fetchExecutions = async (
|
||||
params: FetchExecutionsParams = {}
|
||||
): Promise<ApiListResponse<Execution>> => {
|
||||
const response = await apiClient.get<ApiListResponse<Execution>>('/api/v1/executions', {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Récupère les logs complets d'une exécution spécifique.
|
||||
* Inclut les données de chaque nœud (champ `data`), absent dans la liste.
|
||||
*
|
||||
* @param id - Identifiant de l'exécution
|
||||
* @returns Exécution complète avec données de nœuds
|
||||
*/
|
||||
export const fetchExecutionById = async (id: string): Promise<Execution> => {
|
||||
const response = await apiClient.get<Execution>(`/api/v1/executions/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Supprime une exécution de l'historique n8n.
|
||||
*
|
||||
* @param id - Identifiant de l'exécution à supprimer
|
||||
*/
|
||||
export const deleteExecution = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/api/v1/executions/${id}`);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
/** Représente un nœud dans un workflow n8n */
|
||||
export interface WorkflowNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
position: [number, number];
|
||||
parameters: Record<string, unknown>;
|
||||
typeVersion?: number;
|
||||
}
|
||||
|
||||
/** Tag associé à un workflow */
|
||||
export interface WorkflowTag {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Représente un workflow complet tel que retourné par l'API n8n v1.
|
||||
* Les champs `nodes` et `connections` sont présents dans les réponses détaillées.
|
||||
*/
|
||||
export interface Workflow {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
nodes: WorkflowNode[];
|
||||
connections: Record<string, unknown>;
|
||||
settings?: Record<string, unknown>;
|
||||
tags?: WorkflowTag[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Réponse paginée standard de l'API n8n.
|
||||
* nextCursor est null si on est sur la dernière page.
|
||||
*/
|
||||
export interface ApiListResponse<T> {
|
||||
data: T[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la liste complète des workflows de l'instance n8n.
|
||||
*
|
||||
* @returns Tableau de tous les workflows (actifs et inactifs)
|
||||
* @throws AxiosError — géré par l'intercepteur, remonte via errorHandler
|
||||
*/
|
||||
export const fetchWorkflows = async (): Promise<Workflow[]> => {
|
||||
const response = await apiClient.get<ApiListResponse<Workflow>>('/api/v1/workflows');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Récupère le détail complet d'un workflow, incluant ses nœuds et connexions.
|
||||
*
|
||||
* @param id - Identifiant du workflow
|
||||
* @returns Workflow avec tous ses détails
|
||||
*/
|
||||
export const fetchWorkflowById = async (id: string): Promise<Workflow> => {
|
||||
const response = await apiClient.get<Workflow>(`/api/v1/workflows/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Active un workflow : il répondra désormais à ses déclencheurs.
|
||||
*
|
||||
* @param id - Identifiant du workflow à activer
|
||||
* @returns Workflow mis à jour avec active = true
|
||||
*/
|
||||
export const activateWorkflow = async (id: string): Promise<Workflow> => {
|
||||
const response = await apiClient.post<Workflow>(`/api/v1/workflows/${id}/activate`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Désactive un workflow : ses déclencheurs sont mis en veille.
|
||||
*
|
||||
* @param id - Identifiant du workflow à désactiver
|
||||
* @returns Workflow mis à jour avec active = false
|
||||
*/
|
||||
export const deactivateWorkflow = async (id: string): Promise<Workflow> => {
|
||||
const response = await apiClient.post<Workflow>(`/api/v1/workflows/${id}/deactivate`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** Paramètres optionnels pour le déclenchement manuel d'un workflow */
|
||||
export interface RunWorkflowParams {
|
||||
/** Données injectées dans le nœud de démarrage manuel */
|
||||
workflowData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Déclenche manuellement l'exécution d'un workflow.
|
||||
*
|
||||
* @param id - Identifiant du workflow à déclencher
|
||||
* @param params - Données optionnelles d'entrée
|
||||
* @returns Identifiant de l'exécution créée
|
||||
*/
|
||||
export const runWorkflow = async (
|
||||
id: string,
|
||||
params?: RunWorkflowParams
|
||||
): Promise<{ executionId: string }> => {
|
||||
const response = await apiClient.post<{ executionId: string }>(
|
||||
`/api/v1/workflows/${id}/run`,
|
||||
params ?? {}
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Card, Text, IconButton, useTheme } from 'react-native-paper';
|
||||
import { Execution } from '../api/executions';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import { formatShortDate, formatDuration, formatMode } from '../utils/formatters';
|
||||
|
||||
interface ExecutionCardProps {
|
||||
execution: Execution;
|
||||
/**
|
||||
* Navigue vers les logs complets de cette exécution.
|
||||
* Déclenche router.push('/execution/:id') dans ExecutionsScreen.
|
||||
*/
|
||||
onViewLogs: (id: string) => void;
|
||||
/**
|
||||
* Supprime l'exécution via le hook useExecutions.
|
||||
*/
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte représentant une exécution dans la liste historique.
|
||||
* Affiche : nom du workflow, badge de statut, mode de déclenchement, durée et date.
|
||||
* Les actions (logs, suppression) sont déléguées aux callbacks.
|
||||
*/
|
||||
const ExecutionCard: React.FC<ExecutionCardProps> = ({ execution, onViewLogs, onDelete }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Card style={[styles.card, { backgroundColor: theme.colors.surface }]}>
|
||||
<Card.Content>
|
||||
{/* En-tête : nom du workflow et badge de statut */}
|
||||
<View style={styles.header}>
|
||||
<Text
|
||||
variant="titleSmall"
|
||||
numberOfLines={1}
|
||||
style={[styles.workflowName, { color: theme.colors.onSurface }]}
|
||||
>
|
||||
{execution.workflowName ?? `Workflow ${execution.workflowId}`}
|
||||
</Text>
|
||||
<StatusBadge status={execution.status} size="small" />
|
||||
</View>
|
||||
|
||||
{/* Métadonnées : mode de déclenchement, durée, date */}
|
||||
<View style={styles.meta}>
|
||||
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant }}>
|
||||
{formatMode(execution.mode)}
|
||||
</Text>
|
||||
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant }}>
|
||||
{formatDuration(execution.startedAt, execution.stoppedAt)}
|
||||
</Text>
|
||||
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant }}>
|
||||
{formatShortDate(execution.startedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</Card.Content>
|
||||
|
||||
{/* Actions : consulter les logs et supprimer */}
|
||||
<Card.Actions style={styles.actions}>
|
||||
<IconButton
|
||||
icon="text-box-outline"
|
||||
size={20}
|
||||
iconColor={theme.colors.primary}
|
||||
onPress={() => onViewLogs(execution.id)}
|
||||
accessibilityLabel="Voir les logs de cette exécution"
|
||||
/>
|
||||
<IconButton
|
||||
icon="delete-outline"
|
||||
size={20}
|
||||
iconColor={theme.colors.error}
|
||||
onPress={() => onDelete(execution.id)}
|
||||
accessibilityLabel="Supprimer cette exécution"
|
||||
/>
|
||||
</Card.Actions>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 5,
|
||||
elevation: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 6,
|
||||
},
|
||||
workflowName: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
fontWeight: '500',
|
||||
},
|
||||
meta: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
actions: {
|
||||
justifyContent: 'flex-end',
|
||||
paddingRight: 4,
|
||||
paddingBottom: 2,
|
||||
},
|
||||
});
|
||||
|
||||
export default ExecutionCard;
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, Animated, StyleSheet, ViewStyle } from 'react-native';
|
||||
|
||||
interface SkeletonItemProps {
|
||||
width?: number | `${number}%`;
|
||||
height?: number;
|
||||
borderRadius?: number;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bloc de squelette animé avec effet de pulsation (shimmer).
|
||||
* Composant interne utilisé par SkeletonLoader pour construire les cartes placeholder.
|
||||
*/
|
||||
const SkeletonItem: React.FC<SkeletonItemProps> = ({
|
||||
width = '100%',
|
||||
height = 16,
|
||||
borderRadius = 4,
|
||||
style,
|
||||
}) => {
|
||||
const opacity = useRef(new Animated.Value(0.3)).current;
|
||||
|
||||
useEffect(() => {
|
||||
/** Boucle d'animation : alterne entre 30% et 70% d'opacité */
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0.7,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0.3,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, [opacity]);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.skeleton,
|
||||
{ width: width as number, height, borderRadius, opacity },
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface SkeletonLoaderProps {
|
||||
/** Nombre de cartes placeholder à afficher pendant le chargement initial */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Affiche des cartes placeholder animées pendant le chargement des données.
|
||||
* Simule la structure d'une WorkflowCard/ExecutionCard pour éviter le layout shift
|
||||
* et indiquer à l'utilisateur que du contenu arrive.
|
||||
*/
|
||||
const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({ count = 5 }) => {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<View key={index} style={styles.card}>
|
||||
<SkeletonItem width="60%" height={18} style={{ marginBottom: 8 }} />
|
||||
<SkeletonItem width="85%" height={12} style={{ marginBottom: 6 }} />
|
||||
<SkeletonItem width="40%" height={12} />
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#1E1E1E',
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 6,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
skeleton: {
|
||||
backgroundColor: '#2C2C2C',
|
||||
},
|
||||
});
|
||||
|
||||
export default SkeletonLoader;
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import { ExecutionStatus } from '../api/executions';
|
||||
import { formatStatus } from '../utils/formatters';
|
||||
|
||||
/**
|
||||
* Couleurs de fond des badges — optimisées pour le thème sombre MD3.
|
||||
* Les teintes foncées gardent le contraste sans agresser l'œil.
|
||||
*/
|
||||
const STATUS_BG_COLORS: Record<ExecutionStatus, string> = {
|
||||
success: '#1B5E20',
|
||||
error: '#7F0000',
|
||||
waiting: '#E65100',
|
||||
running: '#0D47A1',
|
||||
canceled: '#424242',
|
||||
};
|
||||
|
||||
/** Couleurs de texte assorties à chaque fond */
|
||||
const STATUS_TEXT_COLORS: Record<ExecutionStatus, string> = {
|
||||
success: '#A5D6A7',
|
||||
error: '#EF9A9A',
|
||||
waiting: '#FFCC80',
|
||||
running: '#90CAF9',
|
||||
canceled: '#BDBDBD',
|
||||
};
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: ExecutionStatus;
|
||||
/**
|
||||
* Taille du badge :
|
||||
* - small : listes denses (ExecutionCard)
|
||||
* - default : vue détail (LogsScreen)
|
||||
*/
|
||||
size?: 'small' | 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge coloré représentant le statut d'une exécution n8n.
|
||||
* Utilise les couleurs MD3 dark pour rester lisible sur les surfaces sombres.
|
||||
*/
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({ status, size = 'default' }) => {
|
||||
const backgroundColor = STATUS_BG_COLORS[status] ?? '#424242';
|
||||
const textColor = STATUS_TEXT_COLORS[status] ?? '#BDBDBD';
|
||||
const fontSize = size === 'small' ? 10 : 12;
|
||||
|
||||
return (
|
||||
<View style={[styles.badge, { backgroundColor }]}>
|
||||
<Text style={[styles.text, { color: textColor, fontSize }]}>
|
||||
{formatStatus(status)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
text: {
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
});
|
||||
|
||||
export default StatusBadge;
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Card, Text, Switch, IconButton, useTheme } from 'react-native-paper';
|
||||
import { Workflow } from '../api/workflows';
|
||||
import { formatRelativeDate } from '../utils/formatters';
|
||||
|
||||
interface WorkflowCardProps {
|
||||
workflow: Workflow;
|
||||
/**
|
||||
* Appelé quand l'utilisateur bascule le switch actif/inactif.
|
||||
* Le hook useWorkflows gère l'appel API et la mise à jour d'état.
|
||||
*/
|
||||
onToggle: (workflow: Workflow) => void;
|
||||
/**
|
||||
* Appelé quand l'utilisateur appuie sur le bouton lecture.
|
||||
* Déclenche une exécution manuelle du workflow.
|
||||
*/
|
||||
onRun: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte représentant un workflow dans la liste WorkflowsScreen.
|
||||
* Affiche : nom, statut actif (switch), date de modification, tags et bouton run.
|
||||
* Le toggle et le run déclenchent des callbacks — la carte n'appelle jamais l'API directement.
|
||||
*/
|
||||
const WorkflowCard: React.FC<WorkflowCardProps> = ({ workflow, onToggle, onRun }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Card style={[styles.card, { backgroundColor: theme.colors.surface }]}>
|
||||
<Card.Content style={styles.content}>
|
||||
{/* En-tête : nom du workflow et switch actif/inactif */}
|
||||
<View style={styles.header}>
|
||||
<Text
|
||||
variant="titleMedium"
|
||||
numberOfLines={2}
|
||||
style={[styles.name, { color: theme.colors.onSurface }]}
|
||||
>
|
||||
{workflow.name}
|
||||
</Text>
|
||||
<Switch
|
||||
value={workflow.active}
|
||||
onValueChange={() => onToggle(workflow)}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Date de dernière modification */}
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
style={{ color: theme.colors.onSurfaceVariant, marginTop: 4 }}
|
||||
>
|
||||
Modifié {formatRelativeDate(workflow.updatedAt)}
|
||||
</Text>
|
||||
|
||||
{/* Tags optionnels */}
|
||||
{workflow.tags && workflow.tags.length > 0 && (
|
||||
<View style={styles.tags}>
|
||||
{workflow.tags.map((tag) => (
|
||||
<View
|
||||
key={tag.id}
|
||||
style={[styles.tag, { backgroundColor: theme.colors.surfaceVariant }]}
|
||||
>
|
||||
<Text variant="labelSmall" style={{ color: theme.colors.onSurfaceVariant }}>
|
||||
{tag.name}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</Card.Content>
|
||||
|
||||
{/* Bouton de déclenchement manuel */}
|
||||
<Card.Actions style={styles.actions}>
|
||||
<IconButton
|
||||
icon="play-circle-outline"
|
||||
size={22}
|
||||
iconColor={theme.colors.primary}
|
||||
onPress={() => onRun(workflow.id)}
|
||||
accessibilityLabel="Déclencher manuellement ce workflow"
|
||||
/>
|
||||
</Card.Actions>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 6,
|
||||
elevation: 2,
|
||||
},
|
||||
content: {
|
||||
paddingBottom: 4,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
name: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
fontWeight: '600',
|
||||
},
|
||||
tags: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 8,
|
||||
gap: 6,
|
||||
},
|
||||
tag: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 8,
|
||||
},
|
||||
actions: {
|
||||
justifyContent: 'flex-end',
|
||||
paddingRight: 8,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export default WorkflowCard;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAppStore } from '../store/appStore';
|
||||
import {
|
||||
fetchExecutions,
|
||||
fetchExecutionById,
|
||||
deleteExecution,
|
||||
Execution,
|
||||
ExecutionStatus,
|
||||
FetchExecutionsParams,
|
||||
} from '../api/executions';
|
||||
import { usePolling } from './usePolling';
|
||||
|
||||
/** Options de filtrage passées au hook à l'initialisation */
|
||||
interface UseExecutionsOptions {
|
||||
/** Nombre max d'exécutions à charger */
|
||||
limit?: number;
|
||||
/** Filtre de statut initial */
|
||||
statusFilter?: ExecutionStatus;
|
||||
/** Restreindre aux exécutions d'un workflow spécifique */
|
||||
workflowId?: string;
|
||||
}
|
||||
|
||||
/** Interface des valeurs et actions exposées par le hook */
|
||||
interface UseExecutionsResult {
|
||||
executions: Execution[];
|
||||
isLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
/** Exécution chargée en détail (avec logs de nœuds) */
|
||||
selectedExecution: Execution | null;
|
||||
/** Recharge la liste (pull-to-refresh) */
|
||||
refresh: () => Promise<void>;
|
||||
/**
|
||||
* Charge les logs complets d'une exécution pour l'écran de détail.
|
||||
* Peuple selectedExecution.
|
||||
*
|
||||
* @param id - Identifiant de l'exécution
|
||||
*/
|
||||
loadExecutionDetail: (id: string) => Promise<void>;
|
||||
/**
|
||||
* Supprime une exécution de l'historique et la retire de l'état local.
|
||||
*
|
||||
* @param id - Identifiant de l'exécution à supprimer
|
||||
*/
|
||||
removeExecution: (id: string) => Promise<void>;
|
||||
/**
|
||||
* Change le filtre de statut actif et relance le chargement.
|
||||
*
|
||||
* @param status - Statut cible ou undefined pour tout afficher
|
||||
*/
|
||||
setStatusFilter: (status: ExecutionStatus | undefined) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de gestion des exécutions : listing avec filtres, polling, détail et suppression.
|
||||
* Sépare clairement la liste (vue Executions) du détail (vue Logs).
|
||||
*/
|
||||
export const useExecutions = (options: UseExecutionsOptions = {}): UseExecutionsResult => {
|
||||
const { preferences, config } = useAppStore();
|
||||
const [executions, setExecutions] = useState<Execution[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [selectedExecution, setSelectedExecution] = useState<Execution | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<ExecutionStatus | undefined>(
|
||||
options.statusFilter
|
||||
);
|
||||
|
||||
/** Charge la liste avec les filtres courants */
|
||||
const loadExecutions = useCallback(async (): Promise<void> => {
|
||||
const params: FetchExecutionsParams = {
|
||||
limit: options.limit ?? 50,
|
||||
...(statusFilter !== undefined && { status: statusFilter }),
|
||||
...(options.workflowId && { workflowId: options.workflowId }),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await fetchExecutions(params);
|
||||
setExecutions(result.data);
|
||||
} catch {
|
||||
// Géré par l'intercepteur Axios
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [options.limit, options.workflowId, statusFilter]);
|
||||
|
||||
/** Polling automatique — désactivé si l'app n'est pas encore configurée */
|
||||
usePolling({
|
||||
callback: loadExecutions,
|
||||
interval: preferences.pollingInterval,
|
||||
enabled: config.isConfigured,
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await loadExecutions();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [loadExecutions]);
|
||||
|
||||
/**
|
||||
* Charge les logs détaillés d'une exécution.
|
||||
* Appelle GET /executions/:id qui inclut le champ `data` avec les nœuds.
|
||||
*/
|
||||
const loadExecutionDetail = useCallback(async (id: string): Promise<void> => {
|
||||
try {
|
||||
const detail = await fetchExecutionById(id);
|
||||
setSelectedExecution(detail);
|
||||
} catch {
|
||||
// Géré par l'intercepteur Axios
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Supprime une exécution côté serveur et la retire optimistiquement de l'état local.
|
||||
*/
|
||||
const removeExecution = useCallback(async (id: string): Promise<void> => {
|
||||
try {
|
||||
await deleteExecution(id);
|
||||
setExecutions((prev) => prev.filter((e) => e.id !== id));
|
||||
} catch {
|
||||
// Géré par l'intercepteur Axios
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
executions,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
selectedExecution,
|
||||
refresh,
|
||||
loadExecutionDetail,
|
||||
removeExecution,
|
||||
setStatusFilter,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
/** Options de configuration du hook de polling générique */
|
||||
interface UsePollingOptions {
|
||||
/** Callback exécuté à chaque intervalle (peut être async) */
|
||||
callback: () => void | Promise<void>;
|
||||
/** Intervalle entre chaque appel en millisecondes */
|
||||
interval: number;
|
||||
/** Si false, le polling est suspendu sans démonter le hook */
|
||||
enabled?: boolean;
|
||||
/** Si true, déclenche le callback immédiatement au montage sans attendre le premier intervalle */
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook générique de polling configurable.
|
||||
* Gère automatiquement le nettoyage de l'intervalle au démontage du composant.
|
||||
* Le callback est mémorisé par ref pour éviter de redémarrer le polling à chaque render.
|
||||
*
|
||||
* @returns stopPolling - Fonction pour arrêter manuellement le polling
|
||||
*/
|
||||
export const usePolling = ({
|
||||
callback,
|
||||
interval,
|
||||
enabled = true,
|
||||
immediate = true,
|
||||
}: UsePollingOptions): { stopPolling: () => void } => {
|
||||
/** Ref sur le callback pour éviter de re-créer l'intervalle si la fonction change */
|
||||
const callbackRef = useRef(callback);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
/** Synchronise la ref à chaque render sans déclencher d'effet */
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
/** Arrête proprement l'intervalle actif */
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
// Appel immédiat au montage si demandé (évite l'attente du premier intervalle)
|
||||
if (immediate) {
|
||||
void callbackRef.current();
|
||||
}
|
||||
|
||||
// Mise en place du polling régulier
|
||||
intervalRef.current = setInterval(() => {
|
||||
void callbackRef.current();
|
||||
}, interval);
|
||||
|
||||
// Nettoyage automatique au démontage
|
||||
return stopPolling;
|
||||
}, [enabled, interval, immediate, stopPolling]);
|
||||
|
||||
return { stopPolling };
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAppStore } from '../store/appStore';
|
||||
import {
|
||||
fetchWorkflows,
|
||||
activateWorkflow,
|
||||
deactivateWorkflow,
|
||||
runWorkflow,
|
||||
Workflow,
|
||||
} from '../api/workflows';
|
||||
import { usePolling } from './usePolling';
|
||||
|
||||
/** Interface des valeurs et actions exposées par le hook */
|
||||
interface UseWorkflowsResult {
|
||||
workflows: Workflow[];
|
||||
isLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
/**
|
||||
* Recharge manuellement la liste des workflows.
|
||||
* Utilisé pour le pull-to-refresh.
|
||||
*/
|
||||
refresh: () => Promise<void>;
|
||||
/**
|
||||
* Bascule le statut actif/inactif d'un workflow.
|
||||
* Met à jour l'état local après confirmation serveur.
|
||||
*
|
||||
* @param workflow - Workflow à basculer
|
||||
*/
|
||||
toggleWorkflow: (workflow: Workflow) => Promise<void>;
|
||||
/**
|
||||
* Déclenche manuellement l'exécution d'un workflow.
|
||||
*
|
||||
* @param id - Identifiant du workflow à déclencher
|
||||
*/
|
||||
triggerWorkflow: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de gestion des workflows : chargement, polling automatique et actions.
|
||||
* Délègue la gestion des erreurs à l'intercepteur Axios (errorHandler).
|
||||
* Les screens ne font pas d'appels API directs — tout passe par ce hook.
|
||||
*/
|
||||
export const useWorkflows = (): UseWorkflowsResult => {
|
||||
const { preferences, config } = useAppStore();
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
/** Charge la liste des workflows depuis la couche API */
|
||||
const loadWorkflows = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const data = await fetchWorkflows();
|
||||
setWorkflows(data);
|
||||
} catch {
|
||||
// Erreur déjà remontée via l'intercepteur Axios → toast affiché
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Polling automatique déclenché uniquement si l'app est configurée.
|
||||
* L'intervalle est lu depuis les préférences utilisateur (configurable dans Settings).
|
||||
*/
|
||||
usePolling({
|
||||
callback: loadWorkflows,
|
||||
interval: preferences.pollingInterval,
|
||||
enabled: config.isConfigured,
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
/** Pull-to-refresh : indicateur distinct du chargement initial */
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await loadWorkflows();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [loadWorkflows]);
|
||||
|
||||
/**
|
||||
* Bascule actif/inactif d'un workflow.
|
||||
* Met à jour l'entrée correspondante dans l'état local après retour serveur.
|
||||
*/
|
||||
const toggleWorkflow = useCallback(async (workflow: Workflow): Promise<void> => {
|
||||
try {
|
||||
const updated = workflow.active
|
||||
? await deactivateWorkflow(workflow.id)
|
||||
: await activateWorkflow(workflow.id);
|
||||
|
||||
// Mise à jour ciblée sans recharger toute la liste
|
||||
setWorkflows((prev) => prev.map((w) => (w.id === updated.id ? updated : w)));
|
||||
} catch {
|
||||
// Géré par l'intercepteur Axios
|
||||
}
|
||||
}, []);
|
||||
|
||||
const triggerWorkflow = useCallback(async (id: string): Promise<void> => {
|
||||
try {
|
||||
await runWorkflow(id);
|
||||
} catch {
|
||||
// Géré par l'intercepteur Axios
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
workflows,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
refresh,
|
||||
toggleWorkflow,
|
||||
triggerWorkflow,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ScrollView, View, StyleSheet, RefreshControl } from 'react-native';
|
||||
import { Text, Card, ActivityIndicator, useTheme } from 'react-native-paper';
|
||||
import { useWorkflows } from '../hooks/useWorkflows';
|
||||
import { useExecutions } from '../hooks/useExecutions';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { formatShortDate } from '../utils/formatters';
|
||||
|
||||
/**
|
||||
* Carte de métrique simple pour le tableau de bord.
|
||||
* Affiche un chiffre clé avec un label et un sous-titre coloré.
|
||||
*/
|
||||
interface MetricCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
subtitle: string;
|
||||
valueColor: string;
|
||||
}
|
||||
|
||||
const MetricCard: React.FC<MetricCardProps> = ({ label, value, subtitle, valueColor }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Card style={[styles.metricCard, { backgroundColor: theme.colors.surface }]}>
|
||||
<Card.Content>
|
||||
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text variant="headlineMedium" style={{ color: valueColor, fontWeight: '700' }}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant }}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Écran principal — tableau de bord.
|
||||
* Affiche les métriques clés (total workflows, taux de succès)
|
||||
* et les 5 dernières exécutions avec leur statut.
|
||||
* Supporte le pull-to-refresh via useWorkflows.
|
||||
*/
|
||||
const DashboardScreen: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const { workflows, isLoading: wLoading, isRefreshing, refresh } = useWorkflows();
|
||||
const { executions, isLoading: eLoading } = useExecutions({ limit: 5 });
|
||||
|
||||
/** Métriques calculées localement depuis l'état des hooks */
|
||||
const metrics = useMemo(() => {
|
||||
const successCount = executions.filter((e) => e.status === 'success').length;
|
||||
return {
|
||||
total: workflows.length,
|
||||
active: workflows.filter((w) => w.active).length,
|
||||
successRate:
|
||||
executions.length > 0
|
||||
? Math.round((successCount / executions.length) * 100)
|
||||
: 0,
|
||||
};
|
||||
}, [workflows, executions]);
|
||||
|
||||
/**
|
||||
* Couleur du taux de succès : vert > 80%, orange > 50%, rouge sinon.
|
||||
* Donne un indicateur visuel immédiat de la santé des workflows.
|
||||
*/
|
||||
const successRateColor =
|
||||
metrics.successRate >= 80
|
||||
? '#A5D6A7'
|
||||
: metrics.successRate >= 50
|
||||
? '#FFCC80'
|
||||
: '#EF9A9A';
|
||||
|
||||
const isLoading = wLoading || eLoading;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.container, { backgroundColor: theme.colors.background }]}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={refresh}
|
||||
tintColor={theme.colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text
|
||||
variant="headlineSmall"
|
||||
style={[styles.title, { color: theme.colors.onBackground }]}
|
||||
>
|
||||
Tableau de bord
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<ActivityIndicator style={styles.loader} color={theme.colors.primary} />
|
||||
) : (
|
||||
<>
|
||||
{/* Métriques rapides */}
|
||||
<View style={styles.metricsRow}>
|
||||
<MetricCard
|
||||
label="Workflows"
|
||||
value={metrics.total}
|
||||
subtitle={`${metrics.active} actifs`}
|
||||
valueColor={theme.colors.primary}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Taux de succès"
|
||||
value={`${metrics.successRate}%`}
|
||||
subtitle="5 dernières exec."
|
||||
valueColor={successRateColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Section dernières exécutions */}
|
||||
<Text
|
||||
variant="titleMedium"
|
||||
style={[styles.sectionTitle, { color: theme.colors.onBackground }]}
|
||||
>
|
||||
Exécutions récentes
|
||||
</Text>
|
||||
|
||||
{executions.length === 0 ? (
|
||||
<Text style={[styles.empty, { color: theme.colors.onSurfaceVariant }]}>
|
||||
Aucune exécution récente.
|
||||
</Text>
|
||||
) : (
|
||||
executions.slice(0, 5).map((exec) => (
|
||||
<Card
|
||||
key={exec.id}
|
||||
style={[styles.execCard, { backgroundColor: theme.colors.surface }]}
|
||||
>
|
||||
<Card.Content style={styles.execHeader}>
|
||||
<Text
|
||||
variant="bodyMedium"
|
||||
numberOfLines={1}
|
||||
style={{ color: theme.colors.onSurface, flex: 1, marginRight: 8 }}
|
||||
>
|
||||
{exec.workflowName ?? `Workflow ${exec.workflowId}`}
|
||||
</Text>
|
||||
<StatusBadge status={exec.status} size="small" />
|
||||
</Card.Content>
|
||||
<Card.Content style={styles.execMeta}>
|
||||
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant }}>
|
||||
{formatShortDate(exec.startedAt)}
|
||||
</Text>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
margin: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
loader: {
|
||||
marginTop: 48,
|
||||
},
|
||||
metricsRow: {
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: 16,
|
||||
gap: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
metricCard: {
|
||||
flex: 1,
|
||||
elevation: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginHorizontal: 16,
|
||||
marginTop: 20,
|
||||
marginBottom: 8,
|
||||
fontWeight: '600',
|
||||
},
|
||||
execCard: {
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 4,
|
||||
elevation: 1,
|
||||
},
|
||||
execHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: 4,
|
||||
},
|
||||
execMeta: {
|
||||
paddingTop: 0,
|
||||
},
|
||||
empty: {
|
||||
textAlign: 'center',
|
||||
marginTop: 24,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
|
||||
export default DashboardScreen;
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FlatList, StyleSheet, View } from 'react-native';
|
||||
import { Text, Chip, Snackbar, useTheme } from 'react-native-paper';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useExecutions } from '../hooks/useExecutions';
|
||||
import ExecutionCard from '../components/ExecutionCard';
|
||||
import SkeletonLoader from '../components/SkeletonLoader';
|
||||
import { ExecutionStatus } from '../api/executions';
|
||||
|
||||
/** Filtres de statut disponibles dans l'interface */
|
||||
const STATUS_FILTERS: Array<{ label: string; value: ExecutionStatus | undefined }> = [
|
||||
{ label: 'Tous', value: undefined },
|
||||
{ label: 'Succès', value: 'success' },
|
||||
{ label: 'Erreur', value: 'error' },
|
||||
{ label: 'En cours', value: 'running' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Écran d'historique des exécutions.
|
||||
* Permet de filtrer par statut via des chips et de naviguer vers
|
||||
* les logs détaillés d'une exécution via Expo Router.
|
||||
*/
|
||||
const ExecutionsScreen: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const [activeFilter, setActiveFilter] = useState<ExecutionStatus | undefined>(undefined);
|
||||
const [snackMessage, setSnackMessage] = useState('');
|
||||
|
||||
const {
|
||||
executions,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
refresh,
|
||||
removeExecution,
|
||||
setStatusFilter,
|
||||
} = useExecutions({ limit: 50 });
|
||||
|
||||
/**
|
||||
* Change le filtre actif et notifie le hook pour relancer le fetch.
|
||||
*
|
||||
* @param value - Nouveau statut filtré, ou undefined pour tout afficher
|
||||
*/
|
||||
const handleFilterChange = (value: ExecutionStatus | undefined): void => {
|
||||
setActiveFilter(value);
|
||||
setStatusFilter(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Supprime une exécution et affiche une confirmation.
|
||||
*
|
||||
* @param id - Identifiant de l'exécution à supprimer
|
||||
*/
|
||||
const handleDelete = async (id: string): Promise<void> => {
|
||||
await removeExecution(id);
|
||||
setSnackMessage('Exécution supprimée');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
{/* Barre de filtres de statut horizontale */}
|
||||
<View style={styles.filterBar}>
|
||||
{STATUS_FILTERS.map((filter) => (
|
||||
<Chip
|
||||
key={filter.label}
|
||||
selected={activeFilter === filter.value}
|
||||
onPress={() => handleFilterChange(filter.value)}
|
||||
style={styles.chip}
|
||||
compact
|
||||
>
|
||||
{filter.label}
|
||||
</Chip>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<SkeletonLoader count={8} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={executions}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<ExecutionCard
|
||||
execution={item}
|
||||
onViewLogs={(id) => router.push(`/execution/${id}` as `/${string}`)}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={refresh}
|
||||
contentContainerStyle={styles.list}
|
||||
ListEmptyComponent={
|
||||
<Text style={[styles.empty, { color: theme.colors.onSurfaceVariant }]}>
|
||||
Aucune exécution trouvée.
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
visible={!!snackMessage}
|
||||
onDismiss={() => setSnackMessage('')}
|
||||
duration={2500}
|
||||
>
|
||||
{snackMessage}
|
||||
</Snackbar>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
filterBar: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
gap: 8,
|
||||
},
|
||||
chip: {
|
||||
height: 32,
|
||||
},
|
||||
list: {
|
||||
paddingVertical: 4,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
empty: {
|
||||
textAlign: 'center',
|
||||
marginTop: 48,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
|
||||
export default ExecutionsScreen;
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, View, ActivityIndicator } from 'react-native';
|
||||
import { Text, Card, Divider, useTheme } from 'react-native-paper';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { useExecutions } from '../hooks/useExecutions';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { formatShortDate, formatDuration, formatMode } from '../utils/formatters';
|
||||
import { NodeExecutionData } from '../api/executions';
|
||||
|
||||
/**
|
||||
* Écran de logs détaillés d'une exécution n8n.
|
||||
* Reçoit l'identifiant de l'exécution via les paramètres de route Expo Router (/execution/:id).
|
||||
* Charge puis affiche les données de chaque nœud exécuté, avec les erreurs éventuelles.
|
||||
*/
|
||||
const LogsScreen: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { selectedExecution, loadExecutionDetail } = useExecutions();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
/** Charge les logs détaillés dès que l'ID de route est disponible */
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadExecutionDetail(id).finally(() => setIsLoading(false));
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={[styles.centered, { backgroundColor: theme.colors.background }]}>
|
||||
<ActivityIndicator color={theme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedExecution) {
|
||||
return (
|
||||
<View style={[styles.centered, { backgroundColor: theme.colors.background }]}>
|
||||
<Text style={{ color: theme.colors.onSurfaceVariant }}>
|
||||
Exécution introuvable ou données indisponibles.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const runData = selectedExecution.data?.resultData?.runData ?? {};
|
||||
const globalError = selectedExecution.data?.resultData?.error;
|
||||
const nodeNames = Object.keys(runData);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.container, { backgroundColor: theme.colors.background }]}
|
||||
contentContainerStyle={styles.content}
|
||||
>
|
||||
{/* En-tête : résumé de l'exécution */}
|
||||
<Card style={[styles.headerCard, { backgroundColor: theme.colors.surface }]}>
|
||||
<Card.Content>
|
||||
<View style={styles.headerRow}>
|
||||
<Text
|
||||
variant="titleMedium"
|
||||
style={{ color: theme.colors.onSurface, flex: 1, marginRight: 8 }}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{selectedExecution.workflowName ?? `Workflow ${selectedExecution.workflowId}`}
|
||||
</Text>
|
||||
<StatusBadge status={selectedExecution.status} />
|
||||
</View>
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
style={{ color: theme.colors.onSurfaceVariant, marginTop: 6 }}
|
||||
>
|
||||
{formatMode(selectedExecution.mode)} ·{' '}
|
||||
{formatShortDate(selectedExecution.startedAt)} ·{' '}
|
||||
{formatDuration(selectedExecution.startedAt, selectedExecution.stoppedAt)}
|
||||
</Text>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
{/* Erreur globale de l'exécution (niveau workflow) */}
|
||||
{globalError && (
|
||||
<Card style={[styles.errorCard, { backgroundColor: '#2C0000' }]}>
|
||||
<Card.Content>
|
||||
<Text variant="labelMedium" style={{ color: '#EF9A9A', marginBottom: 4 }}>
|
||||
Erreur workflow
|
||||
</Text>
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
style={{ color: '#FFCDD2', fontFamily: 'monospace' }}
|
||||
selectable
|
||||
>
|
||||
{globalError.message}
|
||||
</Text>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Liste des nœuds exécutés */}
|
||||
<Text
|
||||
variant="titleSmall"
|
||||
style={[styles.sectionTitle, { color: theme.colors.onBackground }]}
|
||||
>
|
||||
Nœuds exécutés ({nodeNames.length})
|
||||
</Text>
|
||||
|
||||
{nodeNames.map((nodeName, index) => {
|
||||
const nodeRuns: NodeExecutionData[] = runData[nodeName] ?? [];
|
||||
const firstRun = nodeRuns[0];
|
||||
const hasError = !!firstRun?.error;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={nodeName}
|
||||
style={[
|
||||
styles.nodeCard,
|
||||
{ backgroundColor: hasError ? '#1C0000' : theme.colors.surface },
|
||||
]}
|
||||
>
|
||||
<Card.Content>
|
||||
{/* Nom du nœud et durée d'exécution */}
|
||||
<View style={styles.nodeHeader}>
|
||||
<Text
|
||||
variant="bodyMedium"
|
||||
style={{ color: theme.colors.onSurface, fontWeight: '600', flex: 1 }}
|
||||
>
|
||||
{index + 1}. {nodeName}
|
||||
</Text>
|
||||
{firstRun && (
|
||||
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant }}>
|
||||
{firstRun.executionTime}ms
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Erreur spécifique à ce nœud */}
|
||||
{firstRun?.error && (
|
||||
<>
|
||||
<Divider style={{ marginVertical: 6 }} />
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
style={{ color: '#EF9A9A', fontFamily: 'monospace' }}
|
||||
selectable
|
||||
>
|
||||
{firstRun.error.message}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{nodeNames.length === 0 && (
|
||||
<Text style={[styles.empty, { color: theme.colors.onSurfaceVariant }]}>
|
||||
Aucune donnée de nœud disponible pour cette exécution.
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerCard: {
|
||||
marginBottom: 12,
|
||||
elevation: 2,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
errorCard: {
|
||||
marginBottom: 12,
|
||||
elevation: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: 8,
|
||||
fontWeight: '600',
|
||||
},
|
||||
nodeCard: {
|
||||
marginBottom: 8,
|
||||
elevation: 1,
|
||||
},
|
||||
nodeHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
empty: {
|
||||
textAlign: 'center',
|
||||
marginTop: 24,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
|
||||
export default LogsScreen;
|
||||
@@ -0,0 +1,329 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ScrollView, StyleSheet, View, Alert } from 'react-native';
|
||||
import {
|
||||
Text,
|
||||
TextInput,
|
||||
Button,
|
||||
Switch,
|
||||
Divider,
|
||||
useTheme,
|
||||
Snackbar,
|
||||
} from 'react-native-paper';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import * as LocalAuthentication from 'expo-local-authentication';
|
||||
import { useAppStore } from '../store/appStore';
|
||||
import { SECURE_STORE_KEYS } from '../api/client';
|
||||
|
||||
/**
|
||||
* Intervalles de polling proposés à l'utilisateur.
|
||||
* Le choix 30s est le défaut — bon compromis batterie/fraîcheur.
|
||||
*/
|
||||
const POLLING_INTERVALS: Array<{ label: string; value: number }> = [
|
||||
{ label: '15s', value: 15_000 },
|
||||
{ label: '30s', value: 30_000 },
|
||||
{ label: '1 min', value: 60_000 },
|
||||
{ label: '5 min', value: 300_000 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Écran de configuration de l'application.
|
||||
* Gère :
|
||||
* - La connexion à l'instance n8n (URL + clé API + token HAProxy)
|
||||
* - L'activation de l'authentification biométrique
|
||||
* - L'intervalle de polling automatique
|
||||
*
|
||||
* SÉCURITÉ : aucune valeur sensible n'est stockée dans l'état React au-delà de la saisie.
|
||||
* Après sauvegarde, apiKey et appToken sont vidés des champs et ne vivent que dans le secure store.
|
||||
*/
|
||||
const SettingsScreen: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const { config, preferences, setConfig, setPreferences } = useAppStore();
|
||||
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [appToken, setAppToken] = useState('');
|
||||
const [urlError, setUrlError] = useState('');
|
||||
const [isBiometricAvailable, setIsBiometricAvailable] = useState(false);
|
||||
const [snackMessage, setSnackMessage] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
/** Vérifie la disponibilité du hardware biométrique sur cet appareil */
|
||||
useEffect(() => {
|
||||
LocalAuthentication.hasHardwareAsync().then(setIsBiometricAvailable);
|
||||
}, []);
|
||||
|
||||
/** Pré-remplit l'URL depuis le store (jamais la clé API ni le token) */
|
||||
useEffect(() => {
|
||||
setBaseUrl(config.baseUrl);
|
||||
}, [config.baseUrl]);
|
||||
|
||||
/**
|
||||
* Valide que l'URL est bien en HTTPS (TLS obligatoire).
|
||||
* L'app refuse toute URL HTTP pour protéger les secrets en transit.
|
||||
*
|
||||
* @param url - URL saisie par l'utilisateur
|
||||
* @returns true si l'URL est valide et en HTTPS
|
||||
*/
|
||||
const validateUrl = (url: string): boolean => {
|
||||
if (!url.startsWith('https://')) {
|
||||
setUrlError("L'URL doit commencer par https:// (TLS obligatoire)");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
setUrlError('');
|
||||
return true;
|
||||
} catch {
|
||||
setUrlError('URL invalide — vérifiez le format');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sauvegarde la configuration dans le secure store Android Keystore.
|
||||
* Vide les champs sensibles de l'état React immédiatement après sauvegarde.
|
||||
*/
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!validateUrl(baseUrl)) return;
|
||||
if (!apiKey.trim()) {
|
||||
setSnackMessage('La clé API n8n est obligatoire');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await setConfig(baseUrl.trim(), apiKey.trim(), appToken.trim());
|
||||
// Nettoyage immédiat des champs sensibles de l'état React
|
||||
setApiKey('');
|
||||
setAppToken('');
|
||||
setSnackMessage('Configuration sauvegardée avec succès');
|
||||
} catch {
|
||||
setSnackMessage('Erreur lors de la sauvegarde');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Réinitialise complètement la configuration après confirmation explicite.
|
||||
* Supprime toutes les entrées du secure store.
|
||||
*/
|
||||
const handleReset = (): void => {
|
||||
Alert.alert(
|
||||
'Réinitialiser la configuration',
|
||||
'Toutes les données de connexion seront supprimées. Cette action est irréversible.',
|
||||
[
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Réinitialiser',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(SECURE_STORE_KEYS.BASE_URL),
|
||||
SecureStore.deleteItemAsync(SECURE_STORE_KEYS.API_KEY),
|
||||
SecureStore.deleteItemAsync(SECURE_STORE_KEYS.APP_TOKEN),
|
||||
]);
|
||||
setBaseUrl('');
|
||||
setApiKey('');
|
||||
setAppToken('');
|
||||
setSnackMessage('Configuration réinitialisée');
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.container, { backgroundColor: theme.colors.background }]}
|
||||
contentContainerStyle={styles.content}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Section : connexion à l'instance n8n */}
|
||||
<Text
|
||||
variant="titleMedium"
|
||||
style={[styles.sectionTitle, { color: theme.colors.onBackground }]}
|
||||
>
|
||||
Connexion n8n
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label="URL de l'instance (https://…)"
|
||||
value={baseUrl}
|
||||
onChangeText={(v) => {
|
||||
setBaseUrl(v);
|
||||
if (urlError) validateUrl(v);
|
||||
}}
|
||||
onBlur={() => baseUrl && validateUrl(baseUrl)}
|
||||
error={!!urlError}
|
||||
mode="outlined"
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
left={<TextInput.Icon icon="server" />}
|
||||
/>
|
||||
{!!urlError && (
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
style={{ color: theme.colors.error, marginBottom: 8, marginLeft: 4 }}
|
||||
>
|
||||
{urlError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Clé API n8n"
|
||||
value={apiKey}
|
||||
onChangeText={setApiKey}
|
||||
secureTextEntry
|
||||
mode="outlined"
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
left={<TextInput.Icon icon="key" />}
|
||||
placeholder={config.isConfigured ? '••••••••••••••••' : undefined}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Token HAProxy (X-App-Token)"
|
||||
value={appToken}
|
||||
onChangeText={setAppToken}
|
||||
secureTextEntry
|
||||
mode="outlined"
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
left={<TextInput.Icon icon="shield-key" />}
|
||||
placeholder={config.isConfigured ? '••••••••••••••••' : undefined}
|
||||
/>
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleSave}
|
||||
loading={isSaving}
|
||||
disabled={isSaving}
|
||||
style={styles.saveButton}
|
||||
>
|
||||
Sauvegarder
|
||||
</Button>
|
||||
|
||||
<Divider style={styles.divider} />
|
||||
|
||||
{/* Section : préférences utilisateur */}
|
||||
<Text
|
||||
variant="titleMedium"
|
||||
style={[styles.sectionTitle, { color: theme.colors.onBackground }]}
|
||||
>
|
||||
Préférences
|
||||
</Text>
|
||||
|
||||
{/* Biométrie — affichée uniquement si le hardware est disponible */}
|
||||
{isBiometricAvailable && (
|
||||
<View style={styles.row}>
|
||||
<Text
|
||||
variant="bodyMedium"
|
||||
style={{ color: theme.colors.onBackground, flex: 1 }}
|
||||
>
|
||||
Déverrouillage biométrique
|
||||
</Text>
|
||||
<Switch
|
||||
value={preferences.biometricEnabled}
|
||||
onValueChange={(v) => setPreferences({ biometricEnabled: v })}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Intervalle de rafraîchissement automatique */}
|
||||
<Text
|
||||
variant="bodyMedium"
|
||||
style={{ color: theme.colors.onBackground, marginBottom: 10, marginTop: 8 }}
|
||||
>
|
||||
Intervalle de rafraîchissement
|
||||
</Text>
|
||||
<View style={styles.intervalRow}>
|
||||
{POLLING_INTERVALS.map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
mode={preferences.pollingInterval === item.value ? 'contained' : 'outlined'}
|
||||
onPress={() => setPreferences({ pollingInterval: item.value })}
|
||||
style={styles.intervalButton}
|
||||
compact
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Divider style={styles.divider} />
|
||||
|
||||
{/* Zone de danger — réinitialisation */}
|
||||
<Text
|
||||
variant="titleMedium"
|
||||
style={[styles.sectionTitle, { color: theme.colors.error }]}
|
||||
>
|
||||
Zone de danger
|
||||
</Text>
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={handleReset}
|
||||
textColor={theme.colors.error}
|
||||
style={[styles.resetButton, { borderColor: theme.colors.error }]}
|
||||
>
|
||||
Réinitialiser la configuration
|
||||
</Button>
|
||||
|
||||
<Snackbar
|
||||
visible={!!snackMessage}
|
||||
onDismiss={() => setSnackMessage('')}
|
||||
duration={3000}
|
||||
>
|
||||
{snackMessage}
|
||||
</Snackbar>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
},
|
||||
input: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
saveButton: {
|
||||
marginTop: 4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
divider: {
|
||||
marginVertical: 20,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
intervalRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
intervalButton: {
|
||||
flex: 1,
|
||||
minWidth: 60,
|
||||
},
|
||||
resetButton: {
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export default SettingsScreen;
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FlatList, StyleSheet, View } from 'react-native';
|
||||
import { Text, Snackbar, useTheme } from 'react-native-paper';
|
||||
import { useWorkflows } from '../hooks/useWorkflows';
|
||||
import WorkflowCard from '../components/WorkflowCard';
|
||||
import SkeletonLoader from '../components/SkeletonLoader';
|
||||
import { Workflow } from '../api/workflows';
|
||||
|
||||
/**
|
||||
* Écran de liste de tous les workflows de l'instance n8n.
|
||||
* Supporte le pull-to-refresh et affiche un squelette au premier chargement.
|
||||
* Les actions (toggle, run) sont déléguées à useWorkflows — aucun appel API direct ici.
|
||||
*/
|
||||
const WorkflowsScreen: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const [snackMessage, setSnackMessage] = useState('');
|
||||
const { workflows, isLoading, isRefreshing, refresh, toggleWorkflow, triggerWorkflow } =
|
||||
useWorkflows();
|
||||
|
||||
/**
|
||||
* Déclenche l'exécution d'un workflow et affiche une confirmation à l'utilisateur.
|
||||
*
|
||||
* @param id - Identifiant du workflow à lancer
|
||||
*/
|
||||
const handleRun = async (id: string): Promise<void> => {
|
||||
await triggerWorkflow(id);
|
||||
setSnackMessage('Workflow déclenché avec succès');
|
||||
};
|
||||
|
||||
/**
|
||||
* Bascule le statut actif/inactif avec retour visuel.
|
||||
* L'état local est mis à jour par le hook après confirmation serveur.
|
||||
*
|
||||
* @param workflow - Workflow à basculer
|
||||
*/
|
||||
const handleToggle = async (workflow: Workflow): Promise<void> => {
|
||||
await toggleWorkflow(workflow);
|
||||
setSnackMessage(workflow.active ? 'Workflow désactivé' : 'Workflow activé');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
{isLoading ? (
|
||||
<SkeletonLoader count={6} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={workflows}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<WorkflowCard
|
||||
workflow={item}
|
||||
onToggle={handleToggle}
|
||||
onRun={handleRun}
|
||||
/>
|
||||
)}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={refresh}
|
||||
contentContainerStyle={styles.list}
|
||||
ListEmptyComponent={
|
||||
<Text style={[styles.empty, { color: theme.colors.onSurfaceVariant }]}>
|
||||
Aucun workflow trouvé. Vérifiez la connexion à l'instance n8n.
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirmation d'action non destructive */}
|
||||
<Snackbar
|
||||
visible={!!snackMessage}
|
||||
onDismiss={() => setSnackMessage('')}
|
||||
duration={2500}
|
||||
>
|
||||
{snackMessage}
|
||||
</Snackbar>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
list: {
|
||||
paddingVertical: 8,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
empty: {
|
||||
textAlign: 'center',
|
||||
marginTop: 48,
|
||||
marginHorizontal: 32,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
|
||||
export default WorkflowsScreen;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { create } from 'zustand';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { SECURE_STORE_KEYS } from '../api/client';
|
||||
|
||||
/** Configuration de connexion à l'instance n8n auto-hébergée */
|
||||
interface AppConfig {
|
||||
/** URL de base de l'instance (ex: https://n8n.example.com) */
|
||||
baseUrl: string;
|
||||
/** Vrai si une configuration complète a déjà été sauvegardée dans le secure store */
|
||||
isConfigured: boolean;
|
||||
}
|
||||
|
||||
/** Préférences utilisateur persistées localement */
|
||||
interface AppPreferences {
|
||||
/** Intervalle de polling automatique en millisecondes */
|
||||
pollingInterval: number;
|
||||
/** Active l'authentification biométrique au démarrage de l'app */
|
||||
biometricEnabled: boolean;
|
||||
}
|
||||
|
||||
/** Interface complète de l'état global Zustand */
|
||||
interface AppState {
|
||||
config: AppConfig;
|
||||
preferences: AppPreferences;
|
||||
/** Vrai si l'authentification biométrique a été validée pour la session courante */
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
|
||||
/**
|
||||
* Sauvegarde baseUrl, apiKey et appToken dans le secure store Android Keystore.
|
||||
* N'appeler qu'avec des valeurs non vides et validées (HTTPS obligatoire pour baseUrl).
|
||||
*
|
||||
* @param baseUrl - URL de l'instance n8n en HTTPS
|
||||
* @param apiKey - Clé API n8n (scope minimal recommandé)
|
||||
* @param appToken - Token personnalisé HAProxy (X-App-Token)
|
||||
*/
|
||||
setConfig: (baseUrl: string, apiKey: string, appToken: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Charge la configuration au démarrage depuis le secure store.
|
||||
* Appelé une seule fois par le root layout (_layout.tsx).
|
||||
*/
|
||||
loadConfig: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Met à jour les préférences utilisateur de manière partielle.
|
||||
*
|
||||
* @param prefs - Sous-ensemble des préférences à modifier
|
||||
*/
|
||||
setPreferences: (prefs: Partial<AppPreferences>) => void;
|
||||
|
||||
/**
|
||||
* Marque la session comme authentifiée (biométrie validée).
|
||||
*
|
||||
* @param auth - true si la biométrie a été acceptée
|
||||
*/
|
||||
setAuthenticated: (auth: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
config: {
|
||||
baseUrl: '',
|
||||
isConfigured: false,
|
||||
},
|
||||
preferences: {
|
||||
pollingInterval: 30_000, // 30 secondes — bon compromis entre fraîcheur et batterie
|
||||
biometricEnabled: false,
|
||||
},
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
|
||||
setConfig: async (baseUrl, apiKey, appToken) => {
|
||||
// Écriture parallèle dans le secure store pour minimiser la latence
|
||||
await Promise.all([
|
||||
SecureStore.setItemAsync(SECURE_STORE_KEYS.BASE_URL, baseUrl),
|
||||
SecureStore.setItemAsync(SECURE_STORE_KEYS.API_KEY, apiKey),
|
||||
SecureStore.setItemAsync(SECURE_STORE_KEYS.APP_TOKEN, appToken),
|
||||
]);
|
||||
// On ne stocke que l'URL dans l'état React ; jamais la clé API ni le token
|
||||
set({ config: { baseUrl, isConfigured: true } });
|
||||
},
|
||||
|
||||
loadConfig: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
// Seule l'URL est lue dans le store visible — les secrets restent dans le secure store
|
||||
const baseUrl = await SecureStore.getItemAsync(SECURE_STORE_KEYS.BASE_URL);
|
||||
set({
|
||||
config: {
|
||||
baseUrl: baseUrl ?? '',
|
||||
isConfigured: !!baseUrl,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setPreferences: (prefs) =>
|
||||
set((state) => ({
|
||||
preferences: { ...state.preferences, ...prefs },
|
||||
})),
|
||||
|
||||
setAuthenticated: (auth) => set({ isAuthenticated: auth }),
|
||||
}));
|
||||
@@ -0,0 +1,73 @@
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
/** Signature du callback de toast enregistré depuis le root layout */
|
||||
type ToastCallback = (message: string, type: 'error' | 'warning' | 'info') => void;
|
||||
|
||||
/** Référence au callback toast, initialisée au montage du root layout */
|
||||
let toastCallback: ToastCallback | null = null;
|
||||
|
||||
/**
|
||||
* Enregistre le callback de toast depuis le root layout (_layout.tsx).
|
||||
* Doit être appelé une seule fois au démarrage de l'application.
|
||||
*
|
||||
* @param callback - Fonction qui affiche un toast dans l'UI
|
||||
*/
|
||||
export const registerToastCallback = (callback: ToastCallback): void => {
|
||||
toastCallback = callback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Affiche un toast via le callback enregistré.
|
||||
* Ne fait rien si le callback n'a pas encore été enregistré.
|
||||
*/
|
||||
const showToast = (message: string, type: 'error' | 'warning' | 'info' = 'error'): void => {
|
||||
if (toastCallback) {
|
||||
toastCallback(message, type);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extrait un message d'erreur lisible depuis une erreur Axios ou générique.
|
||||
* Ne logue jamais les détails de la requête (qui pourraient contenir des headers secrets).
|
||||
*
|
||||
* @param error - Erreur Axios ou Error générique
|
||||
* @returns Message d'erreur lisible en français
|
||||
*/
|
||||
const extractMessage = (error: unknown): string => {
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response) {
|
||||
// Erreur HTTP avec réponse du serveur (4xx, 5xx)
|
||||
const data = error.response.data as { message?: string; error?: string } | null;
|
||||
return data?.message ?? data?.error ?? `Erreur ${error.response.status}`;
|
||||
}
|
||||
if (error.request) {
|
||||
// Requête émise mais pas de réponse : timeout ou serveur inaccessible
|
||||
return 'Serveur inaccessible. Vérifiez votre connexion ou l\'URL n8n.';
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Une erreur inattendue est survenue.';
|
||||
};
|
||||
|
||||
/**
|
||||
* Point d'entrée unifié pour la gestion des erreurs applicatives.
|
||||
* Affiche un toast et émet un warning console en développement UNIQUEMENT.
|
||||
* Jamais de log en production, jamais de log des valeurs de headers ou tokens.
|
||||
*
|
||||
* @param error - Erreur à traiter
|
||||
* @param context - Contexte optionnel (nom de la fonction appelante)
|
||||
*/
|
||||
export const handleError = (error: unknown, context?: string): void => {
|
||||
const message = extractMessage(error);
|
||||
const fullMessage = context ? `[${context}] ${message}` : message;
|
||||
|
||||
if (__DEV__) {
|
||||
// En développement : log du message uniquement, jamais des headers/tokens
|
||||
console.warn(`[ErrorHandler] ${fullMessage}`);
|
||||
}
|
||||
|
||||
showToast(fullMessage, 'error');
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { formatDistanceToNow, format, differenceInSeconds } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
/**
|
||||
* Formate une date ISO en temps relatif lisible en français.
|
||||
* Ex : "il y a 3 minutes", "dans 2 heures"
|
||||
*
|
||||
* @param dateString - Date au format ISO 8601
|
||||
* @returns Chaîne relative en français, ou "date inconnue" si parsing impossible
|
||||
*/
|
||||
export const formatRelativeDate = (dateString: string): string => {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(dateString), { addSuffix: true, locale: fr });
|
||||
} catch {
|
||||
return 'date inconnue';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Formate une date ISO en format court DD/MM/YYYY HH:mm.
|
||||
*
|
||||
* @param dateString - Date au format ISO 8601
|
||||
* @returns Chaîne formatée, ou "date inconnue" si parsing impossible
|
||||
*/
|
||||
export const formatShortDate = (dateString: string): string => {
|
||||
try {
|
||||
return format(new Date(dateString), 'dd/MM/yyyy HH:mm', { locale: fr });
|
||||
} catch {
|
||||
return 'date inconnue';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Calcule et formate la durée entre deux dates.
|
||||
* Si stoppedAt est absent, calcule jusqu'à maintenant (exécution en cours).
|
||||
*
|
||||
* @param startedAt - Date de début ISO 8601
|
||||
* @param stoppedAt - Date de fin ISO 8601 (optionnelle)
|
||||
* @returns Durée formatée ex : "2m 34s", "45s"
|
||||
*/
|
||||
export const formatDuration = (startedAt: string, stoppedAt?: string): string => {
|
||||
try {
|
||||
const start = new Date(startedAt);
|
||||
const end = stoppedAt ? new Date(stoppedAt) : new Date();
|
||||
const seconds = differenceInSeconds(end, start);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
||||
} catch {
|
||||
return 'durée inconnue';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Traduit le statut d'une exécution n8n en libellé français.
|
||||
*
|
||||
* @param status - Statut brut de l'API n8n
|
||||
* @returns Libellé français correspondant
|
||||
*/
|
||||
export const formatStatus = (status: string): string => {
|
||||
const statusMap: Record<string, string> = {
|
||||
success: 'Succès',
|
||||
error: 'Erreur',
|
||||
waiting: 'En attente',
|
||||
running: 'En cours',
|
||||
canceled: 'Annulé',
|
||||
};
|
||||
return statusMap[status] ?? status;
|
||||
};
|
||||
|
||||
/**
|
||||
* Traduit le mode de déclenchement d'une exécution n8n.
|
||||
*
|
||||
* @param mode - Mode brut retourné par l'API
|
||||
* @returns Libellé français correspondant
|
||||
*/
|
||||
export const formatMode = (mode: string): string => {
|
||||
const modeMap: Record<string, string> = {
|
||||
manual: 'Manuel',
|
||||
trigger: 'Déclencheur',
|
||||
webhook: 'Webhook',
|
||||
internal: 'Interne',
|
||||
retry: 'Réessai',
|
||||
};
|
||||
return modeMap[mode] ?? mode;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.d.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user