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:
parent
37777ca54b
commit
f84f59e0df
15 changed files with 1053 additions and 0 deletions
118
apps/centralcloud_core/lib/centralcloud_core/oncall.ex
Normal file
118
apps/centralcloud_core/lib/centralcloud_core/oncall.ex
Normal 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
|
||||||
13
apps/centralcloud_ops/lib/centralcloud_ops/application.ex
Normal file
13
apps/centralcloud_ops/lib/centralcloud_ops/application.ex
Normal 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
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
defmodule CentralcloudOps.HealthController do
|
||||||
|
use Phoenix.Controller, formats: [:json]
|
||||||
|
|
||||||
|
def check(conn, _params) do
|
||||||
|
json(conn, %{status: "ok"})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -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
|
||||||
29
apps/centralcloud_ops/lib/centralcloud_ops/endpoint.ex
Normal file
29
apps/centralcloud_ops/lib/centralcloud_ops/endpoint.ex
Normal 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
|
||||||
112
apps/centralcloud_ops/lib/centralcloud_ops/layouts.ex
Normal file
112
apps/centralcloud_ops/lib/centralcloud_ops/layouts.ex
Normal 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
|
||||||
|
|
@ -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
|
||||||
155
apps/centralcloud_ops/lib/centralcloud_ops/live/incident_live.ex
Normal file
155
apps/centralcloud_ops/lib/centralcloud_ops/live/incident_live.ex
Normal 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"]} ·
|
||||||
|
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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
48
apps/centralcloud_ops/lib/centralcloud_ops/router.ex
Normal file
48
apps/centralcloud_ops/lib/centralcloud_ops/router.ex
Normal 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
|
||||||
|
|
@ -18,6 +18,10 @@ config :centralcloud_core, :hostbill,
|
||||||
config :centralcloud_core, :dr_portal,
|
config :centralcloud_core, :dr_portal,
|
||||||
base_url: "https://dr.centralcloud.com"
|
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)
|
# Authentik OIDC (defaults — overridden at runtime)
|
||||||
config :centralcloud_core, :oidc,
|
config :centralcloud_core, :oidc,
|
||||||
issuer: "https://sso.centralcloud.com/application/o/centralcloud/"
|
issuer: "https://sso.centralcloud.com/application/o/centralcloud/"
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ config :centralcloud_core, :dr_portal,
|
||||||
base_url: System.get_env("DR_PORTAL_URL", "https://dr.centralcloud.com"),
|
base_url: System.get_env("DR_PORTAL_URL", "https://dr.centralcloud.com"),
|
||||||
api_key: System.get_env("DR_PORTAL_API_KEY")
|
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,
|
config :centralcloud_core, :oidc,
|
||||||
issuer: System.get_env("AUTHENTIK_ISSUER", "https://sso.centralcloud.com/application/o/centralcloud/"),
|
issuer: System.get_env("AUTHENTIK_ISSUER", "https://sso.centralcloud.com/application/o/centralcloud/"),
|
||||||
client_id: System.get_env("OIDC_CLIENT_ID"),
|
client_id: System.get_env("OIDC_CLIENT_ID"),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue