This commit is contained in:
Lizandro Guarnizo
2026-05-07 16:28:55 -05:00
parent 8197069329
commit e8c837cf31
4 changed files with 415 additions and 1 deletions
+151
View File
@@ -0,0 +1,151 @@
package models
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// ─── Estructuras de la API pública de Atlassian Statuspage ────────────────────
type StatuspageStatus struct {
Indicator string `json:"indicator"` // none, minor, major, critical
Description string `json:"description"` // "All Systems Operational", etc.
}
type StatuspagePage struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
TimeZone string `json:"time_zone"`
UpdatedAt time.Time `json:"updated_at"`
}
type StatuspageComponent struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // operational, degraded_performance, partial_outage, major_outage, under_maintenance
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Position int `json:"position"`
Description string `json:"description"`
Showcase bool `json:"showcase"`
GroupID *string `json:"group_id"`
PageID string `json:"page_id"`
Group bool `json:"group"`
OnlyShowIfDegraded bool `json:"only_show_if_degraded"`
}
type StatuspageIncidentUpdate struct {
ID string `json:"id"`
Status string `json:"status"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type StatuspageIncident struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // investigating, identified, monitoring, resolved, postmortem
Impact string `json:"impact"` // none, minor, major, critical
PageID string `json:"page_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MonitoringAt *time.Time `json:"monitoring_at"`
ResolvedAt *time.Time `json:"resolved_at"`
ShortLink string `json:"shortlink"`
IncidentUpdates []StatuspageIncidentUpdate `json:"incident_updates"`
AffectedComponents []StatuspageComponent `json:"components"`
}
type StatuspageScheduledMaintenance struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // scheduled, in_progress, verifying, completed
Impact string `json:"impact"`
PageID string `json:"page_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ScheduledFor time.Time `json:"scheduled_for"`
ScheduledUntil time.Time `json:"scheduled_until"`
ShortLink string `json:"shortlink"`
IncidentUpdates []StatuspageIncidentUpdate `json:"incident_updates"`
AffectedComponents []StatuspageComponent `json:"components"`
}
// StatuspageSummary es la respuesta completa del endpoint /api/v2/summary.json
type StatuspageSummary struct {
Page StatuspagePage `json:"page"`
Status StatuspageStatus `json:"status"`
Components []StatuspageComponent `json:"components"`
Incidents []StatuspageIncident `json:"incidents"`
ScheduledMaintenances []StatuspageScheduledMaintenance `json:"scheduled_maintenances"`
}
// FetchStatuspageSummary consulta la API pública de Atlassian Statuspage.
// pageID es el identificador de tu página (ej. "yh6f0r4529hb").
func FetchStatuspageSummary(pageID string) (*StatuspageSummary, error) {
if pageID == "" {
return nil, fmt.Errorf("statuspage: pageID vacío")
}
url := fmt.Sprintf("https://%s.statuspage.io/api/v2/summary.json", pageID)
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Get(url) // #nosec G107 — URL construida desde config, no desde input de usuario
if err != nil {
return nil, fmt.Errorf("statuspage: error de red: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("statuspage: respuesta HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) // límite 512 KB
if err != nil {
return nil, fmt.Errorf("statuspage: error leyendo body: %w", err)
}
var summary StatuspageSummary
if err := json.Unmarshal(body, &summary); err != nil {
return nil, fmt.Errorf("statuspage: error parseando JSON: %w", err)
}
return &summary, nil
}
// ComponentStatusLabel devuelve una etiqueta legible en español.
func ComponentStatusLabel(status string) string {
switch status {
case "operational":
return "Operacional"
case "degraded_performance":
return "Rendimiento degradado"
case "partial_outage":
return "Interrupción parcial"
case "major_outage":
return "Interrupción mayor"
case "under_maintenance":
return "En mantenimiento"
default:
return status
}
}
// IndicatorColor devuelve la clase de color Tailwind para el indicador global.
func IndicatorColor(indicator string) string {
switch indicator {
case "none":
return "green"
case "minor":
return "yellow"
case "major":
return "orange"
case "critical":
return "red"
default:
return "gray"
}
}
+179
View File
@@ -0,0 +1,179 @@
{{/* Página pública de estado del sistema — Atlassian Statuspage */}}
<div class="min-h-screen bg-slate-50 py-10 px-4" style="width:100vw; max-width:100%;">
<!-- ── Header ─────────────────────────────────────────────────────────── -->
<div class="max-w-3xl mx-auto mb-8 text-center">
<h1 class="text-3xl font-bold text-slate-800 mb-1">
{{if .summary}}{{.summary.Page.Name}}{{else}}Estado del sistema{{end}}
</h1>
<p class="text-slate-500 text-sm">Estado en tiempo real de nuestros servicios</p>
</div>
{{if .error}}
<!-- ── Error ──────────────────────────────────────────────────────────── -->
<div class="max-w-3xl mx-auto mb-6">
<div class="bg-red-50 border border-red-200 rounded-xl p-5 flex items-start gap-3">
<svg class="w-5 h-5 text-red-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"/>
</svg>
<p class="text-red-700 text-sm">{{.error}}</p>
</div>
</div>
{{end}}
{{if .summary}}
<!-- ── Banner de estado global ────────────────────────────────────────── -->
{{$color := .indicatorColor}}
<div class="max-w-3xl mx-auto mb-8">
{{if eq $color "green"}}
<div class="bg-green-500 text-white rounded-2xl px-6 py-5 flex items-center gap-4 shadow-sm">
<svg class="w-8 h-8 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/>
</svg>
<div>
<p class="font-bold text-lg leading-tight">{{.summary.Status.Description}}</p>
<p class="text-green-100 text-sm">Todos los sistemas funcionan con normalidad</p>
</div>
</div>
{{else if eq $color "yellow"}}
<div class="bg-yellow-400 text-yellow-900 rounded-2xl px-6 py-5 flex items-center gap-4 shadow-sm">
<svg class="w-8 h-8 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"/>
</svg>
<div>
<p class="font-bold text-lg leading-tight">{{.summary.Status.Description}}</p>
<p class="text-yellow-800 text-sm">Estamos investigando el problema</p>
</div>
</div>
{{else if eq $color "orange"}}
<div class="bg-orange-500 text-white rounded-2xl px-6 py-5 flex items-center gap-4 shadow-sm">
<svg class="w-8 h-8 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"/>
</svg>
<div>
<p class="font-bold text-lg leading-tight">{{.summary.Status.Description}}</p>
<p class="text-orange-100 text-sm">Algunos servicios presentan interrupciones</p>
</div>
</div>
{{else}}
<div class="bg-red-600 text-white rounded-2xl px-6 py-5 flex items-center gap-4 shadow-sm">
<svg class="w-8 h-8 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
</svg>
<div>
<p class="font-bold text-lg leading-tight">{{.summary.Status.Description}}</p>
<p class="text-red-200 text-sm">Se está trabajando para restablecer los servicios</p>
</div>
</div>
{{end}}
</div>
<!-- ── Incidencias activas ─────────────────────────────────────────────── -->
{{if .hasIncidents}}
<div class="max-w-3xl mx-auto mb-8">
<h2 class="text-base font-semibold text-slate-700 mb-3 uppercase tracking-wide">Incidencias activas</h2>
<div class="bg-white rounded-xl border border-slate-200 shadow-sm divide-y divide-slate-100">
{{range .summary.Incidents}}
<div class="px-5 py-4">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<p class="font-semibold text-slate-800 text-sm">{{.Name}}</p>
{{if .IncidentUpdates}}
<p class="text-slate-500 text-xs mt-1 line-clamp-2">{{(index .IncidentUpdates 0).Body}}</p>
{{end}}
</div>
<span class="shrink-0 text-xs font-medium px-2.5 py-1 rounded-full
{{if eq .Impact "critical"}}bg-red-100 text-red-700
{{else if eq .Impact "major"}}bg-orange-100 text-orange-700
{{else if eq .Impact "minor"}}bg-yellow-100 text-yellow-700
{{else}}bg-slate-100 text-slate-600{{end}}">
{{if eq .Status "investigating"}}Investigando
{{else if eq .Status "identified"}}Identificado
{{else if eq .Status "monitoring"}}Monitoreando
{{else if eq .Status "resolved"}}Resuelto
{{else}}{{.Status}}{{end}}
</span>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
<!-- ── Mantenimientos programados ─────────────────────────────────────── -->
{{if .hasMaintenance}}
<div class="max-w-3xl mx-auto mb-8">
<h2 class="text-base font-semibold text-slate-700 mb-3 uppercase tracking-wide">Mantenimientos programados</h2>
<div class="bg-white rounded-xl border border-slate-200 shadow-sm divide-y divide-slate-100">
{{range .summary.ScheduledMaintenances}}
<div class="px-5 py-4 flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<p class="font-semibold text-slate-800 text-sm">{{.Name}}</p>
<p class="text-slate-400 text-xs mt-1">
Programado: {{.ScheduledFor.Format "02/01/2006 15:04"}} — {{.ScheduledUntil.Format "15:04"}} UTC
</p>
</div>
<span class="shrink-0 text-xs font-medium px-2.5 py-1 rounded-full bg-blue-100 text-blue-700">
{{if eq .Status "scheduled"}}Programado
{{else if eq .Status "in_progress"}}En progreso
{{else if eq .Status "verifying"}}Verificando
{{else if eq .Status "completed"}}Completado
{{else}}{{.Status}}{{end}}
</span>
</div>
{{end}}
</div>
</div>
{{end}}
<!-- ── Componentes ────────────────────────────────────────────────────── -->
<div class="max-w-3xl mx-auto mb-6">
<h2 class="text-base font-semibold text-slate-700 mb-3 uppercase tracking-wide">Componentes</h2>
<div class="bg-white rounded-xl border border-slate-200 shadow-sm divide-y divide-slate-100">
{{range .components}}
{{if .Showcase}}
<div class="px-5 py-3.5 flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-slate-700 truncate">{{.Name}}</p>
{{if .Description}}<p class="text-xs text-slate-400 truncate">{{.Description}}</p>{{end}}
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="w-2 h-2 rounded-full
{{if eq .StatusColor "green"}}bg-green-500
{{else if eq .StatusColor "yellow"}}bg-yellow-400
{{else if eq .StatusColor "orange"}}bg-orange-500
{{else if eq .StatusColor "red"}}bg-red-600
{{else if eq .StatusColor "blue"}}bg-blue-500
{{else}}bg-slate-400{{end}}">
</span>
<span class="text-xs font-medium
{{if eq .StatusColor "green"}}text-green-700
{{else if eq .StatusColor "yellow"}}text-yellow-700
{{else if eq .StatusColor "orange"}}text-orange-700
{{else if eq .StatusColor "red"}}text-red-700
{{else if eq .StatusColor "blue"}}text-blue-700
{{else}}text-slate-500{{end}}">
{{.StatusLabel}}
</span>
</div>
</div>
{{end}}
{{end}}
</div>
</div>
<!-- ── Footer ─────────────────────────────────────────────────────────── -->
<div class="max-w-3xl mx-auto text-center">
<p class="text-slate-400 text-xs">
Actualizado: {{.summary.Page.UpdatedAt.Format "02/01/2006 15:04"}} UTC
&nbsp;·&nbsp;
<a href="https://{{.pageID}}.statuspage.io" target="_blank" rel="noopener noreferrer" class="underline hover:text-slate-600">
Ver en Statuspage
</a>
</p>
</div>
{{end}}{{/* end if .summary */}}
</div>
+80
View File
@@ -0,0 +1,80 @@
package controllers
import (
"os"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// StatusPage sirve la página pública /status que consulta la API de Atlassian Statuspage.
// Configura la variable de entorno STATUSPAGE_PAGE_ID con el ID de tu página
// (ej. "yh6f0r4529hb", visible en https://<id>.statuspage.io).
func StatusPage(c *fiber.Ctx) error {
pageID := os.Getenv("STATUSPAGE_PAGE_ID")
data := fiber.Map{
"title": "Estado del sistema | ",
"pageID": pageID,
}
if pageID == "" {
data["error"] = "STATUSPAGE_PAGE_ID no configurado"
return c.Render("statuspage", data, "layouts/landing")
}
summary, err := models.FetchStatuspageSummary(pageID)
if err != nil {
data["error"] = "No se pudo obtener el estado en este momento. Intenta de nuevo más tarde."
return c.Render("statuspage", data, "layouts/landing")
}
// Separar componentes de grupos y componentes normales
var groups []models.StatuspageComponent
var components []models.StatuspageComponent
for _, comp := range summary.Components {
if comp.Group {
groups = append(groups, comp)
} else {
components = append(components, comp)
}
}
// Helpers de presentación
type CompVM struct {
models.StatuspageComponent
StatusLabel string
StatusColor string
}
toVM := func(comps []models.StatuspageComponent) []CompVM {
out := make([]CompVM, 0, len(comps))
for _, c := range comps {
color := "green"
switch c.Status {
case "degraded_performance":
color = "yellow"
case "partial_outage":
color = "orange"
case "major_outage":
color = "red"
case "under_maintenance":
color = "blue"
}
out = append(out, CompVM{
StatuspageComponent: c,
StatusLabel: models.ComponentStatusLabel(c.Status),
StatusColor: color,
})
}
return out
}
data["summary"] = summary
data["components"] = toVM(components)
data["groups"] = toVM(groups)
data["indicatorColor"] = models.IndicatorColor(summary.Status.Indicator)
data["hasIncidents"] = len(summary.Incidents) > 0
data["hasMaintenance"] = len(summary.ScheduledMaintenances) > 0
return c.Render("statuspage", data, "layouts/landing")
}
+5 -1
View File
@@ -27,5 +27,9 @@ func RutasPublicas(web fiber.Router) {
web.Get("/pago/estado", apiControllers.PagoEstadoAPI)
// ─── Documentación pública ────────────────────────────────────────────────
web.Get("/docs/:saas", controllers.DocsPublicoIndex)
web.Get("/docs/:saas/:slug", controllers.DocsPublicaPagina)}
web.Get("/docs/:saas/:slug", controllers.DocsPublicaPagina)
// ─── Página de estado del sistema (Atlassian Statuspage) ────────────────
web.Get("/status", controllers.StatusPage)
}