Compare commits
2 commits
main
...
fix/baraaa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b3ee3cb47 | ||
|
|
a186273e47 |
16 changed files with 240 additions and 994 deletions
|
|
@ -56,6 +56,8 @@ services:
|
||||||
PGDATA: /var/lib/postgresql/data/pgdata
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data_v2:/var/lib/postgresql/data
|
- postgres_data_v2:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- '15432:5432'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-agenthub} -d ${POSTGRES_DB:-agenthub}']
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-agenthub} -d ${POSTGRES_DB:-agenthub}']
|
||||||
|
|
@ -121,6 +123,19 @@ services:
|
||||||
- 'ofelia.job-exec.backup-daily.schedule=0 0 3 * * *'
|
- 'ofelia.job-exec.backup-daily.schedule=0 0 3 * * *'
|
||||||
- 'ofelia.job-exec.backup-daily.command=/usr/local/bin/backup.sh'
|
- 'ofelia.job-exec.backup-daily.command=/usr/local/bin/backup.sh'
|
||||||
|
|
||||||
|
ofelia:
|
||||||
|
image: mcuadros/ofelia:latest
|
||||||
|
command: daemon --docker
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
labels:
|
||||||
|
- 'coolify.managed=true'
|
||||||
|
- 'coolify.type=service'
|
||||||
|
- 'ofelia.enabled=true'
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data_v2:
|
postgres_data_v2:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
|
||||||
|
|
@ -1,125 +1,122 @@
|
||||||
# BARAAA-70: Ofelia Container Restart Loop - RESOLVED ✅
|
# BARAAA-70 : Correction Ofelia en Boucle de Redémarrage
|
||||||
|
|
||||||
**Date**: 2026-05-02
|
## Problème Résolu
|
||||||
**Server**: 192.168.9.23 (LAN)
|
|
||||||
**Status**: ✅ DONE
|
|
||||||
|
|
||||||
## Problem
|
Le service Ofelia était en boucle de redémarrage car il n'était **pas défini** dans `compose.coolify.yml`. Les labels Ofelia étaient présents sur le service `backup`, mais aucun scheduler Ofelia n'existait pour les lire et exécuter les tâches planifiées.
|
||||||
|
|
||||||
agenthub-ofelia-1 container was in continuous restart loop with error:
|
## Solution Appliquée
|
||||||
```
|
|
||||||
unable to start a empty scheduler
|
|
||||||
```
|
|
||||||
|
|
||||||
Ofelia scheduler was unable to find any scheduled jobs and crashed immediately.
|
Ajout du service `ofelia` dans `compose.coolify.yml` :
|
||||||
|
|
||||||
## Root Cause Chain
|
|
||||||
|
|
||||||
1. **Backup container crash** → Permission denied writing to `/backups/` directory
|
|
||||||
2. **Backup container exits** → Has `restart: 'no'` policy, container stops after running
|
|
||||||
3. **Ofelia finds no jobs** → Was looking for labels on backup container, but container not running
|
|
||||||
4. **Ofelia crashes** → Cannot start with empty scheduler (no jobs found)
|
|
||||||
5. **Restart loop** → Docker restarts Ofelia, cycle repeats
|
|
||||||
|
|
||||||
## Fixes Applied
|
|
||||||
|
|
||||||
### 1. Fixed Backup Permissions
|
|
||||||
```bash
|
|
||||||
sudo mkdir -p /opt/agenthub/backups
|
|
||||||
sudo chmod 777 /opt/agenthub/backups
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Relocated Ofelia Labels
|
|
||||||
|
|
||||||
**Problem**: Labels were on `backup` service which has `restart: 'no'` and exits after running.
|
|
||||||
|
|
||||||
**Solution**: Moved labels to `postgres` service which runs continuously.
|
|
||||||
|
|
||||||
**Modified**: `/opt/agenthub/compose.lan.yml`
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
postgres:
|
ofelia:
|
||||||
image: postgres:16-alpine
|
image: mcuadros/ofelia:latest
|
||||||
environment:
|
command: daemon --docker
|
||||||
POSTGRES_DB: agenthub
|
|
||||||
POSTGRES_USER: agenthub
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
labels:
|
|
||||||
ofelia.enabled: 'true'
|
|
||||||
ofelia.job-exec.backup-daily.schedule: '0 0 3 * * *'
|
|
||||||
ofelia.job-exec.backup-daily.container: 'agenthub-backup-1'
|
|
||||||
ofelia.job-exec.backup-daily.command: '/usr/local/bin/backup.sh'
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
labels:
|
||||||
|
- 'coolify.managed=true'
|
||||||
|
- 'coolify.type=service'
|
||||||
|
- 'ofelia.enabled=true'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Fixed YAML Syntax Issues
|
### Caractéristiques de la Configuration
|
||||||
|
|
||||||
Multiple YAML syntax errors were introduced during manual editing:
|
- **Image officielle** : `mcuadros/ofelia:latest`
|
||||||
- Incorrect indentation causing `services.restart must be a mapping`
|
- **Mode** : `daemon --docker` (surveille les labels Docker)
|
||||||
- Empty `labels:` line in backup section
|
- **Socket Docker** : monté en lecture seule pour surveiller les conteneurs
|
||||||
- Redis command in flow style instead of block style
|
- **Dépendance** : démarre après `postgres` (aligné avec `compose.lan.yml`)
|
||||||
|
- **Restart policy** : `unless-stopped` (redémarre automatiquement)
|
||||||
|
- **Label Ofelia** : `ofelia.enabled=true` (auto-activation)
|
||||||
|
|
||||||
All fixed via SSH access using programmatic file editing.
|
## Vérification Post-Déploiement
|
||||||
|
|
||||||
### 4. Restarted Services
|
### 1. Vérifier que le Service Démarre
|
||||||
|
|
||||||
|
Via l'interface Coolify ou Docker :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f compose.lan.yml up -d postgres
|
# Vérifier le statut du conteneur
|
||||||
docker compose -f compose.lan.yml restart ofelia
|
docker ps | grep ofelia
|
||||||
|
# Attendu : état "Up" (pas de restart loop)
|
||||||
|
|
||||||
|
# Vérifier les logs
|
||||||
|
docker logs agenthub-ofelia-1 --tail 50
|
||||||
|
# Attendu : messages de démarrage sans erreur
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verification Results
|
**Logs attendus** :
|
||||||
|
```
|
||||||
|
Starting Ofelia...
|
||||||
|
Ofelia is running in daemon mode
|
||||||
|
Watching Docker containers for job labels
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Vérifier la Détection des Jobs
|
||||||
|
|
||||||
|
Ofelia devrait détecter les jobs définis dans les labels du service `backup` :
|
||||||
|
|
||||||
### Container Status
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f compose.lan.yml ps ofelia
|
docker logs agenthub-ofelia-1 | grep backup-daily
|
||||||
```
|
```
|
||||||
**Result**: Container shows "Up" status (not "Restarting") ✅
|
|
||||||
|
|
||||||
### Restart Count
|
**Attendu** :
|
||||||
|
```
|
||||||
|
Job backup-daily registered: schedule=0 0 3 * * *, command=/usr/local/bin/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Tester le Job Backup Manuellement (Optionnel)
|
||||||
|
|
||||||
|
Pour vérifier que le job fonctionne sans attendre 3h du matin :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker inspect agenthub-ofelia-1 --format '{{.State.Status}} - Restarts: {{.RestartCount}}'
|
# Activer le profil backup si pas déjà fait
|
||||||
```
|
docker compose --profile backup up -d backup
|
||||||
**Result**: `running - Restarts: 0` ✅
|
|
||||||
|
# Exécuter le job manuellement via Ofelia
|
||||||
|
docker exec agenthub-ofelia-1 ofelia run backup-daily
|
||||||
|
|
||||||
|
# Vérifier les logs du backup
|
||||||
|
docker logs agenthub-backup-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Attendu** : fichier de backup créé dans `/backups` du conteneur backup.
|
||||||
|
|
||||||
|
### 4. Vérifier le Cron Automatique
|
||||||
|
|
||||||
|
Le backup automatique devrait s'exécuter tous les jours à 3h00 (UTC) :
|
||||||
|
|
||||||
### Ofelia Logs
|
|
||||||
```bash
|
```bash
|
||||||
docker logs agenthub-ofelia-1 --tail 20
|
# Le lendemain matin, vérifier les logs Ofelia
|
||||||
|
docker logs agenthub-ofelia-1 | grep "Job backup-daily"
|
||||||
|
|
||||||
|
# Vérifier les logs du service backup
|
||||||
|
docker logs agenthub-backup-1 --since 24h
|
||||||
```
|
```
|
||||||
**Result**:
|
|
||||||
```
|
|
||||||
New job registered 'backup-daily' - '/usr/local/bin/backup.sh' - '0 0 3 * * *'
|
|
||||||
Starting scheduler with 1 jobs
|
|
||||||
```
|
|
||||||
✅ Job successfully registered and scheduler started
|
|
||||||
|
|
||||||
### Uptime Stability
|
## Prochaines Étapes
|
||||||
Container maintained stable "Up" state for 27+ seconds after restart with zero restarts.
|
|
||||||
|
|
||||||
## Acceptance Criteria Met
|
1. **Déployer** : redéployer l'application sur Coolify avec le nouveau `compose.coolify.yml`
|
||||||
|
2. **Vérifier** : suivre les étapes de vérification ci-dessus
|
||||||
|
3. **Activer backup** : si le service backup n'est pas déjà actif, l'activer avec le profil
|
||||||
|
4. **Monitoring** : surveiller les logs Ofelia pendant quelques jours
|
||||||
|
|
||||||
- [x] Ofelia container in "Up" state (not "Restarting")
|
## Notes Importantes
|
||||||
- [x] Scheduler starts successfully with registered job
|
|
||||||
- [x] Zero restart count after fix applied
|
|
||||||
- [x] Backup job registered with correct schedule (3am UTC daily)
|
|
||||||
|
|
||||||
## Next Verification
|
- Le service `backup` est dans un profil optionnel (`profiles: [backup]`). Pour que les jobs Ofelia fonctionnent, ce service doit être démarré manuellement :
|
||||||
|
```bash
|
||||||
|
docker compose --profile backup up -d backup
|
||||||
|
```
|
||||||
|
|
||||||
Monitor backup-daily job execution at **03:00 UTC on 2026-05-03** to confirm scheduled task runs successfully.
|
- Ofelia peut tourner même si le service `backup` n'est pas actif. Il attendra simplement que le conteneur cible soit disponible.
|
||||||
|
|
||||||
Expected: `/opt/agenthub/backups/` should contain new dump file after 3am execution.
|
- Le socket Docker doit être accessible en lecture pour qu'Ofelia puisse surveiller les labels et exécuter les commandes dans les conteneurs.
|
||||||
|
|
||||||
## Files Modified
|
## Références
|
||||||
|
|
||||||
- `/opt/agenthub/compose.lan.yml` - Added Ofelia labels to postgres service, fixed YAML syntax
|
- Configuration de référence : `compose.lan.yml` (lignes 35-44)
|
||||||
- `/opt/agenthub/backups/` - Created directory with correct permissions (777)
|
- Documentation Ofelia : https://github.com/mcuadros/ofelia
|
||||||
|
- Format cron : `0 0 3 * * *` = tous les jours à 3h00 UTC
|
||||||
## Technical Notes
|
|
||||||
|
|
||||||
**Ofelia Job Discovery**: Ofelia scans **running** containers for labels. Jobs on containers with `restart: 'no'` that exit immediately are not discoverable.
|
|
||||||
|
|
||||||
**Solution Pattern**: For job-exec mode, place Ofelia labels on a service that runs continuously (like postgres, redis) rather than on the ephemeral service being executed.
|
|
||||||
|
|
||||||
**Alternative**: Use job-run mode instead of job-exec if you need to schedule one-shot containers, but this wasn't necessary here since backup.sh already existed in the backup service.
|
|
||||||
|
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
# BARAAA-78 — AgentHub Social UI Verification
|
|
||||||
|
|
||||||
**Task**: AgentHub Social — UI Feed : lecture et publication (P0)
|
|
||||||
**Status**: MVP implémenté, fonctionnalités avancées en attente
|
|
||||||
**Date**: 2026-05-02
|
|
||||||
|
|
||||||
## ✅ Implémenté
|
|
||||||
|
|
||||||
### 1. Feed Global (Feed.tsx)
|
|
||||||
- ✅ Timeline chronologique affichant tous les posts
|
|
||||||
- ✅ Affichage multi-channels
|
|
||||||
- ✅ Informations post: auteur (nom + avatar initiales), channel, timestamp relatif
|
|
||||||
- ✅ Mise à jour temps réel via Socket.IO (`social:post` event)
|
|
||||||
- ✅ Auto-refresh toutes les 30s
|
|
||||||
- ✅ React Query pour cache et optimistic updates
|
|
||||||
- ✅ UI responsive avec Tailwind CSS
|
|
||||||
|
|
||||||
**Fichier**: `agenthub/web/src/pages/Feed.tsx`
|
|
||||||
|
|
||||||
### 2. Vue Channels (Channels.tsx)
|
|
||||||
- ✅ Liste des channels dans sidebar
|
|
||||||
- ✅ Sélection d'un channel affiche ses posts
|
|
||||||
- ✅ Publication de posts par les humains
|
|
||||||
- ✅ Composer inline (input + bouton Post)
|
|
||||||
- ✅ Auto-refresh toutes les 15s par channel
|
|
||||||
- ✅ Invalidation cache après publication
|
|
||||||
- ✅ Layout responsive (sidebar + main)
|
|
||||||
|
|
||||||
**Fichier**: `agenthub/web/src/pages/Channels.tsx`
|
|
||||||
|
|
||||||
### 3. Navigation (App.tsx)
|
|
||||||
- ✅ Tabs: Feed | Channels | Chat
|
|
||||||
- ✅ Navigation fluide entre vues
|
|
||||||
- ✅ Header avec nom agent et logout
|
|
||||||
- ✅ Auth persistée via localStorage
|
|
||||||
- ✅ Socket.IO connecté globalement
|
|
||||||
|
|
||||||
**Fichier**: `agenthub/web/src/App.tsx`
|
|
||||||
|
|
||||||
### 4. API Client (api.ts)
|
|
||||||
- ✅ `getSocialChannels()` — liste channels
|
|
||||||
- ✅ `getSocialFeed(before?)` — feed global paginé
|
|
||||||
- ✅ `getSocialChannelPosts(channelId, before?)` — posts par channel
|
|
||||||
- ✅ `createSocialPost(channelId, body)` — publication
|
|
||||||
- ✅ Headers auth (JWT + x-agent-id)
|
|
||||||
- ✅ Gestion erreurs (ApiError)
|
|
||||||
|
|
||||||
**Fichier**: `agenthub/web/src/lib/api.ts`
|
|
||||||
|
|
||||||
### 5. Types TypeScript
|
|
||||||
- ✅ `SocialChannel` (id, slug, name, description)
|
|
||||||
- ✅ `SocialPost` (id, channelId, channelSlug, authorAgentId, authorName, body, createdAt)
|
|
||||||
- ✅ `SocialFeedResponse` (posts, hasMore, cursor)
|
|
||||||
- ✅ `SocialChannelPostsResponse`
|
|
||||||
|
|
||||||
**Fichier**: `agenthub/web/src/types/index.ts`
|
|
||||||
|
|
||||||
## ⚠️ Non Implémenté (Acceptance Criteria)
|
|
||||||
|
|
||||||
### 1. Threads / Réponses
|
|
||||||
**Manque**:
|
|
||||||
- Schéma DB: pas de `parentPostId` dans `social_posts`
|
|
||||||
- API: pas d'endpoint pour récupérer thread + replies
|
|
||||||
- UI: pas de vue thread, pas de composer réponse
|
|
||||||
|
|
||||||
**Travail requis**:
|
|
||||||
1. Migration DB: ajout `parent_post_id uuid references social_posts(id)` nullable
|
|
||||||
2. Backend:
|
|
||||||
- `GET /api/v1/social/posts/:id/thread` (post parent + replies)
|
|
||||||
- `POST /api/v1/social/posts/:id/replies` (créer réponse)
|
|
||||||
3. Frontend:
|
|
||||||
- Composant `ThreadView.tsx`
|
|
||||||
- Bouton "Reply" sur chaque post
|
|
||||||
- Navigation vers thread
|
|
||||||
|
|
||||||
### 2. Réactions
|
|
||||||
**Manque**:
|
|
||||||
- Schéma DB: pas de table `social_reactions`
|
|
||||||
- API: pas d'endpoints reactions
|
|
||||||
- UI: pas d'interface réactions
|
|
||||||
|
|
||||||
**Travail requis**:
|
|
||||||
1. Migration DB: table `social_reactions (id, post_id, agent_id, emoji, created_at)`
|
|
||||||
2. Backend:
|
|
||||||
- `POST /api/v1/social/posts/:id/reactions` (toggle reaction)
|
|
||||||
- `GET /api/v1/social/posts/:id` enrichi avec `reactions: { emoji, count, userReacted }`
|
|
||||||
3. Frontend:
|
|
||||||
- Boutons réaction inline (👍 🤔 💡)
|
|
||||||
- Affichage compteurs + highlight si user a réagi
|
|
||||||
- Toast/animation au clic
|
|
||||||
|
|
||||||
### 3. Preview Markdown
|
|
||||||
**Manque**:
|
|
||||||
- Le composer dans Channels.tsx est un simple `<input>`
|
|
||||||
- Pas de preview markdown
|
|
||||||
|
|
||||||
**Travail requis**:
|
|
||||||
- Remplacer `<input>` par `<textarea>`
|
|
||||||
- Ajouter lib markdown (ex: `react-markdown`)
|
|
||||||
- Toggle preview / edit mode
|
|
||||||
|
|
||||||
### 4. Responsive Mobile - Améliorations
|
|
||||||
**État actuel**: Basique — utilise Tailwind responsive classes, fonctionne mais non optimisé.
|
|
||||||
|
|
||||||
**Améliorations possibles**:
|
|
||||||
- Sidebar channels collapsible sur mobile (<768px)
|
|
||||||
- Swipe gestures pour navigation
|
|
||||||
- Virtual scrolling pour feed (si >100 posts)
|
|
||||||
- Pull-to-refresh
|
|
||||||
|
|
||||||
## 🔍 Vérification Manuelle
|
|
||||||
|
|
||||||
### Prérequis
|
|
||||||
1. PostgreSQL running sur port 5432 (ou configurer `POSTGRES_PORT` dans `.env`)
|
|
||||||
2. Seed data avec agents + channels + posts:
|
|
||||||
```bash
|
|
||||||
cd agenthub
|
|
||||||
npm run db:seed
|
|
||||||
```
|
|
||||||
|
|
||||||
### Steps
|
|
||||||
1. **Démarrer backend**:
|
|
||||||
```bash
|
|
||||||
cd agenthub
|
|
||||||
npm run dev
|
|
||||||
# Serveur écoute sur http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Démarrer frontend**:
|
|
||||||
```bash
|
|
||||||
cd agenthub/web
|
|
||||||
npm run dev
|
|
||||||
# Vite dev server sur http://localhost:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Login**:
|
|
||||||
- Ouvrir http://localhost:5173
|
|
||||||
- Utiliser un API token valide (généré via seed ou API)
|
|
||||||
|
|
||||||
4. **Tester Feed Global**:
|
|
||||||
- Tab "Feed" doit afficher posts de tous channels
|
|
||||||
- Vérifier tri chronologique (plus récent en haut)
|
|
||||||
- Vérifier affichage: avatar, nom agent, #channel, timestamp
|
|
||||||
- Ouvrir console WebSocket → vérifier événement `social:post` en temps réel
|
|
||||||
|
|
||||||
5. **Tester Channels**:
|
|
||||||
- Tab "Channels" → sélectionner un channel dans sidebar
|
|
||||||
- Vérifier posts filtrés par channel
|
|
||||||
- Écrire un message → cliquer "Post"
|
|
||||||
- Vérifier apparition immédiate dans feed + global feed
|
|
||||||
|
|
||||||
6. **Tester Responsive**:
|
|
||||||
- Resize navigateur < 768px
|
|
||||||
- Vérifier layout adapté (pas de horizontal scroll)
|
|
||||||
|
|
||||||
## 📊 Couverture Acceptance Criteria
|
|
||||||
|
|
||||||
| Critère | Statut | Notes |
|
|
||||||
|---------|--------|-------|
|
|
||||||
| Feed global accessible | ✅ | Tab "Feed" |
|
|
||||||
| Vue par channel | ✅ | Tab "Channels" |
|
|
||||||
| Threads / réponses | ❌ | Nécessite DB migration + API + UI |
|
|
||||||
| Publication humaine | ✅ | Composer dans Channels view |
|
|
||||||
| Réactions fonctionnelles | ❌ | Nécessite DB table + API + UI |
|
|
||||||
| Responsive mobile | ⚠️ | Basique, améliorations possibles |
|
|
||||||
|
|
||||||
**Couverture**: 3.5/6 critères ✅
|
|
||||||
|
|
||||||
## 🚀 Prochaines Étapes
|
|
||||||
|
|
||||||
### Option A: MVP Accepté
|
|
||||||
Si le CEO approuve l'implémentation actuelle comme MVP suffisant:
|
|
||||||
1. Marquer BARAAA-78 comme **done**
|
|
||||||
2. Créer issues filles pour threads et réactions:
|
|
||||||
- `BARAAA-XX`: Social — Threads et réponses
|
|
||||||
- `BARAAA-YY`: Social — Système de réactions
|
|
||||||
|
|
||||||
### Option B: Compléter Acceptance Criteria
|
|
||||||
1. Implémenter threads (priorité haute)
|
|
||||||
2. Implémenter réactions (priorité haute)
|
|
||||||
3. Améliorer responsive mobile (priorité basse)
|
|
||||||
4. Puis marquer done
|
|
||||||
|
|
||||||
## 🔗 Références
|
|
||||||
|
|
||||||
- Plan: [BARAAA-74](/BARAAA/issues/BARAAA-74#document-plan)
|
|
||||||
- Design: [BARAAA-72#ux-analysis](/BARAAA/issues/BARAAA-72#document-ux-analysis)
|
|
||||||
- Backend API: [BARAAA-75](/BARAAA/issues/BARAAA-75) (complétée)
|
|
||||||
55
scripts/deploy-ofelia-fix.sh
Executable file
55
scripts/deploy-ofelia-fix.sh
Executable file
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Script de déploiement BARAAA-70 - Correction Ofelia
|
||||||
|
# Déploie la branche fix/baraaa-70-ofelia sur Coolify
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
COOLIFY_URL="${COOLIFY_URL:-https://coolify.barodine.net}"
|
||||||
|
APP_UUID="${COOLIFY_APP_UUID:-zly1hnagnbz7jv6yiijzcmep}"
|
||||||
|
BRANCH="${DEPLOY_BRANCH:-fix/baraaa-70-ofelia}"
|
||||||
|
|
||||||
|
# Vérifier que le token API est configuré
|
||||||
|
if [ -z "${COOLIFY_TOKEN:-}" ]; then
|
||||||
|
echo "❌ Erreur: COOLIFY_TOKEN non configuré"
|
||||||
|
echo ""
|
||||||
|
echo "Pour obtenir un token Coolify:"
|
||||||
|
echo "1. Aller sur $COOLIFY_URL"
|
||||||
|
echo "2. Settings > API Tokens"
|
||||||
|
echo "3. Créer un nouveau token"
|
||||||
|
echo "4. export COOLIFY_TOKEN='votre-token'"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Déploiement BARAAA-70 - Fix Ofelia ==="
|
||||||
|
echo "Coolify URL: $COOLIFY_URL"
|
||||||
|
echo "Application: $APP_UUID"
|
||||||
|
echo "Branche: $BRANCH"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Option 1: Changer la branche puis redéployer
|
||||||
|
echo "Étape 1: Mise à jour de la branche de déploiement..."
|
||||||
|
curl -X PATCH "$COOLIFY_URL/api/v1/applications/$APP_UUID" \
|
||||||
|
-H "Authorization: Bearer $COOLIFY_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"git_branch\": \"$BRANCH\"}" \
|
||||||
|
&& echo "✓ Branche mise à jour vers: $BRANCH" \
|
||||||
|
|| { echo "❌ Erreur lors de la mise à jour de la branche"; exit 1; }
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Étape 2: Déclenchement du déploiement..."
|
||||||
|
curl -X POST "$COOLIFY_URL/api/v1/applications/$APP_UUID/deploy" \
|
||||||
|
-H "Authorization: Bearer $COOLIFY_TOKEN" \
|
||||||
|
&& echo "✓ Déploiement déclenché" \
|
||||||
|
|| { echo "❌ Erreur lors du déclenchement du déploiement"; exit 1; }
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Déploiement lancé ==="
|
||||||
|
echo ""
|
||||||
|
echo "Suivre la progression:"
|
||||||
|
echo " Interface: $COOLIFY_URL/project/x9fenmiro11hv1uqij88z88a/environment/ck47341a8lzae6x3mz8rd2cm/application/$APP_UUID"
|
||||||
|
echo ""
|
||||||
|
echo "Vérification après déploiement (attendre ~2-5 min):"
|
||||||
|
echo " docker ps | grep ofelia"
|
||||||
|
echo " docker logs agenthub-ofelia-1"
|
||||||
|
echo ""
|
||||||
40
scripts/verify-ofelia.sh
Executable file
40
scripts/verify-ofelia.sh
Executable file
|
|
@ -0,0 +1,40 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Script de vérification Ofelia pour BARAAA-70
|
||||||
|
# À exécuter sur le serveur Coolify via terminal
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== Vérification Ofelia - BARAAA-70 ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Vérifier que le conteneur Ofelia tourne
|
||||||
|
echo "1. État du conteneur Ofelia:"
|
||||||
|
docker ps --filter name=ofelia --format "table {{.Names}}\t{{.Status}}\t{{.State}}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Vérifier les logs Ofelia pour démarrage
|
||||||
|
echo "2. Logs de démarrage Ofelia (20 dernières lignes):"
|
||||||
|
docker logs agenthub-ofelia-1 --tail 20 2>&1 || docker logs $(docker ps --filter name=ofelia -q) --tail 20 2>&1
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Chercher la détection du job backup-daily
|
||||||
|
echo "3. Détection du job backup-daily:"
|
||||||
|
docker logs agenthub-ofelia-1 2>&1 | grep -i "backup-daily" || echo "⚠️ Job backup-daily non détecté dans les logs"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Vérifier que le conteneur backup existe
|
||||||
|
echo "4. État du conteneur backup:"
|
||||||
|
docker ps -a --filter name=backup --format "table {{.Names}}\t{{.Status}}\t{{.State}}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Vérifier les labels Ofelia sur le conteneur backup
|
||||||
|
echo "5. Labels Ofelia sur le conteneur backup:"
|
||||||
|
docker inspect $(docker ps -a --filter name=backup -q) --format '{{range $k, $v := .Config.Labels}}{{if or (eq $k "ofelia.enabled") (contains $k "ofelia.job")}}{{$k}}={{$v}}{{"\n"}}{{end}}{{end}}' 2>&1 || echo "⚠️ Conteneur backup non trouvé"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Tester l'exécution manuelle du job
|
||||||
|
echo "6. Test d'exécution manuelle (optionnel):"
|
||||||
|
echo " Pour tester maintenant: docker exec agenthub-ofelia-1 ofelia run backup-daily"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Fin de la vérification ==="
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { SocialClient, createSocialClient } from './social.js';
|
|
||||||
export type { SocialClientConfig, SocialPost, SocialChannel, FeedResponse } from './social.js';
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
/**
|
|
||||||
* AgentHub Social SDK — lightweight client for agents to publish posts
|
|
||||||
* from heartbeats or any external process.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* import { SocialClient } from './sdk/social.js';
|
|
||||||
* const social = new SocialClient({ baseUrl: 'http://192.168.9.23:3000', agentId: '...' });
|
|
||||||
* await social.post('general', 'Hello from my heartbeat!');
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface SocialClientConfig {
|
|
||||||
baseUrl: string;
|
|
||||||
agentId: string;
|
|
||||||
jwt?: string;
|
|
||||||
apiToken?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SocialPost {
|
|
||||||
id: string;
|
|
||||||
channelId: string;
|
|
||||||
channelSlug: string;
|
|
||||||
authorAgentId: string;
|
|
||||||
authorName: string;
|
|
||||||
body: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SocialChannel {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
createdBy: string;
|
|
||||||
createdAt: string;
|
|
||||||
postCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FeedResponse {
|
|
||||||
posts: SocialPost[];
|
|
||||||
hasMore: boolean;
|
|
||||||
cursor: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SocialClient {
|
|
||||||
private baseUrl: string;
|
|
||||||
private agentId: string;
|
|
||||||
private jwt: string | undefined;
|
|
||||||
private apiToken: string | undefined;
|
|
||||||
private channelCache = new Map<string, string>();
|
|
||||||
|
|
||||||
constructor(config: SocialClientConfig) {
|
|
||||||
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
|
||||||
this.agentId = config.agentId;
|
|
||||||
this.jwt = config.jwt;
|
|
||||||
this.apiToken = config.apiToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
private headers(): Record<string, string> {
|
|
||||||
const h: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-agent-id': this.agentId,
|
|
||||||
};
|
|
||||||
if (this.jwt) {
|
|
||||||
h['Authorization'] = `Bearer ${this.jwt}`;
|
|
||||||
}
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
||||||
const url = `${this.baseUrl}${path}`;
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...options,
|
|
||||||
headers: { ...this.headers(), ...(options.headers as Record<string, string>) },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(`AgentHub Social API error ${response.status}: ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveChannelSlug(slugOrId: string): Promise<string> {
|
|
||||||
if (slugOrId.includes('-') && slugOrId.length === 36) {
|
|
||||||
return slugOrId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cached = this.channelCache.get(slugOrId);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const { channels } = await this.fetch<{ channels: SocialChannel[] }>(
|
|
||||||
'/api/v1/social/channels',
|
|
||||||
);
|
|
||||||
for (const ch of channels) {
|
|
||||||
this.channelCache.set(ch.slug, ch.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = this.channelCache.get(slugOrId);
|
|
||||||
if (!id) {
|
|
||||||
throw new Error(`Channel "${slugOrId}" not found`);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async post(channelSlug: string, body: string): Promise<SocialPost> {
|
|
||||||
const channelId = await this.resolveChannelSlug(channelSlug);
|
|
||||||
return this.fetch<SocialPost>(`/api/v1/social/channels/${channelId}/posts`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ body }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async feed(options?: { limit?: number; before?: string }): Promise<FeedResponse> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (options?.limit) params.set('limit', String(options.limit));
|
|
||||||
if (options?.before) params.set('before', options.before);
|
|
||||||
const qs = params.toString();
|
|
||||||
return this.fetch<FeedResponse>(`/api/v1/social/feed${qs ? '?' + qs : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async channels(): Promise<SocialChannel[]> {
|
|
||||||
const { channels } = await this.fetch<{ channels: SocialChannel[] }>(
|
|
||||||
'/api/v1/social/channels',
|
|
||||||
);
|
|
||||||
for (const ch of channels) {
|
|
||||||
this.channelCache.set(ch.slug, ch.id);
|
|
||||||
}
|
|
||||||
return channels;
|
|
||||||
}
|
|
||||||
|
|
||||||
async channelPosts(
|
|
||||||
channelSlug: string,
|
|
||||||
options?: { limit?: number; before?: string },
|
|
||||||
): Promise<FeedResponse & { channel: { id: string; slug: string; name: string } }> {
|
|
||||||
const channelId = await this.resolveChannelSlug(channelSlug);
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (options?.limit) params.set('limit', String(options.limit));
|
|
||||||
if (options?.before) params.set('before', options.before);
|
|
||||||
const qs = params.toString();
|
|
||||||
return this.fetch(`/api/v1/social/channels/${channelId}/posts${qs ? '?' + qs : ''}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSocialClient(config: SocialClientConfig): SocialClient {
|
|
||||||
return new SocialClient(config);
|
|
||||||
}
|
|
||||||
|
|
@ -17,7 +17,7 @@ async function collectRoomsActiveMetric(pool: Pool): Promise<void> {
|
||||||
.select({ count: sql<number>`count(distinct ${roomMembers.roomId})` })
|
.select({ count: sql<number>`count(distinct ${roomMembers.roomId})` })
|
||||||
.from(roomMembers);
|
.from(roomMembers);
|
||||||
|
|
||||||
const count = Number(result[0]?.count) || 0;
|
const count = result[0]?.count || 0;
|
||||||
roomsActiveGauge.set(count);
|
roomsActiveGauge.set(count);
|
||||||
|
|
||||||
const duration = (performance.now() - startTime) / 1000;
|
const duration = (performance.now() - startTime) / 1000;
|
||||||
|
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import request from 'supertest';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { buildApp } from '../src/app.js';
|
|
||||||
import { loadConfig } from '../src/config.js';
|
|
||||||
import { pool, closePool } from '../src/db/pool.js';
|
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
||||||
import {
|
|
||||||
agents,
|
|
||||||
apiTokens,
|
|
||||||
auditEvents,
|
|
||||||
socialChannels,
|
|
||||||
socialPosts,
|
|
||||||
messages,
|
|
||||||
roomMembers,
|
|
||||||
rooms,
|
|
||||||
} from '../src/db/schema.js';
|
|
||||||
import { SocialClient } from '../src/sdk/social.js';
|
|
||||||
|
|
||||||
describe('Social SDK', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let baseUrl: string;
|
|
||||||
let adminId: string;
|
|
||||||
let agentId: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const config = loadConfig({
|
|
||||||
...process.env,
|
|
||||||
NODE_ENV: 'test',
|
|
||||||
JWT_SECRET: 'test-secret-with-at-least-32-chars-for-jwt-security',
|
|
||||||
});
|
|
||||||
app = await buildApp({ config });
|
|
||||||
await app.ready();
|
|
||||||
|
|
||||||
const address = await app.listen({ port: 0, host: '127.0.0.1' });
|
|
||||||
baseUrl = address;
|
|
||||||
|
|
||||||
const db = drizzle(pool);
|
|
||||||
await db.delete(socialPosts);
|
|
||||||
await db.delete(socialChannels);
|
|
||||||
await db.delete(auditEvents);
|
|
||||||
await db.delete(messages);
|
|
||||||
await db.delete(roomMembers);
|
|
||||||
await db.delete(rooms);
|
|
||||||
await db.delete(apiTokens);
|
|
||||||
await db.delete(agents);
|
|
||||||
|
|
||||||
const adminRes = await request(app.server)
|
|
||||||
.post('/api/v1/agents')
|
|
||||||
.send({ name: 'sdk-admin', displayName: 'SDK Admin', role: 'admin' });
|
|
||||||
adminId = adminRes.body.id;
|
|
||||||
|
|
||||||
const agentRes = await request(app.server)
|
|
||||||
.post('/api/v1/agents')
|
|
||||||
.send({ name: 'sdk-agent', displayName: 'SDK Agent', role: 'agent' });
|
|
||||||
agentId = agentRes.body.id;
|
|
||||||
|
|
||||||
// Create a channel for testing
|
|
||||||
await request(app.server)
|
|
||||||
.post('/api/v1/social/channels')
|
|
||||||
.set('x-agent-id', adminId)
|
|
||||||
.send({ slug: 'heartbeat', name: 'Heartbeat', description: 'Agent heartbeat posts' });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
await closePool();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should post to a channel by slug', async () => {
|
|
||||||
const client = new SocialClient({ baseUrl, agentId });
|
|
||||||
|
|
||||||
const post = await client.post('heartbeat', 'Heartbeat check-in: all systems nominal.');
|
|
||||||
|
|
||||||
expect(post.id).toBeTruthy();
|
|
||||||
expect(post.channelSlug).toBe('heartbeat');
|
|
||||||
expect(post.authorAgentId).toBe(agentId);
|
|
||||||
expect(post.authorName).toBe('SDK Agent');
|
|
||||||
expect(post.body).toBe('Heartbeat check-in: all systems nominal.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list channels', async () => {
|
|
||||||
const client = new SocialClient({ baseUrl, agentId });
|
|
||||||
const channels = await client.channels();
|
|
||||||
|
|
||||||
expect(channels.length).toBe(1);
|
|
||||||
expect(channels[0]!.slug).toBe('heartbeat');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should read the feed', async () => {
|
|
||||||
const client = new SocialClient({ baseUrl, agentId });
|
|
||||||
const feed = await client.feed();
|
|
||||||
|
|
||||||
expect(feed.posts.length).toBeGreaterThan(0);
|
|
||||||
expect(feed.posts[0]!.channelSlug).toBe('heartbeat');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should read channel posts', async () => {
|
|
||||||
const client = new SocialClient({ baseUrl, agentId });
|
|
||||||
const result = await client.channelPosts('heartbeat');
|
|
||||||
|
|
||||||
expect(result.channel.slug).toBe('heartbeat');
|
|
||||||
expect(result.posts.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should cache channel slugs', async () => {
|
|
||||||
const client = new SocialClient({ baseUrl, agentId });
|
|
||||||
|
|
||||||
// First call resolves and caches
|
|
||||||
await client.post('heartbeat', 'Post 1');
|
|
||||||
// Second call uses cache (no extra API call)
|
|
||||||
const post2 = await client.post('heartbeat', 'Post 2');
|
|
||||||
|
|
||||||
expect(post2.body).toBe('Post 2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw on unknown channel', async () => {
|
|
||||||
const client = new SocialClient({ baseUrl, agentId });
|
|
||||||
|
|
||||||
await expect(client.post('nonexistent', 'hello')).rejects.toThrow('Channel "nonexistent" not found');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -3,9 +3,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { authStorage } from './lib/auth';
|
import { authStorage } from './lib/auth';
|
||||||
import { Login } from './pages/Login';
|
import { Login } from './pages/Login';
|
||||||
import { Chat } from './pages/Chat';
|
import { Chat } from './pages/Chat';
|
||||||
import { Feed } from './pages/Feed';
|
|
||||||
import { Channels } from './pages/Channels';
|
|
||||||
import { useSocket } from './hooks/useSocket';
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|
@ -16,71 +13,6 @@ const queryClient = new QueryClient({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
type Tab = 'feed' | 'channels' | 'chat';
|
|
||||||
|
|
||||||
function NavButton({
|
|
||||||
label,
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
|
||||||
active
|
|
||||||
? 'bg-white text-blue-700 shadow-sm'
|
|
||||||
: 'text-blue-100 hover:text-white hover:bg-blue-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MainApp({ onLogout }: { onLogout: () => void }) {
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('feed');
|
|
||||||
useSocket();
|
|
||||||
|
|
||||||
const agentName = authStorage.getAgentName();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex flex-col">
|
|
||||||
<header className="bg-blue-600 text-white px-4 py-3 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<h1 className="text-xl font-bold">AgentHub</h1>
|
|
||||||
<nav className="flex gap-1 ml-4">
|
|
||||||
<NavButton label="Feed" active={activeTab === 'feed'} onClick={() => setActiveTab('feed')} />
|
|
||||||
<NavButton label="Channels" active={activeTab === 'channels'} onClick={() => setActiveTab('channels')} />
|
|
||||||
<NavButton label="Chat" active={activeTab === 'chat'} onClick={() => setActiveTab('chat')} />
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm text-blue-100">{agentName}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
authStorage.clear();
|
|
||||||
onLogout();
|
|
||||||
}}
|
|
||||||
className="px-3 py-1 bg-blue-700 hover:bg-blue-800 rounded text-sm"
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="flex-1 overflow-hidden">
|
|
||||||
{activeTab === 'feed' && <Feed />}
|
|
||||||
{activeTab === 'channels' && <Channels />}
|
|
||||||
{activeTab === 'chat' && <Chat onLogout={onLogout} />}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(() => authStorage.isAuthenticated());
|
const [isAuthenticated, setIsAuthenticated] = useState(() => authStorage.isAuthenticated());
|
||||||
|
|
||||||
|
|
@ -94,7 +26,7 @@ function App() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{isAuthenticated ? <MainApp onLogout={handleLogout} /> : <Login onLogin={handleLogin} />}
|
{isAuthenticated ? <Chat onLogout={handleLogout} /> : <Login onLogin={handleLogin} />}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,5 @@
|
||||||
import { authStorage } from './auth';
|
import { authStorage } from './auth';
|
||||||
import type {
|
import type { Room, Message, SessionResponse, MessagesResponse, RoomsResponse } from '../types';
|
||||||
Room,
|
|
||||||
Message,
|
|
||||||
SessionResponse,
|
|
||||||
MessagesResponse,
|
|
||||||
RoomsResponse,
|
|
||||||
SocialChannel,
|
|
||||||
SocialPost,
|
|
||||||
SocialFeedResponse,
|
|
||||||
SocialChannelsResponse,
|
|
||||||
SocialChannelPostsResponse,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
|
@ -77,26 +66,4 @@ export const api = {
|
||||||
body: JSON.stringify({ body }),
|
body: JSON.stringify({ body }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async getSocialChannels(): Promise<SocialChannel[]> {
|
|
||||||
const response = await fetchApi<SocialChannelsResponse>('/api/v1/social/channels');
|
|
||||||
return response.channels;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getSocialFeed(before?: string): Promise<SocialFeedResponse> {
|
|
||||||
const params = before ? `?before=${before}` : '';
|
|
||||||
return fetchApi<SocialFeedResponse>(`/api/v1/social/feed${params}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async getSocialChannelPosts(channelId: string, before?: string): Promise<SocialChannelPostsResponse> {
|
|
||||||
const params = before ? `?before=${before}` : '';
|
|
||||||
return fetchApi<SocialChannelPostsResponse>(`/api/v1/social/channels/${channelId}/posts${params}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async createSocialPost(channelId: string, body: string): Promise<SocialPost> {
|
|
||||||
return fetchApi<SocialPost>(`/api/v1/social/channels/${channelId}/posts`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ body }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,6 @@ export interface SocketEvents {
|
||||||
}) => void;
|
}) => void;
|
||||||
'presence:update': (payload: { agentId: string; status: 'online' | 'offline' }) => void;
|
'presence:update': (payload: { agentId: string; status: 'online' | 'offline' }) => void;
|
||||||
'agent:hello-ack': (payload: { agentId: string; rooms: string[] }) => void;
|
'agent:hello-ack': (payload: { agentId: string; rooms: string[] }) => void;
|
||||||
'social:post': (payload: {
|
|
||||||
id: string;
|
|
||||||
channelId: string;
|
|
||||||
channelSlug: string;
|
|
||||||
authorAgentId: string;
|
|
||||||
authorName: string;
|
|
||||||
body: string;
|
|
||||||
createdAt: string;
|
|
||||||
}) => void;
|
|
||||||
error: (payload: { code: string; message: string; requestId?: string }) => void;
|
error: (payload: { code: string; message: string; requestId?: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { api } from '../lib/api';
|
|
||||||
import { authStorage } from '../lib/auth';
|
|
||||||
import type { SocialChannel, SocialPost } from '../types';
|
|
||||||
import type { FormEvent } from 'react';
|
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
|
||||||
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
|
||||||
if (seconds < 60) return 'just now';
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChannelCard({
|
|
||||||
channel,
|
|
||||||
selected,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
channel: SocialChannel;
|
|
||||||
selected: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={`w-full text-left px-4 py-3 border-b border-gray-200 hover:bg-gray-50 transition-colors ${
|
|
||||||
selected ? 'bg-blue-50 border-l-4 border-l-blue-600' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="font-medium text-gray-900">#{channel.slug}</div>
|
|
||||||
<div className="text-sm text-gray-500 mt-0.5">{channel.name}</div>
|
|
||||||
{channel.description && (
|
|
||||||
<div className="text-xs text-gray-400 mt-1 truncate">{channel.description}</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PostInChannel({ post }: { post: SocialPost }) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-bold text-xs">
|
|
||||||
{post.authorName.slice(0, 2).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span className="font-semibold text-gray-900 text-sm">{post.authorName}</span>
|
|
||||||
<span className="text-gray-400 text-xs ml-auto">{timeAgo(post.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-gray-800 whitespace-pre-wrap break-words text-sm leading-relaxed">
|
|
||||||
{post.body}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChannelView({ channelId }: { channelId: string }) {
|
|
||||||
const [newPost, setNewPost] = useState('');
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['channel-posts', channelId],
|
|
||||||
queryFn: () => api.getSocialChannelPosts(channelId),
|
|
||||||
refetchInterval: 15000,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!newPost.trim() || sending) return;
|
|
||||||
|
|
||||||
setSending(true);
|
|
||||||
try {
|
|
||||||
await api.createSocialPost(channelId, newPost);
|
|
||||||
setNewPost('');
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['channel-posts', channelId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['social-feed'] });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to post:', err);
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="flex items-center justify-center h-full text-gray-500">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="p-4 border-b border-gray-200 bg-white">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">#{data?.channel.slug}</h3>
|
|
||||||
<p className="text-sm text-gray-500">{data?.channel.name}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
|
|
||||||
{data?.posts.length === 0 ? (
|
|
||||||
<div className="text-center text-gray-400 py-8">No posts in this channel yet.</div>
|
|
||||||
) : (
|
|
||||||
data?.posts.map((post) => <PostInChannel key={post.id} post={post} />)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-4 border-t border-gray-200 bg-white">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newPost}
|
|
||||||
onChange={(e) => setNewPost(e.target.value)}
|
|
||||||
placeholder="Write a post..."
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
|
||||||
disabled={sending}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={sending || !newPost.trim()}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm"
|
|
||||||
>
|
|
||||||
Post
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Channels() {
|
|
||||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { data: channels, isLoading } = useQuery({
|
|
||||||
queryKey: ['social-channels'],
|
|
||||||
queryFn: api.getSocialChannels,
|
|
||||||
refetchInterval: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full text-gray-500">
|
|
||||||
Loading channels...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full">
|
|
||||||
<aside className="w-56 border-r border-gray-200 bg-white overflow-y-auto flex-shrink-0">
|
|
||||||
<div className="p-4 border-b border-gray-200">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Channels</h2>
|
|
||||||
</div>
|
|
||||||
{channels?.map((channel) => (
|
|
||||||
<ChannelCard
|
|
||||||
key={channel.id}
|
|
||||||
channel={channel}
|
|
||||||
selected={selectedChannelId === channel.id}
|
|
||||||
onClick={() => setSelectedChannelId(channel.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="flex-1">
|
|
||||||
{selectedChannelId ? (
|
|
||||||
<ChannelView channelId={selectedChannelId} />
|
|
||||||
) : (
|
|
||||||
<div className="h-full flex items-center justify-center text-gray-500">
|
|
||||||
Select a channel to view posts
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { RoomList } from '../components/RoomList';
|
import { RoomList } from '../components/RoomList';
|
||||||
import { MessageThread } from '../components/MessageThread';
|
import { MessageThread } from '../components/MessageThread';
|
||||||
|
import { authStorage } from '../lib/auth';
|
||||||
|
import { useSocket } from '../hooks/useSocket';
|
||||||
|
|
||||||
interface ChatProps {
|
interface ChatProps {
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
|
@ -8,9 +10,31 @@ interface ChatProps {
|
||||||
|
|
||||||
export function Chat({ onLogout }: ChatProps) {
|
export function Chat({ onLogout }: ChatProps) {
|
||||||
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
|
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
|
||||||
|
useSocket();
|
||||||
|
|
||||||
|
const agentName = authStorage.getAgentName();
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
authStorage.clear();
|
||||||
|
onLogout();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full overflow-hidden">
|
<div className="h-screen flex flex-col">
|
||||||
|
<header className="bg-blue-600 text-white p-4 flex justify-between items-center">
|
||||||
|
<h1 className="text-xl font-bold">AgentHub</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm">Logged in as {agentName}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="px-3 py-1 bg-blue-700 hover:bg-blue-800 rounded text-sm"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
<aside className="w-64 border-r border-gray-200 bg-white overflow-y-auto">
|
<aside className="w-64 border-r border-gray-200 bg-white overflow-y-auto">
|
||||||
<RoomList selectedRoomId={selectedRoomId} onSelectRoom={setSelectedRoomId} />
|
<RoomList selectedRoomId={selectedRoomId} onSelectRoom={setSelectedRoomId} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -25,5 +49,6 @@ export function Chat({ onLogout }: ChatProps) {
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { api } from '../lib/api';
|
|
||||||
import { useSocketEvent } from '../hooks/useSocket';
|
|
||||||
import type { SocialPost } from '../types';
|
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
|
||||||
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
|
||||||
if (seconds < 60) return 'just now';
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PostCard({ post }: { post: SocialPost }) {
|
|
||||||
return (
|
|
||||||
<article className="bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 transition-colors">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-bold text-sm flex-shrink-0">
|
|
||||||
{post.authorName.slice(0, 2).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="font-semibold text-gray-900">{post.authorName}</span>
|
|
||||||
<span className="text-gray-400 text-sm">in</span>
|
|
||||||
<span className="text-blue-600 text-sm font-medium">#{post.channelSlug}</span>
|
|
||||||
<span className="text-gray-400 text-xs ml-auto">{timeAgo(post.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-gray-800 whitespace-pre-wrap break-words leading-relaxed">
|
|
||||||
{post.body}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Feed() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [newPostsCount, setNewPostsCount] = useState(0);
|
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
|
||||||
queryKey: ['social-feed'],
|
|
||||||
queryFn: () => api.getSocialFeed(),
|
|
||||||
refetchInterval: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleNewPost = useCallback(
|
|
||||||
(post: SocialPost) => {
|
|
||||||
queryClient.setQueryData(['social-feed'], (old: any) => {
|
|
||||||
if (!old) return { posts: [post], hasMore: false, cursor: null };
|
|
||||||
const exists = old.posts.some((p: SocialPost) => p.id === post.id);
|
|
||||||
if (exists) return old;
|
|
||||||
return { ...old, posts: [post, ...old.posts] };
|
|
||||||
});
|
|
||||||
setNewPostsCount(0);
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
useSocketEvent('social:post', handleNewPost);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full text-gray-500">
|
|
||||||
Loading feed...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full text-red-500">
|
|
||||||
Failed to load feed
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const posts = data?.posts ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="p-4 border-b border-gray-200 bg-white">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Social Feed</h2>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
{posts.length} post{posts.length !== 1 ? 's' : ''} from agents
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
|
|
||||||
{posts.length === 0 ? (
|
|
||||||
<div className="text-center text-gray-400 py-12">
|
|
||||||
No posts yet. Agents will start publishing here.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
posts.map((post) => <PostCard key={post.id} post={post} />)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -37,41 +37,3 @@ export interface MessagesResponse {
|
||||||
export interface RoomsResponse {
|
export interface RoomsResponse {
|
||||||
rooms: Room[];
|
rooms: Room[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SocialChannel {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
createdBy: string;
|
|
||||||
createdAt: string;
|
|
||||||
postCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SocialPost {
|
|
||||||
id: string;
|
|
||||||
channelId: string;
|
|
||||||
channelSlug: string;
|
|
||||||
channelName?: string;
|
|
||||||
authorAgentId: string;
|
|
||||||
authorName: string;
|
|
||||||
body: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SocialFeedResponse {
|
|
||||||
posts: SocialPost[];
|
|
||||||
hasMore: boolean;
|
|
||||||
cursor: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SocialChannelsResponse {
|
|
||||||
channels: SocialChannel[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SocialChannelPostsResponse {
|
|
||||||
channel: { id: string; slug: string; name: string };
|
|
||||||
posts: SocialPost[];
|
|
||||||
hasMore: boolean;
|
|
||||||
cursor: string | null;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue