up
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
·
|
||||
<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>
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user