sf snapshot: uncommitted changes after 2133m inactivity
This commit is contained in:
parent
1758b2465e
commit
f40632b297
76 changed files with 61516 additions and 58043 deletions
27
.gitignore
vendored
27
.gitignore
vendored
|
|
@ -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
10
.sf/NON-GOALS.md
Normal 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
10
.sf/PRINCIPLES.md
Normal 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
10
.sf/STYLE.md
Normal 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
10
.sf/harness/AGENTS.md
Normal 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.
|
||||
11
.sf/harness/evals/AGENTS.md
Normal file
11
.sf/harness/evals/AGENTS.md
Normal 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.
|
||||
9
.sf/harness/graders/AGENTS.md
Normal file
9
.sf/harness/graders/AGENTS.md
Normal 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.
|
||||
10
.sf/harness/specs/AGENTS.md
Normal file
10
.sf/harness/specs/AGENTS.md
Normal 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.
|
||||
20
.sf/harness/specs/bootstrap.md
Normal file
20
.sf/harness/specs/bootstrap.md
Normal 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
12
.sf/preferences.yaml
Normal 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
142
.sf/scaffold-manifest.json
Normal 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
19
.siftignore
Normal 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
17
AGENTS.md
Normal 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
21
ARCHITECTURE.md
Normal 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.
|
||||
15
Dockerfile
15
Dockerfile
|
|
@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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"}]}.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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',[]}}]}.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
395
apps/centralcloud_core/lib/centralcloud_core/security.ex
Normal file
395
apps/centralcloud_core/lib/centralcloud_core/security.ex
Normal 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
|
||||
225
apps/centralcloud_core/lib/centralcloud_core/wazuh_mcp.ex
Normal file
225
apps/centralcloud_core/lib/centralcloud_core/wazuh_mcp.ex
Normal 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
|
||||
3
apps/centralcloud_my/assets/css/app.css
Normal file
3
apps/centralcloud_my/assets/css/app.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
14
apps/centralcloud_my/assets/tailwind.config.js
Normal file
14
apps/centralcloud_my/assets/tailwind.config.js
Normal 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")],
|
||||
};
|
||||
|
|
@ -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>
|
||||
"""
|
||||
|
|
|
|||
357
apps/centralcloud_my/lib/centralcloud_my/live/security_live.ex
Normal file
357
apps/centralcloud_my/lib/centralcloud_my/live/security_live.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
17
apps/centralcloud_my/lib/centralcloud_my_web.ex
Normal file
17
apps/centralcloud_my/lib/centralcloud_my_web.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
0
apps/centralcloud_my/priv/static/assets/.keep
Normal file
0
apps/centralcloud_my/priv/static/assets/.keep
Normal file
33
apps/centralcloud_staff/assets/css/app.css
Normal file
33
apps/centralcloud_staff/assets/css/app.css
Normal 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; }
|
||||
14
apps/centralcloud_staff/assets/tailwind.config.js
Normal file
14
apps/centralcloud_staff/assets/tailwind.config.js
Normal 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")],
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]} ·
|
||||
<p class="text-slate-500 text-sm">
|
||||
Source: {@alert_group["alert_receive_channel"]["verbal_name"]}
|
||||
·
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
17
apps/centralcloud_staff/lib/centralcloud_staff_web.ex
Normal file
17
apps/centralcloud_staff/lib/centralcloud_staff_web.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
0
apps/centralcloud_staff/priv/static/assets/.keep
Normal file
0
apps/centralcloud_staff/priv/static/assets/.keep
Normal 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__)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
9
docs/AGENTS.md
Normal file
9
docs/AGENTS.md
Normal 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
36
docs/RECORDS_KEEPER.md
Normal 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
4
docs/RELIABILITY.md
Normal 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
4
docs/SECURITY.md
Normal 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
7
docs/records/AGENTS.md
Normal 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
4
docs/records/index.md
Normal 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.
|
||||
116493
erl_crash.dump
116493
erl_crash.dump
File diff suppressed because one or more lines are too long
21
flake.lock
generated
21
flake.lock
generated
|
|
@ -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
119
flake.nix
|
|
@ -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
16
mix.exs
|
|
@ -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
|
||||
|
|
|
|||
4
mix.lock
4
mix.lock
|
|
@ -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
1
result
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/nix/store/xygfmyaniavc5zgsbnq24x07irxgpihm-image-centralcloud-staff.json
|
||||
Loading…
Add table
Reference in a new issue