Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Deploy
on:
push:
branches: [ main ]

jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20.11'
cache: 'npm'
cache-dependency-path: 'react-app/package-lock.json'
- name: Build (react-app)
working-directory: react-app
run: |
npm ci
npm run build

- name: Add SSH key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: |
${{ secrets.SSH_PRIVATE_KEY }}

- name: Sync build to server & reload nginx
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_PATH: ${{ secrets.SSH_PATH }}
run: |
rsync -avz --delete -e "ssh -o StrictHostKeyChecking=no" react-app/build/ ${SSH_USER}@${SSH_HOST}:${SSH_PATH}/
ssh -o StrictHostKeyChecking=no ${SSH_USER}@${SSH_HOST} "sudo nginx -t && sudo systemctl reload nginx"
58 changes: 58 additions & 0 deletions docs/FISHERMEN_API_FULL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 🌊 API Pescadores - Integración Completa (Geo + Pronóstico + Forense)

## Endpoints (Backend)

- GET `/api/fishermen/location?latitude=<lat>&longitude=<lon>`
- Devuelve info de geolocalización usando `modules.geo.get_location`
- POST `/api/fishermen/weather`
- Body: `{ latitude, longitude, include_fishing_recommendations }`
- Usa `FishermenApp.get_weather` y agrega `fishing_tips`
- GET `/api/fishermen/complete?latitude=<lat>&longitude=<lon>`
- Devuelve `get_geo_info` con clima, mapas y ubicación + extra para pescadores
- GET `/api/fishermen/status`
- Estado de integraciones (OpenWeather, Google Maps, IBGE, local)

Fuente principal: `backend/iuri_server_dark.py` (sección API PESCADORES)

## Frontend React

Servicio: `react-app/src/services/fishermenGeo.ts`
- `getLocation(lat, lon)`
- `getWeather({ latitude, longitude, include_fishing_recommendations })`
- `getComplete(lat, lon)`
- `getStatus()`

UI: `react-app/src/components/FishermenDashboard.tsx` (listado) y App base (`react-app/src/App.tsx`).

## Integración Geo

- Módulo: `modules/geo` (location, coordinates, weather, mapas)
- Función integrada: `get_geo_info(lat, lon)`
- Archivos de referencia:
- `backend/fishermen_geo_api.py`
- `docs/GEO_FISHERMEN_INTEGRATION.md`

## Zonas óptimas y previsión (base)

- Config especies y zonas: `configs/offshore_species.yaml`
- Cálculo hotspots (scoring): `scripts/fetch_offshore.py`
- Datos oceánicos/marinos demo: `modules/oceanus/service.py`

Siguiente paso sugerido (PR futuro):
- Exponer `GET /api/fishermen/hotspots?lat=&lon=&species=` que consuma `fetch_offshore.py` o un servicio ligero para puntuar zonas.

## Forense y auditoría

- Guardian de logs: `backend/log_guardian.py`
- Config de triaje y auditorías: `configs/triage_config.yaml`, `scripts/triage.py`
- Propuesta: Loggear cada request `fishermen_*` con `log_guardian.log_critical_action` y auditar periódicamente.

## Comandos de verificación

- Backend local: `uvicorn backend.iuri_server_dark:app --reload`
- Frontend: `cd react-app && npm ci && npm run build && npm test -- --watch=false --passWithNoTests`

## CI/CD

- CI: `.github/workflows/ci.yml` (ejecuta en `react-app/`)
- Deploy: `.github/workflows/deploy.yml` + scripts Hetzner
42 changes: 42 additions & 0 deletions react-app/src/services/fishermenGeo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export interface Coordinates {
latitude: number;
longitude: number;
}

export interface WeatherRequest extends Coordinates {
include_fishing_recommendations?: boolean;
}

export async function getLocation(latitude: number, longitude: number, base = '/api/fishermen') {
const url = `${base}/location?latitude=${encodeURIComponent(latitude)}&longitude=${encodeURIComponent(longitude)}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}

export async function getWeather(body: WeatherRequest, base = '/api/fishermen') {
const url = `${base}/weather`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}

export async function getComplete(latitude: number, longitude: number, base = '/api/fishermen') {
const url = `${base}/complete?latitude=${encodeURIComponent(latitude)}&longitude=${encodeURIComponent(longitude)}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}

export async function getStatus(base = '/api/fishermen') {
const url = `${base}/status`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}


30 changes: 30 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React, { useState } from 'react'
import MemoryConsole from './components/MemoryConsole'
import FishermenDashboard from './components/FishermenDashboard'

function App() {
const [view, setView] = useState<'home'|'memory'|'fishermen'>('home')
return (
<div className="App" style={{minHeight:'100vh', background:'#0b0f16', color:'#fff', padding:16}}>
<header style={{marginBottom:12}}>
<h1 style={{margin:0}}>iURi React App</h1>
<div style={{marginTop:8, display:'flex', gap:8}}>
<button onClick={()=>setView('home')} style={{padding:'6px 10px'}}>Inicio</button>
<button onClick={()=>setView('memory')} style={{padding:'6px 10px'}}>Memoria</button>
<button onClick={()=>setView('fishermen')} style={{padding:'6px 10px'}}>Pescadores</button>
</div>
</header>
{view==='home' && (
<p>Build limpio y verificable</p>
)}
{view==='memory' && (
<MemoryConsole />
)}
{view==='fishermen' && (
<FishermenDashboard />
)}
</div>
)
}

export default App
58 changes: 58 additions & 0 deletions src/components/FishermenDashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React, { useState } from 'react';
import { getLocation, getWeather, getComplete } from '../services/fishermen';

export default function FishermenDashboard() {
const [lat, setLat] = useState<number>(-22.9194);
const [lon, setLon] = useState<number>(-42.8186);
const [loading, setLoading] = useState(false);
const [out, setOut] = useState<string>('');
const [error, setError] = useState<string | null>(null);

async function run(fn: () => Promise<any>) {
setLoading(true);
setError(null);
setOut('');
try {
const data = await fn();
setOut(JSON.stringify(data, null, 2));
} catch (e: any) {
setError(e?.message ?? 'Error');
} finally {
setLoading(false);
}
}

return (
<div style={{ background:'#0d1117', color:'#c9d1d9', padding:16, border:'1px solid #30363d', borderRadius:8 }}>
<h2 style={{marginBottom:8}}>🌊 Pescadores</h2>
<div style={{display:'grid', gap:8, gridTemplateColumns:'1fr 1fr'}}>
<div style={{background:'#161b22', padding:12, borderRadius:6, border:'1px solid #30363d'}}>
<div style={{marginBottom:8}}>
<label>Latitud</label>
<input type="number" step="any" value={lat} onChange={e=>setLat(parseFloat(e.target.value))} style={{width:'100%', background:'#0d1117', color:'#c9d1d9', border:'1px solid #30363d', borderRadius:4, padding:8}} />
</div>
<div style={{marginBottom:8}}>
<label>Longitud</label>
<input type="number" step="any" value={lon} onChange={e=>setLon(parseFloat(e.target.value))} style={{width:'100%', background:'#0d1117', color:'#c9d1d9', border:'1px solid #30363d', borderRadius:4, padding:8}} />
</div>
<div style={{display:'flex', gap:8, flexWrap:'wrap'}}>
<button disabled={loading} onClick={()=>run(()=>getLocation(lat, lon))} style={{padding:'8px 12px', background:'#58a6ff', color:'#0d1117', border:'none', borderRadius:4, cursor:'pointer'}}>📍 Ubicación</button>
<button disabled={loading} onClick={()=>run(()=>getWeather({ latitude: lat, longitude: lon, include_fishing_recommendations: true }))} style={{padding:'8px 12px', background:'#58a6ff', color:'#0d1117', border:'none', borderRadius:4, cursor:'pointer'}}>🌤️ Clima</button>
<button disabled={loading} onClick={()=>run(()=>getComplete(lat, lon))} style={{padding:'8px 12px', background:'#58a6ff', color:'#0d1117', border:'none', borderRadius:4, cursor:'pointer'}}>🎯 Completo</button>
</div>
</div>
<div style={{background:'#161b22', padding:12, borderRadius:6, border:'1px solid #30363d'}}>
<h3 style={{marginTop:0}}>Resultado</h3>
{loading && <div>🔄 Cargando…</div>}
{error && <div style={{color:'#f85149'}}>❌ {error}</div>}
{out && (
<pre style={{whiteSpace:'pre-wrap', background:'#0d1117', border:'1px solid #30363d', borderRadius:4, padding:8, maxHeight:320, overflow:'auto'}}>{out}</pre>
)}
{!loading && !error && !out && <div>—</div>}
</div>
</div>
</div>
);
}


132 changes: 132 additions & 0 deletions src/components/MemoryConsole.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import React, { useEffect, useMemo, useState } from 'react';
import { appendEntry, fetchEntries, fetchStats, type MemoryEntry } from '../services/memory';

export default function MemoryConsole() {
const [query, setQuery] = useState('');
const [limit, setLimit] = useState(100);
const [loading, setLoading] = useState(false);
const [entries, setEntries] = useState<MemoryEntry[]>([]);
const [stats, setStats] = useState<Record<string, unknown> | null>(null);
const [text, setText] = useState('');
const [tags, setTags] = useState('');
const [error, setError] = useState<string | null>(null);

const parsedTags = useMemo(() => (
tags
.split(',')
.map(s => s.trim())
.filter(Boolean)
), [tags]);

async function load() {
setLoading(true);
setError(null);
try {
const [e, s] = await Promise.all([
fetchEntries(query, limit),
fetchStats().catch(() => null),
]);
setEntries(e);
setStats(s as Record<string, unknown> | null);
} catch (err: any) {
setError(err?.message ?? 'Error cargando memoria');
} finally {
setLoading(false);
}
}

async function onAppend() {
if (!text.trim()) return;
setLoading(true);
setError(null);
try {
await appendEntry(text.trim(), parsedTags);
setText('');
setTags('');
await load();
} catch (err: any) {
setError(err?.message ?? 'Error agregando entrada');
} finally {
setLoading(false);
}
}

useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<div style={{
background: '#0d1117', color: '#c9d1d9', padding: 16,
border: '1px solid #30363d', borderRadius: 8
}}>
<h2 style={{marginBottom: 8}}>🧠 Memoria iURi</h2>

<div style={{display: 'grid', gap: 8, gridTemplateColumns: '1fr 1fr'}}>
<div style={{background:'#161b22', padding: 12, borderRadius: 6, border:'1px solid #30363d'}}>
<h3 style={{marginBottom: 8}}>Agregar nota</h3>
<textarea
placeholder="Escribe aquí…"
value={text}
onChange={e => setText(e.target.value)}
rows={4}
style={{width:'100%', background:'#0d1117', color:'#c9d1d9', border:'1px solid #30363d', borderRadius: 4, padding: 8}}
/>
<input
placeholder="tags (opcional, separadas por coma)"
value={tags}
onChange={e => setTags(e.target.value)}
style={{width:'100%', marginTop:8, background:'#0d1117', color:'#c9d1d9', border:'1px solid #30363d', borderRadius: 4, padding: 8}}
/>
<button onClick={onAppend} disabled={loading} style={{marginTop:8, padding:'8px 12px', background:'#58a6ff', color:'#0d1117', border:'none', borderRadius:4, cursor:'pointer'}}>
{loading ? 'Guardando…' : 'Guardar'}
</button>
</div>

<div style={{background:'#161b22', padding: 12, borderRadius: 6, border:'1px solid #30363d'}}>
<h3 style={{marginBottom: 8}}>Buscar</h3>
<div style={{display:'flex', gap:8}}>
<input
placeholder="texto…"
value={query}
onChange={e => setQuery(e.target.value)}
style={{flex:1, background:'#0d1117', color:'#c9d1d9', border:'1px solid #30363d', borderRadius: 4, padding: 8}}
/>
<input
type="number"
min={1}
value={limit}
onChange={e => setLimit(Number(e.target.value))}
style={{width:100, background:'#0d1117', color:'#c9d1d9', border:'1px solid #30363d', borderRadius: 4, padding: 8}}
/>
<button onClick={load} disabled={loading} style={{padding:'8px 12px', background:'#58a6ff', color:'#0d1117', border:'none', borderRadius:4, cursor:'pointer'}}>Refrescar</button>
</div>
{stats && (
<pre style={{marginTop:8, background:'#0d1117', border:'1px solid #30363d', borderRadius:4, padding:8, maxHeight:200, overflow:'auto'}}>{JSON.stringify(stats, null, 2)}</pre>
)}
</div>
</div>

{error && (
<div style={{marginTop:8, color:'#f85149'}}>❌ {error}</div>
)}

<div style={{marginTop:12, background:'#161b22', padding:12, borderRadius:6, border:'1px solid #30363d'}}>
<h3 style={{marginBottom:8}}>Entradas</h3>
{loading && <div>🔄 Cargando…</div>}
{!loading && entries.length === 0 && <div>Sin resultados.</div>}
<div style={{display:'grid', gap:8}}>
{entries.map((e, idx) => (
<div key={e.id ?? e.timestamp + '-' + idx} style={{background:'#0d1117', border:'1px solid #30363d', borderRadius:4, padding:8}}>
<div style={{fontSize:12, color:'#8b949e'}}>{e.timestamp} {e.tags && e.tags.length ? '• ' + e.tags.join(', ') : ''}</div>
<div style={{whiteSpace:'pre-wrap'}}>{e.text}</div>
</div>
))}
</div>
</div>
</div>
);
}


Loading