feat: build out centralcloud_ops staff portal with Grafana OnCall backend

- Add CentralcloudCore.OnCall API client (alert groups, schedules, users,
  escalation chains) — talks to Grafana OnCall HTTP API v1
- Add centralcloud_ops application, endpoint, router, layouts
- Add RequireStaff auth plug, SessionController, HealthController
- Add DashboardLive: firing/acked alerts with ack/resolve actions, auto-refresh
- Add IncidentsLive: filterable incident list by status
- Add IncidentLive: incident detail with ack/resolve/silence actions
- Add OnCallLive: schedule cards showing who is currently on-call
- Add StakeholdersLive: HostBill client search + service view + comms panel
- Wire ONCALL_URL / ONCALL_API_TOKEN env vars in config and runtime.exs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-10 22:54:33 +02:00
parent 37777ca54b
commit f84f59e0df
15 changed files with 1053 additions and 0 deletions

View file

@ -0,0 +1,118 @@
defmodule CentralcloudCore.OnCall do
@moduledoc """
Grafana OnCall API client.
Talks to the Grafana OnCall HTTP API (v1).
Auth: Authorization: Token <token>
"""
# ---------------------------------------------------------------------------
# Alert groups (incidents)
@spec list_alert_groups(keyword()) :: {:ok, map()} | {:error, term()}
def list_alert_groups(opts \\ []) do
params = %{}
params = if s = opts[:status], do: Map.put(params, :status, s), else: params
get("/alert_groups/", params)
end
@spec get_alert_group(String.t()) :: {:ok, map()} | {:error, term()}
def get_alert_group(id), do: get("/alert_groups/#{id}/")
@spec acknowledge_alert_group(String.t()) :: {:ok, map()} | {:error, term()}
def acknowledge_alert_group(id), do: post("/alert_groups/#{id}/acknowledge/", %{})
@spec resolve_alert_group(String.t()) :: {:ok, map()} | {:error, term()}
def resolve_alert_group(id), do: post("/alert_groups/#{id}/resolve/", %{})
@spec silence_alert_group(String.t(), pos_integer()) :: {:ok, map()} | {:error, term()}
def silence_alert_group(id, duration_seconds),
do: post("/alert_groups/#{id}/silence/", %{delay: duration_seconds})
# ---------------------------------------------------------------------------
# Schedules
@spec list_schedules() :: {:ok, map()} | {:error, term()}
def list_schedules, do: get("/schedules/")
@spec get_schedule(String.t()) :: {:ok, map()} | {:error, term()}
def get_schedule(id), do: get("/schedules/#{id}/")
@spec get_schedule_shifts(String.t()) :: {:ok, map()} | {:error, term()}
def get_schedule_shifts(id), do: get("/schedules/#{id}/final_shifts/")
# ---------------------------------------------------------------------------
# On-call users
@spec current_oncall(String.t()) :: {:ok, list(map())} | {:error, term()}
def current_oncall(schedule_id) do
case get_schedule_shifts(schedule_id) do
{:ok, %{"results" => shifts}} ->
now = DateTime.utc_now()
on_call =
shifts
|> Enum.filter(fn s ->
{:ok, from, _} = DateTime.from_iso8601(s["shift_start"])
{:ok, to, _} = DateTime.from_iso8601(s["shift_end"])
DateTime.compare(from, now) != :gt and DateTime.compare(to, now) == :gt
end)
|> Enum.map(& &1["user"])
{:ok, on_call}
err ->
err
end
end
# ---------------------------------------------------------------------------
# Users
@spec list_users() :: {:ok, map()} | {:error, term()}
def list_users, do: get("/users/")
@spec get_user(String.t()) :: {:ok, map()} | {:error, term()}
def get_user(id), do: get("/users/#{id}/")
# ---------------------------------------------------------------------------
# Escalation chains
@spec list_escalation_chains() :: {:ok, map()} | {:error, term()}
def list_escalation_chains, do: get("/escalation_chains/")
# ---------------------------------------------------------------------------
# Private
defp get(path, params \\ %{}) do
url = base_url() <> path
headers = auth_headers()
case Req.get(url, params: params, headers: headers) do
{:ok, %{status: 200, body: body}} -> {:ok, body}
{:ok, %{status: status, body: body}} -> {:error, {:http, status, body}}
{:error, reason} -> {:error, reason}
end
end
defp post(path, body) do
url = base_url() <> path
headers = auth_headers()
case Req.post(url, json: body, headers: headers) do
{:ok, %{status: s, body: b}} when s in 200..299 -> {:ok, b}
{:ok, %{status: status, body: body}} -> {:error, {:http, status, body}}
{:error, reason} -> {:error, reason}
end
end
defp base_url do
cfg = Application.fetch_env!(:centralcloud_core, :oncall)
cfg[:base_url] <> "/api/v1"
end
defp auth_headers do
cfg = Application.fetch_env!(:centralcloud_core, :oncall)
[{"Authorization", "Token #{cfg[:token]}"}]
end
end

View file

@ -0,0 +1,13 @@
defmodule CentralcloudOps.Application do
use Application
@impl true
def start(_type, _args) do
children = [
CentralcloudOps.Endpoint
]
opts = [strategy: :one_for_one, name: CentralcloudOps.Supervisor]
Supervisor.start_link(children, opts)
end
end

View file

@ -0,0 +1,7 @@
defmodule CentralcloudOps.HealthController do
use Phoenix.Controller, formats: [:json]
def check(conn, _params) do
json(conn, %{status: "ok"})
end
end

View file

@ -0,0 +1,90 @@
defmodule CentralcloudOps.SessionController do
use Phoenix.Controller, formats: [:html]
import Plug.Conn
def new(conn, _params) do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, login_page(conn))
end
def create(conn, %{"username" => _username, "password" => _password}) do
# TODO: validate against Authentik / staff directory
conn
|> put_flash(:error, "Invalid credentials")
|> redirect(to: "/login")
end
def delete(conn, _params) do
conn
|> clear_session()
|> redirect(to: "/login")
end
def oidc_callback(conn, params) do
# TODO: exchange code with Authentik, verify staff group membership, set session
_ = params
conn
|> put_flash(:info, "SSO login coming soon")
|> redirect(to: "/login")
end
defp login_page(conn) do
csrf = Phoenix.Controller.get_csrf_token()
flash_error = Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error)
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="csrf-token" content="#{csrf}"/>
<title>Sign in CentralCloud Ops</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0a0f1e; color: #e2e8f0; display: flex;
align-items: center; justify-content: center; min-height: 100vh; }
.card { background: #0f1629; border: 1px solid #1e3a5f; border-radius: 12px;
padding: 2.5rem; width: 100%; max-width: 380px; }
h1 { font-size: 1.4rem; font-weight: 700; color: #f97316; margin-bottom: 0.25rem; }
p.sub { color: #64748b; font-size: 0.85rem; margin-bottom: 2rem; }
label { display: block; font-size: 0.8rem; color: #64748b; margin-bottom: 0.3rem; }
input { width: 100%; padding: 0.6rem 0.8rem; background: #0a0f1e;
border: 1px solid #1e3a5f; border-radius: 6px; color: #e2e8f0;
font-size: 0.9rem; margin-bottom: 1rem; }
button { width: 100%; padding: 0.7rem; background: #f97316; border: none;
border-radius: 6px; color: #fff; font-weight: 600; cursor: pointer;
font-size: 0.95rem; }
button:hover { background: #fb923c; }
.divider { text-align: center; color: #334155; font-size: 0.8rem; margin: 1rem 0; }
.sso { width: 100%; padding: 0.7rem; background: #0a0f1e; border: 1px solid #1e3a5f;
border-radius: 6px; color: #e2e8f0; font-size: 0.9rem; cursor: pointer;
text-align: center; text-decoration: none; display: block; }
.sso:hover { border-color: #f97316; }
.error { background: #4c0519; border: 1px solid #dc2626; border-radius: 6px;
padding: 0.6rem 0.8rem; font-size: 0.85rem; margin-bottom: 1rem; }
</style>
</head>
<body>
<div class="card">
<h1>🔥 CentralCloud Ops</h1>
<p class="sub">Staff portal sign in to continue</p>
#{if flash_error, do: "<div class='error'>#{flash_error}</div>", else: ""}
<form method="post" action="/login">
<input type="hidden" name="_csrf_token" value="#{csrf}"/>
<label>Username</label>
<input type="text" name="username" autocomplete="username" required/>
<label>Password</label>
<input type="password" name="password" autocomplete="current-password" required/>
<button type="submit">Sign in</button>
</form>
<div class="divider">or</div>
<a class="sso" href="/auth/callback">🔐 Sign in with CentralCloud SSO</a>
</div>
</body>
</html>
"""
end
end

View file

@ -0,0 +1,29 @@
defmodule CentralcloudOps.Endpoint do
use Phoenix.Endpoint, otp_app: :centralcloud_ops, adapter: Bandit.PhoenixAdapter
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: {CentralcloudOps.Router, :session_opts, []}]]
plug Plug.Static,
at: "/",
from: {:centralcloud_ops, "priv/static"},
gzip: false
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session,
store: :cookie,
key: "_centralcloud_ops_session",
signing_salt: "ops_salt_2026",
same_site: "Lax"
plug CentralcloudOps.Router
end

View file

@ -0,0 +1,112 @@
defmodule CentralcloudOps.Layouts do
use Phoenix.Component
def render("root.html", assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()}/>
<title>CentralCloud Ops</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0a0f1e; color: #e2e8f0; min-height: 100vh; }
nav { background: #0f1629; padding: 0.75rem 2rem; display: flex;
align-items: center; justify-content: space-between;
border-bottom: 1px solid #1e3a5f; }
nav .brand { font-weight: 700; font-size: 1rem; color: #f97316;
display: flex; align-items: center; gap: 0.5rem; }
nav .links { display: flex; gap: 0.25rem; }
nav a { color: #94a3b8; text-decoration: none; padding: 0.4rem 0.8rem;
border-radius: 5px; font-size: 0.875rem; }
nav a:hover, nav a.active { background: #1e293b; color: #e2e8f0; }
nav .signout { color: #64748b; font-size: 0.8rem; margin-left: 1rem;
border-left: 1px solid #1e3a5f; padding-left: 1rem; }
main { padding: 1.5rem 2rem; max-width: 1400px; margin: 0 auto; }
.flash { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem;
font-size: 0.875rem; }
.flash-info { background: #1e3a5f; border: 1px solid #2563eb; }
.flash-error { background: #4c0519; border: 1px solid #dc2626; }
/* Cards */
.card { background: #0f1629; border: 1px solid #1e3a5f; border-radius: 8px;
padding: 1.25rem; }
/* Status badges */
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 4px;
font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }
.badge-firing { background: #4c0519; color: #fca5a5; border: 1px solid #dc2626; }
.badge-acked { background: #422006; color: #fdba74; border: 1px solid #f97316; }
.badge-resolved { background: #052e16; color: #86efac; border: 1px solid #22c55e; }
.badge-silenced { background: #1e1b4b; color: #a5b4fc; border: 1px solid #6366f1; }
/* Table */
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th { text-align: left; padding: 0.6rem 1rem; color: #64748b; font-weight: 500;
border-bottom: 1px solid #1e3a5f; font-size: 0.75rem; text-transform: uppercase; }
td { padding: 0.75rem 1rem; border-bottom: 1px solid #0f172a; vertical-align: middle; }
tr:hover td { background: #0f1a2e; }
a.row-link { color: inherit; text-decoration: none; }
/* Buttons */
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.4rem 0.9rem;
border-radius: 5px; font-size: 0.8rem; font-weight: 600; cursor: pointer;
border: none; text-decoration: none; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #3b82f6; }
.btn-success { background: #15803d; color: #fff; }
.btn-success:hover { background: #16a34a; }
.btn-warning { background: #b45309; color: #fff; }
.btn-warning:hover { background: #d97706; }
.btn-danger { background: #991b1b; color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-ghost { background: transparent; color: #94a3b8; border: 1px solid #334155; }
.btn-ghost:hover { border-color: #64748b; color: #e2e8f0; }
/* Section header */
.section-title { font-size: 1.1rem; font-weight: 700; color: #f1f5f9;
margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem; }
/* Grid */
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px,1fr));
gap: 1rem; margin-bottom: 1.5rem; }
.stat-card { background: #0f1629; border: 1px solid #1e3a5f; border-radius: 8px;
padding: 1.25rem; }
.stat-label { color: #64748b; font-size: 0.7rem; font-weight: 600;
text-transform: uppercase; margin-bottom: 0.4rem; }
.stat-value { font-size: 2rem; font-weight: 800; line-height: 1; }
.stat-value.red { color: #f87171; }
.stat-value.orange { color: #fb923c; }
.stat-value.green { color: #4ade80; }
.stat-value.blue { color: #60a5fa; }
</style>
<.live_title suffix=" — CentralCloud Ops">
{assigns[:page_title] || "Ops"}
</.live_title>
</head>
<body>
<nav>
<span class="brand">🔥 CentralCloud Ops</span>
<div class="links">
<a href="/">Dashboard</a>
<a href="/oncall">On-Call</a>
<a href="/incidents">Incidents</a>
<a href="/stakeholders">Stakeholders</a>
</div>
<div class="signout">
<a href="/logout" style="color:#64748b;text-decoration:none;">Sign out</a>
</div>
</nav>
<main>
<p :if={msg = Phoenix.Flash.get(@flash, :info)} class="flash flash-info">{msg}</p>
<p :if={msg = Phoenix.Flash.get(@flash, :error)} class="flash flash-error">{msg}</p>
{@inner_content}
</main>
</body>
</html>
"""
end
def render("app.html", assigns) do
~H"""
{@inner_content}
"""
end
end

View file

@ -0,0 +1,149 @@
defmodule CentralcloudOps.DashboardLive do
use Phoenix.LiveView
alias CentralcloudCore.OnCall
@refresh_ms 30_000
def mount(_params, _session, socket) do
if connected?(socket), do: Process.send_after(self(), :refresh, @refresh_ms)
{:ok, load(socket)}
end
def handle_info(:refresh, socket) do
Process.send_after(self(), :refresh, @refresh_ms)
{:noreply, load(socket)}
end
def handle_event("ack", %{"id" => id}, socket) do
case OnCall.acknowledge_alert_group(id) do
{:ok, _} -> {:noreply, load(put_flash(socket, :info, "Alert acknowledged"))}
{:error, _} -> {:noreply, put_flash(socket, :error, "Failed to acknowledge")}
end
end
def handle_event("resolve", %{"id" => id}, socket) do
case OnCall.resolve_alert_group(id) do
{:ok, _} -> {:noreply, load(put_flash(socket, :info, "Alert resolved"))}
{:error, _} -> {:noreply, put_flash(socket, :error, "Failed to resolve")}
end
end
defp load(socket) do
firing = fetch_groups("firing")
acked = fetch_groups("acknowledged")
resolved = fetch_groups("resolved", limit: 5)
assign(socket,
page_title: "Dashboard",
firing: firing,
acked: acked,
recent_resolved: resolved,
firing_count: length(firing),
acked_count: length(acked),
last_updated: DateTime.utc_now()
)
end
defp fetch_groups(status, _opts \\ []) do
case OnCall.list_alert_groups(status: status) do
{:ok, %{"results" => results}} -> results
_ -> []
end
end
def render(assigns) do
~H"""
<div class="section-title">🔥 Operations Dashboard</div>
<div class="stat-grid">
<div class="stat-card">
<div class="stat-label">Firing</div>
<div class={"stat-value #{if @firing_count > 0, do: "red", else: "green"}"}>{@firing_count}</div>
</div>
<div class="stat-card">
<div class="stat-label">Acknowledged</div>
<div class={"stat-value #{if @acked_count > 0, do: "orange", else: "green"}"}>{@acked_count}</div>
</div>
<div class="stat-card">
<div class="stat-label">Recent Resolved</div>
<div class="stat-value blue">{length(@recent_resolved)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Last Updated</div>
<div style="font-size:0.8rem;color:#64748b;margin-top:0.5rem;">
{Calendar.strftime(@last_updated, "%H:%M:%S")} UTC
</div>
</div>
</div>
<div :if={@firing != []} style="margin-bottom:1.5rem;">
<div class="section-title" style="color:#f87171;">🚨 Firing Alerts</div>
<div class="card" style="padding:0;">
<table>
<thead>
<tr>
<th>Alert</th>
<th>Source</th>
<th>Started</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr :for={ag <- @firing}>
<td>
<a href={"/incidents/#{ag["id"]}"} style="color:#f87171;font-weight:600;">
{ag["render_for_web"]["title"] || ag["id"]}
</a>
</td>
<td style="color:#64748b;font-size:0.8rem;">{ag["alert_receive_channel"]["verbal_name"]}</td>
<td style="color:#64748b;font-size:0.8rem;">{format_dt(ag["started_at"])}</td>
<td>
<button class="btn btn-warning" phx-click="ack" phx-value-id={ag["id"]}>Ack</button>
<button class="btn btn-success" phx-click="resolve" phx-value-id={ag["id"]} style="margin-left:0.4rem;">Resolve</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div :if={@acked != []} style="margin-bottom:1.5rem;">
<div class="section-title" style="color:#fb923c;"> Acknowledged</div>
<div class="card" style="padding:0;">
<table>
<thead>
<tr><th>Alert</th><th>Source</th><th>Acked At</th><th>Actions</th></tr>
</thead>
<tbody>
<tr :for={ag <- @acked}>
<td>
<a href={"/incidents/#{ag["id"]}"} style="color:#fb923c;font-weight:600;">
{ag["render_for_web"]["title"] || ag["id"]}
</a>
</td>
<td style="color:#64748b;font-size:0.8rem;">{ag["alert_receive_channel"]["verbal_name"]}</td>
<td style="color:#64748b;font-size:0.8rem;">{format_dt(ag["acknowledged_at"])}</td>
<td>
<button class="btn btn-success" phx-click="resolve" phx-value-id={ag["id"]}>Resolve</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div :if={@firing == [] and @acked == []} class="card" style="text-align:center;padding:3rem;color:#4ade80;">
<div style="font-size:2rem;margin-bottom:0.5rem;"></div>
<div style="font-weight:600;">All clear no active alerts</div>
</div>
"""
end
defp format_dt(nil), do: ""
defp format_dt(iso) do
case DateTime.from_iso8601(iso) do
{:ok, dt, _} -> Calendar.strftime(dt, "%d %b %H:%M UTC")
_ -> iso
end
end
end

View file

@ -0,0 +1,155 @@
defmodule CentralcloudOps.IncidentLive do
use Phoenix.LiveView
alias CentralcloudCore.OnCall
def mount(%{"id" => id}, _session, socket) do
socket = load(socket, id)
{:ok, socket}
end
def handle_event("ack", _, socket) do
id = socket.assigns.id
case OnCall.acknowledge_alert_group(id) do
{:ok, _} -> {:noreply, load(put_flash(socket, :info, "Acknowledged"), id)}
_ -> {:noreply, put_flash(socket, :error, "Failed to acknowledge")}
end
end
def handle_event("resolve", _, socket) do
id = socket.assigns.id
case OnCall.resolve_alert_group(id) do
{:ok, _} -> {:noreply, load(put_flash(socket, :info, "Resolved"), id)}
_ -> {:noreply, put_flash(socket, :error, "Failed to resolve")}
end
end
def handle_event("silence", %{"duration" => dur}, socket) do
id = socket.assigns.id
seconds = String.to_integer(dur) * 60
case OnCall.silence_alert_group(id, seconds) do
{:ok, _} -> {:noreply, load(put_flash(socket, :info, "Silenced for #{dur} minutes"), id)}
_ -> {:noreply, put_flash(socket, :error, "Failed to silence")}
end
end
defp load(socket, id) do
{ag, alerts} =
case OnCall.get_alert_group(id) do
{:ok, ag} ->
alerts = ag["alerts"] || []
{ag, alerts}
_ ->
{nil, []}
end
assign(socket,
page_title: "Incident #{id}",
id: id,
alert_group: ag,
alerts: alerts
)
end
def render(assigns) do
~H"""
<div style="margin-bottom:1rem;">
<a href="/incidents" style="color:#64748b;font-size:0.875rem;"> Back to Incidents</a>
</div>
<div :if={is_nil(@alert_group)} class="card" style="color:#64748b;">
Alert group not found.
</div>
<div :if={@alert_group}>
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
<div>
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.4rem;">
<span class={"badge badge-#{@alert_group["status"]}"}>{@alert_group["status"]}</span>
<h1 style="font-size:1.25rem;font-weight:700;">
{@alert_group["render_for_web"]["title"] || @alert_group["id"]}
</h1>
</div>
<div style="color:#64748b;font-size:0.8rem;">
Source: {@alert_group["alert_receive_channel"]["verbal_name"]} &nbsp;·&nbsp;
Started: {format_dt(@alert_group["started_at"])}
</div>
</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
<button :if={@alert_group["status"] == "firing"}
class="btn btn-warning" phx-click="ack">Acknowledge</button>
<button :if={@alert_group["status"] in ["firing","acknowledged"]}
class="btn btn-success" phx-click="resolve">Resolve</button>
<button :if={@alert_group["status"] == "firing"}
class="btn btn-ghost" phx-click="silence" phx-value-duration="30">
Silence 30m
</button>
<button :if={@alert_group["status"] == "firing"}
class="btn btn-ghost" phx-click="silence" phx-value-duration="120">
Silence 2h
</button>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.5rem;">
<div class="card">
<div class="section-title" style="font-size:0.9rem;">Details</div>
<table style="font-size:0.85rem;">
<tr>
<td style="color:#64748b;padding:0.3rem 1rem 0.3rem 0;">ID</td>
<td style="font-family:monospace;">{@alert_group["id"]}</td>
</tr>
<tr>
<td style="color:#64748b;padding:0.3rem 1rem 0.3rem 0;">Status</td>
<td>{@alert_group["status"]}</td>
</tr>
<tr :if={@alert_group["acknowledged_at"]}>
<td style="color:#64748b;padding:0.3rem 1rem 0.3rem 0;">Acknowledged</td>
<td>{format_dt(@alert_group["acknowledged_at"])}</td>
</tr>
<tr :if={@alert_group["resolved_at"]}>
<td style="color:#64748b;padding:0.3rem 1rem 0.3rem 0;">Resolved</td>
<td>{format_dt(@alert_group["resolved_at"])}</td>
</tr>
</table>
</div>
<div class="card">
<div class="section-title" style="font-size:0.9rem;">Render Preview</div>
<div style="font-size:0.85rem;color:#94a3b8;white-space:pre-wrap;">
{@alert_group["render_for_web"]["message"] || "No message"}
</div>
</div>
</div>
<div :if={@alerts != []} class="card" style="margin-bottom:1.5rem;padding:0;">
<div style="padding:1rem 1.25rem;border-bottom:1px solid #1e3a5f;font-weight:600;">
Raw Alerts ({length(@alerts)})
</div>
<table>
<thead>
<tr><th>Received</th><th>Title</th><th>Message</th></tr>
</thead>
<tbody>
<tr :for={a <- @alerts}>
<td style="color:#64748b;font-size:0.8rem;">{format_dt(a["created_at"])}</td>
<td style="font-size:0.85rem;">{a["render_for_web"]["title"]}</td>
<td style="color:#94a3b8;font-size:0.8rem;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{a["render_for_web"]["message"]}
</td>
</tr>
</tbody>
</table>
</div>
</div>
"""
end
defp format_dt(nil), do: ""
defp format_dt(iso) do
case DateTime.from_iso8601(iso) do
{:ok, dt, _} -> Calendar.strftime(dt, "%d %b %H:%M UTC")
_ -> iso
end
end
end

View file

@ -0,0 +1,74 @@
defmodule CentralcloudOps.IncidentsLive do
use Phoenix.LiveView
alias CentralcloudCore.OnCall
def mount(_params, _session, socket) do
{:ok, load(socket, "firing")}
end
def handle_event("filter", %{"status" => status}, socket) do
{:noreply, load(socket, status)}
end
defp load(socket, status) do
groups =
case OnCall.list_alert_groups(status: status) do
{:ok, %{"results" => r}} -> r
_ -> []
end
assign(socket, page_title: "Incidents", groups: groups, status: status)
end
def render(assigns) do
~H"""
<div class="section-title">🗂 Incidents</div>
<div style="display:flex;gap:0.5rem;margin-bottom:1.25rem;">
<button class={"btn #{if @status == "firing", do: "btn-danger", else: "btn-ghost"}"}
phx-click="filter" phx-value-status="firing">Firing</button>
<button class={"btn #{if @status == "acknowledged", do: "btn-warning", else: "btn-ghost"}"}
phx-click="filter" phx-value-status="acknowledged">Acknowledged</button>
<button class={"btn #{if @status == "resolved", do: "btn-success", else: "btn-ghost"}"}
phx-click="filter" phx-value-status="resolved">Resolved</button>
<button class={"btn #{if @status == "silenced", do: "btn-primary", else: "btn-ghost"}"}
phx-click="filter" phx-value-status="silenced">Silenced</button>
</div>
<div :if={@groups == []} class="card" style="color:#64748b;text-align:center;padding:2rem;">
No {@status} incidents.
</div>
<div :if={@groups != []} class="card" style="padding:0;">
<table>
<thead>
<tr>
<th>Status</th>
<th>Title</th>
<th>Source</th>
<th>Started</th>
<th></th>
</tr>
</thead>
<tbody>
<tr :for={ag <- @groups}>
<td><span class={"badge badge-#{ag["status"]}"}>{ag["status"]}</span></td>
<td style="font-weight:600;">{ag["render_for_web"]["title"] || ag["id"]}</td>
<td style="color:#64748b;font-size:0.8rem;">{ag["alert_receive_channel"]["verbal_name"]}</td>
<td style="color:#64748b;font-size:0.8rem;">{format_dt(ag["started_at"])}</td>
<td><a href={"/incidents/#{ag["id"]}"} class="btn btn-ghost">View </a></td>
</tr>
</tbody>
</table>
</div>
"""
end
defp format_dt(nil), do: ""
defp format_dt(iso) do
case DateTime.from_iso8601(iso) do
{:ok, dt, _} -> Calendar.strftime(dt, "%d %b %H:%M UTC")
_ -> iso
end
end
end

View file

@ -0,0 +1,78 @@
defmodule CentralcloudOps.OnCallLive do
use Phoenix.LiveView
alias CentralcloudCore.OnCall
@refresh_ms 60_000
def mount(_params, _session, socket) do
if connected?(socket), do: Process.send_after(self(), :refresh, @refresh_ms)
{:ok, load(socket)}
end
def handle_info(:refresh, socket) do
Process.send_after(self(), :refresh, @refresh_ms)
{:noreply, load(socket)}
end
defp load(socket) do
schedules =
case OnCall.list_schedules() do
{:ok, %{"results" => results}} ->
Enum.map(results, fn s ->
on_call =
case OnCall.current_oncall(s["id"]) do
{:ok, users} -> users
_ -> []
end
Map.put(s, "current_oncall", on_call)
end)
_ ->
[]
end
assign(socket, page_title: "On-Call", schedules: schedules, last_updated: DateTime.utc_now())
end
def render(assigns) do
~H"""
<div class="section-title">📅 On-Call Schedules</div>
<p style="color:#64748b;font-size:0.8rem;margin-bottom:1.5rem;">
Updated at {Calendar.strftime(@last_updated, "%H:%M:%S")} UTC refreshes every 60s
</p>
<div :if={@schedules == []} class="card" style="color:#64748b;">
No schedules found in Grafana OnCall.
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem;">
<div :for={s <- @schedules} class="card">
<div style="font-weight:700;font-size:1rem;margin-bottom:0.75rem;color:#f1f5f9;">
{s["name"]}
</div>
<div style="margin-bottom:0.75rem;">
<div style="font-size:0.7rem;color:#64748b;text-transform:uppercase;font-weight:600;margin-bottom:0.4rem;">
Currently on-call
</div>
<div :if={s["current_oncall"] == []} style="color:#64748b;font-size:0.875rem;">
Nobody on-call
</div>
<div :for={u <- s["current_oncall"]} style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;">
<span style="width:28px;height:28px;background:#1e3a5f;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.7rem;color:#60a5fa;">
{String.first(u["username"] || "?")}
</span>
<span style="font-weight:600;">{u["username"]}</span>
<span :if={u["email"]} style="color:#64748b;font-size:0.8rem;">({u["email"]})</span>
</div>
</div>
<div style="font-size:0.75rem;color:#475569;">
<a href={"/oncall/schedules/#{s["id"]}"} style="color:#60a5fa;">View schedule </a>
</div>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,154 @@
defmodule CentralcloudOps.StakeholdersLive do
use Phoenix.LiveView
alias CentralcloudCore.{HostBill, OnCall}
def mount(_params, _session, socket) do
{:ok, load(socket)}
end
def handle_event("search", %{"q" => q}, socket) do
filtered =
socket.assigns.clients
|> Enum.filter(fn c ->
name = String.downcase(c["firstname"] <> " " <> c["lastname"] <> " " <> (c["companyname"] || ""))
String.contains?(name, String.downcase(q))
end)
{:noreply, assign(socket, filtered_clients: filtered, query: q)}
end
def handle_event("select_client", %{"id" => id}, socket) do
client_id = String.to_integer(id)
services =
case HostBill.get_client_services(client_id) do
{:ok, %{"products" => p}} -> p
_ -> []
end
client = Enum.find(socket.assigns.clients, &(&1["id"] == id))
{:noreply, assign(socket, selected_client: client, client_services: services)}
end
defp load(socket) do
clients =
case HostBill.get_clients() do
{:ok, %{"clients" => c}} -> c
_ -> []
end
firing =
case OnCall.list_alert_groups(status: "firing") do
{:ok, %{"results" => r}} -> r
_ -> []
end
assign(socket,
page_title: "Stakeholders",
clients: clients,
filtered_clients: clients,
selected_client: nil,
client_services: [],
active_incidents: firing,
query: ""
)
end
def render(assigns) do
~H"""
<div class="section-title">👥 Stakeholder Management</div>
<div style="display:grid;grid-template-columns:320px 1fr;gap:1.5rem;align-items:start;">
<%!-- Client list --%>
<div class="card" style="padding:0;">
<div style="padding:1rem;border-bottom:1px solid #1e3a5f;">
<form phx-change="search" phx-submit="search">
<input type="text" name="q" value={@query} placeholder="Search clients…"
style="width:100%;padding:0.5rem 0.75rem;background:#0a0f1e;border:1px solid #1e3a5f;
border-radius:5px;color:#e2e8f0;font-size:0.85rem;"/>
</form>
</div>
<div style="max-height:600px;overflow-y:auto;">
<div :for={c <- @filtered_clients}
phx-click="select_client" phx-value-id={c["id"]}
style={"padding:0.75rem 1rem;cursor:pointer;border-bottom:1px solid #0f172a;font-size:0.875rem;
#{if @selected_client && @selected_client["id"] == c["id"], do: "background:#1e3a5f;", else: ""}"}>
<div style="font-weight:600;">{c["firstname"]} {c["lastname"]}</div>
<div :if={c["companyname"] != ""} style="color:#64748b;font-size:0.78rem;">{c["companyname"]}</div>
</div>
<div :if={@filtered_clients == []} style="padding:1.5rem;color:#64748b;text-align:center;font-size:0.875rem;">
No clients found
</div>
</div>
</div>
<%!-- Client detail / comms panel --%>
<div>
<div :if={is_nil(@selected_client)} class="card" style="color:#64748b;text-align:center;padding:3rem;">
Select a client to view their services and send a status update.
</div>
<div :if={@selected_client}>
<div class="card" style="margin-bottom:1rem;">
<div style="font-size:1.1rem;font-weight:700;margin-bottom:0.25rem;">
{@selected_client["firstname"]} {@selected_client["lastname"]}
</div>
<div style="color:#64748b;font-size:0.85rem;">{@selected_client["email"]}</div>
<div :if={@selected_client["companyname"] != ""}
style="color:#94a3b8;font-size:0.8rem;">{@selected_client["companyname"]}</div>
</div>
<div class="card" style="margin-bottom:1rem;padding:0;">
<div style="padding:0.75rem 1.25rem;border-bottom:1px solid #1e3a5f;font-weight:600;font-size:0.9rem;">
Active Services ({length(@client_services)})
</div>
<div :if={@client_services == []} style="padding:1rem 1.25rem;color:#64748b;font-size:0.85rem;">
No services found
</div>
<table :if={@client_services != []}>
<thead><tr><th>Service</th><th>Status</th><th>Next Due</th></tr></thead>
<tbody>
<tr :for={svc <- @client_services}>
<td style="font-size:0.85rem;">{svc["name"]}</td>
<td>
<span class={"badge #{if svc["status"] == "Active", do: "badge-resolved", else: "badge-acked"}"}>
{svc["status"]}
</span>
</td>
<td style="color:#64748b;font-size:0.8rem;">{svc["nextduedate"]}</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<div style="font-weight:600;margin-bottom:1rem;font-size:0.9rem;">📢 Send Status Update</div>
<div :if={@active_incidents != []} style="margin-bottom:1rem;">
<div style="font-size:0.75rem;color:#64748b;text-transform:uppercase;font-weight:600;margin-bottom:0.5rem;">
Link to active incident
</div>
<div style="display:flex;flex-wrap:wrap;gap:0.4rem;">
<span :for={inc <- @active_incidents}
style="background:#4c0519;border:1px solid #dc2626;border-radius:4px;
padding:0.2rem 0.5rem;font-size:0.75rem;cursor:pointer;">
{inc["render_for_web"]["title"] || inc["id"]}
</span>
</div>
</div>
<textarea rows="4"
placeholder="Type a status update to send to this client via HostBill ticket…"
style="width:100%;padding:0.6rem;background:#0a0f1e;border:1px solid #1e3a5f;
border-radius:5px;color:#e2e8f0;font-size:0.85rem;resize:vertical;margin-bottom:0.75rem;">
</textarea>
<button class="btn btn-primary" style="opacity:0.5;cursor:not-allowed;" disabled>
Send via HostBill ticket (coming soon)
</button>
</div>
</div>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,18 @@
defmodule CentralcloudOps.Plugs.RequireStaff do
import Plug.Conn
import Phoenix.Controller, only: [redirect: 2]
def init(opts), do: opts
def call(conn, _opts) do
case get_session(conn, :staff_id) do
nil ->
conn
|> redirect(to: "/login")
|> halt()
_id ->
conn
end
end
end

View file

@ -0,0 +1,48 @@
defmodule CentralcloudOps.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
@session_opts [store: :cookie, key: "_centralcloud_ops_session", signing_salt: "ops_salt_2026"]
def session_opts, do: @session_opts
pipeline :api do
plug :accepts, ["json"]
end
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {CentralcloudOps.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :auth do
plug CentralcloudOps.Plugs.RequireStaff
end
scope "/api", CentralcloudOps do
pipe_through :api
get "/health", HealthController, :check
end
scope "/", CentralcloudOps do
pipe_through :browser
get "/login", SessionController, :new
post "/login", SessionController, :create
delete "/logout", SessionController, :delete
get "/auth/callback", SessionController, :oidc_callback
end
scope "/", CentralcloudOps do
pipe_through [:browser, :auth]
live "/", DashboardLive, :index
live "/oncall", OnCallLive, :index
live "/incidents", IncidentsLive, :index
live "/incidents/:id", IncidentLive, :show
live "/stakeholders", StakeholdersLive, :index
end
end

View file

@ -18,6 +18,10 @@ config :centralcloud_core, :hostbill,
config :centralcloud_core, :dr_portal,
base_url: "https://dr.centralcloud.com"
# Grafana OnCall API (defaults — overridden at runtime)
config :centralcloud_core, :oncall,
base_url: "https://oncall.centralcloud.com"
# Authentik OIDC (defaults — overridden at runtime)
config :centralcloud_core, :oidc,
issuer: "https://sso.centralcloud.com/application/o/centralcloud/"

View file

@ -20,6 +20,10 @@ config :centralcloud_core, :dr_portal,
base_url: System.get_env("DR_PORTAL_URL", "https://dr.centralcloud.com"),
api_key: System.get_env("DR_PORTAL_API_KEY")
config :centralcloud_core, :oncall,
base_url: System.get_env("ONCALL_URL", "https://oncall.centralcloud.com"),
token: System.get_env("ONCALL_API_TOKEN")
config :centralcloud_core, :oidc,
issuer: System.get_env("AUTHENTIK_ISSUER", "https://sso.centralcloud.com/application/o/centralcloud/"),
client_id: System.get_env("OIDC_CLIENT_ID"),