diff --git a/Backend/db/migrations/003_post_mortem_fields.sql b/Backend/db/migrations/003_post_mortem_fields.sql new file mode 100644 index 0000000..5aaf874 --- /dev/null +++ b/Backend/db/migrations/003_post_mortem_fields.sql @@ -0,0 +1,4 @@ +-- Campos para post-mortem editable/exportable por incidente. +ALTER TABLE incidents + ADD COLUMN IF NOT EXISTS post_mortem_md TEXT, + ADD COLUMN IF NOT EXISTS post_mortem_updated_at TIMESTAMPTZ; diff --git a/Backend/routers/incidents.py b/Backend/routers/incidents.py index 9241544..90d4ee7 100644 --- a/Backend/routers/incidents.py +++ b/Backend/routers/incidents.py @@ -2,6 +2,7 @@ from typing import Literal, Optional from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from fastapi.responses import JSONResponse, PlainTextResponse from pydantic import BaseModel, Field from auth import get_current_user @@ -10,6 +11,12 @@ from services.agents.memory.incidents import query_incidents from services.agents.memory.runbooks import query_runbooks from services.langgraph_engine import run_langgraph_engine +from services.postmortem.formatters import format_incident_export_markdown +from services.postmortem.service import ( + build_incident_export_payload, + generate_post_mortem_for_incident, + save_post_mortem, +) from services.prometheus import get_container_metrics, get_postgres_metrics router = APIRouter(prefix="/api/incidents", tags=["incidents"]) @@ -74,17 +81,108 @@ async def get_incident(incident_id: str, user=Depends(get_current_user)): async def update_incident_status( incident_id: str, body: IncidentStatusUpdate, + background_tasks: BackgroundTasks, user=Depends(get_current_user), ): + update_fields = {"status": body.status} + if body.status == "resolved": + from datetime import datetime, timezone + update_fields["resolved_at"] = datetime.now(tz=timezone.utc).isoformat() + response = ( supabase.table("incidents") - .update({"status": body.status}) + .update(update_fields) .eq("id", incident_id) .execute() ) + + if body.status == "resolved": + background_tasks.add_task(generate_post_mortem_for_incident, incident_id) + return response.data +class UpdatePostMortemRequest(BaseModel): + content: str = Field(..., min_length=1, max_length=100000) + + +@router.get("/{incident_id}/post-mortem") +async def get_post_mortem(incident_id: str, user=Depends(get_current_user)): + try: + response = ( + supabase.table("incidents") + .select("id,status,post_mortem_md,post_mortem_updated_at") + .eq("id", incident_id) + .single() + .execute() + ) + except Exception: + raise HTTPException(status_code=404, detail="Incidente no encontrado") + + incident = response.data + if not incident: + raise HTTPException(status_code=404, detail="Incidente no encontrado") + + content = (incident.get("post_mortem_md") or "").strip() + generated = False + if not content and incident.get("status") == "resolved": + generated_content = generate_post_mortem_for_incident(incident_id) + content = (generated_content or "").strip() + generated = bool(content) + + return { + "incident_id": incident_id, + "status": incident.get("status"), + "content": content, + "updated_at": incident.get("post_mortem_updated_at"), + "generated": generated, + } + + +@router.put("/{incident_id}/post-mortem") +async def update_post_mortem( + incident_id: str, + body: UpdatePostMortemRequest, + user=Depends(get_current_user), +): + try: + exists = ( + supabase.table("incidents") + .select("id") + .eq("id", incident_id) + .single() + .execute() + ) + except Exception: + raise HTTPException(status_code=404, detail="Incidente no encontrado") + + if not exists.data: + raise HTTPException(status_code=404, detail="Incidente no encontrado") + + save_post_mortem(incident_id, body.content) + return {"incident_id": incident_id, "saved": True} + + +@router.get("/{incident_id}/export") +async def export_incident( + incident_id: str, + format: str = "json", + user=Depends(get_current_user), +): + payload = build_incident_export_payload(incident_id) + if not payload: + raise HTTPException(status_code=404, detail="Incidente no encontrado") + + fmt = (format or "json").lower() + if fmt == "json": + return JSONResponse(content=payload) + if fmt == "markdown": + md = format_incident_export_markdown(payload) + return PlainTextResponse(content=md, media_type="text/markdown") + + raise HTTPException(status_code=400, detail="Formato no soportado. Usa 'json' o 'markdown'.") + + class CreateIncidentManual(BaseModel): title: str = Field(..., min_length=1, max_length=200) target: str = Field(..., min_length=1, max_length=100) diff --git a/Backend/services/alert_processor.py b/Backend/services/alert_processor.py index cb2e78f..34da951 100644 --- a/Backend/services/alert_processor.py +++ b/Backend/services/alert_processor.py @@ -6,6 +6,7 @@ import requests from db.supabase_client import supabase +from services.postmortem.service import generate_post_mortem_for_incident logger = logging.getLogger(__name__) @@ -319,5 +320,6 @@ def _resolve_incident(target: str) -> None: from services.incident_events import record_event for row in response.data: record_event(row["id"], "resolved") + generate_post_mortem_for_incident(row["id"]) except Exception as e: logger.error(f"Error resolviendo incidente para '{target}': {e}") diff --git a/Backend/services/postmortem/__init__.py b/Backend/services/postmortem/__init__.py new file mode 100644 index 0000000..3fec982 --- /dev/null +++ b/Backend/services/postmortem/__init__.py @@ -0,0 +1,11 @@ +from services.postmortem.service import ( + build_incident_export_payload, + generate_post_mortem_for_incident, + save_post_mortem, +) + +__all__ = [ + "build_incident_export_payload", + "generate_post_mortem_for_incident", + "save_post_mortem", +] diff --git a/Backend/services/postmortem/formatters.py b/Backend/services/postmortem/formatters.py new file mode 100644 index 0000000..9cb224c --- /dev/null +++ b/Backend/services/postmortem/formatters.py @@ -0,0 +1,122 @@ +from datetime import datetime +from typing import Any + + +def parse_iso(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except Exception: + return None + + +def format_mttr(created_at: str | None, resolved_at: str | None) -> str: + start = parse_iso(created_at) + end = parse_iso(resolved_at) + if not start or not end: + return "No disponible" + + delta = end - start + total_sec = int(delta.total_seconds()) + if total_sec < 0: + return "No disponible" + + mins, sec = divmod(total_sec, 60) + hrs, mins = divmod(mins, 60) + if hrs: + return f"{hrs}h {mins}m {sec}s" + if mins: + return f"{mins}m {sec}s" + return f"{sec}s" + + +def format_timeline_markdown(events: list[dict[str, Any]]) -> str: + if not events: + return "- Sin eventos registrados" + + lines: list[str] = [] + for event in events: + status = event.get("status", "unknown") + when = event.get("created_at") or event.get("updated_at") or "" + pretty_when = when.replace("T", " ").replace("+00:00", " UTC") if when else "sin fecha" + lines.append(f"- [{pretty_when}] `{status}`") + return "\n".join(lines) + + +def format_incident_export_markdown(payload: dict[str, Any]) -> str: + incident = payload.get("metadata", {}) + evidence = payload.get("evidence", {}) + timeline = payload.get("timeline", []) + decisions = payload.get("decisions", []) + actions = payload.get("actions", []) + + lines = [ + f"# Incident Export: {incident.get('title') or 'Sin título'}", + "", + "## Metadata", + f"- ID: `{incident.get('id', 'N/A')}`", + f"- Severity: `{incident.get('severity', 'N/A')}`", + f"- Status: `{incident.get('status', 'N/A')}`", + f"- Source type: `{incident.get('source_type', 'N/A')}`", + f"- Target: `{incident.get('target', 'N/A')}`", + f"- Created at: `{incident.get('created_at', 'N/A')}`", + f"- Resolved at: `{incident.get('resolved_at', 'N/A')}`", + f"- MTTR: `{incident.get('mttr', 'No disponible')}`", + "", + "## Evidence", + "### Metrics Snapshot", + "```json", + str(evidence.get("metrics_snapshot") or {}), + "```", + "", + "### Logs", + (evidence.get("logs") or "Sin logs")[:4000], + "", + "### Agent Reasoning", + evidence.get("agent_reasoning") or "Sin razonamiento disponible", + "", + "## Timeline", + ] + + if timeline: + for event in timeline: + lines.append( + f"- [{event.get('created_at', 'N/A')}] `{event.get('status', 'unknown')}`" + ) + else: + lines.append("- Sin eventos") + + lines.extend(["", "## Decisions"]) + if decisions: + for decision in decisions: + detail = decision.get('detail', '') + lines.append( + f"- [{decision.get('at', 'N/A')}]" + f" {decision.get('type', 'decision')}: {detail}" + ) + else: + lines.append("- Sin decisiones registradas") + + lines.extend(["", "## Actions"]) + if actions: + for action in actions: + lines.extend([ + f"### {action.get('name', 'action')}", + f"- Command: `{action.get('command', 'N/A')}`", + f"- Executed at: `{action.get('executed_at', 'N/A')}`", + f"- Status: `{action.get('status', 'N/A')}`", + "- Output:", + "```", + (action.get("output") or "")[0:3000], + "```", + "", + ]) + else: + lines.append("- Sin acciones registradas") + + post_mortem = payload.get("post_mortem") + if post_mortem: + lines.extend(["", "## Post-Mortem", post_mortem]) + + return "\n".join(lines).strip() + "\n" diff --git a/Backend/services/postmortem/generator.py b/Backend/services/postmortem/generator.py new file mode 100644 index 0000000..71fcc0e --- /dev/null +++ b/Backend/services/postmortem/generator.py @@ -0,0 +1,92 @@ +import json +import logging +import os +from typing import Any + +from langchain_core.messages import HumanMessage +from langchain_openai import ChatOpenAI + +from services.postmortem.formatters import format_timeline_markdown +from services.postmortem.template import render_post_mortem_template + +logger = logging.getLogger(__name__) + + +def _fallback_post_mortem( + incident: dict[str, Any], timeline: list[dict[str, Any]], mttr: str +) -> str: + resolution = ( + incident.get("action_result") + or "Se estabilizó el servicio y quedó en estado resolved." + )[:700] + return render_post_mortem_template( + title=incident.get("title") or "Incidente", + summary=(incident.get("logs") or "No hay descripción disponible")[:700], + root_cause="Pendiente de validación manual por el equipo de guardia.", + timeline=format_timeline_markdown(timeline), + resolution=resolution, + mttr=mttr, + lessons_learned=( + "1. Mejorar observabilidad en componentes críticos.\n" + "2. Definir runbooks más específicos por tipo de incidente." + ), + preventive_actions=( + "1. Agregar alertas tempranas con umbrales más conservadores.\n" + "2. Revisar capacidad y límites del recurso afectado." + ), + ) + + +def generate_post_mortem_with_llm( + incident: dict[str, Any], + timeline: list[dict[str, Any]], + mttr: str, +) -> str: + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + return _fallback_post_mortem(incident, timeline, mttr) + + timeline_text = format_timeline_markdown(timeline) + payload = { + "title": incident.get("title"), + "severity": incident.get("severity"), + "status": incident.get("status"), + "target": incident.get("target"), + "source_type": incident.get("source_type"), + "logs": (incident.get("logs") or "")[:2500], + "agent_reasoning": (incident.get("agent_reasoning") or "")[:3500], + "action_result": (incident.get("action_result") or "")[:1500], + "action_error": (incident.get("action_error") or "")[:1000], + "timeline": timeline_text, + "mttr": mttr, + } + + prompt = ( + "Genera un post-mortem técnico en español en formato Markdown. " + "Responde SOLO con markdown y usando exactamente estas secciones:\n" + "# Post-Mortem: [Incident Title]\n" + "## Summary\n" + "## Root Cause\n" + "## Timeline\n" + "## Resolution\n" + "## MTTR\n" + "## Lessons Learned\n" + "## Preventive Actions\n\n" + "Debe ser concreto, accionable y no inventar datos fuera del contexto.\n\n" + f"Contexto JSON:\n{json.dumps(payload, ensure_ascii=False)}" + ) + + try: + llm = ChatOpenAI( + model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"), + temperature=0.2, + api_key=api_key, + timeout=45, + max_retries=1, + ) + response = llm.invoke([HumanMessage(content=prompt)]) + content = (response.content or "").strip() + return content or _fallback_post_mortem(incident, timeline, mttr) + except Exception as exc: + logger.warning(f"[postmortem.generator] Fallback por error de LLM: {exc}") + return _fallback_post_mortem(incident, timeline, mttr) diff --git a/Backend/services/postmortem/knowledge_base.py b/Backend/services/postmortem/knowledge_base.py new file mode 100644 index 0000000..d076993 --- /dev/null +++ b/Backend/services/postmortem/knowledge_base.py @@ -0,0 +1,33 @@ +import logging + +from services.agents.memory.chroma_client import get_or_create_collection + +logger = logging.getLogger(__name__) + +_POST_MORTEM_COLLECTION = "postmortems-incidents" + + +def store_post_mortem_in_kb(incident: dict, content_md: str) -> None: + incident_id = str(incident.get("id") or "") + if not incident_id or not content_md.strip(): + return + + metadata = { + "incident_id": incident_id, + "title": incident.get("title") or "", + "severity": incident.get("severity") or "", + "status": incident.get("status") or "", + "target": incident.get("target") or "", + "source_type": incident.get("source_type") or "", + "resolved_at": incident.get("resolved_at") or "", + } + + try: + collection = get_or_create_collection(_POST_MORTEM_COLLECTION) + collection.upsert( + ids=[incident_id], + documents=[content_md[:12000]], + metadatas=[metadata], + ) + except Exception as exc: + logger.warning(f"[postmortem.kb] No se pudo guardar en ChromaDB {incident_id[:8]}: {exc}") diff --git a/Backend/services/postmortem/service.py b/Backend/services/postmortem/service.py new file mode 100644 index 0000000..6953a70 --- /dev/null +++ b/Backend/services/postmortem/service.py @@ -0,0 +1,174 @@ +import logging +from datetime import datetime, timezone +from typing import Any + +from db.supabase_client import supabase +from services.postmortem.formatters import format_mttr +from services.postmortem.generator import generate_post_mortem_with_llm +from services.postmortem.knowledge_base import store_post_mortem_in_kb + +logger = logging.getLogger(__name__) +_EVENTS_HAS_CREATED_AT: bool | None = None + + +def _get_incident(incident_id: str) -> dict[str, Any] | None: + try: + response = ( + supabase.table("incidents") + .select("*") + .eq("id", incident_id) + .single() + .execute() + ) + return response.data + except Exception: + return None + + +def _get_timeline(incident_id: str) -> list[dict[str, Any]]: + global _EVENTS_HAS_CREATED_AT + + if _EVENTS_HAS_CREATED_AT is None: + try: + supabase.table("incident_events").select("created_at").limit(1).execute() + _EVENTS_HAS_CREATED_AT = True + except Exception: + _EVENTS_HAS_CREATED_AT = False + + if _EVENTS_HAS_CREATED_AT: + try: + response = ( + supabase.table("incident_events") + .select("status,created_at") + .eq("incident_id", incident_id) + .order("created_at", desc=False) + .execute() + ) + return response.data or [] + except Exception as exc: + logger.warning( + f"[postmortem.service] Timeline con created_at falló" + f" para {incident_id[:8]}: {exc}" + ) + + try: + fallback = ( + supabase.table("incident_events") + .select("status") + .eq("incident_id", incident_id) + .execute() + ) + events = fallback.data or [] + return [{"status": e.get("status"), "created_at": None} for e in events] + except Exception as fallback_exc: + logger.warning( + f"[postmortem.service] No se pudo cargar timeline fallback" + f" {incident_id[:8]}: {fallback_exc}" + ) + return [] + + +def generate_post_mortem_for_incident(incident_id: str, force: bool = False) -> str | None: + incident = _get_incident(incident_id) + if not incident: + return None + + if incident.get("status") != "resolved": + return None + + current = (incident.get("post_mortem_md") or "").strip() + if current and not force: + return current + + resolved_at = incident.get("resolved_at") or incident.get("updated_at") + mttr = format_mttr(incident.get("created_at"), resolved_at) + timeline = _get_timeline(incident_id) + generated = generate_post_mortem_with_llm(incident, timeline, mttr) + + save_post_mortem(incident_id, generated, incident=incident) + return generated + + +def save_post_mortem( + incident_id: str, content_md: str, incident: dict[str, Any] | None = None +) -> None: + if not content_md.strip(): + return + + now_iso = datetime.now(tz=timezone.utc).isoformat() + try: + supabase.table("incidents").update({ + "post_mortem_md": content_md, + "post_mortem_updated_at": now_iso, + }).eq("id", incident_id).execute() + except Exception as exc: + logger.warning( + f"[postmortem.service] No se pudo persistir post-mortem" + f" {incident_id[:8]}: {exc}" + ) + return + + if not incident: + incident = _get_incident(incident_id) + if incident: + incident["post_mortem_updated_at"] = now_iso + store_post_mortem_in_kb(incident, content_md) + + +def build_incident_export_payload(incident_id: str) -> dict[str, Any] | None: + incident = _get_incident(incident_id) + if not incident: + return None + + timeline = _get_timeline(incident_id) + mttr = format_mttr( + incident.get("created_at"), + incident.get("resolved_at") or incident.get("updated_at"), + ) + + decisions: list[dict[str, Any]] = [] + if incident.get("proposed_action"): + decisions.append({ + "type": "proposed_action", + "detail": incident.get("proposed_action"), + "at": incident.get("updated_at") or incident.get("created_at"), + }) + if incident.get("action_error"): + decisions.append({ + "type": "action_error", + "detail": incident.get("action_error"), + "at": incident.get("updated_at") or incident.get("created_at"), + }) + + actions: list[dict[str, Any]] = [] + if incident.get("proposed_action") or incident.get("action_result"): + actions.append({ + "name": "incident_action", + "command": incident.get("proposed_action") or "", + "executed_at": incident.get("executed_at"), + "status": incident.get("status"), + "output": incident.get("action_result") or incident.get("action_error") or "", + }) + + return { + "metadata": { + "id": incident.get("id"), + "title": incident.get("title"), + "severity": incident.get("severity"), + "status": incident.get("status"), + "source_type": incident.get("source_type"), + "target": incident.get("target"), + "created_at": incident.get("created_at"), + "resolved_at": incident.get("resolved_at"), + "mttr": mttr, + }, + "evidence": { + "logs": incident.get("logs") or "", + "metrics_snapshot": incident.get("metrics_snapshot") or {}, + "agent_reasoning": incident.get("agent_reasoning") or "", + }, + "timeline": timeline, + "decisions": decisions, + "actions": actions, + "post_mortem": incident.get("post_mortem_md") or "", + } diff --git a/Backend/services/postmortem/template.py b/Backend/services/postmortem/template.py new file mode 100644 index 0000000..6186b3f --- /dev/null +++ b/Backend/services/postmortem/template.py @@ -0,0 +1,33 @@ +POST_MORTEM_TEMPLATE = ( + "# Post-Mortem: {title}\n\n" + "## Summary\n{summary}\n\n" + "## Root Cause\n{root_cause}\n\n" + "## Timeline\n{timeline}\n\n" + "## Resolution\n{resolution}\n\n" + "## MTTR\n{mttr}\n\n" + "## Lessons Learned\n{lessons_learned}\n\n" + "## Preventive Actions\n{preventive_actions}\n" +) + + +def render_post_mortem_template( + *, + title: str, + summary: str, + root_cause: str, + timeline: str, + resolution: str, + mttr: str, + lessons_learned: str, + preventive_actions: str, +) -> str: + return POST_MORTEM_TEMPLATE.format( + title=title, + summary=summary, + root_cause=root_cause, + timeline=timeline, + resolution=resolution, + mttr=mttr, + lessons_learned=lessons_learned, + preventive_actions=preventive_actions, + ) diff --git a/Backend/services/verification.py b/Backend/services/verification.py index 350ef64..5c41035 100644 --- a/Backend/services/verification.py +++ b/Backend/services/verification.py @@ -23,6 +23,7 @@ from db.supabase_client import supabase from services.incident_events import record_event +from services.postmortem.service import generate_post_mortem_for_incident logger = logging.getLogger(__name__) @@ -73,6 +74,8 @@ def verify_resolution( supabase.table("incidents").update(update).eq("id", incident_id).execute() record_event(incident_id, new_status) + if new_status == "resolved": + generate_post_mortem_for_incident(incident_id) logger.info(f"[Verification] {incident_id[:8]} → {new_status}") except Exception as exc: @@ -89,6 +92,7 @@ def verify_resolution( "resolved_at": datetime.now(tz=timezone.utc).isoformat(), }).eq("id", incident_id).execute() record_event(incident_id, "resolved") + generate_post_mortem_for_incident(incident_id) # ── Inspección de contenedores Docker/Podman ────────────────────────────────── diff --git a/Frontend/package-lock.json b/Frontend/package-lock.json index 517e9ef..de4e6b0 100644 --- a/Frontend/package-lock.json +++ b/Frontend/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "@supabase/supabase-js": "^2.97.0", - "lucide-react": "^1.14.0", + "jspdf": "^3.0.2", + "lucide-react": "^1.16.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.0" @@ -300,6 +301,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2008,12 +2018,25 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/phoenix": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2034,6 +2057,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2335,6 +2365,16 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -2434,6 +2474,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -2525,6 +2585,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2540,6 +2612,16 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -2627,6 +2709,16 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz", + "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2972,6 +3064,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2990,6 +3093,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3211,6 +3320,20 @@ "dev": true, "license": "MIT" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3298,6 +3421,12 @@ "node": ">=0.8.19" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3532,6 +3661,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz", + "integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.2.4", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3858,9 +4004,9 @@ } }, "node_modules/lucide-react": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", - "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", + "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -4044,6 +4190,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4131,6 +4283,13 @@ "node": ">= 14.16" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4200,6 +4359,16 @@ "node": ">=6" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -4269,6 +4438,13 @@ "react-dom": ">=18" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4279,6 +4455,16 @@ "node": ">=4" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -4433,6 +4619,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -4590,6 +4786,16 @@ "node": ">=8" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -4672,6 +4878,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4845,6 +5061,16 @@ "punycode": "^2.1.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "7.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", diff --git a/Frontend/package.json b/Frontend/package.json index 184e9e2..50827a5 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -13,7 +13,8 @@ }, "dependencies": { "@supabase/supabase-js": "^2.97.0", - "lucide-react": "^1.14.0", + "jspdf": "^3.0.2", + "lucide-react": "^1.16.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.0" diff --git a/Frontend/src/components/ExportModal.jsx b/Frontend/src/components/ExportModal.jsx new file mode 100644 index 0000000..55d9693 --- /dev/null +++ b/Frontend/src/components/ExportModal.jsx @@ -0,0 +1,69 @@ +import { useState } from 'react' +import { fetchIncidentExport, triggerTextDownload } from '../services/incidentExports' + +export default function ExportModal({ incident, onClose }) { + const [loading, setLoading] = useState(null) + const [error, setError] = useState(null) + + const handleExport = async (format) => { + if (!incident?.id) return + setLoading(format) + setError(null) + try { + const { content, extension } = await fetchIncidentExport(incident.id, format) + const safeTitle = String(incident.title || incident.id).replace(/[^a-zA-Z0-9_-]/g, '_') + const filename = `incident_${safeTitle}_${incident.id.slice(0, 8)}.${extension}` + const mime = format === 'json' ? 'application/json;charset=utf-8' : 'text/markdown;charset=utf-8' + triggerTextDownload(content, filename, mime) + onClose() + } catch (e) { + setError(e.message) + } finally { + setLoading(null) + } + } + + return ( +
Selecciona el formato para descargar inmediatamente.
+Incidente
+{incident?.title || '—'}
+{incident?.id || '—'}
++ {error} +
+ )} + ++ El post-mortem se genera automáticamente cuando el incidente cambia a estado resuelto. +
+Post-Mortem
++ Última actualización: {fmtDate(updatedAt)} · {contentSize} caracteres +
++ {error} +
+ )} + + {loading ? ( +