sf snapshot: uncommitted changes after 2133m inactivity

This commit is contained in:
Mikael Hugo 2026-05-13 01:30:33 +02:00
parent 1758b2465e
commit f40632b297
76 changed files with 61516 additions and 58043 deletions

27
.gitignore vendored
View file

@ -3,3 +3,30 @@ _build/
deps/
.elixir_ls/
*.beam
# ── SF baseline (auto-generated) ──
.DS_Store
Thumbs.db
*.swp
*.swo
*~
.idea/
.vscode/
*.code-workspace
.env
.env.*
!.env.example
node_modules/
.next/
dist/
build/
__pycache__/
*.pyc
.venv/
venv/
target/
vendor/
*.log
coverage/
.cache/
tmp/

10
.sf/NON-GOALS.md Normal file
View file

@ -0,0 +1,10 @@
<!-- sf-doc: version=2.75.3 template=.sf/NON-GOALS.md state=pending hash=sha256:906860a75a8ae97d473efa0530675aded011c4897d858a8eff44ce42ea73a74a -->
# Non-goals
What this project explicitly does not want. Things that look attractive but have been decided against.
This is gold — most wrong agent calls come from not knowing what to avoid. Each entry: 1-2 sentences with the rationale.
## Examples
- (replace with your own)

10
.sf/PRINCIPLES.md Normal file
View file

@ -0,0 +1,10 @@
<!-- sf-doc: version=2.75.3 template=.sf/PRINCIPLES.md state=pending hash=sha256:9d5c50cb3d602f66468a33a4324068fab8a022fab0fd6940c371a5986af2947e -->
# Principles
Durable design philosophy. Things this codebase believes are true.
Add entries as you make decisions. Each entry: 1-2 sentences. Cite the rationale (the why, not just the what).
## Examples
- (replace with your own)

10
.sf/STYLE.md Normal file
View file

@ -0,0 +1,10 @@
<!-- sf-doc: version=2.75.3 template=.sf/STYLE.md state=pending hash=sha256:a78cb9cb5a95271ff43d91acf246f1390216a30aca48f3796a6aa135301f167f -->
# Style
What good code looks like here. Idioms, conventions, "we prefer X over Y" calls.
Add entries as you notice patterns worth preserving. Each entry: 1-2 sentences with a concrete example.
## Examples
- (replace with your own)

10
.sf/harness/AGENTS.md Normal file
View file

@ -0,0 +1,10 @@
<!-- sf-doc: version=2.75.3 template=.sf/harness/AGENTS.md state=pending hash=sha256:685c41e601340086b8076263a71315c66554efdaeb074bc1b907eebf879174c6 -->
# Harness Agent Notes
The harness is SF-local operational scaffolding the agent can read and verify against.
- `specs/`: behavior contracts. Each spec states what "done" looks like and the command that proves it.
- `evals/`: task definitions for behaviors tests cannot cover — model output quality, multi-turn flows, agent decisions.
- `graders/`: reusable grader scripts (code-based checks, LLM-judge prompts used by evals).
**Rule:** Before marking a task done, run the relevant spec's verification command. Record the result in the completion summary or execution plan.

View file

@ -0,0 +1,11 @@
<!-- sf-doc: version=2.75.3 template=.sf/harness/evals/AGENTS.md state=pending hash=sha256:6f88bf8a2bad95d8db5985c9b3317b9edd65592c12e98bb0bff1a24ec152d768 -->
# Harness Evals Agent Notes
Evals verify behavior that unit tests cannot cover — model output quality, agent decisions, multi-turn flows.
Each eval should include:
- The input fixture or prompt
- The expected output or scoring rubric
- The command to run it (`promptfoo eval`, custom script, etc.)
Keep evals deterministic where possible. Log results to `docs/records/` at milestone close.

View file

@ -0,0 +1,9 @@
<!-- sf-doc: version=2.75.3 template=.sf/harness/graders/AGENTS.md state=pending hash=sha256:2db17feae1acfe62d85aafbe32d016873c3036d4d76e9dd0db478375fae0794e -->
# Harness Graders Agent Notes
Graders are reusable scripts or prompts that score eval outputs.
- Code-based graders: shell scripts or test files that check structured outputs deterministically.
- LLM-judge graders: prompt templates that ask a model to score free-text output against a rubric.
Prefer code-based graders. Add LLM-judge graders only when deterministic checking is impossible.

View file

@ -0,0 +1,10 @@
<!-- sf-doc: version=2.75.3 template=.sf/harness/specs/AGENTS.md state=pending hash=sha256:0f4fbf4111704d05744e4a4e13a9bf3eada262f0da9517c2010f0b46f4bd3c45 -->
# Harness Specs Agent Notes
Each spec file in this directory:
- States the behavior being specified (not the implementation).
- Includes the exact command that proves the spec passes.
- Is referenced by the relevant execution plan or ADR.
Write the spec before implementation. Run it after. Record the result.

View file

@ -0,0 +1,20 @@
<!-- sf-doc: version=2.75.3 template=.sf/harness/specs/bootstrap.md state=pending hash=sha256:b86ba7cf2cec39a7a9f9d94f885998cfe26eebfc5b76fdd8375ef125e927e0cf -->
# Bootstrap Spec: Agent Legibility
Verifies that this repo is minimally agent-legible.
## Criteria
- [ ] `AGENTS.md` exists at repo root and is non-empty.
- [ ] `ARCHITECTURE.md` exists at repo root and is non-empty.
- [ ] `docs/exec-plans/active/` exists.
- [ ] `docs/exec-plans/tech-debt-tracker.md` exists.
- [ ] `docs/design-docs/ADR-TEMPLATE.md` exists.
## Verification command
```bash
for f in AGENTS.md ARCHITECTURE.md docs/exec-plans/active/index.md docs/exec-plans/tech-debt-tracker.md docs/design-docs/ADR-TEMPLATE.md .sf/harness/specs/bootstrap.md; do [ -s "$f" ] && echo "OK: $f" || echo "MISSING: $f"; done
```
All lines should start with `OK:` for the bootstrap spec to pass.

12
.sf/preferences.yaml Normal file
View file

@ -0,0 +1,12 @@
# SF preferences — see ~/.sf/agent/extensions/sf/docs/preferences-reference.md for docs
version: 1
last_synced_with_sf: 2.75.3
sf_template_state: pending
always_use_skills: []
prefer_skills: []
avoid_skills: []
skill_rules: []
custom_instructions: []
models: {}
skill_discovery: {}
auto_supervisor: {}

142
.sf/scaffold-manifest.json Normal file
View file

@ -0,0 +1,142 @@
{
"schemaVersion": 1,
"profile": "infra",
"applied": [
{
"path": ".siftignore",
"template": ".siftignore",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:e40f5757f1e3d603cc8f342e53e3e06fb86ea895f33c5b7dfdc7d693f7fd3e96"
},
{
"path": "AGENTS.md",
"template": "AGENTS.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:dc04211ddd84103bb94b805effcf00f436ea678c1e88301969647ded2e2a787a"
},
{
"path": "ARCHITECTURE.md",
"template": "ARCHITECTURE.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:b93561af871cbfd6c7445b66273600a5654359dd3db578b424a0c3fa587ee5f3"
},
{
"path": "docs/AGENTS.md",
"template": "docs/AGENTS.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:b35804ce78ca309cab8769719f6e0738141f1121682fbd46490419abd2c6f870"
},
{
"path": "docs/records/AGENTS.md",
"template": "docs/records/AGENTS.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:dc21117dfa7607d7ce4cc6ce5724658348a95e9807673ff526b9cf02e2568de0"
},
{
"path": "docs/records/index.md",
"template": "docs/records/index.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:03e974ded1b733db1a84dbf92096ff91bdc28a2ae37f457c536bc184fdc79cb9"
},
{
"path": "docs/RECORDS_KEEPER.md",
"template": "docs/RECORDS_KEEPER.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:3872de9cd72bd9129814a5e77e3b86abe76bef33f3ca34e04ae7582b4cfd066a"
},
{
"path": "docs/RELIABILITY.md",
"template": "docs/RELIABILITY.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:cda2b3d8f7f6323c5185e32fe832d8c181e6383c1a515b20c3e530eb6f133407"
},
{
"path": "docs/SECURITY.md",
"template": "docs/SECURITY.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:baf816ba2591d9e3859b82ba1dbe8b05f7bb8003edab90071c086eee3edfd445"
},
{
"path": ".sf/harness/AGENTS.md",
"template": ".sf/harness/AGENTS.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:685c41e601340086b8076263a71315c66554efdaeb074bc1b907eebf879174c6"
},
{
"path": ".sf/harness/specs/AGENTS.md",
"template": ".sf/harness/specs/AGENTS.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:0f4fbf4111704d05744e4a4e13a9bf3eada262f0da9517c2010f0b46f4bd3c45"
},
{
"path": ".sf/harness/specs/bootstrap.md",
"template": ".sf/harness/specs/bootstrap.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:b86ba7cf2cec39a7a9f9d94f885998cfe26eebfc5b76fdd8375ef125e927e0cf"
},
{
"path": ".sf/harness/evals/AGENTS.md",
"template": ".sf/harness/evals/AGENTS.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:6f88bf8a2bad95d8db5985c9b3317b9edd65592c12e98bb0bff1a24ec152d768"
},
{
"path": ".sf/harness/graders/AGENTS.md",
"template": ".sf/harness/graders/AGENTS.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:2db17feae1acfe62d85aafbe32d016873c3036d4d76e9dd0db478375fae0794e"
},
{
"path": ".sf/PRINCIPLES.md",
"template": ".sf/PRINCIPLES.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:9d5c50cb3d602f66468a33a4324068fab8a022fab0fd6940c371a5986af2947e"
},
{
"path": ".sf/STYLE.md",
"template": ".sf/STYLE.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:a78cb9cb5a95271ff43d91acf246f1390216a30aca48f3796a6aa135301f167f"
},
{
"path": ".sf/NON-GOALS.md",
"template": ".sf/NON-GOALS.md",
"version": "2.75.3",
"appliedAt": "2026-05-12T23:29:27.296Z",
"stateAtApply": "pending",
"contentHash": "sha256:906860a75a8ae97d473efa0530675aded011c4897d858a8eff44ce42ea73a74a"
}
]
}

19
.siftignore Normal file
View file

@ -0,0 +1,19 @@
.git/**
.sf/**
.bg-shell/**
.pytest_cache/**
.venv/**
venv/**
node_modules/**
**/node_modules/**
**/__pycache__/**
*.pyc
*.egg-info/**
build/**
dist/**
target/**
vendor/**
coverage/**
.cache/**
tmp/**
*.log

17
AGENTS.md Normal file
View file

@ -0,0 +1,17 @@
<!-- sf-doc: version=2.75.3 template=AGENTS.md state=pending hash=sha256:dc04211ddd84103bb94b805effcf00f436ea678c1e88301969647ded2e2a787a -->
# Agent Map
Keep this file short. Use it as a table of contents for agents and humans.
- Treat the repo as a purpose-to-software pipeline: intent -> purpose/consumer/contract/evidence -> tests -> implementation -> verification.
- Read `ARCHITECTURE.md` first for the system map and invariants.
- Read `docs/PLANS.md` and `docs/exec-plans/active/` for current work.
- Read `docs/QUALITY_SCORE.md`, `docs/RELIABILITY.md`, and `docs/SECURITY.md` before changing production behavior.
- Put durable product decisions in `docs/product-specs/`.
- Put durable design and architecture decisions in `docs/design-docs/`.
- Put generated reference material in `docs/generated/`.
- Use `docs/RECORDS_KEEPER.md` as the repo-order checklist after meaningful changes.
- Use the `records-keeper` skill when repo docs, plans, or architecture records need triage.
- Follow deeper `AGENTS.md` files when present. The closest one to the changed file wins.
Before implementation, inspect the relevant docs and source files, state observed facts before inferred facts, name the real consumer, and define the command or eval that proves the change.

21
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,21 @@
<!-- sf-doc: version=2.75.3 template=ARCHITECTURE.md state=pending hash=sha256:b93561af871cbfd6c7445b66273600a5654359dd3db578b424a0c3fa587ee5f3 -->
# Architecture
This file is the short map of the codebase. Keep it current and compact.
## Purpose
Describe the product, its users, and the job this repository exists to do.
## Codemap
- `src/`: primary implementation.
- `tests/`: behavior and regression coverage.
- `docs/`: durable product, design, plan, reliability, and security context.
## Invariants
- Prefer small, named modules with clear ownership.
- Behavior changes need tests or an explicit eval.
- Keep generated artifacts out of hand-written design docs.
- Update this map when new top-level concepts or directories become important.

View file

@ -32,8 +32,19 @@ COPY apps/centralcloud_my ./apps/centralcloud_my
COPY apps/centralcloud_staff ./apps/centralcloud_staff
COPY rel ./rel
RUN mix compile && \
mix release centralcloud_my && \
RUN mix compile
# Install Tailwind CLI and compile CSS for both apps
RUN mix tailwind.install --if-missing
RUN mix tailwind staff --minify && mix tailwind my --minify
# Copy phoenix.min.js + phoenix_live_view.min.js to the my app (my app's priv/static is empty)
RUN cp apps/centralcloud_staff/priv/static/phoenix.min.js \
apps/centralcloud_my/priv/static/ && \
cp apps/centralcloud_staff/priv/static/phoenix_live_view.min.js \
apps/centralcloud_my/priv/static/
RUN mix release centralcloud_my && \
mix release centralcloud_staff
# ── Shared runtime base ───────────────────────────────────────────────────────

Binary file not shown.

View file

@ -1,9 +1 @@
{application,centralcloud_core,
[{modules,['Elixir.CentralcloudCore.DrPortal',
'Elixir.CentralcloudCore.HostBill',
'Elixir.CentralcloudCore.OnCall']},
{optional_applications,[]},
{applications,[kernel,stdlib,elixir,logger,req,jason,jose]},
{description,"centralcloud_core"},
{registered,[]},
{vsn,"0.1.0"}]}.
{application,centralcloud_core,[{modules,['Elixir.CentralcloudCore.DrPortal','Elixir.CentralcloudCore.HostBill','Elixir.CentralcloudCore.OnCall','Elixir.CentralcloudCore.Security','Elixir.CentralcloudCore.WazuhMcp']},{optional_applications,[]},{applications,[kernel,stdlib,elixir,logger,req,jason,jose]},{description,"centralcloud_core"},{registered,[]},{vsn,"0.1.0"}]}.

View file

@ -1,35 +1 @@
{application,centralcloud_my,
[{modules,
['Elixir.CentralcloudMy.Application',
'Elixir.CentralcloudMy.BillingLive',
'Elixir.CentralcloudMy.DashboardLive',
'Elixir.CentralcloudMy.Endpoint',
'Elixir.CentralcloudMy.HealthController',
'Elixir.CentralcloudMy.Layouts',
'Elixir.CentralcloudMy.Plugs.RequireAuth',
'Elixir.CentralcloudMy.Release',
'Elixir.CentralcloudMy.ReplicationLive',
'Elixir.CentralcloudMy.Router',
'Elixir.CentralcloudMy.Router.Helpers',
'Elixir.CentralcloudMy.SessionController',
'Elixir.CentralcloudMy.SupportLive']},
{compile_env,
[{centralcloud_my,
['Elixir.CentralcloudMy.Endpoint',code_reloader],
{ok,true}},
{centralcloud_my,
['Elixir.CentralcloudMy.Endpoint',debug_errors],
{ok,true}},
{centralcloud_my,
['Elixir.CentralcloudMy.Endpoint',force_ssl],
error}]},
{optional_applications,[]},
{applications,
[kernel,stdlib,elixir,logger,runtime_tools,centralcloud_core,phoenix,
phoenix_live_view,phoenix_html,phoenix_live_reload,bandit,ecto_sql,
postgrex,swoosh,finch,telemetry_metrics,telemetry_poller,jason,
dns_cluster,oidcc]},
{description,"centralcloud_my"},
{registered,[]},
{vsn,"0.1.0"},
{mod,{'Elixir.CentralcloudMy.Application',[]}}]}.
{application,centralcloud_my,[{modules,['Elixir.CentralcloudMy.Application','Elixir.CentralcloudMy.BillingLive','Elixir.CentralcloudMy.DashboardLive','Elixir.CentralcloudMy.Endpoint','Elixir.CentralcloudMy.HealthController','Elixir.CentralcloudMy.Layouts','Elixir.CentralcloudMy.Plugs.RequireAuth','Elixir.CentralcloudMy.Release','Elixir.CentralcloudMy.ReplicationLive','Elixir.CentralcloudMy.Router','Elixir.CentralcloudMy.Router.Helpers','Elixir.CentralcloudMy.SecurityLive','Elixir.CentralcloudMy.SessionController','Elixir.CentralcloudMy.SupportLive','Elixir.CentralcloudMyWeb']},{compile_env,[{centralcloud_my,['Elixir.CentralcloudMy.Endpoint',code_reloader],{ok,true}},{centralcloud_my,['Elixir.CentralcloudMy.Endpoint',debug_errors],{ok,true}},{centralcloud_my,['Elixir.CentralcloudMy.Endpoint',force_ssl],error}]},{optional_applications,[]},{applications,[kernel,stdlib,elixir,logger,runtime_tools,centralcloud_core,phoenix,phoenix_live_view,phoenix_html,phoenix_live_reload,bandit,ecto_sql,postgrex,swoosh,finch,telemetry_metrics,telemetry_poller,jason,dns_cluster,oidcc,petal_components]},{description,"centralcloud_my"},{registered,[]},{vsn,"0.1.0"},{mod,{'Elixir.CentralcloudMy.Application',[]}}]}.

View file

@ -113,6 +113,6 @@ defmodule CentralcloudCore.OnCall do
defp auth_headers do
cfg = Application.fetch_env!(:centralcloud_core, :oncall)
[{"Authorization", "Token #{cfg[:token]}"}]
[{"Authorization", to_string(cfg[:token])}]
end
end

View file

@ -0,0 +1,395 @@
defmodule CentralcloudCore.Security do
@moduledoc """
Client for the endpoint security monitoring platform.
Provides customer-scoped agent listing, vulnerability counts, alert queries,
SCA compliance results, and HostBill service-gate logic for the Security Center.
Customers are grouped by the convention `cc_client_<hostbill_customer_id>`.
"""
alias CentralcloudCore.HostBill
# ---------------------------------------------------------------------------
# Public API — agents
# ---------------------------------------------------------------------------
@doc "Return all monitored endpoints for a given customer."
def get_monitored_agents(customer_id) when is_integer(customer_id) do
with {:ok, token} <- authenticate() do
result =
do_get(token, "/agents", %{
groups: customer_group(customer_id),
limit: 500,
select: "id,name,status,ip,version,lastKeepAlive,os.name,os.platform"
})
case result do
{:ok, %{"data" => %{"affected_items" => items}}} -> {:ok, items}
{:ok, _} -> {:ok, []}
err -> err
end
end
end
@doc """
List agents with optional scoping.
Options:
- `:customer_id` integer, scopes to cc_client_{id} group
- `:status` string, e.g. "active", "disconnected"
- `:limit` integer, max results (default 100, max 500)
"""
def list_agents(opts \\ []) do
customer_id = Keyword.get(opts, :customer_id)
status = Keyword.get(opts, :status)
limit = min(Keyword.get(opts, :limit, 100), 500)
params =
%{
limit: limit,
select: "id,name,status,ip,version,lastKeepAlive,os.name,os.platform,groups"
}
|> maybe_put(:groups, customer_id && customer_group(customer_id))
|> maybe_put(:status, status)
with {:ok, token} <- authenticate(),
{:ok, body} <- do_get(token, "/agents", params) do
items = get_in(body, ["data", "affected_items"]) || []
total = get_in(body, ["data", "total_affected_items"]) || length(items)
{:ok, %{total: total, agents: items}}
end
end
@doc "Connectivity health summary: active / disconnected / never_connected counts."
def agent_summary(opts \\ []) do
with {:ok, %{agents: agents}} <- list_agents(Keyword.put(opts, :limit, 500)) do
by_status = Enum.frequencies_by(agents, & &1["status"])
disconnected =
Enum.filter(agents, &(&1["status"] in ["disconnected", "never_connected"]))
|> Enum.map(&Map.take(&1, ["id", "name", "ip", "status", "lastKeepAlive"]))
{:ok, %{total: length(agents), by_status: by_status, disconnected_agents: disconnected}}
end
end
# ---------------------------------------------------------------------------
# Public API — vulnerabilities
# ---------------------------------------------------------------------------
@doc """
Return vulnerability counts by severity for a specific agent ID.
"""
def get_agent_vulnerability_counts(agent_id) do
with {:ok, token} <- authenticate() do
result =
do_get(token, "/vulnerability/#{agent_id}", %{
limit: 500,
distinct: true,
select: "cve,severity"
})
case result do
{:ok, %{"data" => %{"affected_items" => items}}} ->
counts =
Enum.frequencies_by(items, fn item ->
String.downcase(item["severity"] || "unknown")
end)
{:ok, counts}
_ ->
{:ok, %{}}
end
end
end
@doc """
Return CVE vulnerabilities for a specific agent.
Options:
- `:severity` "Critical", "High", "Medium", "Low"
- `:limit` max results (default 100)
"""
def get_vulnerabilities(agent_id, opts \\ []) do
severity = Keyword.get(opts, :severity)
limit = min(Keyword.get(opts, :limit, 100), 500)
params =
%{
limit: limit,
distinct: true,
select: "cve,severity,cvss3_score,cvss2_score,name,version,architecture,title,published"
}
|> maybe_put(:severity, severity)
with {:ok, token} <- authenticate(),
{:ok, body} <- do_get(token, "/vulnerability/#{agent_id}", params) do
items = get_in(body, ["data", "affected_items"]) || []
total = get_in(body, ["data", "total_affected_items"]) || length(items)
{:ok, %{agent_id: agent_id, total: total, vulnerabilities: items}}
end
end
@doc """
Top N critical CVEs across the fleet (or a customer), ranked by
affected-host count then CVSS score.
"""
def top_critical_cves(opts \\ []) do
top_n = Keyword.get(opts, :top_n, 20)
with {:ok, %{agents: agents}} <- list_agents(Keyword.put(opts, :status, "active") |> Keyword.put(:limit, 500)) do
cve_map =
Enum.reduce(agents, %{}, fn agent, acc ->
aid = agent["id"]
case get_vulnerabilities(aid, severity: "Critical", limit: 500) do
{:ok, %{vulnerabilities: vulns}} ->
Enum.reduce(vulns, acc, fn v, inner ->
cve_id = v["cve"]
if cve_id do
entry =
Map.get_lazy(inner, cve_id, fn ->
%{
cve: cve_id,
title: v["title"],
severity: v["severity"],
cvss3_score: v["cvss3_score"],
cvss2_score: v["cvss2_score"],
affected_agents: []
}
end)
updated =
Map.update!(entry, :affected_agents, fn agents ->
[%{id: aid, name: agent["name"]} | agents]
end)
Map.put(inner, cve_id, updated)
else
inner
end
end)
_ ->
acc
end
end)
ranked =
cve_map
|> Map.values()
|> Enum.sort_by(
fn c ->
{length(c.affected_agents),
c.cvss3_score || c.cvss2_score || 0.0}
end,
:desc
)
|> Enum.take(top_n)
{:ok,
%{
scanned_agents: length(agents),
unique_critical_cves: map_size(cve_map),
top_cves: ranked
}}
end
end
# ---------------------------------------------------------------------------
# Public API — alerts
# ---------------------------------------------------------------------------
@doc """
Return recent SIEM alerts.
Options:
- `:customer_id` scope to a customer's agents
- `:agent_id` scope to a single agent
- `:level` minimum rule level (default 7)
- `:limit` max results (default 50)
"""
def get_alerts(opts \\ []) do
customer_id = Keyword.get(opts, :customer_id)
agent_id = Keyword.get(opts, :agent_id)
level = Keyword.get(opts, :level, 7)
limit = min(Keyword.get(opts, :limit, 50), 500)
params = %{
limit: limit,
sort: "-timestamp",
select:
"id,timestamp,agent.id,agent.name,rule.id,rule.description,rule.level,rule.groups,data.srcip,data.dstuser",
level: "#{level}-15"
}
with {:ok, token} <- authenticate() do
params =
cond do
agent_id ->
Map.put(params, :agents_list, agent_id)
customer_id ->
case list_agents(customer_id: customer_id, limit: 500) do
{:ok, %{agents: agents}} ->
ids = Enum.map_join(agents, ",", & &1["id"])
if ids == "", do: :empty, else: Map.put(params, :agents_list, ids)
_ ->
params
end
true ->
params
end
case params do
:empty ->
{:ok, %{total: 0, alerts: []}}
params ->
case do_get(token, "/alerts", params) do
{:ok, body} ->
items = get_in(body, ["data", "affected_items"]) || []
total = get_in(body, ["data", "total_affected_items"]) || length(items)
{:ok, %{total: total, alerts: items}}
err ->
err
end
end
end
end
# ---------------------------------------------------------------------------
# Public API — SCA (CIS compliance)
# ---------------------------------------------------------------------------
@doc """
Return SCA/CIS benchmark results for an agent.
If `policy_id` is given, returns individual check results for that policy.
Otherwise returns all policy summaries.
"""
def get_sca(agent_id, opts \\ []) do
policy_id = Keyword.get(opts, :policy_id)
with {:ok, token} <- authenticate() do
if policy_id do
case do_get(token, "/sca/#{agent_id}/checks/#{policy_id}", %{limit: 500}) do
{:ok, body} ->
items = get_in(body, ["data", "affected_items"]) || []
failed = Enum.filter(items, &(&1["result"] == "failed"))
{:ok,
%{
agent_id: agent_id,
policy_id: policy_id,
total_checks: length(items),
failed_checks: length(failed),
failed: failed
}}
err ->
err
end
else
case do_get(token, "/sca/#{agent_id}", %{limit: 100}) do
{:ok, body} ->
policies = get_in(body, ["data", "affected_items"]) || []
{:ok, %{agent_id: agent_id, policies: policies}}
err ->
err
end
end
end
end
# ---------------------------------------------------------------------------
# HostBill service gating
# ---------------------------------------------------------------------------
@doc """
Return true when the list of HostBill service products includes a security
monitoring subscription. Matches product names containing "security",
"threat", or "monitor" (case-insensitive).
"""
def has_security_service?(services) when is_list(services) do
Enum.any?(services, fn svc ->
name = String.downcase(svc["name"] || "")
String.contains?(name, "security") or
String.contains?(name, "threat") or
String.contains?(name, "monitor")
end)
end
def has_security_service?(_), do: false
@doc "Fetch HostBill services for a customer and gate on security service."
def customer_has_security_service?(customer_id) when is_integer(customer_id) do
case HostBill.get_client_services(customer_id) do
{:ok, %{"products" => products}} -> has_security_service?(products)
_ -> :unavailable
end
end
@doc "The monitoring platform group name for a customer."
def customer_group(customer_id), do: "cc_client_#{customer_id}"
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
defp authenticate do
cfg = config()
url = cfg[:url]
credential = Base.encode64("#{cfg[:username]}:#{cfg[:password]}")
case Req.post(
"#{url}/security/user/authenticate?raw=true",
headers: [{"authorization", "Basic #{credential}"}],
connect_options: ssl_opts(cfg)
) do
{:ok, %{status: 200, body: token}} when is_binary(token) ->
{:ok, String.trim(token)}
{:ok, %{status: status}} ->
{:error, {:auth_failed, status}}
{:error, reason} ->
{:error, {:connect_error, reason}}
end
end
defp do_get(token, path, params) do
cfg = config()
case Req.get(
"#{cfg[:url]}#{path}",
headers: [{"authorization", "Bearer #{token}"}],
params: params,
connect_options: ssl_opts(cfg)
) do
{:ok, %{status: 200, body: body}} -> {:ok, body}
{:ok, %{status: status}} -> {:error, {:http, status}}
{:error, reason} -> {:error, {:connect_error, reason}}
end
end
defp ssl_opts(cfg) do
if cfg[:verify_ssl] == false do
[transport_opts: [verify: :verify_none]]
else
[]
end
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp config, do: Application.fetch_env!(:centralcloud_core, :security)
end

View file

@ -0,0 +1,225 @@
defmodule CentralcloudCore.WazuhMcp do
@moduledoc """
MCP (Model Context Protocol) server implementation for Wazuh endpoint security.
Implements the JSON-RPC 2.0 / MCP streamable-HTTP protocol so that the
Hermes security-analyst agent can call Wazuh tools directly from the
centralcloud_staff app no separate Python MCP sidecar required.
Supported methods:
initialize handshake, returns capabilities
tools/list enumerates available tools + JSON schemas
tools/call dispatches a named tool call and returns the result
All tools are read-only wrappers around CentralcloudCore.Security.
"""
alias CentralcloudCore.Security
@protocol_version "2024-11-05"
@server_info %{"name" => "wazuh-mcp", "version" => "1.0.0"}
@tools [
%{
"name" => "list_agents",
"description" =>
"List monitored endpoints, optionally scoped to a customer. " <>
"Pass customer_id (HostBill integer) to scope to cc_client_{id}.",
"inputSchema" => %{
"type" => "object",
"properties" => %{
"customer_id" => %{"type" => "integer", "description" => "HostBill customer ID"},
"status" => %{
"type" => "string",
"enum" => ["active", "disconnected", "pending", "never_connected"],
"description" => "Filter by agent status"
},
"limit" => %{"type" => "integer", "description" => "Max agents (default 100, max 500)"}
}
}
},
%{
"name" => "get_vulnerabilities",
"description" =>
"Return CVE vulnerabilities for a specific Wazuh agent, filterable by severity.",
"inputSchema" => %{
"type" => "object",
"required" => ["agent_id"],
"properties" => %{
"agent_id" => %{"type" => "string", "description" => "Wazuh agent ID, e.g. \"001\""},
"severity" => %{
"type" => "string",
"enum" => ["Critical", "High", "Medium", "Low"],
"description" => "Filter by severity"
},
"limit" => %{"type" => "integer", "description" => "Max CVEs (default 100)"}
}
}
},
%{
"name" => "get_alerts",
"description" =>
"Return recent SIEM alerts at or above a rule level, optionally scoped to a customer or agent.",
"inputSchema" => %{
"type" => "object",
"properties" => %{
"customer_id" => %{"type" => "integer", "description" => "Scope to a customer's agents"},
"agent_id" => %{"type" => "string", "description" => "Scope to a single agent"},
"level" => %{
"type" => "integer",
"description" => "Minimum rule level 0-15 (default 7)"
},
"limit" => %{"type" => "integer", "description" => "Max alerts (default 50)"}
}
}
},
%{
"name" => "get_sca",
"description" =>
"Return SCA/CIS benchmark compliance results for an agent. " <>
"Omit policy_id to list all policies; provide it for per-check detail.",
"inputSchema" => %{
"type" => "object",
"required" => ["agent_id"],
"properties" => %{
"agent_id" => %{"type" => "string", "description" => "Wazuh agent ID"},
"policy_id" => %{
"type" => "string",
"description" => "SCA policy ID for per-check results"
}
}
}
},
%{
"name" => "agent_summary",
"description" =>
"Connectivity health: counts of active / disconnected / never-connected agents.",
"inputSchema" => %{
"type" => "object",
"properties" => %{
"customer_id" => %{"type" => "integer", "description" => "Scope to a customer. Omit for all."}
}
}
},
%{
"name" => "top_critical_cves",
"description" =>
"Top N Critical CVEs ranked by affected-host count + CVSS score across all active agents.",
"inputSchema" => %{
"type" => "object",
"properties" => %{
"customer_id" => %{"type" => "integer", "description" => "Scope to a customer. Omit for all."},
"top_n" => %{"type" => "integer", "description" => "Number of CVEs to return (default 20)"}
}
}
}
]
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
@doc "Handle a single JSON-RPC request map. Returns a JSON-RPC result map."
def handle(%{"method" => "initialize", "id" => id}) do
result(%{
"protocolVersion" => @protocol_version,
"capabilities" => %{"tools" => %{}},
"serverInfo" => @server_info
}, id)
end
def handle(%{"method" => "tools/list", "id" => id}) do
result(%{"tools" => @tools}, id)
end
def handle(%{"method" => "tools/call", "id" => id, "params" => %{"name" => name} = params}) do
args = Map.get(params, "arguments", %{})
case call_tool(name, args) do
{:ok, content} ->
result(%{"content" => [%{"type" => "text", "text" => Jason.encode!(content)}]}, id)
{:error, reason} ->
error_result(-32_000, format_error(reason), id)
end
end
def handle(%{"method" => method, "id" => id}) do
error_result(-32_601, "Method not found: #{method}", id)
end
def handle(_), do: error_result(-32_600, "Invalid request", nil)
# ---------------------------------------------------------------------------
# Tool implementations
# ---------------------------------------------------------------------------
defp call_tool("list_agents", args) do
opts =
[]
|> maybe_opt(:customer_id, args["customer_id"])
|> maybe_opt(:status, args["status"])
|> maybe_opt(:limit, args["limit"])
Security.list_agents(opts)
end
defp call_tool("get_vulnerabilities", %{"agent_id" => agent_id} = args) do
opts =
[]
|> maybe_opt(:severity, args["severity"])
|> maybe_opt(:limit, args["limit"])
Security.get_vulnerabilities(agent_id, opts)
end
defp call_tool("get_alerts", args) do
opts =
[]
|> maybe_opt(:customer_id, args["customer_id"])
|> maybe_opt(:agent_id, args["agent_id"])
|> maybe_opt(:level, args["level"])
|> maybe_opt(:limit, args["limit"])
Security.get_alerts(opts)
end
defp call_tool("get_sca", %{"agent_id" => agent_id} = args) do
opts = maybe_opt([], :policy_id, args["policy_id"])
Security.get_sca(agent_id, opts)
end
defp call_tool("agent_summary", args) do
opts = maybe_opt([], :customer_id, args["customer_id"])
Security.agent_summary(opts)
end
defp call_tool("top_critical_cves", args) do
opts =
[]
|> maybe_opt(:customer_id, args["customer_id"])
|> maybe_opt(:top_n, args["top_n"])
Security.top_critical_cves(opts)
end
defp call_tool(name, _args), do: {:error, "Unknown tool: #{name}"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp result(data, id), do: %{"jsonrpc" => "2.0", "id" => id, "result" => data}
defp error_result(code, message, id),
do: %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => code, "message" => message}}
defp maybe_opt(opts, _key, nil), do: opts
defp maybe_opt(opts, key, value), do: Keyword.put(opts, key, value)
defp format_error({:http, status}), do: "Wazuh API returned HTTP #{status}"
defp format_error({:auth_failed, status}), do: "Wazuh authentication failed (#{status})"
defp format_error({:connect_error, reason}), do: "Connection error: #{inspect(reason)}"
defp format_error(reason) when is_binary(reason), do: reason
defp format_error(reason), do: inspect(reason)
end

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -0,0 +1,14 @@
module.exports = {
darkMode: "class",
content: [
"apps/centralcloud_my/lib/**/*.{ex,heex}",
"deps/petal_components/**/*.ex",
],
safelist: [
{ pattern: /^(bg|text|border)-(red|orange|amber|yellow|green|sky|blue|indigo|purple|slate|zinc|gray)-(100|200|300|400|500|600|700|800|900|950)$/ },
],
theme: {
extend: {},
},
plugins: [require("@tailwindcss/forms")],
};

View file

@ -4,46 +4,68 @@ defmodule CentralcloudMy.Layouts do
def render("root.html", assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<html lang="en" class="dark">
<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</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f172a; color: #e2e8f0; min-height: 100vh; }
nav { background: #1e293b; padding: 1rem 2rem; display: flex;
align-items: center; justify-content: space-between; border-bottom: 1px solid #334155; }
nav .brand { font-weight: 700; font-size: 1.1rem; color: #38bdf8; }
nav a { color: #94a3b8; text-decoration: none; margin-left: 1.5rem; font-size: 0.9rem; }
nav a:hover { color: #e2e8f0; }
main { padding: 2rem; max-width: 1200px; margin: 0 auto; }
.flash { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
.flash-info { background: #1d4ed8; }
.flash-error { background: #dc2626; }
</style>
<link rel="stylesheet" href="/assets/app.css"/>
<.live_title suffix=" — CentralCloud">
{assigns[:page_title] || "My CentralCloud"}
</.live_title>
</head>
<body>
<nav>
<span class="brand"> CentralCloud</span>
<div>
<a href="/">Dashboard</a>
<a href="/dr">DR Status</a>
<a href="/billing">Billing</a>
<a href="/support">Support</a>
<a href="/logout" data-method="delete">Sign out</a>
<body class="bg-slate-900 text-slate-200 min-h-screen antialiased">
<nav class="bg-slate-800 px-6 py-3.5 flex items-center justify-between border-b border-slate-700">
<span class="font-bold text-sky-400 text-base"> CentralCloud</span>
<div class="flex gap-0.5">
<a href="/"
class="text-slate-400 no-underline px-3 py-1.5 rounded text-sm hover:bg-slate-700 hover:text-slate-100 transition-colors">
Dashboard
</a>
<a href="/dr"
class="text-slate-400 no-underline px-3 py-1.5 rounded text-sm hover:bg-slate-700 hover:text-slate-100 transition-colors">
DR Status
</a>
<a href="/billing"
class="text-slate-400 no-underline px-3 py-1.5 rounded text-sm hover:bg-slate-700 hover:text-slate-100 transition-colors">
Billing
</a>
<a href="/security"
class="text-slate-400 no-underline px-3 py-1.5 rounded text-sm hover:bg-slate-700 hover:text-slate-100 transition-colors">
🛡 Security
</a>
<a href="/support"
class="text-slate-400 no-underline px-3 py-1.5 rounded text-sm hover:bg-slate-700 hover:text-slate-100 transition-colors">
Support
</a>
</div>
<a href="/logout" data-method="delete"
class="text-slate-500 text-sm hover:text-slate-300 transition-colors no-underline">
Sign out
</a>
</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>
<main class="px-6 py-6 max-w-5xl mx-auto">
<p :if={msg = Phoenix.Flash.get(@flash, :info)}
class="px-4 py-3 mb-4 rounded-lg bg-sky-900/60 border border-sky-700 text-sky-200 text-sm">
{msg}
</p>
<p :if={msg = Phoenix.Flash.get(@flash, :error)}
class="px-4 py-3 mb-4 rounded-lg bg-red-900/60 border border-red-700 text-red-200 text-sm">
{msg}
</p>
{@inner_content}
</main>
<script src="/phoenix.min.js"></script>
<script src="/phoenix_live_view.min.js"></script>
<script defer src="https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js"></script>
<script>
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let liveSocket = new LiveView.LiveSocket("/live", Phoenix.Socket, {
params: { _csrf_token: csrfToken }
});
liveSocket.connect();
window.liveSocket = liveSocket;
</script>
</body>
</html>
"""

View file

@ -0,0 +1,357 @@
defmodule CentralcloudMy.SecurityLive do
use CentralcloudMyWeb, :live_view
alias CentralcloudCore.Security
@refresh_ms 120_000
def mount(_params, %{"customer_id" => raw_id}, socket) do
customer_id = parse_customer_id(raw_id)
if is_nil(customer_id) do
{:ok, assign(socket, page_title: "Security Center", state: :error, customer_id: nil) |> assign_defaults()}
else
{state, data} = load_data(customer_id)
if connected?(socket) and state == :active do
Process.send_after(self(), :refresh, @refresh_ms)
end
{:ok,
socket
|> assign(page_title: "Security Center", customer_id: customer_id, state: state)
|> assign(data)}
end
end
def mount(_params, _session, socket) do
{:ok, assign(socket, page_title: "Security Center", state: :error, customer_id: nil) |> assign_defaults()}
end
def handle_info(:refresh, socket) do
Process.send_after(self(), :refresh, @refresh_ms)
{state, data} = load_data(socket.assigns.customer_id)
{:noreply, assign(socket, Map.put(data, :state, state))}
end
def handle_event("refresh", _params, %{assigns: %{customer_id: nil}} = socket),
do: {:noreply, socket}
def handle_event("refresh", _params, socket) do
{state, data} = load_data(socket.assigns.customer_id)
{:noreply, assign(socket, Map.put(data, :state, state))}
end
# ---------------------------------------------------------------------------
# Render
# ---------------------------------------------------------------------------
def render(assigns) do
~H"""
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<h1 class="text-xl font-bold text-slate-100 flex items-center gap-2">
🛡 Security Center
</h1>
<p class="text-slate-400 text-sm mt-1">
Endpoint security monitoring for your infrastructure
</p>
</div>
<div :if={@state == :active} class="text-slate-500 text-xs flex items-center gap-2 mt-2">
Updated {Calendar.strftime(@last_updated, "%H:%M UTC")}
<button
phx-click="refresh"
class="text-sky-400 hover:text-sky-300 transition-colors text-base leading-none"
>
</button>
</div>
</div>
<!-- Session / auth error -->
<div :if={@state == :error} class="bg-slate-800 border border-slate-700 rounded-xl p-8 text-center">
<div class="text-3xl mb-3"></div>
<p class="text-slate-300 font-medium">Could not load your account information.</p>
<p class="text-slate-500 text-sm mt-1">Please refresh the page or contact support.</p>
</div>
<!-- HostBill temporarily unavailable neutral state, no upsell -->
<div
:if={@state == :service_unavailable}
class="bg-slate-800 border border-slate-700 rounded-xl p-8 text-center"
>
<div class="text-3xl mb-3"></div>
<p class="text-slate-300 font-medium">Security status temporarily unavailable</p>
<p class="text-slate-500 text-sm mt-1">Please try again in a moment.</p>
</div>
<!-- Security platform unreachable -->
<div
:if={@state == :load_error}
class="bg-slate-800 border border-amber-900/50 rounded-xl p-8 text-center"
>
<div class="text-3xl mb-3">📡</div>
<p class="text-amber-300 font-medium">Could not reach the security monitoring system</p>
<p class="text-slate-500 text-sm mt-1">Please try again or contact support if this persists.</p>
</div>
<!-- No security service upsell -->
<div
:if={@state == :no_service}
class="bg-slate-800/50 border border-slate-700 rounded-xl p-10 text-center"
>
<div class="text-5xl mb-4">🛡</div>
<h2 class="text-lg font-semibold text-slate-200 mb-2">Security Monitoring not enabled</h2>
<p class="text-slate-400 text-sm mb-6 max-w-sm mx-auto leading-relaxed">
Add endpoint security monitoring to your account for real-time threat detection,
vulnerability scanning, and compliance insights across your infrastructure.
</p>
<a
href="/support"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-sky-600 hover:bg-sky-500
text-white rounded-lg text-sm font-medium transition-colors"
>
Contact us to enable
</a>
</div>
<!-- Active Security Dashboard -->
<div :if={@state == :active}>
<!-- Summary stats row -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<div class="bg-slate-800 border border-slate-700 rounded-lg p-4">
<p class="text-slate-500 text-[11px] font-semibold uppercase tracking-wider mb-2">
Monitored Endpoints
</p>
<p class="text-3xl font-bold text-sky-400">{length(@agents)}</p>
<p class="text-slate-500 text-xs mt-1">{@online_count} online</p>
</div>
<div class={[
"bg-slate-800 border rounded-lg p-4",
if(@total_critical > 0, do: "border-red-800", else: "border-slate-700")
]}>
<p class="text-slate-500 text-[11px] font-semibold uppercase tracking-wider mb-2">
Critical CVEs
</p>
<p class={[
"text-3xl font-bold",
if(@total_critical > 0, do: "text-red-400", else: "text-slate-400")
]}>
{@total_critical}
</p>
</div>
<div class={[
"bg-slate-800 border rounded-lg p-4",
if(@total_high > 0, do: "border-orange-800", else: "border-slate-700")
]}>
<p class="text-slate-500 text-[11px] font-semibold uppercase tracking-wider mb-2">
High CVEs
</p>
<p class={[
"text-3xl font-bold",
if(@total_high > 0, do: "text-orange-400", else: "text-slate-400")
]}>
{@total_high}
</p>
</div>
<div class="bg-slate-800 border border-slate-700 rounded-lg p-4">
<p class="text-slate-500 text-[11px] font-semibold uppercase tracking-wider mb-2">
Risk Level
</p>
<p class={["font-bold text-base mt-2", risk_color(@total_critical, @total_high)]}>
{risk_label(@total_critical, @total_high)}
</p>
</div>
</div>
<!-- No agents found -->
<div
:if={@agents == []}
class="bg-slate-800 border border-slate-700 rounded-lg p-8 text-center text-slate-400"
>
<div class="text-3xl mb-2">📡</div>
<p class="font-medium">No monitored endpoints found</p>
<p class="text-sm mt-1">Contact support to add your systems.</p>
</div>
<!-- Agents table -->
<div :if={@agents != []} class="bg-slate-800 border border-slate-700 rounded-lg overflow-hidden">
<div class="px-5 py-3.5 border-b border-slate-700">
<h2 class="font-semibold text-slate-200 text-sm">Monitored Endpoints</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left border-b border-slate-700/50">
<th class="px-5 py-3 text-slate-500 text-xs font-semibold uppercase tracking-wider">
Endpoint
</th>
<th class="px-5 py-3 text-slate-500 text-xs font-semibold uppercase tracking-wider">
OS
</th>
<th class="px-5 py-3 text-slate-500 text-xs font-semibold uppercase tracking-wider">
Status
</th>
<th class="px-5 py-3 text-slate-500 text-xs font-semibold uppercase tracking-wider">
Critical
</th>
<th class="px-5 py-3 text-slate-500 text-xs font-semibold uppercase tracking-wider">
High
</th>
<th class="px-5 py-3 text-slate-500 text-xs font-semibold uppercase tracking-wider">
Medium
</th>
<th class="px-5 py-3 text-slate-500 text-xs font-semibold uppercase tracking-wider">
Last Seen
</th>
</tr>
</thead>
<tbody>
<tr
:for={agent <- @agents}
class="border-t border-slate-700/30 hover:bg-slate-700/20 transition-colors"
>
<td class="px-5 py-3.5">
<div class="font-medium text-slate-200">{agent["name"]}</div>
<div class="text-slate-500 text-xs font-mono mt-0.5">{agent["ip"]}</div>
</td>
<td class="px-5 py-3.5 text-slate-400 text-xs">
{get_in(agent, ["os", "name"]) || ""}
</td>
<td class="px-5 py-3.5">
<span class={agent_status_badge(agent["status"])}>
{agent["status"] || "unknown"}
</span>
</td>
<td class="px-5 py-3.5">
<span class={vuln_class(agent.vuln_counts["critical"] || 0, :red)}>
{agent.vuln_counts["critical"] || 0}
</span>
</td>
<td class="px-5 py-3.5">
<span class={vuln_class(agent.vuln_counts["high"] || 0, :orange)}>
{agent.vuln_counts["high"] || 0}
</span>
</td>
<td class="px-5 py-3.5">
<span class={vuln_class(agent.vuln_counts["medium"] || 0, :yellow)}>
{agent.vuln_counts["medium"] || 0}
</span>
</td>
<td class="px-5 py-3.5 text-slate-400 text-xs">
{format_time(agent["lastKeepAlive"])}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p class="mt-4 text-slate-600 text-xs">
Contact support for detailed reports or guided remediation.
</p>
</div>
"""
end
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
defp load_data(customer_id) do
case Security.customer_has_security_service?(customer_id) do
:unavailable ->
{:service_unavailable, default_assigns()}
false ->
{:no_service, default_assigns()}
true ->
case Security.get_monitored_agents(customer_id) do
{:ok, agents} ->
enriched =
Enum.map(agents, fn agent ->
counts =
case Security.get_agent_vulnerability_counts(agent["id"]) do
{:ok, c} -> c
_ -> %{}
end
Map.put(agent, :vuln_counts, counts)
end)
total_critical = Enum.sum(Enum.map(enriched, &(&1.vuln_counts["critical"] || 0)))
total_high = Enum.sum(Enum.map(enriched, &(&1.vuln_counts["high"] || 0)))
online_count = Enum.count(enriched, &(&1["status"] == "active"))
{:active,
%{
agents: enriched,
total_critical: total_critical,
total_high: total_high,
online_count: online_count,
last_updated: DateTime.utc_now()
}}
{:error, _} ->
{:load_error, default_assigns()}
end
end
end
defp default_assigns do
%{agents: [], total_critical: 0, total_high: 0, online_count: 0, last_updated: DateTime.utc_now()}
end
defp assign_defaults(socket) do
assign(socket, agents: [], total_critical: 0, total_high: 0, online_count: 0, last_updated: DateTime.utc_now())
end
defp parse_customer_id(id) when is_integer(id), do: id
defp parse_customer_id(id) when is_binary(id) do
case Integer.parse(id) do
{n, ""} -> n
_ -> nil
end
end
defp parse_customer_id(_), do: nil
defp agent_status_badge("active"),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold bg-green-900/60 text-green-300 border border-green-700/50"
defp agent_status_badge("disconnected"),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold bg-red-900/60 text-red-300 border border-red-700/50"
defp agent_status_badge(_),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold bg-slate-700 text-slate-400 border border-slate-600"
defp vuln_class(0, _), do: "text-slate-500 font-medium"
defp vuln_class(_, :red), do: "text-red-400 font-bold"
defp vuln_class(_, :orange), do: "text-orange-400 font-bold"
defp vuln_class(_, :yellow), do: "text-yellow-400 font-bold"
defp vuln_class(_, _), do: "text-slate-300 font-medium"
defp risk_color(crit, _) when crit > 0, do: "text-red-400"
defp risk_color(_, high) when high > 5, do: "text-orange-400"
defp risk_color(_, high) when high > 0, do: "text-yellow-400"
defp risk_color(_, _), do: "text-green-400"
defp risk_label(crit, _) when crit > 0, do: "⚠️ Critical"
defp risk_label(_, high) when high > 5, do: "🔶 High"
defp risk_label(_, high) when high > 0, do: "🔸 Elevated"
defp risk_label(_, _), do: "✅ Low"
defp format_time(nil), do: ""
defp format_time(iso) do
case DateTime.from_iso8601(iso) do
{:ok, dt, _} -> Calendar.strftime(dt, "%d %b %H:%M")
_ -> iso
end
end
end

View file

@ -45,6 +45,7 @@ defmodule CentralcloudMy.Router do
live "/", DashboardLive, :index
live "/dr", ReplicationLive, :index
live "/billing", BillingLive, :index
live "/security", SecurityLive, :index
live "/support", SupportLive, :index
end
end

View file

@ -0,0 +1,17 @@
defmodule CentralcloudMyWeb do
@moduledoc """
Entry point for CentralcloudMy LiveViews provides `use CentralcloudMyWeb, :live_view`
which includes Phoenix.LiveView and imports PetalComponents.
"""
def live_view do
quote do
use Phoenix.LiveView
use PetalComponents
end
end
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View file

@ -38,7 +38,8 @@ defmodule CentralcloudMy.MixProject do
{:telemetry_poller, "~> 1.0"},
{:jason, "~> 1.4"},
{:dns_cluster, "~> 0.1.1"},
{:oidcc, "~> 3.2"} # OIDC client for Authentik SSO
{:oidcc, "~> 3.2"}, # OIDC client for Authentik SSO
{:petal_components, "~> 2.0"}
]
end
end

View file

@ -0,0 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
/* Chat cursor blink */
.chat-cursor {
display: inline-block;
animation: blink 0.8s step-end infinite;
}
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* AI typing indicator dots */
@keyframes bounce-dot {
0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-5px); }
}
.bounce-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #60a5fa;
display: inline-block;
animation: bounce-dot 1.2s infinite;
}
.bounce-dot:nth-child(2) { animation-delay: 0.2s; }
.bounce-dot:nth-child(3) { animation-delay: 0.4s; }

View file

@ -0,0 +1,14 @@
module.exports = {
darkMode: "class",
content: [
"apps/centralcloud_staff/lib/**/*.{ex,heex}",
"deps/petal_components/**/*.ex",
],
safelist: [
{ pattern: /^(bg|text|border)-(red|orange|amber|yellow|green|sky|blue|indigo|purple|slate|zinc|gray)-(100|200|300|400|500|600|700|800|900|950)$/ },
],
theme: {
extend: {},
},
plugins: [require("@tailwindcss/forms")],
};

View file

@ -0,0 +1,13 @@
defmodule CentralcloudStaff.ErrorHTML do
use Phoenix.Component
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end
defmodule CentralcloudStaff.ErrorJSON do
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end

View file

@ -3,88 +3,44 @@ defmodule CentralcloudStaff.SessionController do
import Plug.Conn
def new(conn, _params) do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, login_page(conn))
end
case header(conn, "x-authentik-username") do
nil ->
conn
|> put_resp_content_type("text/html")
|> send_resp(401, no_headers_page())
def create(conn, %{"username" => _username, "password" => _password}) do
# TODO: validate against Authentik / staff directory
conn
|> put_flash(:error, "Invalid credentials")
|> redirect(to: "/login")
username ->
conn
|> put_session(:staff_id, username)
|> put_session(:staff_email, header(conn, "x-authentik-email"))
|> put_session(:staff_name, header(conn, "x-authentik-name"))
|> put_session(:staff_groups, header(conn, "x-authentik-groups", "") |> String.split(",", trim: true))
|> redirect(to: "/")
end
end
def delete(conn, _params) do
conn
|> clear_session()
|> redirect(to: "/login")
|> redirect(to: "/outpost.goauthentik.io/sign_out")
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")
defp header(conn, name, default \\ nil) do
case get_req_header(conn, name) do
[v | _] -> v
[] -> default
end
end
defp login_page(conn) do
csrf = Phoenix.Controller.get_csrf_token()
flash_error = Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error)
defp no_headers_page do
"""
<!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>
<html lang="en"><head><meta charset="utf-8"/><title>Auth error</title></head>
<body style="font-family:sans-serif;padding:2rem;background:#0a0f1e;color:#e2e8f0">
<h1>Authentication missing</h1>
<p>No <code>X-authentik-*</code> headers were received. This request did not pass
through the Authentik forward-auth outpost. Check the IngressRoute middleware.</p>
</body></html>
"""
end
end

View file

@ -0,0 +1,56 @@
defmodule CentralcloudStaff.WazuhMcpController do
@moduledoc """
MCP (Model Context Protocol) endpoint for the Wazuh security analyst agent.
Accepts JSON-RPC 2.0 POST requests at /api/mcp/wazuh and dispatches to
CentralcloudCore.WazuhMcp.
Auth: Bearer token via WAZUH_MCP_API_KEY env var (in-cluster agents only).
"""
use Phoenix.Controller, formats: [:json]
alias CentralcloudCore.WazuhMcp
# Handle batched and single JSON-RPC requests
def call(conn, _params) do
with :ok <- check_auth(conn),
{:ok, body, conn} <- Plug.Conn.read_body(conn),
{:ok, rpc} <- Jason.decode(body) do
response =
case rpc do
requests when is_list(requests) -> Enum.map(requests, &WazuhMcp.handle/1)
request when is_map(request) -> WazuhMcp.handle(request)
end
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(response))
else
{:error, :unauthorized} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(401, Jason.encode!(%{error: "unauthorized"}))
{:error, :invalid_json} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(400, Jason.encode!(%{error: "invalid JSON"}))
{:error, reason} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(500, Jason.encode!(%{error: inspect(reason)}))
end
end
defp check_auth(conn) do
expected = Application.get_env(:centralcloud_staff, :wazuh_mcp_api_key, "")
case Plug.Conn.get_req_header(conn, "authorization") do
["Bearer " <> token] when expected != "" and token == expected -> :ok
_ when expected == "" -> :ok
_ -> {:error, :unauthorized}
end
end
end

View file

@ -4,123 +4,74 @@ defmodule CentralcloudStaff.Layouts do
def render("root.html", assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<html lang="en" class="dark">
<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>
<link rel="stylesheet" href="/assets/app.css"/>
<.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>
<a href="/chat" style="color:#60a5fa;">🤖 AI Chat</a>
</div>
<div class="signout">
<a href="/logout" style="color:#64748b;text-decoration:none;">Sign out</a>
<body class="bg-[#0a0f1e] text-slate-200 min-h-screen antialiased">
<nav class="bg-[#0f1629] px-8 py-3 flex items-center justify-between border-b border-[#1e3a5f]">
<span class="font-bold text-orange-400 flex items-center gap-2">🔥 CentralCloud Ops</span>
<div class="flex gap-0.5">
<a href="/"
class="text-slate-400 no-underline px-3 py-1.5 rounded text-sm hover:bg-slate-800 hover:text-slate-100 transition-colors">
Dashboard
</a>
<a href="/oncall"
class="text-slate-400 no-underline px-3 py-1.5 rounded text-sm hover:bg-slate-800 hover:text-slate-100 transition-colors">
On-Call
</a>
<a href="/incidents"
class="text-slate-400 no-underline px-3 py-1.5 rounded text-sm hover:bg-slate-800 hover:text-slate-100 transition-colors">
Incidents
</a>
<a href="/stakeholders"
class="text-slate-400 no-underline px-3 py-1.5 rounded text-sm hover:bg-slate-800 hover:text-slate-100 transition-colors">
Stakeholders
</a>
<a href="/chat"
class="text-sky-400 no-underline px-3 py-1.5 rounded text-sm hover:bg-slate-800 hover:text-sky-300 transition-colors">
🤖 AI Chat
</a>
</div>
<a href="/logout"
class="text-slate-500 text-sm hover:text-slate-300 transition-colors no-underline border-l border-[#1e3a5f] pl-4">
Sign out
</a>
</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>
<main class="px-8 py-6 max-w-[1400px] mx-auto">
<p :if={msg = Phoenix.Flash.get(@flash, :info)}
class="px-4 py-3 mb-4 rounded-lg bg-[#1e3a5f]/60 border border-blue-600 text-blue-200 text-sm">
{msg}
</p>
<p :if={msg = Phoenix.Flash.get(@flash, :error)}
class="px-4 py-3 mb-4 rounded-lg bg-red-950/60 border border-red-600 text-red-200 text-sm">
{msg}
</p>
{@inner_content}
</main>
<script src="/phoenix.min.js"></script>
<script src="/phoenix_live_view.min.js"></script>
<script defer src="https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js"></script>
<script>
let Hooks = {};
Hooks.ScrollBottom = {
mounted() { this.scrollToBottom(); },
updated() { this.scrollToBottom(); },
scrollToBottom() {
this.el.scrollTop = this.el.scrollHeight;
}
mounted() { this.scrollToBottom(); },
updated() { this.scrollToBottom(); },
scrollToBottom() { this.el.scrollTop = this.el.scrollHeight; }
};
let csrfToken = document.querySelector("meta[name='csrf-token']")
.getAttribute("content");
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let liveSocket = new LiveView.LiveSocket("/live", Phoenix.Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks
});
liveSocket.connect();
window.liveSocket = liveSocket;
</script>

View file

@ -1,30 +1,57 @@
defmodule CentralcloudStaff.ChatLive do
use Phoenix.LiveView
use CentralcloudStaffWeb, :live_view
alias CentralcloudStaff.RouterAgent
def mount(_params, _session, socket) do
default = "router"
{:ok,
assign(socket,
page_title: "AI Chat",
messages: [],
agents: RouterAgent.agents(),
agent_keys: RouterAgent.agent_keys(),
current_agent: default,
conversations: empty_conversations(),
streaming: false,
streaming_content: "",
error: nil
)}
end
# Form submit
defp empty_conversations,
do: Map.new(RouterAgent.agent_keys(), fn k -> {k, []} end)
defp messages(socket), do: Map.fetch!(socket.assigns.conversations, socket.assigns.current_agent)
defp put_messages(socket, msgs),
do:
assign(socket,
conversations: Map.put(socket.assigns.conversations, socket.assigns.current_agent, msgs)
)
def handle_event("select_agent", %{"agent" => key}, socket) do
if socket.assigns.streaming do
{:noreply, socket}
else
key = if key in socket.assigns.agent_keys, do: key, else: "router"
{:noreply,
assign(socket,
current_agent: key,
streaming_content: "",
error: nil
)}
end
end
def handle_event("send", %{"message" => text}, socket), do: do_send(text, socket)
# Suggestion button (phx-value-message)
def handle_event("send", %{"value" => text}, socket), do: do_send(text, socket)
def handle_event("clear", _params, socket) do
{:noreply,
assign(socket,
messages: [],
streaming: false,
streaming_content: "",
error: nil
)}
socket
|> put_messages([])
|> assign(streaming: false, streaming_content: "", error: nil)}
end
defp do_send(text, socket) do
@ -39,18 +66,16 @@ defmodule CentralcloudStaff.ChatLive do
true ->
user_msg = %{role: "user", content: text}
messages = socket.assigns.messages ++ [user_msg]
msgs = messages(socket) ++ [user_msg]
agent_key = socket.assigns.current_agent
caller = self()
Task.start(fn -> CentralcloudStaff.RouterAgent.stream_chat(messages, caller) end)
Task.start(fn -> RouterAgent.stream_chat(msgs, caller, agent_key) end)
{:noreply,
assign(socket,
messages: messages,
streaming: true,
streaming_content: "",
error: nil
)}
socket
|> put_messages(msgs)
|> assign(streaming: true, streaming_content: "", error: nil)}
end
end
@ -62,14 +87,12 @@ defmodule CentralcloudStaff.ChatLive do
content = socket.assigns.streaming_content
if content != "" do
assistant_msg = %{role: "assistant", content: content}
assistant_msg = %{role: "assistant", content: content, agent: socket.assigns.current_agent}
{:noreply,
assign(socket,
messages: socket.assigns.messages ++ [assistant_msg],
streaming: false,
streaming_content: ""
)}
socket
|> put_messages(messages(socket) ++ [assistant_msg])
|> assign(streaming: false, streaming_content: "")}
else
{:noreply, assign(socket, streaming: false)}
end
@ -80,141 +103,133 @@ defmodule CentralcloudStaff.ChatLive do
assign(socket,
streaming: false,
streaming_content: "",
error: "Router agent error: #{reason}"
error: "Agent error: #{reason}"
)}
end
defp current(assigns), do: Map.fetch!(assigns.agents, assigns.current_agent)
defp bubble_name(assigns, msg) do
case msg do
%{agent: a} when is_binary(a) -> Map.get(assigns.agents, a, current(assigns))[:name]
_ -> current(assigns).name
end
end
def render(assigns) do
~H"""
<style>
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.chat-input { background: #0a0f1e; border: 1px solid #1e3a5f; border-radius: 6px;
padding: 0.6rem 0.9rem; color: #e2e8f0; font-size: 0.875rem;
outline: none; flex: 1; }
.chat-input:focus { border-color: #2563eb; }
.chat-input:disabled { opacity: 0.5; cursor: not-allowed; }
.chat-wrap { display: flex; flex-direction: column;
height: calc(100vh - 180px); min-height: 400px; }
.chat-msgs { flex: 1; overflow-y: auto; padding: 1rem;
display: flex; flex-direction: column; gap: 0.75rem; }
.msg-user { display: flex; justify-content: flex-end; }
.msg-ai { display: flex; justify-content: flex-start; align-items: flex-start; gap: 0.5rem; }
.bubble-user { max-width: 75%; padding: 0.65rem 0.9rem; border-radius: 14px;
border-bottom-right-radius: 4px; background: #2563eb; color: #fff;
font-size: 0.875rem; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
.bubble-ai { max-width: 80%; padding: 0.65rem 0.9rem; border-radius: 14px;
border-bottom-left-radius: 4px; background: #0f1629; border: 1px solid #1e3a5f;
color: #e2e8f0; font-size: 0.875rem; line-height: 1.6;
white-space: pre-wrap; word-break: break-word; }
.ai-icon { width: 28px; height: 28px; border-radius: 50%; background: #1e3a5f;
display: flex; align-items: center; justify-content: center;
font-size: 0.85rem; flex-shrink: 0; margin-top: 2px; }
.cursor { display: inline-block; animation: blink 0.8s step-end infinite; }
.chat-footer { border-top: 1px solid #1e3a5f; padding: 0.75rem 1rem; }
.chat-form { display: flex; gap: 0.5rem; align-items: center; }
.dot { width: 6px; height: 6px; border-radius: 50%; background: #60a5fa;
display: inline-block; animation: bounce 1.2s infinite; }
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce { 0%,80%,100%{transform:translateY(0)} 40%{transform:translateY(-6px)} }
.suggestion { background: #0f1629; border: 1px solid #1e3a5f; border-radius: 8px;
padding: 0.5rem 0.8rem; font-size: 0.8rem; color: #94a3b8;
cursor: pointer; text-align: left; }
.suggestion:hover { border-color: #2563eb; color: #e2e8f0; }
.suggestions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center;
margin-top: 1rem; max-width: 500px; }
</style>
<div class="flex items-center justify-between mb-4">
<h1 class="text-lg font-bold text-slate-100 flex items-center gap-2">
🤖 AI Operations Chat
</h1>
<div class="flex items-center gap-2 text-xs text-slate-500">
<span>Agent:</span>
<form phx-change="select_agent">
<select
name="agent"
disabled={@streaming}
class="bg-[#0f1629] border border-[#1e3a5f] rounded px-2 py-1 text-slate-200 text-xs outline-none focus:border-blue-600 disabled:opacity-50"
>
<option :for={k <- @agent_keys} value={k} selected={k == @current_agent}>
{@agents[k][:name]}
</option>
</select>
</form>
</div>
</div>
<div class="section-title">🤖 AI Operations Chat</div>
<div class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg overflow-hidden flex flex-col h-[calc(100vh-180px)] min-h-[400px]">
<div class="px-4 py-2 border-b border-[#1e3a5f] flex items-center gap-2 text-xs">
<span class="text-orange-400 font-semibold">{current(@assigns).name}</span>
<span class="text-slate-500"> {current(@assigns).description}</span>
</div>
<div class="card" style="padding: 0; overflow: hidden;">
<div class="chat-wrap">
<div
id={"chat-messages-#{@current_agent}"}
phx-hook="ScrollBottom"
class="flex-1 overflow-y-auto p-4 flex flex-col gap-3"
>
<% msgs = Map.fetch!(@conversations, @current_agent) %>
<div
:if={msgs == [] and not @streaming}
class="flex-1 flex flex-col items-center justify-center text-slate-500 gap-3 py-12 px-4 text-center"
>
<div class="text-4xl">🔥</div>
<div class="text-base font-semibold text-slate-400">
Talking to <span class="text-orange-400">{current(@assigns).name}</span>
</div>
<div class="text-sm text-slate-500">{current(@assigns).description}</div>
</div>
<div id="chat-messages" phx-hook="ScrollBottom" class="chat-msgs">
<%!-- Empty / welcome state --%>
<div :if={@messages == [] and not @streaming}
style="flex:1;display:flex;flex-direction:column;align-items:center;
justify-content:center;color:#475569;gap:0.5rem;padding:3rem 1rem;text-align:center;">
<div style="font-size: 2.5rem;">🔥</div>
<div style="font-size: 1rem; font-weight: 600; color: #94a3b8;">
Ask me anything about your infrastructure
</div>
<div style="font-size: 0.8rem; color: #64748b;">
I route to the right specialist incident commander, ops, or comms
</div>
<div class="suggestions">
<button class="suggestion" phx-click="send" phx-value-value="What alerts are firing right now?">
🚨 What's firing?
</button>
<button class="suggestion" phx-click="send" phx-value-value="Who is on-call right now?">
📟 Who's on-call?
</button>
<button class="suggestion" phx-click="send" phx-value-value="Show me recent incidents">
📋 Recent incidents
</button>
<button class="suggestion" phx-click="send" phx-value-value="Check cluster health">
Cluster health
</button>
<div :for={msg <- msgs}>
<div :if={msg.role == "user"} class="flex justify-end">
<div class="max-w-[75%] px-3.5 py-2.5 rounded-2xl rounded-br-sm bg-blue-600 text-white text-sm leading-relaxed whitespace-pre-wrap break-words">
{msg.content}
</div>
</div>
<%!-- History --%>
<div :for={msg <- @messages}>
<div :if={msg.role == "user"} class="msg-user">
<div class="bubble-user">{msg.content}</div>
<div :if={msg.role == "assistant"} class="flex justify-start items-start gap-2">
<div class="w-7 h-7 rounded-full bg-[#1e3a5f] flex items-center justify-center text-sm flex-shrink-0 mt-0.5">
🤖
</div>
<div :if={msg.role == "assistant"} class="msg-ai">
<div class="ai-icon">🤖</div>
<div class="bubble-ai">{msg.content}</div>
<div class="max-w-[80%]">
<div class="text-[10px] text-slate-500 mb-1 ml-1">{bubble_name(@assigns, msg)}</div>
<div class="px-3.5 py-2.5 rounded-2xl rounded-bl-sm bg-[#0f1629] border border-[#1e3a5f] text-slate-200 text-sm leading-relaxed whitespace-pre-wrap break-words">
{msg.content}
</div>
</div>
</div>
</div>
<%!-- Streaming --%>
<div :if={@streaming} class="msg-ai">
<div class="ai-icon">🤖</div>
<div class="bubble-ai">
<span :if={@streaming_content == ""}>
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
<div :if={@streaming} class="flex justify-start items-start gap-2">
<div class="w-7 h-7 rounded-full bg-[#1e3a5f] flex items-center justify-center text-sm flex-shrink-0 mt-0.5">
🤖
</div>
<div class="max-w-[80%]">
<div class="text-[10px] text-slate-500 mb-1 ml-1">{current(@assigns).name}</div>
<div class="px-3.5 py-2.5 rounded-2xl rounded-bl-sm bg-[#0f1629] border border-[#1e3a5f] text-slate-200 text-sm leading-relaxed">
<span :if={@streaming_content == ""} class="flex gap-1 items-center py-0.5">
<span class="bounce-dot"></span>
<span class="bounce-dot"></span>
<span class="bounce-dot"></span>
</span>
<span :if={@streaming_content != ""} class="whitespace-pre-wrap break-words">
{@streaming_content}<span class="chat-cursor"></span>
</span>
<span :if={@streaming_content != ""}>{@streaming_content}<span class="cursor"></span></span>
</div>
</div>
</div>
</div>
<%!-- Error bar --%>
<div :if={@error}
style="padding:0.5rem 1rem;background:#4c0519;color:#fca5a5;font-size:0.8rem;border-top:1px solid #7f1d1d;">
{@error}
</div>
<%!-- Input --%>
<div class="chat-footer">
<form phx-submit="send" class="chat-form">
<input
class="chat-input"
type="text"
name="message"
placeholder={if @streaming, do: "Waiting for response…", else: "Ask the ops AI anything…"}
autocomplete="off"
disabled={@streaming}
id="chat-input"
/>
<button type="submit" class="btn btn-primary"
disabled={@streaming}
style={if @streaming, do: "opacity:0.5;cursor:not-allowed;", else: ""}>
Send
</button>
<button :if={@messages != [] and not @streaming}
type="button" phx-click="clear" class="btn btn-ghost">
Clear
</button>
</form>
</div>
<div
:if={@error}
class="px-4 py-2 bg-red-950/80 border-t border-red-800 text-red-300 text-sm"
>
{@error}
</div>
<div class="border-t border-[#1e3a5f] px-4 py-3">
<form phx-submit="send" class="flex gap-2 items-center">
<input
id="chat-input"
type="text"
name="message"
autocomplete="off"
disabled={@streaming}
placeholder={if @streaming, do: "Waiting for response…", else: "Ask #{current(@assigns).name}"}
class="flex-1 bg-[#0a0f1e] border border-[#1e3a5f] rounded-lg px-3 py-2 text-slate-200 text-sm outline-none focus:border-blue-600 disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-slate-600"
/>
<.button type="submit" size="sm" color="primary" disabled={@streaming}>Send</.button>
<.button
:if={Map.fetch!(@conversations, @current_agent) != [] and not @streaming}
type="button"
size="sm"
color="gray"
variant="outline"
phx-click="clear"
>
Clear
</.button>
</form>
</div>
</div>
"""

View file

@ -1,12 +1,30 @@
defmodule CentralcloudStaff.DashboardLive do
use Phoenix.LiveView
use CentralcloudStaffWeb, :live_view
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)}
try do
{:ok, load(socket)}
rescue
e ->
require Logger
Logger.error("DashboardLive.mount crashed: #{Exception.format(:error, e, __STACKTRACE__)}")
{:ok,
assign(socket,
page_title: "Dashboard",
firing: [],
acked: [],
recent_resolved: [],
firing_count: 0,
acked_count: 0,
last_updated: DateTime.utc_now()
)}
end
end
def handle_info(:refresh, socket) do
@ -29,19 +47,39 @@ defmodule CentralcloudStaff.DashboardLive do
end
defp load(socket) do
firing = fetch_groups("firing")
acked = fetch_groups("acknowledged")
resolved = fetch_groups("resolved", limit: 5)
if oncall_configured?() do
[firing, acked, resolved] =
["firing", "acknowledged", "resolved"]
|> Enum.map(fn s -> Task.async(fn -> fetch_groups(s) end) end)
|> Task.await_many(2_000)
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()
)
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()
)
else
assign(socket,
page_title: "Dashboard",
firing: [],
acked: [],
recent_resolved: [],
firing_count: 0,
acked_count: 0,
last_updated: DateTime.utc_now()
)
end
end
defp oncall_configured? do
case Application.get_env(:centralcloud_core, :oncall) do
nil -> false
cfg -> is_binary(cfg[:token]) and cfg[:token] != ""
end
end
defp fetch_groups(status, _opts \\ []) do
@ -53,53 +91,57 @@ defmodule CentralcloudStaff.DashboardLive do
def render(assigns) do
~H"""
<div class="section-title">🔥 Operations Dashboard</div>
<h1 class="text-lg font-bold text-slate-100 mb-4 flex items-center gap-2">
🔥 Operations Dashboard
</h1>
<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 class="grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-4 mb-6">
<div class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg p-5">
<p class="text-slate-500 text-[11px] font-semibold uppercase tracking-wider mb-1">Firing</p>
<p class={["text-3xl font-extrabold leading-none", if(@firing_count > 0, do: "text-red-400", else: "text-green-400")]}>
{@firing_count}
</p>
</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 class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg p-5">
<p class="text-slate-500 text-[11px] font-semibold uppercase tracking-wider mb-1">Acknowledged</p>
<p class={["text-3xl font-extrabold leading-none", if(@acked_count > 0, do: "text-orange-400", else: "text-green-400")]}>
{@acked_count}
</p>
</div>
<div class="stat-card">
<div class="stat-label">Recent Resolved</div>
<div class="stat-value blue">{length(@recent_resolved)}</div>
<div class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg p-5">
<p class="text-slate-500 text-[11px] font-semibold uppercase tracking-wider mb-1">Recent Resolved</p>
<p class="text-3xl font-extrabold leading-none text-sky-400">{length(@recent_resolved)}</p>
</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 class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg p-5">
<p class="text-slate-500 text-[11px] font-semibold uppercase tracking-wider mb-1">Last Updated</p>
<p class="text-slate-500 text-sm mt-2">{Calendar.strftime(@last_updated, "%H:%M:%S")} UTC</p>
</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>
<div :if={@firing != []} class="mb-6">
<h2 class="text-base font-bold text-red-400 mb-3 flex items-center gap-2">🚨 Firing Alerts</h2>
<div class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr>
<th>Alert</th>
<th>Source</th>
<th>Started</th>
<th>Actions</th>
<tr class="text-left border-b border-[#1e3a5f]">
<th class="px-4 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Alert</th>
<th class="px-4 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Source</th>
<th class="px-4 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Started</th>
<th class="px-4 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
<tr :for={ag <- @firing}>
<td>
<a href={"/incidents/#{ag["id"]}"} style="color:#f87171;font-weight:600;">
<tr :for={ag <- @firing} class="border-t border-[#0a0f1e] hover:bg-[#0f1a2e] transition-colors">
<td class="px-4 py-3">
<a href={"/incidents/#{ag["id"]}"} class="text-red-400 font-semibold no-underline hover:text-red-300">
{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 class="px-4 py-3 text-slate-500 text-xs">{ag["alert_receive_channel"]["verbal_name"]}</td>
<td class="px-4 py-3 text-slate-500 text-xs">{format_dt(ag["started_at"])}</td>
<td class="px-4 py-3 flex items-center gap-1.5">
<.button size="sm" color="warning" phx-click="ack" phx-value-id={ag["id"]}>Ack</.button>
<.button size="sm" color="success" phx-click="resolve" phx-value-id={ag["id"]}>Resolve</.button>
</td>
</tr>
</tbody>
@ -107,24 +149,29 @@ defmodule CentralcloudStaff.DashboardLive do
</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>
<div :if={@acked != []} class="mb-6">
<h2 class="text-base font-bold text-orange-400 mb-3 flex items-center gap-2"> Acknowledged</h2>
<div class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr><th>Alert</th><th>Source</th><th>Acked At</th><th>Actions</th></tr>
<tr class="text-left border-b border-[#1e3a5f]">
<th class="px-4 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Alert</th>
<th class="px-4 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Source</th>
<th class="px-4 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Acked At</th>
<th class="px-4 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
<tr :for={ag <- @acked}>
<td>
<a href={"/incidents/#{ag["id"]}"} style="color:#fb923c;font-weight:600;">
<tr :for={ag <- @acked} class="border-t border-[#0a0f1e] hover:bg-[#0f1a2e] transition-colors">
<td class="px-4 py-3">
<a href={"/incidents/#{ag["id"]}"} class="text-orange-400 font-semibold no-underline hover:text-orange-300">
{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 class="px-4 py-3 text-slate-500 text-xs">{ag["alert_receive_channel"]["verbal_name"]}</td>
<td class="px-4 py-3 text-slate-500 text-xs">{format_dt(ag["acknowledged_at"])}</td>
<td class="px-4 py-3">
<.button size="sm" color="success" phx-click="resolve" phx-value-id={ag["id"]}>Resolve</.button>
</td>
</tr>
</tbody>
@ -132,9 +179,12 @@ defmodule CentralcloudStaff.DashboardLive do
</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
:if={@firing == [] and @acked == []}
class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg p-12 text-center text-green-400"
>
<div class="text-3xl mb-2"></div>
<div class="font-semibold">All clear no active alerts</div>
</div>
"""
end

View file

@ -1,5 +1,5 @@
defmodule CentralcloudStaff.IncidentLive do
use Phoenix.LiveView
use CentralcloudStaffWeb, :live_view
alias CentralcloudCore.OnCall
def mount(%{"id" => id}, _session, socket) do
@ -35,11 +35,8 @@ defmodule CentralcloudStaff.IncidentLive do
defp load(socket, id) do
{ag, alerts} =
case OnCall.get_alert_group(id) do
{:ok, ag} ->
alerts = ag["alerts"] || []
{ag, alerts}
_ ->
{nil, []}
{:ok, ag} -> {ag, ag["alerts"] || []}
_ -> {nil, []}
end
assign(socket,
@ -52,89 +49,109 @@ defmodule CentralcloudStaff.IncidentLive do
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 class="mb-4">
<a href="/incidents" class="text-slate-500 text-sm hover:text-slate-300 no-underline">
Back to Incidents
</a>
</div>
<div :if={is_nil(@alert_group)} class="card" style="color:#64748b;">
<div :if={is_nil(@alert_group)}
class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg p-8 text-slate-500">
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 class="flex items-start justify-between mb-6 gap-4 flex-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;">
<div class="flex items-center gap-3 mb-1.5">
<span class={status_badge(@alert_group["status"])}>{@alert_group["status"]}</span>
<h1 class="text-xl font-bold text-slate-100">
{@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;
<p class="text-slate-500 text-sm">
Source: {@alert_group["alert_receive_channel"]["verbal_name"]}
&nbsp;·&nbsp;
Started: {format_dt(@alert_group["started_at"])}
</div>
</p>
</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">
<div class="flex gap-2 flex-wrap">
<.button
:if={@alert_group["status"] == "firing"}
size="sm" color="warning" phx-click="ack"
>
Acknowledge
</.button>
<.button
:if={@alert_group["status"] in ["firing", "acknowledged"]}
size="sm" color="success" phx-click="resolve"
>
Resolve
</.button>
<.button
:if={@alert_group["status"] == "firing"}
size="sm" color="gray" variant="outline" 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">
</.button>
<.button
:if={@alert_group["status"] == "firing"}
size="sm" color="gray" variant="outline" phx-click="silence" phx-value-duration="120"
>
Silence 2h
</button>
</.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 class="grid grid-cols-2 gap-4 mb-5">
<div class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg p-5">
<h2 class="text-sm font-semibold text-slate-300 mb-3">Details</h2>
<dl class="text-sm space-y-1.5">
<div class="flex gap-3">
<dt class="text-slate-500 w-28 flex-shrink-0">ID</dt>
<dd class="font-mono text-slate-300">{@alert_group["id"]}</dd>
</div>
<div class="flex gap-3">
<dt class="text-slate-500 w-28 flex-shrink-0">Status</dt>
<dd class="text-slate-300">{@alert_group["status"]}</dd>
</div>
<div :if={@alert_group["acknowledged_at"]} class="flex gap-3">
<dt class="text-slate-500 w-28 flex-shrink-0">Acknowledged</dt>
<dd class="text-slate-300">{format_dt(@alert_group["acknowledged_at"])}</dd>
</div>
<div :if={@alert_group["resolved_at"]} class="flex gap-3">
<dt class="text-slate-500 w-28 flex-shrink-0">Resolved</dt>
<dd class="text-slate-300">{format_dt(@alert_group["resolved_at"])}</dd>
</div>
</dl>
</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;">
<div class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg p-5">
<h2 class="text-sm font-semibold text-slate-300 mb-3">Message</h2>
<p class="text-slate-400 text-sm whitespace-pre-wrap leading-relaxed">
{@alert_group["render_for_web"]["message"] || "No message"}
</div>
</p>
</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;">
<div :if={@alerts != []} class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg overflow-hidden">
<div class="px-5 py-3 border-b border-[#1e3a5f] font-semibold text-sm text-slate-200">
Raw Alerts ({length(@alerts)})
</div>
<table>
<table class="w-full text-sm">
<thead>
<tr><th>Received</th><th>Title</th><th>Message</th></tr>
<tr class="text-left border-b border-[#1e3a5f]">
<th class="px-5 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Received</th>
<th class="px-5 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Title</th>
<th class="px-5 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">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;">
<tr :for={a <- @alerts} class="border-t border-[#0a0f1e]">
<td class="px-5 py-3 text-slate-500 text-xs">{format_dt(a["created_at"])}</td>
<td class="px-5 py-3 text-slate-200 text-sm">{a["render_for_web"]["title"]}</td>
<td class="px-5 py-3 text-slate-500 text-xs max-w-xs overflow-hidden text-ellipsis whitespace-nowrap">
{a["render_for_web"]["message"]}
</td>
</tr>
@ -145,6 +162,17 @@ defmodule CentralcloudStaff.IncidentLive do
"""
end
defp status_badge("firing"),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase bg-red-950 text-red-300 border border-red-700"
defp status_badge("acknowledged"),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase bg-amber-950 text-amber-300 border border-amber-700"
defp status_badge("resolved"),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase bg-green-950 text-green-300 border border-green-700"
defp status_badge("silenced"),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase bg-indigo-950 text-indigo-300 border border-indigo-700"
defp status_badge(_),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase bg-slate-700 text-slate-300"
defp format_dt(nil), do: ""
defp format_dt(iso) do
case DateTime.from_iso8601(iso) do

View file

@ -1,5 +1,5 @@
defmodule CentralcloudStaff.IncidentsLive do
use Phoenix.LiveView
use CentralcloudStaffWeb, :live_view
alias CentralcloudCore.OnCall
def mount(_params, _session, socket) do
@ -22,41 +22,53 @@ defmodule CentralcloudStaff.IncidentsLive do
def render(assigns) do
~H"""
<div class="section-title">🗂 Incidents</div>
<h1 class="text-lg font-bold text-slate-100 mb-4 flex items-center gap-2">🗂 Incidents</h1>
<div style="display:flex;gap:0.5rem;margin-bottom:1.25rem;">
<button class={"btn #{if @status == "firing", do: "btn-danger", else: "btn-ghost"}"}
<div class="flex gap-2 mb-5">
<button class={filter_btn_class(@status == "firing", "red")}
phx-click="filter" phx-value-status="firing">Firing</button>
<button class={"btn #{if @status == "acknowledged", do: "btn-warning", else: "btn-ghost"}"}
<button class={filter_btn_class(@status == "acknowledged", "orange")}
phx-click="filter" phx-value-status="acknowledged">Acknowledged</button>
<button class={"btn #{if @status == "resolved", do: "btn-success", else: "btn-ghost"}"}
<button class={filter_btn_class(@status == "resolved", "green")}
phx-click="filter" phx-value-status="resolved">Resolved</button>
<button class={"btn #{if @status == "silenced", do: "btn-primary", else: "btn-ghost"}"}
<button class={filter_btn_class(@status == "silenced", "blue")}
phx-click="filter" phx-value-status="silenced">Silenced</button>
</div>
<div :if={@groups == []} class="card" style="color:#64748b;text-align:center;padding:2rem;">
<div :if={@groups == []}
class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg p-8 text-center text-slate-500">
No {@status} incidents.
</div>
<div :if={@groups != []} class="card" style="padding:0;">
<table>
<div :if={@groups != []} class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr>
<th>Status</th>
<th>Title</th>
<th>Source</th>
<th>Started</th>
<th></th>
<tr class="text-left border-b border-[#1e3a5f]">
<th class="px-4 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Status</th>
<th class="px-4 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Title</th>
<th class="px-4 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Source</th>
<th class="px-4 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Started</th>
<th class="px-4 py-2.5"></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 :for={ag <- @groups} class="border-t border-[#0a0f1e] hover:bg-[#0f1a2e] transition-colors">
<td class="px-4 py-3">
<span class={status_badge(ag["status"])}>{ag["status"]}</span>
</td>
<td class="px-4 py-3 font-semibold text-slate-200">
{ag["render_for_web"]["title"] || ag["id"]}
</td>
<td class="px-4 py-3 text-slate-500 text-xs">{ag["alert_receive_channel"]["verbal_name"]}</td>
<td class="px-4 py-3 text-slate-500 text-xs">{format_dt(ag["started_at"])}</td>
<td class="px-4 py-3">
<a href={"/incidents/#{ag["id"]}"}
class="inline-flex items-center gap-1 px-3 py-1 rounded text-xs font-medium
text-slate-400 border border-slate-700 hover:border-slate-500 hover:text-slate-200
no-underline transition-colors">
View
</a>
</td>
</tr>
</tbody>
</table>
@ -64,6 +76,28 @@ defmodule CentralcloudStaff.IncidentsLive do
"""
end
defp filter_btn_class(true, "red"),
do: "px-3.5 py-1.5 rounded-md text-sm font-semibold bg-red-700 text-white transition-colors"
defp filter_btn_class(true, "orange"),
do: "px-3.5 py-1.5 rounded-md text-sm font-semibold bg-amber-700 text-white transition-colors"
defp filter_btn_class(true, "green"),
do: "px-3.5 py-1.5 rounded-md text-sm font-semibold bg-green-700 text-white transition-colors"
defp filter_btn_class(true, "blue"),
do: "px-3.5 py-1.5 rounded-md text-sm font-semibold bg-blue-700 text-white transition-colors"
defp filter_btn_class(false, _),
do: "px-3.5 py-1.5 rounded-md text-sm font-medium text-slate-400 border border-slate-700 hover:border-slate-500 hover:text-slate-200 transition-colors"
defp status_badge("firing"),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase bg-red-950 text-red-300 border border-red-700"
defp status_badge("acknowledged"),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase bg-amber-950 text-amber-300 border border-amber-700"
defp status_badge("resolved"),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase bg-green-950 text-green-300 border border-green-700"
defp status_badge("silenced"),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase bg-indigo-950 text-indigo-300 border border-indigo-700"
defp status_badge(_),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase bg-slate-700 text-slate-300"
defp format_dt(nil), do: ""
defp format_dt(iso) do
case DateTime.from_iso8601(iso) do

View file

@ -1,5 +1,5 @@
defmodule CentralcloudStaff.OnCallLive do
use Phoenix.LiveView
use CentralcloudStaffWeb, :live_view
alias CentralcloudCore.OnCall
@refresh_ms 60_000
@ -36,40 +36,38 @@ defmodule CentralcloudStaff.OnCallLive do
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;">
<h1 class="text-lg font-bold text-slate-100 mb-1 flex items-center gap-2">📅 On-Call Schedules</h1>
<p class="text-slate-500 text-xs mb-5">
Updated at {Calendar.strftime(@last_updated, "%H:%M:%S")} UTC refreshes every 60s
</p>
<div :if={@schedules == []} class="card" style="color:#64748b;">
<div :if={@schedules == []}
class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg p-6 text-slate-500">
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 class="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4">
<div :for={s <- @schedules} class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg p-5">
<p class="font-bold text-base text-slate-100 mb-3">{s["name"]}</p>
<p class="text-slate-500 text-[11px] font-semibold uppercase tracking-wider mb-2">
Currently on-call
</p>
<div :if={s["current_oncall"] == []} class="text-slate-500 text-sm">
Nobody on-call
</div>
<div :for={u <- s["current_oncall"]} class="flex items-center gap-2 mb-1.5">
<span class="w-7 h-7 bg-[#1e3a5f] rounded-full flex items-center justify-center text-[11px] text-sky-400 flex-shrink-0 font-bold">
{String.first(u["username"] || "?")}
</span>
<span class="font-semibold text-sm">{u["username"]}</span>
<span :if={u["email"]} class="text-slate-500 text-xs">({u["email"]})</span>
</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 class="mt-3 text-xs">
<a href={"/oncall/schedules/#{s["id"]}"} class="text-sky-400 hover:text-sky-300 no-underline">
View schedule
</a>
</div>
</div>
</div>

View file

@ -1,5 +1,5 @@
defmodule CentralcloudStaff.StakeholdersLive do
use Phoenix.LiveView
use CentralcloudStaffWeb, :live_view
alias CentralcloudCore.{HostBill, OnCall}
def mount(_params, _session, socket) do
@ -57,92 +57,116 @@ defmodule CentralcloudStaff.StakeholdersLive do
def render(assigns) do
~H"""
<div class="section-title">👥 Stakeholder Management</div>
<h1 class="text-lg font-bold text-slate-100 mb-5 flex items-center gap-2">👥 Stakeholder Management</h1>
<div style="display:grid;grid-template-columns:320px 1fr;gap:1.5rem;align-items:start;">
<div class="grid grid-cols-[320px_1fr] gap-6 items-start">
<%!-- Client list --%>
<div class="card" style="padding:0;">
<div style="padding:1rem;border-bottom:1px solid #1e3a5f;">
<%!-- Client list panel --%>
<div class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg overflow-hidden">
<div class="p-3 border-b border-[#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;"/>
<input
type="text"
name="q"
value={@query}
placeholder="Search clients…"
class="w-full px-3 py-1.5 bg-[#0a0f1e] border border-[#1e3a5f] rounded text-slate-200
text-sm outline-none focus:border-blue-600 placeholder-slate-600"
/>
</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 class="max-h-[600px] overflow-y-auto">
<div
:for={c <- @filtered_clients}
phx-click="select_client"
phx-value-id={c["id"]}
class={[
"px-4 py-3 cursor-pointer border-b border-[#0f172a] text-sm transition-colors hover:bg-[#0f1a2e]",
if(@selected_client && @selected_client["id"] == c["id"], do: "bg-[#1e3a5f]")
]}
>
<div class="font-semibold text-slate-200">{c["firstname"]} {c["lastname"]}</div>
<div :if={c["companyname"] != ""} class="text-slate-500 text-xs">{c["companyname"]}</div>
</div>
<div :if={@filtered_clients == []} style="padding:1.5rem;color:#64748b;text-align:center;font-size:0.875rem;">
<div :if={@filtered_clients == []}
class="p-6 text-slate-500 text-center text-sm">
No clients found
</div>
</div>
</div>
<%!-- Client detail / comms panel --%>
<%!-- Client detail panel --%>
<div>
<div :if={is_nil(@selected_client)} class="card" style="color:#64748b;text-align:center;padding:3rem;">
<div :if={is_nil(@selected_client)}
class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg p-12 text-center text-slate-500">
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;">
<div :if={@selected_client} class="flex flex-col gap-4">
<div class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg p-5">
<p class="text-lg font-bold text-slate-100">
{@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>
</p>
<p class="text-slate-500 text-sm mt-0.5">{@selected_client["email"]}</p>
<p :if={@selected_client["companyname"] != ""}
class="text-slate-400 text-xs mt-0.5">{@selected_client["companyname"]}</p>
</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;">
<div class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg overflow-hidden">
<div class="px-5 py-3 border-b border-[#1e3a5f] font-semibold text-sm text-slate-200">
Active Services ({length(@client_services)})
</div>
<div :if={@client_services == []} style="padding:1rem 1.25rem;color:#64748b;font-size:0.85rem;">
<div :if={@client_services == []}
class="px-5 py-4 text-slate-500 text-sm">
No services found
</div>
<table :if={@client_services != []}>
<thead><tr><th>Service</th><th>Status</th><th>Next Due</th></tr></thead>
<table :if={@client_services != []} class="w-full text-sm">
<thead>
<tr class="text-left border-b border-[#1e3a5f]">
<th class="px-5 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Service</th>
<th class="px-5 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">Status</th>
<th class="px-5 py-2.5 text-slate-500 text-xs font-semibold uppercase tracking-wider">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>
<tr :for={svc <- @client_services} class="border-t border-[#0a0f1e]">
<td class="px-5 py-3 text-slate-200">{svc["name"]}</td>
<td class="px-5 py-3">
<span class={svc_status_badge(svc["status"])}>{svc["status"]}</span>
</td>
<td style="color:#64748b;font-size:0.8rem;">{svc["nextduedate"]}</td>
<td class="px-5 py-3 text-slate-500 text-xs">{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;">
<div class="bg-[#0f1629] border border-[#1e3a5f] rounded-lg p-5">
<p class="font-semibold text-sm text-slate-200 mb-3">📢 Send Status Update</p>
<div :if={@active_incidents != []} class="mb-4">
<p class="text-slate-500 text-[11px] font-semibold uppercase tracking-wider mb-2">
Active incidents
</p>
<div class="flex flex-wrap gap-1.5">
<span
:for={inc <- @active_incidents}
class="bg-red-950 border border-red-700 rounded px-2 py-0.5 text-red-300 text-xs"
>
{inc["render_for_web"]["title"] || inc["id"]}
</span>
</div>
</div>
<textarea rows="4"
<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;">
class="w-full px-3 py-2 bg-[#0a0f1e] border border-[#1e3a5f] rounded text-slate-200
text-sm resize-y mb-3 focus:border-blue-600 outline-none placeholder-slate-600"
>
</textarea>
<button class="btn btn-primary" style="opacity:0.5;cursor:not-allowed;" disabled>
<button
disabled
class="inline-flex items-center px-4 py-1.5 rounded bg-blue-700 text-white text-sm
font-medium opacity-50 cursor-not-allowed"
>
Send via HostBill ticket (coming soon)
</button>
</div>
@ -151,4 +175,9 @@ defmodule CentralcloudStaff.StakeholdersLive do
</div>
"""
end
defp svc_status_badge("Active"),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase bg-green-950 text-green-300 border border-green-700"
defp svc_status_badge(_),
do: "inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase bg-amber-950 text-amber-300 border border-amber-700"
end

View file

@ -25,15 +25,14 @@ defmodule CentralcloudStaff.Router do
scope "/api", CentralcloudStaff do
pipe_through :api
get "/health", HealthController, :check
post "/mcp/wazuh", WazuhMcpController, :call
end
scope "/", CentralcloudStaff do
pipe_through :browser
get "/login", SessionController, :new
post "/login", SessionController, :create
delete "/logout", SessionController, :delete
get "/auth/callback", SessionController, :oidc_callback
end
scope "/", CentralcloudStaff do

View file

@ -1,6 +1,7 @@
defmodule CentralcloudStaff.RouterAgent do
@moduledoc """
Streams chat completions from the router-agent via the OpenAI-compatible SSE API.
Streams chat completions from any of the CentralCloud Hermes agents
(OpenAI-compatible SSE).
Sends messages to `caller_pid`:
{:chunk, text} incremental token from the stream
@ -8,19 +9,55 @@ defmodule CentralcloudStaff.RouterAgent do
{:stream_error, s} HTTP or network error
"""
@default_url "http://router-agent.router-agent.svc:8642"
@agents %{
"router" => %{
name: "Dispatcher",
description: "Routes to the right specialist",
url: "http://router-agent.router-agent.svc:8642",
model: "router-agent"
},
"incident" => %{
name: "Incident Commander",
description: "Server down, pod crash, node failure",
url: "http://incident-commander.incident-commander.svc:8642",
model: "incident-commander"
},
"ops" => %{
name: "Ops Agent",
description: "Oncall, billing, monitoring",
url: "http://operations-agent.operations-agent.svc:8642",
model: "operations-agent"
},
"comms" => %{
name: "Comms Agent",
description: "Customer notifications & status updates",
url: "http://communications-agent.communications-agent.svc:8642",
model: "communications-agent"
}
}
def stream_chat(messages, caller_pid) do
url = base_url() <> "/v1/chat/completions"
@system_prompt """
You are the CentralCloud Operations Assistant. Identify yourself only by your
role (e.g. "Dispatcher", "Incident Commander", "Ops Agent", "Comms Agent").
Never identify yourself as Hermes, Nous Research, or any underlying model.
If asked who you are or what model you use, answer with your role only.
"""
def agents, do: @agents
def agent_keys, do: Map.keys(@agents) |> Enum.sort()
def agent(key), do: Map.get(@agents, key) || Map.fetch!(@agents, "router")
def stream_chat(messages, caller_pid, agent_key \\ "router") do
agent = agent(agent_key)
url = agent.url <> "/v1/chat/completions"
key = api_key()
body = %{
model: "hermes-agent",
messages: messages,
model: agent.model,
messages: [%{role: "system", content: @system_prompt} | messages],
stream: true
}
# Use process dictionary for SSE line buffering across chunks
Process.put(:sse_buf, "")
result =
@ -48,14 +85,12 @@ defmodule CentralcloudStaff.RouterAgent do
end
end
# Split on SSE event boundary (\n\n); return {complete_events, incomplete_tail}
defp split_events(data) do
parts = String.split(data, "\n\n")
{complete, [tail]} = Enum.split(parts, length(parts) - 1)
{complete, tail}
end
# Parse a single SSE event (may contain multiple lines)
defp dispatch_event("", _pid), do: :ok
defp dispatch_event(event, pid) do
@ -79,17 +114,9 @@ defmodule CentralcloudStaff.RouterAgent do
end)
end
defp base_url do
config(:url, @default_url)
end
defp api_key do
config(:api_key, "")
end
defp config(key, default) do
Application.get_env(:centralcloud_staff, :router_agent, [])
|> Keyword.get(key, default)
|> Keyword.get(:api_key, "")
end
defp format_error(%{reason: reason}), do: inspect(reason)

View file

@ -0,0 +1,17 @@
defmodule CentralcloudStaffWeb do
@moduledoc """
Entry point for CentralcloudStaff LiveViews provides `use CentralcloudStaffWeb, :live_view`
which includes Phoenix.LiveView and imports PetalComponents.
"""
def live_view do
quote do
use Phoenix.LiveView
use PetalComponents
end
end
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View file

@ -32,7 +32,8 @@ defmodule CentralcloudStaff.MixProject do
{:bandit, "~> 1.2"},
{:jason, "~> 1.4"},
{:oidcc, "~> 3.2"},
{:req, "~> 0.5"}
{:req, "~> 0.5"},
{:petal_components, "~> 2.0"}
]
end
end

View file

@ -8,7 +8,12 @@ config :centralcloud_my, CentralcloudMy.Endpoint,
config :centralcloud_staff, CentralcloudStaff.Endpoint,
adapter: Bandit.PhoenixAdapter,
url: [host: "ops.centralcloud.com"],
http: [port: 4000]
http: [port: 4000],
live_view: [signing_salt: "ops_lv_salt_2026"],
render_errors: [
formats: [html: CentralcloudStaff.ErrorHTML, json: CentralcloudStaff.ErrorJSON],
layout: false
]
# HostBill Admin API (defaults — overridden at runtime)
config :centralcloud_core, :hostbill,
@ -27,3 +32,25 @@ config :centralcloud_core, :oidc,
issuer: "https://sso.centralcloud.com/application/o/centralcloud/"
import_config "#{config_env()}.exs"
# ---------------------------------------------------------------------------
# Tailwind CSS build profiles
# ---------------------------------------------------------------------------
config :tailwind,
version: "3.4.14",
staff: [
args: ~w(
--config=apps/centralcloud_staff/assets/tailwind.config.js
--input=apps/centralcloud_staff/assets/css/app.css
--output=apps/centralcloud_staff/priv/static/assets/app.css
),
cd: Path.expand("..", __DIR__)
],
my: [
args: ~w(
--config=apps/centralcloud_my/assets/tailwind.config.js
--input=apps/centralcloud_my/assets/css/app.css
--output=apps/centralcloud_my/priv/static/assets/app.css
),
cd: Path.expand("..", __DIR__)
]

View file

@ -6,14 +6,16 @@ config :centralcloud_my, CentralcloudMy.Endpoint,
secret_key_base: "dev_only_secret_key_base_do_not_use_in_prod_needs_64_chars_minimum_xx",
code_reloader: true,
debug_errors: true,
check_origin: false
check_origin: false,
watchers: [tailwind: {Tailwind, :install_and_run, [:my, ~w(--watch)]}]
config :centralcloud_staff, CentralcloudStaff.Endpoint,
http: [port: 4000],
secret_key_base: "dev_only_ops_secret_key_base_do_not_use_in_prod_needs_64_chars_min_xx",
code_reloader: true,
debug_errors: true,
check_origin: false
check_origin: false,
watchers: [tailwind: {Tailwind, :install_and_run, [:staff, ~w(--watch)]}]
# Disable Swoosh HTTP client in dev — no real email sending
config :swoosh, :api_client, false

View file

@ -41,3 +41,12 @@ config :centralcloud_staff, :router_agent,
config :centralcloud_staff, :ops_engine,
url: System.get_env("OPS_ENGINE_URL", "http://centralcloud-ops.centralcloud-ops.svc.cluster.local")
# Security monitoring — only available in centralcloud_my release
if config_env() == :prod and System.get_env("SECURITY_URL") do
config :centralcloud_core, :security,
url: System.get_env("SECURITY_URL"),
username: System.get_env("SECURITY_USERNAME"),
password: System.get_env("SECURITY_PASSWORD"),
verify_ssl: System.get_env("SECURITY_VERIFY_SSL", "true") != "false"
end

9
docs/AGENTS.md Normal file
View file

@ -0,0 +1,9 @@
<!-- sf-doc: version=2.75.3 template=docs/AGENTS.md state=pending hash=sha256:b35804ce78ca309cab8769719f6e0738141f1121682fbd46490419abd2c6f870 -->
# Docs Agent Notes
- Docs are the durable project memory. Keep them concise, navigable, and current.
- Treat `docs/adr/0000-purpose-to-software-compiler.md` as the root SF product contract.
- Put stable decisions here; keep transient execution state in active plans.
- Prefer links to source paths, commands, and eval artifacts over broad prose.
- When docs and code disagree, inspect the code and update the stale document.
- Run the records keeper checklist in `RECORDS_KEEPER.md` after meaningful code, product, or architecture changes.

36
docs/RECORDS_KEEPER.md Normal file
View file

@ -0,0 +1,36 @@
<!-- sf-doc: version=2.75.3 template=docs/RECORDS_KEEPER.md state=pending hash=sha256:3872de9cd72bd9129814a5e77e3b86abe76bef33f3ca34e04ae7582b4cfd066a -->
# Records Keeper
The records keeper keeps repo memory ordered after meaningful changes. Run this checklist at milestone close, after architecture changes, after product behavior changes, and whenever docs/source disagree.
Use the `records-keeper` skill for this workflow when SF skills are available. Use `context-doctor` instead when stale state lives under `.sf/` or the memory store.
## Canonical Homes
- Root `AGENTS.md`: short routing map for agents.
- `ARCHITECTURE.md`: short system map, boundaries, invariants, critical flows, and verification.
- `docs/product-specs/`: durable user-facing behavior and product decisions.
- `docs/design-docs/`: durable design and architecture decisions.
- `docs/exec-plans/`: active/completed work plans and technical debt.
- `docs/generated/`: generated references only.
- `docs/records/`: audits, ledgers, and context-gardening outputs.
## Checklist
- Root map is current: `AGENTS.md` points to the right canonical docs and local `AGENTS.md` files.
- Architecture is current: new subsystems, boundaries, invariants, data/state, or critical flows are reflected in `ARCHITECTURE.md`.
- Product specs are current: user-visible behavior changes are reflected in `docs/product-specs/`.
- Execution plans are filed: active work is in `docs/exec-plans/active/`; completed summaries and evidence are in `docs/exec-plans/completed/`.
- Debt is visible: discovered cleanup is listed in `docs/exec-plans/tech-debt-tracker.md`.
- Generated docs are marked: generated material stays under `docs/generated/` or clearly says how to regenerate it.
- Contradictions are resolved: stale docs are updated or marked superseded with links to the source of truth.
- Verification is recorded: changed checks, evals, and commands are listed in the relevant plan or quality document.
## Output
When records work is non-trivial, write a dated note under `docs/records/` with:
- What changed.
- What canonical docs were updated.
- What contradictions were found.
- What remains unresolved.

4
docs/RELIABILITY.md Normal file
View file

@ -0,0 +1,4 @@
<!-- sf-doc: version=2.75.3 template=docs/RELIABILITY.md state=pending hash=sha256:cda2b3d8f7f6323c5185e32fe832d8c181e6383c1a515b20c3e530eb6f133407 -->
# Reliability
Document expected failure modes, recovery paths, observability, and release checks here.

4
docs/SECURITY.md Normal file
View file

@ -0,0 +1,4 @@
<!-- sf-doc: version=2.75.3 template=docs/SECURITY.md state=pending hash=sha256:baf816ba2591d9e3859b82ba1dbe8b05f7bb8003edab90071c086eee3edfd445 -->
# Security
Document trust boundaries, secrets handling, dependency risk, and security review requirements here.

7
docs/records/AGENTS.md Normal file
View file

@ -0,0 +1,7 @@
<!-- sf-doc: version=2.75.3 template=docs/records/AGENTS.md state=pending hash=sha256:dc21117dfa7607d7ce4cc6ce5724658348a95e9807673ff526b9cf02e2568de0 -->
# Records Agent Notes
- Keep repository memory ordered, current, and easy to inspect.
- Prefer moving durable facts to the narrowest canonical document over duplicating them.
- Preserve historical decisions; mark superseded records instead of deleting useful context.
- Escalate conflicts between docs and source by citing the exact files that disagree.

4
docs/records/index.md Normal file
View file

@ -0,0 +1,4 @@
<!-- sf-doc: version=2.75.3 template=docs/records/index.md state=pending hash=sha256:03e974ded1b733db1a84dbf92096ff91bdc28a2ae37f457c536bc184fdc79cb9 -->
# Records
This folder holds repo-memory audits, decision ledgers, context-gardening notes, and records-keeper outputs.

File diff suppressed because one or more lines are too long

21
flake.lock generated
View file

@ -18,6 +18,26 @@
"type": "github"
}
},
"nix2container": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1775487831,
"narHash": "sha256-2lguQpLPQaxpQCJjXhmEEAfabwsAhkP29Z7fgLzHARA=",
"owner": "nlewo",
"repo": "nix2container",
"rev": "76be9608a7f4d6c985d28b0e7be903ae2547df3e",
"type": "github"
},
"original": {
"owner": "nlewo",
"repo": "nix2container",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1778003029,
@ -37,6 +57,7 @@
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nix2container": "nix2container",
"nixpkgs": "nixpkgs"
}
},

119
flake.nix
View file

@ -4,6 +4,10 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
flake-utils.url = "github:numtide/flake-utils";
nix2container = {
url = "github:nlewo/nix2container";
inputs.nixpkgs.follows = "nixpkgs";
};
};
nixConfig = {
@ -15,29 +19,112 @@
];
};
outputs = { self, nixpkgs, flake-utils }:
outputs = { self, nixpkgs, flake-utils, nix2container }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
# Elixir 1.20.0-rc.4 — not yet in nixpkgs; override elixir_1_19 against cached OTP 28
# Uses standard erlang (already in binary cache) to avoid full recompile
elixir_1_20_rc4 = pkgs.beamPackages.elixir_1_19.overrideAttrs (_old: {
version = "1.20.0-rc.4";
src = pkgs.fetchFromGitHub {
owner = "elixir-lang";
repo = "elixir";
rev = "v1.20.0-rc.4";
hash = "sha256-sboB+GW3T+t9gEcOGtd6NllmIlyWio1+cgWyyxE+484=";
};
beam = pkgs.beam.packages.erlang_28.extend (_self: super: {
elixir = super.elixir_1_19.overrideAttrs (_old: {
version = "1.20.0-rc.4";
src = pkgs.fetchFromGitHub {
owner = "elixir-lang";
repo = "elixir";
rev = "v1.20.0-rc.4";
hash = "sha256-sboB+GW3T+t9gEcOGtd6NllmIlyWio1+cgWyyxE+484=";
};
});
});
n2c = nix2container.packages.${system}.nix2container;
src = ./.;
staffRelease = beam.mixRelease rec {
pname = "centralcloud-staff";
version = "0.2.1";
inherit src;
mixFodDeps = beam.fetchMixDeps {
pname = "centralcloud-staff-deps";
inherit src version;
hash = "sha256-MJGIZPdeK5aVHSJ8ZTPRoxqHKyTA4WYR+uFdmJCpFy4=";
};
INCLUDE_ERTS = "false";
nativeBuildInputs = [
pkgs.tailwindcss
];
preBuild = ''
mkdir -p apps/centralcloud_staff/priv/static/assets
${pkgs.tailwindcss}/bin/tailwindcss \
--config apps/centralcloud_staff/assets/tailwind.config.js \
--input apps/centralcloud_staff/assets/css/app.css \
--output apps/centralcloud_staff/priv/static/assets/app.css \
--minify
'';
mixReleaseName = "centralcloud_staff";
postInstall = ''
chmod +x "$out"/bin/* || true
'';
};
staffImageRoot = pkgs.buildEnv {
name = "centralcloud-staff-image-root";
paths = [
staffRelease
pkgs.beam.packages.erlang_28.erlang
pkgs.cacert
pkgs.bash
pkgs.coreutils
];
pathsToLink = [
"/bin"
"/etc"
];
};
staffContainer = n2c.buildImage {
name = "registry.infra.centralcloud.com/centralcloud/centralcloud-staff";
tag = staffRelease.version;
copyToRoot = [
staffImageRoot
];
maxLayers = 32;
config = {
Cmd = ["/bin/centralcloud_staff" "start"];
Env = [
"HOME=/tmp"
"PHX_SERVER=true"
"ELIXIR_ERL_OPTIONS=+fnu"
"LANG=C.UTF-8"
"LC_ALL=C.UTF-8"
"SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt"
];
ExposedPorts = {
"4000/tcp" = {};
};
Labels = {
"org.opencontainers.image.title" = "CentralCloud Staff";
"org.opencontainers.image.source" = "https://git.infra.centralcloud.com/centralcloud/portal";
};
};
};
elixir_1_20_rc4 = beam.elixir;
in {
packages.centralcloud-staff = staffRelease;
packages.container-staff = staffContainer;
packages.default = staffRelease;
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
elixir_1_20_rc4 # 1.20.0-rc.4 built from source against OTP 28
pkgs.beamPackages.erlang # OTP 28.5 (from binary cache)
nodejs_22 # Phoenix asset pipeline
inotify-tools # LiveReload on Linux
elixir_1_20_rc4
pkgs.beam.packages.erlang_28.erlang
nodejs_22
inotify-tools
];
shellHook = ''

16
mix.exs
View file

@ -7,15 +7,18 @@ defmodule Centralcloud.MixProject do
version: "0.1.0",
start_permanent: Mix.env() == :prod,
deps: deps(),
aliases: aliases(),
releases: [
centralcloud_my: [
applications: [centralcloud_core: :permanent, centralcloud_my: :permanent],
overlays: "rel/overlays",
include_erts: System.get_env("INCLUDE_ERTS", "true") == "true",
steps: [:assemble, :tar]
],
centralcloud_staff: [
applications: [centralcloud_core: :permanent, centralcloud_staff: :permanent],
overlays: "rel/overlays",
include_erts: System.get_env("INCLUDE_ERTS", "true") == "true",
steps: [:assemble, :tar]
]
]
@ -23,6 +26,17 @@ defmodule Centralcloud.MixProject do
end
defp deps do
[]
[
{:tailwind, "~> 0.2", runtime: false}
]
end
defp aliases do
[
"assets.deploy": [
"tailwind staff --minify",
"tailwind my --minify"
]
]
end
end

View file

@ -16,8 +16,11 @@
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"oidcc": {:hex, :oidcc, "3.7.2", "2047949832ca7984d6d9c218cc5f23e8096bf50ebb809124d3a01673ee2bfe12", [:mix, :rebar3], [{:igniter, "~> 0.6.3 or ~> 0.7.0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.3.1", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "e3f1ed91509fdeb31ec8b9de4ecda0e80cb68b463a9f5b7a9ee1ee40e521e445"},
"petal_components": {:hex, :petal_components, "2.9.3", "47623ad291a4ce5400d02f92db5bef4a9a218a3003239c8c0df5487e25292a07", [:mix], [{:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.4", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.7", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "1fb005eb82fe3cc755310c31bb2fa995fd209295b829f38e0bae31fea351e4e3"},
"phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.30", "a84af1610755dc208da35d4d45564485edbf18c3f3c77373c4a650dc994cdcdb", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a353c51ac1e3190910f01a6100c7d5cc02c5e22e7374fd817bd3aedd21149039"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
@ -27,6 +30,7 @@
"postgrex": {:hex, :postgrex, "0.22.1", "b3665ad17e15441557da8f45eeebfcd56e4a2b0b98538b855679a13d05e5cc5d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "df59f828b167b49a5853f645b65f57eb1bc5f3b230497ceaca7af5d8ac05afef"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"swoosh": {:hex, :swoosh, "1.25.1", "569fcff34817da8a03f28775146b3c8b71b4c9b14f8f78d37ff3ef422862a18b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "58b3e8db6406fe417a89b5042358d2e8f15d32a3317d4f8581d7a3ae501e410b"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},

1
result Symbolic link
View file

@ -0,0 +1 @@
/nix/store/xygfmyaniavc5zgsbnq24x07irxgpihm-image-centralcloud-staff.json