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,
|
||||
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/"
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue