Compare commits
25 commits
c7fe67c65b
...
d6e846e940
| Author | SHA1 | Date | |
|---|---|---|---|
| d6e846e940 | |||
| de627353f6 | |||
| 53ae816523 | |||
| abd660ffb1 | |||
| c3b5d3a97c | |||
| 73fb32e359 | |||
| 9f98b780ca | |||
| 9472e7fe2e | |||
| c411d6b4ea | |||
| 47fd9cd066 | |||
| 2a37c2db43 | |||
| 99f53084fb | |||
| cd957a7667 | |||
| fa7700355a | |||
| da1eb33faf | |||
| 9ef5717f5b | |||
| 0bee7695bc | |||
| a4f3595142 | |||
| 63bf11f1f5 | |||
| a3aa74a7bf | |||
| 66d420b737 | |||
| 4f20a2ff06 | |||
| 57de739f80 | |||
| e857b1fb99 | |||
| 504660367e |
83 changed files with 3572 additions and 453 deletions
14
.drone-notice.tpl
Normal file
14
.drone-notice.tpl
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
happyDomain
|
||||
===========
|
||||
|
||||
This product bundles third-party components under the following licenses.
|
||||
The original license texts are reproduced in full below.
|
||||
|
||||
{{ range . }}
|
||||
--------------------------------------------------------------------------------
|
||||
Module: {{ .Name }}
|
||||
License: {{ .LicenseName }}
|
||||
{{ with .LicenseURL }}Source: {{ . }}
|
||||
{{ end }}
|
||||
{{ .LicenseText }}
|
||||
{{ end }}
|
||||
57
.drone.yml
57
.drone.yml
|
|
@ -28,8 +28,8 @@ steps:
|
|||
commands:
|
||||
- apk --no-cache add tar
|
||||
- yarn config set network-timeout 100000
|
||||
- yarn --cwd web install
|
||||
- tar --transform="s@.@./happydomain-${DRONE_COMMIT}@" --exclude-vcs --exclude=./web/node_modules/.cache -czf /dev/shm/happydomain-src.tar.gz .
|
||||
- yarn install
|
||||
- tar --transform="s@.@./happydomain-${DRONE_COMMIT}@" --exclude-vcs --exclude=./node_modules/.cache --exclude=./web/node_modules/.cache --exclude=./web-admin/node_modules/.cache -czf /dev/shm/happydomain-src.tar.gz ./package.json ./package-lock.json ./.npmrc ./node_modules/ ./web/ ./web-admin/
|
||||
- mkdir deploy
|
||||
- mv /dev/shm/happydomain-src.tar.gz deploy
|
||||
- yarn --cwd web --offline run svelte-kit sync && yarn --cwd web --offline generate:api && sed -i "s/hey-api\.ts';/hey-api';/" web/src/lib/api-base/client.gen.ts
|
||||
|
|
@ -37,14 +37,25 @@ steps:
|
|||
- yarn --cwd web-admin --offline run svelte-kit sync && yarn --cwd web-admin --offline generate:api && sed -i "s/hey-api\.ts';/hey-api';/" web/src/lib/api-admin/client.gen.ts
|
||||
- yarn --cwd web-admin --offline build
|
||||
|
||||
- name: frontend NOTICE
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- npx --yes generate-license-file@3 --input web/package.json --output deploy/NOTICE.web --ci
|
||||
- npx --yes generate-license-file@3 --input web-admin/package.json --output deploy/NOTICE.web-admin --ci
|
||||
- for f in deploy/NOTICE.web deploy/NOTICE.web-admin; do { echo '------'; tail -n +3 "$f"; } > "$f.tmp" && mv "$f.tmp" "$f"; done
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
- name: backend-commit
|
||||
image: golang:1-alpine
|
||||
commands:
|
||||
- apk add --no-cache git
|
||||
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
|
||||
- go build -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
|
||||
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
|
||||
environment:
|
||||
CGO_ENABLED: 0
|
||||
GOFLAGS: "-tags=netgo,swagger,web"
|
||||
when:
|
||||
event:
|
||||
exclude:
|
||||
|
|
@ -54,10 +65,16 @@ steps:
|
|||
image: golang:1-alpine
|
||||
commands:
|
||||
- apk add --no-cache git
|
||||
- go build -tags netgo,swagger,web -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
|
||||
- go build -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
|
||||
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
|
||||
# Generate NOTICE file (merging Go + frontend)
|
||||
- go install github.com/google/go-licenses@v1.6.0
|
||||
- go-licenses report ./cmd/happyDomain --template .drone-notice.tpl > deploy/NOTICE.go
|
||||
- cat deploy/NOTICE.go deploy/NOTICE.web deploy/NOTICE.web-admin > deploy/NOTICE
|
||||
- rm deploy/NOTICE.go deploy/NOTICE.web deploy/NOTICE.web-admin
|
||||
environment:
|
||||
CGO_ENABLED: 0
|
||||
GOFLAGS: "-tags=netgo,swagger,web"
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
|
@ -154,11 +171,7 @@ steps:
|
|||
from_secret: git_nemunaire_token
|
||||
base_url: https://git.nemunai.re
|
||||
draft: true
|
||||
files:
|
||||
- happydomain-src.tar.gz
|
||||
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
- happydomain-darwin-${DRONE_STAGE_ARCH}
|
||||
- happydomain-sbom.spdx.json
|
||||
files: deploy/*
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
|
@ -170,11 +183,7 @@ steps:
|
|||
from_secret: codeberg_token
|
||||
base_url: https://codeberg.org
|
||||
draft: true
|
||||
files:
|
||||
- happydomain-src.tar.gz
|
||||
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
- happydomain-darwin-${DRONE_STAGE_ARCH}
|
||||
- happydomain-sbom.spdx.json
|
||||
files: deploy/*
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
|
@ -186,11 +195,7 @@ steps:
|
|||
from_secret: github_release_token
|
||||
draft: true
|
||||
github_url: https://github.com
|
||||
files:
|
||||
- happydomain-src.tar.gz
|
||||
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
- happydomain-darwin-${DRONE_STAGE_ARCH}
|
||||
- happydomain-sbom.spdx.json
|
||||
files: deploy/*
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
|
@ -232,8 +237,8 @@ steps:
|
|||
- name: frontend
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- cd web
|
||||
- npm install --network-timeout=100000
|
||||
- cd web
|
||||
- npx svelte-kit sync && npm run generate:api
|
||||
- npm test
|
||||
- npm run build
|
||||
|
|
@ -362,9 +367,7 @@ steps:
|
|||
base_url: https://git.nemunai.re
|
||||
draft: true
|
||||
prerelease: true
|
||||
files:
|
||||
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
- happydomain-darwin-${DRONE_STAGE_ARCH}
|
||||
files: deploy/*
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
|
@ -377,9 +380,7 @@ steps:
|
|||
base_url: https://codeberg.org
|
||||
draft: true
|
||||
prerelease: true
|
||||
files:
|
||||
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
- happydomain-darwin-${DRONE_STAGE_ARCH}
|
||||
files: deploy/*
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
|
@ -392,9 +393,7 @@ steps:
|
|||
draft: true
|
||||
prerelease: true
|
||||
github_url: https://github.com
|
||||
files:
|
||||
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
- happydomain-darwin-${DRONE_STAGE_ARCH}
|
||||
files: deploy/*
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
./happydomain
|
||||
./happyDomain
|
||||
node_modules
|
||||
BIN
docs/domain-prometheus-url.png
Normal file
BIN
docs/domain-prometheus-url.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
275
docs/prometheus.md
Normal file
275
docs/prometheus.md
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
# Scraping happyDomain checker metrics with Prometheus
|
||||
|
||||
happyDomain exposes check results as time-series metrics that Prometheus can
|
||||
scrape directly. This lets you alert on DNS health checks, track trends over
|
||||
time, and correlate domain health with the rest of your infrastructure.
|
||||
|
||||
> **Scope of this document:** user-facing metrics from the checker subsystem.
|
||||
> The admin-socket `/metrics` endpoint (happyDomain internal instrumentation)
|
||||
> is covered separately in [metrics.md](metrics.md).
|
||||
|
||||
---
|
||||
|
||||
## Getting started
|
||||
|
||||
### 1. Create an API token
|
||||
|
||||
The metrics endpoints require authentication. You need a long-lived API token
|
||||
(not your login session).
|
||||
|
||||
1. Log in to happyDomain.
|
||||
2. Go to the **Account** page (top-right user menu).
|
||||
3. Scroll down to the **Security & Access** section.
|
||||
4. Click **Create API key**.
|
||||
5. Give the key a descriptive name (e.g. `prometheus-scraper`).
|
||||
6. Click **Create API key** in the modal.
|
||||
7. **Copy the secret immediately** (it is shown only once).
|
||||
|
||||

|
||||
|
||||
The secret is used as a Bearer token in every request:
|
||||
|
||||
```
|
||||
Authorization: Bearer <your-secret-here>
|
||||
```
|
||||
|
||||
### 2. Verify manually
|
||||
|
||||
Before configuring Prometheus, confirm that the endpoint is reachable and
|
||||
returns Prometheus-formatted data:
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <your-secret>" \
|
||||
-H "Accept: text/plain" \
|
||||
https://happydomain.example.com/api/checkers/metrics
|
||||
```
|
||||
|
||||
You should see lines like:
|
||||
|
||||
```
|
||||
# HELP dns_rtt_seconds unit: s
|
||||
# TYPE dns_rtt_seconds untyped
|
||||
dns_rtt_seconds{...} 0.042 1744800000000
|
||||
```
|
||||
|
||||
> **Format selection:** the API returns JSON by default. Add
|
||||
> `Accept: text/plain` (or `Accept: application/openmetrics-text`) to receive
|
||||
> Prometheus text exposition format 0.0.4. Prometheus itself sends the right
|
||||
> header automatically when you use the `params` and `headers` scrape options
|
||||
> shown below.
|
||||
|
||||
### 3. Minimal Prometheus scrape config
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: happydomain_checks
|
||||
metrics_path: /api/checkers/metrics
|
||||
scheme: https
|
||||
authorization:
|
||||
type: Bearer
|
||||
credentials: <your-secret>
|
||||
params:
|
||||
# Not needed; Prometheus sends Accept: application/openmetrics-text
|
||||
# by default and the API honours it.
|
||||
static_configs:
|
||||
- targets:
|
||||
- happydomain.example.com
|
||||
```
|
||||
|
||||
That's it. Prometheus will now scrape all check metrics for your account on
|
||||
every evaluation interval.
|
||||
|
||||
---
|
||||
|
||||
## Available endpoints
|
||||
|
||||
All endpoints are under `/api` and require `Authorization: Bearer <token>`.
|
||||
|
||||
### All user metrics
|
||||
|
||||
```
|
||||
GET /api/checkers/metrics
|
||||
```
|
||||
|
||||
Returns metrics from recent executions of **every checker across all your
|
||||
domains**. This is the broadest scrape target, useful when you want a single
|
||||
job covering everything.
|
||||
|
||||
| Query parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `limit` | `100` | Maximum number of recent executions to include. |
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Accept: text/plain" \
|
||||
"https://happydomain.example.com/api/checkers/metrics?limit=200"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Domain metrics
|
||||
|
||||
```
|
||||
GET /api/domains/{domain}/checkers/metrics
|
||||
```
|
||||
|
||||
Returns metrics for **all checkers on a single domain**, including checkers
|
||||
running on its services. `{domain}` is your domain FQDN (e.g.
|
||||
`example.com`) or its internal identifier; both are accepted.
|
||||
|
||||
| Query parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `limit` | `100` | Maximum number of recent executions to include. |
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Accept: text/plain" \
|
||||
"https://happydomain.example.com/api/domains/example.com/checkers/metrics"
|
||||
```
|
||||
|
||||
Prometheus config for per-domain scraping:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: happydomain_example_com
|
||||
metrics_path: /api/domains/example.com/checkers/metrics
|
||||
scheme: https
|
||||
authorization:
|
||||
type: Bearer
|
||||
credentials: <your-secret>
|
||||
static_configs:
|
||||
- targets:
|
||||
- happydomain.example.com
|
||||
```
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Per-checker metrics (domain level)
|
||||
|
||||
```
|
||||
GET /api/domains/{domain}/checkers/{checkerId}/metrics
|
||||
```
|
||||
|
||||
Returns metrics for **one specific checker on a domain**. Use this when you
|
||||
want fine-grained scrape intervals or separate Prometheus jobs per checker
|
||||
type.
|
||||
|
||||
`{checkerId}` is the checker identifier (e.g. `dnssec`, `mx_reachability`).
|
||||
The easiest way to obtain the exact URL is the **Prometheus Metrics** button
|
||||
on the checker configuration page (visible when the checker produces metrics).
|
||||
|
||||
| Query parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `limit` | `100` | Maximum number of recent executions to include. |
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Accept: text/plain" \
|
||||
"https://happydomain.example.com/api/domains/example.com/checkers/dnssec/metrics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Per-checker metrics (service level)
|
||||
|
||||
```
|
||||
GET /api/domains/{domain}/zone/{zoneId}/{subdomain}/services/{serviceId}/checkers/{checkerId}/metrics
|
||||
```
|
||||
|
||||
Returns metrics for **one specific checker on a DNS service** (a structured
|
||||
record group, e.g. an MX configuration or an SPF record).
|
||||
|
||||
The URL for a service-level checker can be copied from the **Prometheus
|
||||
Metrics** button on the service's checker configuration page.
|
||||
|
||||
| Path segment | Description |
|
||||
|---|---|
|
||||
| `{domain}` | Domain FQDN or identifier |
|
||||
| `{zoneId}` | Zone snapshot identifier (opaque string) |
|
||||
| `{subdomain}` | Relative owner name within the zone (e.g. `@`, `mail`) |
|
||||
| `{serviceId}` | Service identifier (opaque string) |
|
||||
| `{checkerId}` | Checker identifier |
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Accept: text/plain" \
|
||||
"https://happydomain.example.com/api/domains/example.com/zone/<zoneId>/@/services/<serviceId>/checkers/spf_policy/metrics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Single-execution metrics
|
||||
|
||||
```
|
||||
GET /api/domains/{domain}/checkers/{checkerId}/executions/{executionId}/metrics
|
||||
GET /api/domains/{domain}/zone/{zoneId}/{subdomain}/services/{serviceId}/checkers/{checkerId}/executions/{executionId}/metrics
|
||||
```
|
||||
|
||||
Returns metrics from **one specific execution run**. This is not typically
|
||||
used for Prometheus scraping (the data is historical and does not change after
|
||||
the execution completes); it is more useful for debugging or one-off
|
||||
inspections.
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Accept: text/plain" \
|
||||
"https://happydomain.example.com/api/domains/example.com/checkers/dnssec/executions/<executionId>/metrics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-domain Prometheus configuration
|
||||
|
||||
To scrape several domains with a single Prometheus job, use
|
||||
`relabel_configs` to build the path dynamically from a label:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: happydomain_domains
|
||||
scheme: https
|
||||
authorization:
|
||||
type: Bearer
|
||||
credentials: <your-secret>
|
||||
metrics_path: /api/checkers/metrics # fallback; overridden per target
|
||||
relabel_configs:
|
||||
- source_labels: [__address__]
|
||||
regex: (.+)@(.+)
|
||||
target_label: __metrics_path__
|
||||
replacement: /api/domains/$1/checkers/metrics
|
||||
- source_labels: [__address__]
|
||||
regex: .+@(.+)
|
||||
target_label: __address__
|
||||
replacement: $1
|
||||
- source_labels: [domain]
|
||||
target_label: domain
|
||||
static_configs:
|
||||
- targets:
|
||||
- example.com@happydomain.example.com
|
||||
- otherdomain.net@happydomain.example.com
|
||||
labels:
|
||||
instance: happydomain.example.com
|
||||
```
|
||||
|
||||
Each target encodes `domain@host`; the relabel rules split them into the
|
||||
correct `__metrics_path__` and `__address__`.
|
||||
|
||||
---
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **Treat the API token like a password.** It grants read access to all check
|
||||
metrics associated with your account.
|
||||
- Use a dedicated token for each scraper. You can revoke individual tokens from
|
||||
the **Security & Access** page without affecting your login session.
|
||||
- Prefer HTTPS so the `Authorization` header is not transmitted in the clear.
|
||||
- The metrics endpoints return only aggregated numeric values; they do not
|
||||
expose DNS zone content, provider credentials, or other sensitive
|
||||
configuration.
|
||||
BIN
docs/user-create-api-key.png
Normal file
BIN
docs/user-create-api-key.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
2
go.mod
2
go.mod
|
|
@ -27,7 +27,7 @@ require (
|
|||
github.com/libdns/libdns v1.1.1
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/oracle/nosql-go-sdk v1.4.7
|
||||
github.com/oracle/nosql-go-sdk v1.4.8
|
||||
github.com/ovh/go-ovh v1.9.0
|
||||
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d
|
||||
github.com/swaggo/files v1.0.1
|
||||
|
|
|
|||
11
go.sum
11
go.sum
|
|
@ -135,6 +135,10 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF
|
|||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
|
|
@ -472,8 +476,8 @@ github.com/openrdap/rdap v0.9.1 h1:Rv6YbanbiVPsKRvOLdUmlU1AL5+2OFuEFLjFN+mQsCM=
|
|||
github.com/openrdap/rdap v0.9.1/go.mod h1:vKSiotbsENrjM/vaHXLddXbW8iQkBfa+ldEuYEjyLTQ=
|
||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
|
||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
|
||||
github.com/oracle/nosql-go-sdk v1.4.7 h1:dqVBSMulObDj0JHm1mAncTHrQg8wIiQJNC0JRNKPACg=
|
||||
github.com/oracle/nosql-go-sdk v1.4.7/go.mod h1:xgJE9wxADDbk7vR4FGA4NOt4RNAaIsQOj4sCATmCVXM=
|
||||
github.com/oracle/nosql-go-sdk v1.4.8 h1:eMzz+yNLHvB0GCPAWxe0qYttBJF7Fh0Aup+zectpgc4=
|
||||
github.com/oracle/nosql-go-sdk v1.4.8/go.mod h1:xgJE9wxADDbk7vR4FGA4NOt4RNAaIsQOj4sCATmCVXM=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.111.0 h1:eDkWg6ZN0uKwWzSekoFcQJhR+C+F/aVdTwr+lGHU9Qk=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.111.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
|
||||
github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
|
||||
|
|
@ -629,6 +633,8 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
|
|||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo=
|
||||
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
|
||||
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
|
||||
|
|
@ -648,7 +654,6 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC
|
|||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
|
|
|
|||
|
|
@ -120,6 +120,46 @@ func (bc *BackupController) DoBackup() (ret happydns.Backup) {
|
|||
}
|
||||
}
|
||||
|
||||
// Checker configurations (positional, one entry per (checker, user?, domain?, service?)).
|
||||
if cfgIter, err := bc.store.ListAllCheckerConfigurations(); err != nil {
|
||||
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve CheckerConfigurations: %s", err.Error()))
|
||||
} else {
|
||||
defer cfgIter.Close()
|
||||
for cfgIter.Next() {
|
||||
ret.CheckerConfigurations = append(ret.CheckerConfigurations, cfgIter.Item())
|
||||
}
|
||||
}
|
||||
|
||||
// Check plans.
|
||||
if planIter, err := bc.store.ListAllCheckPlans(); err != nil {
|
||||
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve CheckPlans: %s", err.Error()))
|
||||
} else {
|
||||
defer planIter.Close()
|
||||
for planIter.Next() {
|
||||
ret.CheckPlans = append(ret.CheckPlans, planIter.Item())
|
||||
}
|
||||
}
|
||||
|
||||
// Check evaluations.
|
||||
if evalIter, err := bc.store.ListAllEvaluations(); err != nil {
|
||||
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve CheckEvaluations: %s", err.Error()))
|
||||
} else {
|
||||
defer evalIter.Close()
|
||||
for evalIter.Next() {
|
||||
ret.CheckEvaluations = append(ret.CheckEvaluations, evalIter.Item())
|
||||
}
|
||||
}
|
||||
|
||||
// Executions.
|
||||
if execIter, err := bc.store.ListAllExecutions(); err != nil {
|
||||
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve Executions: %s", err.Error()))
|
||||
} else {
|
||||
defer execIter.Close()
|
||||
for execIter.Next() {
|
||||
ret.Executions = append(ret.Executions, execIter.Item())
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +215,29 @@ func (bc *BackupController) DoRestore(backup *happydns.Backup) (errs error) {
|
|||
errs = errors.Join(errs, bc.store.UpdateSession(session))
|
||||
}
|
||||
|
||||
// Checker configurations.
|
||||
for _, cfg := range backup.CheckerConfigurations {
|
||||
if cfg == nil {
|
||||
continue
|
||||
}
|
||||
errs = errors.Join(errs, bc.store.UpdateCheckerConfiguration(cfg.CheckName, cfg.UserId, cfg.DomainId, cfg.ServiceId, cfg.Options))
|
||||
}
|
||||
|
||||
// Check plans.
|
||||
for _, plan := range backup.CheckPlans {
|
||||
errs = errors.Join(errs, bc.store.RestoreCheckPlan(plan))
|
||||
}
|
||||
|
||||
// Check evaluations (reference plans, restored above).
|
||||
for _, eval := range backup.CheckEvaluations {
|
||||
errs = errors.Join(errs, bc.store.RestoreEvaluation(eval))
|
||||
}
|
||||
|
||||
// Executions.
|
||||
for _, exec := range backup.Executions {
|
||||
errs = errors.Join(errs, bc.store.RestoreExecution(exec))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
|
|
@ -41,14 +44,25 @@ func NewTidyController(tidyUpService happydns.TidyUpUseCase) *TidyController {
|
|||
//
|
||||
// @Summary Tidy up the database
|
||||
// @Schemes
|
||||
// @Description Performs cleanup and maintenance operations on the database, removing orphaned records and optimizing storage.
|
||||
// @Description Performs cleanup and maintenance operations on the database, removing orphaned records and optimizing storage. When drop_invalid is true (default), records that fail to decode (e.g. after a schema change) are deleted; set it to false to only log them.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param drop_invalid query bool false "Delete records that fail to decode instead of only logging (default true)"
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {boolean} bool
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
|
||||
// @Router /tidy [post]
|
||||
func (tc *TidyController) TidyDB(c *gin.Context) {
|
||||
happydns.ApiResponse(c, true, tc.tidyUpService.TidyAll())
|
||||
dropInvalid := true
|
||||
if v := c.Query("drop_invalid"); v != "" {
|
||||
parsed, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
happydns.ApiResponse(c, nil, fmt.Errorf("drop_invalid must be a boolean: %w", err))
|
||||
return
|
||||
}
|
||||
dropInvalid = parsed
|
||||
}
|
||||
|
||||
happydns.ApiResponse(c, true, tc.tidyUpService.TidyAll(dropInvalid))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -244,7 +244,7 @@ func (cc *CheckerController) TriggerCheck(c *gin.Context) {
|
|||
} else {
|
||||
go func() {
|
||||
if _, err := cc.engine.RunExecution(context.WithoutCancel(c.Request.Context()), exec, plan, req.Options); err != nil {
|
||||
log.Printf("async RunExecution error for checker %q execution %v: %v", cname, exec.Id, err)
|
||||
log.Printf("async RunExecution error for checker %q execution %s: %v", cname, exec.Id.String(), err)
|
||||
}
|
||||
}()
|
||||
c.JSON(http.StatusAccepted, exec)
|
||||
|
|
|
|||
514
internal/api/controller/notification.go
Normal file
514
internal/api/controller/notification.go
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// NotificationController handles notification-related API endpoints.
|
||||
type NotificationController struct {
|
||||
dispatcher *notifUC.Dispatcher
|
||||
channelStore notifUC.NotificationChannelStorage
|
||||
prefStore notifUC.NotificationPreferenceStorage
|
||||
recordStore notifUC.NotificationRecordStorage
|
||||
}
|
||||
|
||||
// NewNotificationController creates a new NotificationController.
|
||||
func NewNotificationController(
|
||||
dispatcher *notifUC.Dispatcher,
|
||||
channelStore notifUC.NotificationChannelStorage,
|
||||
prefStore notifUC.NotificationPreferenceStorage,
|
||||
recordStore notifUC.NotificationRecordStorage,
|
||||
) *NotificationController {
|
||||
return &NotificationController{
|
||||
dispatcher: dispatcher,
|
||||
channelStore: channelStore,
|
||||
prefStore: prefStore,
|
||||
recordStore: recordStore,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Channel CRUD ---
|
||||
|
||||
// ListChannels returns all notification channels for the authenticated user.
|
||||
//
|
||||
// @Summary List notification channels
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
// @Success 200 {array} happydns.NotificationChannel
|
||||
// @Router /notifications/channels [get]
|
||||
func (nc *NotificationController) ListChannels(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
channels, err := nc.channelStore.ListChannelsByUser(user.Id)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
if channels == nil {
|
||||
channels = []*happydns.NotificationChannel{}
|
||||
}
|
||||
c.JSON(http.StatusOK, channels)
|
||||
}
|
||||
|
||||
// CreateChannel creates a new notification channel.
|
||||
//
|
||||
// @Summary Create a notification channel
|
||||
// @Tags notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body happydns.NotificationChannel true "Channel configuration"
|
||||
// @Success 201 {object} happydns.NotificationChannel
|
||||
// @Router /notifications/channels [post]
|
||||
func (nc *NotificationController) CreateChannel(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
var ch happydns.NotificationChannel
|
||||
if err := c.ShouldBindJSON(&ch); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ch.UserId = user.Id
|
||||
|
||||
if err := nc.channelStore.CreateChannel(&ch); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, ch)
|
||||
}
|
||||
|
||||
// GetChannel returns a specific notification channel.
|
||||
//
|
||||
// @Summary Get a notification channel
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
// @Param channelId path string true "Channel ID"
|
||||
// @Success 200 {object} happydns.NotificationChannel
|
||||
// @Router /notifications/channels/{channelId} [get]
|
||||
func (nc *NotificationController) GetChannel(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
channelId, err := happydns.NewIdentifierFromString(c.Param("channelId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid channel ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := nc.channelStore.GetChannel(channelId)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Channel not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !ch.UserId.Equals(user.Id) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ch)
|
||||
}
|
||||
|
||||
// UpdateChannel updates a notification channel.
|
||||
//
|
||||
// @Summary Update a notification channel
|
||||
// @Tags notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param channelId path string true "Channel ID"
|
||||
// @Param body body happydns.NotificationChannel true "Channel configuration"
|
||||
// @Success 200 {object} happydns.NotificationChannel
|
||||
// @Router /notifications/channels/{channelId} [put]
|
||||
func (nc *NotificationController) UpdateChannel(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
channelId, err := happydns.NewIdentifierFromString(c.Param("channelId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid channel ID"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := nc.channelStore.GetChannel(channelId)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Channel not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !existing.UserId.Equals(user.Id) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
var ch happydns.NotificationChannel
|
||||
if err := c.ShouldBindJSON(&ch); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ch.Id = channelId
|
||||
ch.UserId = user.Id
|
||||
|
||||
if err := nc.channelStore.UpdateChannel(&ch); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ch)
|
||||
}
|
||||
|
||||
// DeleteChannel deletes a notification channel.
|
||||
//
|
||||
// @Summary Delete a notification channel
|
||||
// @Tags notifications
|
||||
// @Param channelId path string true "Channel ID"
|
||||
// @Success 204
|
||||
// @Router /notifications/channels/{channelId} [delete]
|
||||
func (nc *NotificationController) DeleteChannel(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
channelId, err := happydns.NewIdentifierFromString(c.Param("channelId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid channel ID"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := nc.channelStore.GetChannel(channelId)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Channel not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !existing.UserId.Equals(user.Id) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := nc.channelStore.DeleteChannel(channelId); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// TestChannel sends a test notification through a channel.
|
||||
//
|
||||
// @Summary Send a test notification
|
||||
// @Tags notifications
|
||||
// @Param channelId path string true "Channel ID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /notifications/channels/{channelId}/test [post]
|
||||
func (nc *NotificationController) TestChannel(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
channelId, err := happydns.NewIdentifierFromString(c.Param("channelId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid channel ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := nc.channelStore.GetChannel(channelId)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Channel not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !ch.UserId.Equals(user.Id) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := nc.dispatcher.SendTestNotification(ch, user); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
|
||||
}
|
||||
|
||||
// --- Preference CRUD ---
|
||||
|
||||
// ListPreferences returns all notification preferences for the authenticated user.
|
||||
//
|
||||
// @Summary List notification preferences
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
// @Success 200 {array} happydns.NotificationPreference
|
||||
// @Router /notifications/preferences [get]
|
||||
func (nc *NotificationController) ListPreferences(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
prefs, err := nc.prefStore.ListPreferencesByUser(user.Id)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
if prefs == nil {
|
||||
prefs = []*happydns.NotificationPreference{}
|
||||
}
|
||||
c.JSON(http.StatusOK, prefs)
|
||||
}
|
||||
|
||||
// CreatePreference creates a new notification preference.
|
||||
//
|
||||
// @Summary Create a notification preference
|
||||
// @Tags notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body happydns.NotificationPreference true "Preference configuration"
|
||||
// @Success 201 {object} happydns.NotificationPreference
|
||||
// @Router /notifications/preferences [post]
|
||||
func (nc *NotificationController) CreatePreference(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
var pref happydns.NotificationPreference
|
||||
if err := c.ShouldBindJSON(&pref); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
pref.UserId = user.Id
|
||||
|
||||
if err := nc.prefStore.CreatePreference(&pref); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, pref)
|
||||
}
|
||||
|
||||
// GetPreference returns a specific notification preference.
|
||||
//
|
||||
// @Summary Get a notification preference
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
// @Param prefId path string true "Preference ID"
|
||||
// @Success 200 {object} happydns.NotificationPreference
|
||||
// @Router /notifications/preferences/{prefId} [get]
|
||||
func (nc *NotificationController) GetPreference(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
prefId, err := happydns.NewIdentifierFromString(c.Param("prefId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid preference ID"})
|
||||
return
|
||||
}
|
||||
|
||||
pref, err := nc.prefStore.GetPreference(prefId)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Preference not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !pref.UserId.Equals(user.Id) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, pref)
|
||||
}
|
||||
|
||||
// UpdatePreference updates a notification preference.
|
||||
//
|
||||
// @Summary Update a notification preference
|
||||
// @Tags notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param prefId path string true "Preference ID"
|
||||
// @Param body body happydns.NotificationPreference true "Preference configuration"
|
||||
// @Success 200 {object} happydns.NotificationPreference
|
||||
// @Router /notifications/preferences/{prefId} [put]
|
||||
func (nc *NotificationController) UpdatePreference(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
prefId, err := happydns.NewIdentifierFromString(c.Param("prefId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid preference ID"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := nc.prefStore.GetPreference(prefId)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Preference not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !existing.UserId.Equals(user.Id) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
var pref happydns.NotificationPreference
|
||||
if err := c.ShouldBindJSON(&pref); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
pref.Id = prefId
|
||||
pref.UserId = user.Id
|
||||
|
||||
if err := nc.prefStore.UpdatePreference(&pref); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, pref)
|
||||
}
|
||||
|
||||
// DeletePreference deletes a notification preference.
|
||||
//
|
||||
// @Summary Delete a notification preference
|
||||
// @Tags notifications
|
||||
// @Param prefId path string true "Preference ID"
|
||||
// @Success 204
|
||||
// @Router /notifications/preferences/{prefId} [delete]
|
||||
func (nc *NotificationController) DeletePreference(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
prefId, err := happydns.NewIdentifierFromString(c.Param("prefId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid preference ID"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := nc.prefStore.GetPreference(prefId)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Preference not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !existing.UserId.Equals(user.Id) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := nc.prefStore.DeletePreference(prefId); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- History ---
|
||||
|
||||
// ListHistory returns recent notification records for the authenticated user.
|
||||
//
|
||||
// @Summary List notification history
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
// @Param limit query int false "Maximum number of records" default(50)
|
||||
// @Success 200 {array} happydns.NotificationRecord
|
||||
// @Router /notifications/history [get]
|
||||
func (nc *NotificationController) ListHistory(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
limit := 50
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
records, err := nc.recordStore.ListRecordsByUser(user.Id, limit)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
if records == nil {
|
||||
records = []*happydns.NotificationRecord{}
|
||||
}
|
||||
c.JSON(http.StatusOK, records)
|
||||
}
|
||||
|
||||
// --- Acknowledgement ---
|
||||
|
||||
// AcknowledgeIssue marks a checker issue as acknowledged.
|
||||
//
|
||||
// @Summary Acknowledge a checker issue
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param body body happydns.AcknowledgeRequest true "Acknowledgement"
|
||||
// @Success 200 {object} happydns.NotificationState
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/acknowledge [post]
|
||||
func (nc *NotificationController) AcknowledgeIssue(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
|
||||
var req happydns.AcknowledgeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// Body is optional for acknowledgement.
|
||||
req = happydns.AcknowledgeRequest{}
|
||||
}
|
||||
|
||||
if err := nc.dispatcher.AcknowledgeIssue(user.Id, checkerID, target, user.Email, req.Annotation); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
state, err := nc.dispatcher.GetState(user.Id, checkerID, target)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
||||
// ClearAcknowledgement removes an acknowledgement from a checker issue.
|
||||
//
|
||||
// @Summary Clear acknowledgement
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Success 200 {object} happydns.NotificationState
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/acknowledge [delete]
|
||||
func (nc *NotificationController) ClearAcknowledgement(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
|
||||
if err := nc.dispatcher.ClearAcknowledgement(user.Id, checkerID, target); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
state, err := nc.dispatcher.GetState(user.Id, checkerID, target)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ func SameUserHandler(c *gin.Context) {
|
|||
user := c.MustGet("user").(*happydns.User)
|
||||
|
||||
if !bytes.Equal(user.Id, myuser.Id) {
|
||||
log.Printf("%s: tries to do action as %s (logged %s)", c.ClientIP(), myuser.Id, user.Id)
|
||||
log.Printf("%s: tries to do action as %s (logged %s)", c.ClientIP(), myuser.Id.String(), user.Id.String())
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, happydns.ErrorResponse{Message: "Not authorized"})
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,8 @@ func DeclareCheckerRoutes(
|
|||
|
||||
// DeclareScopedCheckerRoutes registers checker routes scoped to a domain or service.
|
||||
// Called for both /api/domains/:domain/checkers and .../services/:serviceid/checkers.
|
||||
func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController) {
|
||||
// nc may be nil if the notification system is not configured.
|
||||
func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController, nc *controller.NotificationController) {
|
||||
checkers := scopedRouter.Group("/checkers")
|
||||
checkers.GET("", cc.ListAvailableChecks)
|
||||
checkers.GET("/metrics", cc.GetDomainMetrics)
|
||||
|
|
@ -113,4 +114,10 @@ func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.Ch
|
|||
// Results (under execution).
|
||||
executionID.GET("/results", cc.GetExecutionResults)
|
||||
executionID.GET("/results/:ruleName", cc.GetExecutionResult)
|
||||
|
||||
// Acknowledgement (requires notification system).
|
||||
if nc != nil {
|
||||
checkerID.POST("/acknowledge", nc.AcknowledgeIssue)
|
||||
checkerID.DELETE("/acknowledge", nc.ClearAcknowledgement)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ func DeclareDomainRoutes(
|
|||
cc *controller.CheckerController,
|
||||
checkStatusUC *checkerUC.CheckStatusUsecase,
|
||||
domainInfoUC happydns.DomainInfoUsecase,
|
||||
nc *controller.NotificationController,
|
||||
) {
|
||||
dc := controller.NewDomainController(
|
||||
domainUC,
|
||||
|
|
@ -69,7 +70,7 @@ func DeclareDomainRoutes(
|
|||
|
||||
// Mount domain-scoped checker routes.
|
||||
if cc != nil {
|
||||
DeclareScopedCheckerRoutes(apiDomainsRoutes, cc)
|
||||
DeclareScopedCheckerRoutes(apiDomainsRoutes, cc, nc)
|
||||
}
|
||||
|
||||
DeclareZoneRoutes(
|
||||
|
|
@ -80,5 +81,6 @@ func DeclareDomainRoutes(
|
|||
zoneServiceUC,
|
||||
serviceUC,
|
||||
cc,
|
||||
nc,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
69
internal/api/route/notification.go
Normal file
69
internal/api/route/notification.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
|
||||
)
|
||||
|
||||
// DeclareNotificationRoutes registers notification routes under /api/notifications.
|
||||
func DeclareNotificationRoutes(
|
||||
apiAuthRoutes *gin.RouterGroup,
|
||||
dispatcher *notifUC.Dispatcher,
|
||||
channelStore notifUC.NotificationChannelStorage,
|
||||
prefStore notifUC.NotificationPreferenceStorage,
|
||||
recordStore notifUC.NotificationRecordStorage,
|
||||
) *controller.NotificationController {
|
||||
nc := controller.NewNotificationController(dispatcher, channelStore, prefStore, recordStore)
|
||||
|
||||
notif := apiAuthRoutes.Group("/notifications")
|
||||
|
||||
// Channels
|
||||
channels := notif.Group("/channels")
|
||||
channels.GET("", nc.ListChannels)
|
||||
channels.POST("", nc.CreateChannel)
|
||||
|
||||
channelID := channels.Group("/:channelId")
|
||||
channelID.GET("", nc.GetChannel)
|
||||
channelID.PUT("", nc.UpdateChannel)
|
||||
channelID.DELETE("", nc.DeleteChannel)
|
||||
channelID.POST("/test", nc.TestChannel)
|
||||
|
||||
// Preferences
|
||||
prefs := notif.Group("/preferences")
|
||||
prefs.GET("", nc.ListPreferences)
|
||||
prefs.POST("", nc.CreatePreference)
|
||||
|
||||
prefID := prefs.Group("/:prefId")
|
||||
prefID.GET("", nc.GetPreference)
|
||||
prefID.PUT("", nc.UpdatePreference)
|
||||
prefID.DELETE("", nc.DeletePreference)
|
||||
|
||||
// History
|
||||
notif.GET("/history", nc.ListHistory)
|
||||
|
||||
return nc
|
||||
}
|
||||
|
||||
|
|
@ -32,6 +32,7 @@ import (
|
|||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -66,6 +67,11 @@ type Dependencies struct {
|
|||
PlannedProvider checkerUC.PlannedJobProvider
|
||||
BudgetChecker checkerUC.BudgetChecker
|
||||
CountManualTriggers bool
|
||||
|
||||
NotificationDispatcher *notifUC.Dispatcher
|
||||
NotificationChannels notifUC.NotificationChannelStorage
|
||||
NotificationPrefs notifUC.NotificationPreferenceStorage
|
||||
NotificationRecords notifUC.NotificationRecordStorage
|
||||
}
|
||||
|
||||
// @title happyDomain API
|
||||
|
|
@ -152,6 +158,18 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
|
|||
)
|
||||
}
|
||||
|
||||
// Initialize notification controller if dispatcher is available.
|
||||
var nc *controller.NotificationController
|
||||
if dep.NotificationDispatcher != nil {
|
||||
nc = DeclareNotificationRoutes(
|
||||
apiAuthRoutes,
|
||||
dep.NotificationDispatcher,
|
||||
dep.NotificationChannels,
|
||||
dep.NotificationPrefs,
|
||||
dep.NotificationRecords,
|
||||
)
|
||||
}
|
||||
|
||||
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
|
||||
DeclareDomainRoutes(
|
||||
apiAuthRoutes,
|
||||
|
|
@ -166,6 +184,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
|
|||
cc,
|
||||
dep.CheckStatusUC,
|
||||
dep.DomainInfo,
|
||||
nc,
|
||||
)
|
||||
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
|
||||
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ func DeclareZoneServiceRoutes(
|
|||
serviceUC happydns.ServiceUsecase,
|
||||
zoneUC happydns.ZoneUsecase,
|
||||
cc *controller.CheckerController,
|
||||
nc *controller.NotificationController,
|
||||
) {
|
||||
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC)
|
||||
|
||||
|
|
@ -51,6 +52,6 @@ func DeclareZoneServiceRoutes(
|
|||
|
||||
// Mount service-scoped checker routes.
|
||||
if cc != nil {
|
||||
DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc)
|
||||
DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc, nc)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ func DeclareZoneRoutes(
|
|||
zoneServiceUC happydns.ZoneServiceUsecase,
|
||||
serviceUC happydns.ServiceUsecase,
|
||||
cc *controller.CheckerController,
|
||||
nc *controller.NotificationController,
|
||||
) {
|
||||
var checkStatusUC *checkerUC.CheckStatusUsecase
|
||||
if cc != nil {
|
||||
|
|
@ -74,6 +75,7 @@ func DeclareZoneRoutes(
|
|||
serviceUC,
|
||||
zoneUC,
|
||||
cc,
|
||||
nc,
|
||||
)
|
||||
|
||||
apiZonesRoutes.POST("/records", zc.AddRecords)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import (
|
|||
"git.happydns.org/happyDomain/internal/mailer"
|
||||
"git.happydns.org/happyDomain/internal/metrics"
|
||||
"git.happydns.org/happyDomain/internal/newsletter"
|
||||
notifPkg "git.happydns.org/happyDomain/internal/notification"
|
||||
"git.happydns.org/happyDomain/internal/session"
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/internal/usecase"
|
||||
|
|
@ -42,6 +43,7 @@ import (
|
|||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
|
||||
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
|
||||
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
|
||||
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
|
||||
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
|
||||
|
|
@ -81,6 +83,8 @@ type Usecases struct {
|
|||
checkerScheduler *checkerUC.Scheduler
|
||||
checkerJanitor *checkerUC.Janitor
|
||||
checkerUserGater *checkerUC.UserGater
|
||||
|
||||
notificationDispatcher *notifUC.Dispatcher
|
||||
}
|
||||
|
||||
type App struct {
|
||||
|
|
@ -316,6 +320,29 @@ func (app *App) initUsecases() {
|
|||
// Wire scheduler notifications for incremental queue updates.
|
||||
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
|
||||
// Notification system.
|
||||
senders := map[happydns.NotificationChannelType]notifPkg.ChannelSender{
|
||||
happydns.NotificationChannelEmail: notifPkg.NewEmailSender(app.mailer),
|
||||
happydns.NotificationChannelWebhook: notifPkg.NewWebhookSender(),
|
||||
happydns.NotificationChannelUnifiedPush: notifPkg.NewUnifiedPushSender(),
|
||||
}
|
||||
|
||||
app.usecases.notificationDispatcher = notifUC.NewDispatcher(
|
||||
app.store, // NotificationChannelStorage
|
||||
app.store, // NotificationPreferenceStorage
|
||||
app.store, // NotificationStateStorage
|
||||
app.store, // NotificationRecordStorage
|
||||
app.store, // UserGetter
|
||||
app.store, // DomainGetter
|
||||
senders,
|
||||
app.cfg.ExternalURL.String(),
|
||||
)
|
||||
|
||||
// Wire execution callback for notifications.
|
||||
if setter, ok := app.usecases.checkerEngine.(checkerUC.ExecutionCallbackSetter); ok {
|
||||
setter.SetExecutionCallback(app.usecases.notificationDispatcher.OnExecutionComplete)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) setupRouter() {
|
||||
|
|
@ -370,6 +397,11 @@ func (app *App) setupRouter() {
|
|||
PlannedProvider: app.usecases.checkerScheduler,
|
||||
BudgetChecker: app.usecases.checkerUserGater,
|
||||
CountManualTriggers: app.cfg.CheckerCountManualTriggers,
|
||||
|
||||
NotificationDispatcher: app.usecases.notificationDispatcher,
|
||||
NotificationChannels: app.store,
|
||||
NotificationPrefs: app.store,
|
||||
NotificationRecords: app.store,
|
||||
},
|
||||
)
|
||||
web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier)
|
||||
|
|
|
|||
|
|
@ -127,6 +127,11 @@ func (s *instrumentedStorage) CreateAuthUser(user *happydns.UserAuth) (err error
|
|||
return s.inner.CreateAuthUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateChannel(ch *happydns.NotificationChannel) (err error) {
|
||||
defer observe("create", "notification_channel")(&err)
|
||||
return s.inner.CreateChannel(ch)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateCheckPlan(plan *happydns.CheckPlan) (err error) {
|
||||
defer observe("create", "check_plan")(&err)
|
||||
return s.inner.CreateCheckPlan(plan)
|
||||
|
|
@ -157,11 +162,21 @@ func (s *instrumentedStorage) CreateOrUpdateUser(user *happydns.User) (err error
|
|||
return s.inner.CreateOrUpdateUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreatePreference(pref *happydns.NotificationPreference) (err error) {
|
||||
defer observe("create", "notification_preference")(&err)
|
||||
return s.inner.CreatePreference(pref)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateProvider(prvd *happydns.Provider) (err error) {
|
||||
defer observe("create", "provider")(&err)
|
||||
return s.inner.CreateProvider(prvd)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateRecord(rec *happydns.NotificationRecord) (err error) {
|
||||
defer observe("create", "notification_record")(&err)
|
||||
return s.inner.CreateRecord(rec)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) (err error) {
|
||||
defer observe("create", "observation_snapshot")(&err)
|
||||
return s.inner.CreateSnapshot(snap)
|
||||
|
|
@ -177,6 +192,11 @@ func (s *instrumentedStorage) DeleteAuthUser(user *happydns.UserAuth) (err error
|
|||
return s.inner.DeleteAuthUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteChannel(channelId happydns.Identifier) (err error) {
|
||||
defer observe("delete", "notification_channel")(&err)
|
||||
return s.inner.DeleteChannel(channelId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteCheckPlan(planID happydns.Identifier) (err error) {
|
||||
defer observe("delete", "check_plan")(&err)
|
||||
return s.inner.DeleteCheckPlan(planID)
|
||||
|
|
@ -217,11 +237,21 @@ func (s *instrumentedStorage) DeleteExecutionsByChecker(checkerID string, target
|
|||
return s.inner.DeleteExecutionsByChecker(checkerID, target)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeletePreference(prefId happydns.Identifier) (err error) {
|
||||
defer observe("delete", "notification_preference")(&err)
|
||||
return s.inner.DeletePreference(prefId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteProvider(prvdid happydns.Identifier) (err error) {
|
||||
defer observe("delete", "provider")(&err)
|
||||
return s.inner.DeleteProvider(prvdid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteRecordsOlderThan(before time.Time) (err error) {
|
||||
defer observe("delete", "notification_record")(&err)
|
||||
return s.inner.DeleteRecordsOlderThan(before)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteSession(sessionid string) (err error) {
|
||||
defer observe("delete", "session")(&err)
|
||||
return s.inner.DeleteSession(sessionid)
|
||||
|
|
@ -232,6 +262,11 @@ func (s *instrumentedStorage) DeleteSnapshot(snapID happydns.Identifier) (err er
|
|||
return s.inner.DeleteSnapshot(snapID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (err error) {
|
||||
defer observe("delete", "notification_state")(&err)
|
||||
return s.inner.DeleteState(checkerID, target, userId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteUser(userid happydns.Identifier) (err error) {
|
||||
defer observe("delete", "user")(&err)
|
||||
return s.inner.DeleteUser(userid)
|
||||
|
|
@ -257,6 +292,11 @@ func (s *instrumentedStorage) GetCachedObservation(target happydns.CheckTarget,
|
|||
return s.inner.GetCachedObservation(target, key)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetChannel(channelId happydns.Identifier) (ret *happydns.NotificationChannel, err error) {
|
||||
defer observe("get", "notification_channel")(&err)
|
||||
return s.inner.GetChannel(channelId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetCheckPlan(planID happydns.Identifier) (ret *happydns.CheckPlan, err error) {
|
||||
defer observe("get", "check_plan")(&err)
|
||||
return s.inner.GetCheckPlan(planID)
|
||||
|
|
@ -297,6 +337,11 @@ func (s *instrumentedStorage) GetLatestEvaluation(planID happydns.Identifier) (r
|
|||
return s.inner.GetLatestEvaluation(planID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetPreference(prefId happydns.Identifier) (ret *happydns.NotificationPreference, err error) {
|
||||
defer observe("get", "notification_preference")(&err)
|
||||
return s.inner.GetPreference(prefId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetProvider(prvdid happydns.Identifier) (ret *happydns.ProviderMessage, err error) {
|
||||
defer observe("get", "provider")(&err)
|
||||
return s.inner.GetProvider(prvdid)
|
||||
|
|
@ -312,6 +357,11 @@ func (s *instrumentedStorage) GetSnapshot(snapID happydns.Identifier) (ret *happ
|
|||
return s.inner.GetSnapshot(snapID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (ret *happydns.NotificationState, err error) {
|
||||
defer observe("get", "notification_state")(&err)
|
||||
return s.inner.GetState(checkerID, target, userId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetUser(userid happydns.Identifier) (ret *happydns.User, err error) {
|
||||
defer observe("get", "user")(&err)
|
||||
return s.inner.GetUser(userid)
|
||||
|
|
@ -412,6 +462,11 @@ func (s *instrumentedStorage) ListAuthUserSessions(user *happydns.UserAuth) (ret
|
|||
return s.inner.ListAuthUserSessions(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListChannelsByUser(userId happydns.Identifier) (ret []*happydns.NotificationChannel, err error) {
|
||||
defer observe("list", "notification_channel")(&err)
|
||||
return s.inner.ListChannelsByUser(userId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListCheckPlansByChecker(checkerID string) (ret []*happydns.CheckPlan, err error) {
|
||||
defer observe("list", "check_plan")(&err)
|
||||
return s.inner.ListCheckPlansByChecker(checkerID)
|
||||
|
|
@ -452,14 +507,14 @@ func (s *instrumentedStorage) ListEvaluationsByPlan(planID happydns.Identifier)
|
|||
return s.inner.ListEvaluationsByPlan(planID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) (ret []*happydns.Execution, err error) {
|
||||
func (s *instrumentedStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int, filter func(*happydns.Execution) bool) (ret []*happydns.Execution, err error) {
|
||||
defer observe("list", "execution")(&err)
|
||||
return s.inner.ListExecutionsByChecker(checkerID, target, limit)
|
||||
return s.inner.ListExecutionsByChecker(checkerID, target, limit, filter)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListExecutionsByDomain(domainId happydns.Identifier, limit int) (ret []*happydns.Execution, err error) {
|
||||
func (s *instrumentedStorage) ListExecutionsByDomain(domainId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) (ret []*happydns.Execution, err error) {
|
||||
defer observe("list", "execution")(&err)
|
||||
return s.inner.ListExecutionsByDomain(domainId, limit)
|
||||
return s.inner.ListExecutionsByDomain(domainId, limit, filter)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListExecutionsByPlan(planID happydns.Identifier) (ret []*happydns.Execution, err error) {
|
||||
|
|
@ -467,9 +522,14 @@ func (s *instrumentedStorage) ListExecutionsByPlan(planID happydns.Identifier) (
|
|||
return s.inner.ListExecutionsByPlan(planID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListExecutionsByUser(userId happydns.Identifier, limit int) (ret []*happydns.Execution, err error) {
|
||||
func (s *instrumentedStorage) ListExecutionsByUser(userId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) (ret []*happydns.Execution, err error) {
|
||||
defer observe("list", "execution")(&err)
|
||||
return s.inner.ListExecutionsByUser(userId, limit)
|
||||
return s.inner.ListExecutionsByUser(userId, limit, filter)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListPreferencesByUser(userId happydns.Identifier) (ret []*happydns.NotificationPreference, err error) {
|
||||
defer observe("list", "notification_preference")(&err)
|
||||
return s.inner.ListPreferencesByUser(userId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListProviders(user *happydns.User) (ret happydns.ProviderMessages, err error) {
|
||||
|
|
@ -477,6 +537,16 @@ func (s *instrumentedStorage) ListProviders(user *happydns.User) (ret happydns.P
|
|||
return s.inner.ListProviders(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListRecordsByUser(userId happydns.Identifier, limit int) (ret []*happydns.NotificationRecord, err error) {
|
||||
defer observe("list", "notification_record")(&err)
|
||||
return s.inner.ListRecordsByUser(userId, limit)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListStatesByUser(userId happydns.Identifier) (ret []*happydns.NotificationState, err error) {
|
||||
defer observe("list", "notification_state")(&err)
|
||||
return s.inner.ListStatesByUser(userId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListUserSessions(userid happydns.Identifier) (ret []*happydns.Session, err error) {
|
||||
defer observe("list", "session")(&err)
|
||||
return s.inner.ListUserSessions(userid)
|
||||
|
|
@ -489,6 +559,26 @@ func (s *instrumentedStorage) PutCachedObservation(target happydns.CheckTarget,
|
|||
return s.inner.PutCachedObservation(target, key, entry)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) RestoreCheckPlan(plan *happydns.CheckPlan) (err error) {
|
||||
defer observe("restore", "check_plan")(&err)
|
||||
return s.inner.RestoreCheckPlan(plan)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) RestoreEvaluation(eval *happydns.CheckEvaluation) (err error) {
|
||||
defer observe("restore", "check_evaluation")(&err)
|
||||
return s.inner.RestoreEvaluation(eval)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) RestoreExecution(exec *happydns.Execution) (err error) {
|
||||
defer observe("restore", "execution")(&err)
|
||||
return s.inner.RestoreExecution(exec)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) PutState(state *happydns.NotificationState) (err error) {
|
||||
defer observe("put", "notification_state")(&err)
|
||||
return s.inner.PutState(state)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) SchemaVersion() int { return s.inner.SchemaVersion() }
|
||||
|
||||
func (s *instrumentedStorage) SetLastSchedulerRun(t time.Time) (err error) {
|
||||
|
|
@ -516,6 +606,11 @@ func (s *instrumentedStorage) UpdateAuthUser(user *happydns.UserAuth) (err error
|
|||
return s.inner.UpdateAuthUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateChannel(ch *happydns.NotificationChannel) (err error) {
|
||||
defer observe("update", "notification_channel")(&err)
|
||||
return s.inner.UpdateChannel(ch)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateCheckPlan(plan *happydns.CheckPlan) (err error) {
|
||||
defer observe("update", "check_plan")(&err)
|
||||
return s.inner.UpdateCheckPlan(plan)
|
||||
|
|
@ -541,6 +636,11 @@ func (s *instrumentedStorage) UpdateExecution(exec *happydns.Execution) (err err
|
|||
return s.inner.UpdateExecution(exec)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdatePreference(pref *happydns.NotificationPreference) (err error) {
|
||||
defer observe("update", "notification_preference")(&err)
|
||||
return s.inner.UpdatePreference(pref)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateProvider(prvd *happydns.Provider) (err error) {
|
||||
defer observe("update", "provider")(&err)
|
||||
return s.inner.UpdateProvider(prvd)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ func declareFlags(o *happydns.Options) {
|
|||
flag.IntVar(&o.CheckerInactivityPauseDays, "checker-inactivity-pause-days", 90, "Pause checks for users that haven't logged in for this many days (0 disables, overridable per user)")
|
||||
flag.IntVar(&o.CheckerMaxChecksPerDay, "checker-max-checks-per-day", 0, "System-wide default cap on scheduled checker executions per user per day; counter resets at 00:00 UTC and is in-memory only (0 = unlimited, overridable per user; see docs/checker-quotas.md)")
|
||||
flag.BoolVar(&o.CheckerCountManualTriggers, "checker-count-manual-triggers", true, "When true (default), manual checker triggers count against UserQuota.MaxChecksPerDay and are refused with HTTP 429 once exhausted; when false, manual triggers bypass the quota entirely (see docs/checker-quotas.md)")
|
||||
flag.IntVar(&o.NotificationRetentionDays, "notification-retention-days", 90, "How many days of notification history records to keep")
|
||||
|
||||
flag.Var(&URL{&o.ListmonkURL}, "newsletter-server-url", "Base URL of the listmonk newsletter server")
|
||||
flag.IntVar(&o.ListmonkID, "newsletter-id", 1, "Listmonk identifier of the list receiving the new user")
|
||||
|
|
|
|||
80
internal/notification/email_sender.go
Normal file
80
internal/notification/email_sender.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// EmailSender sends notifications via email using the existing Mailer.
|
||||
type EmailSender struct {
|
||||
mailer happydns.Mailer
|
||||
}
|
||||
|
||||
// NewEmailSender creates a new EmailSender.
|
||||
func NewEmailSender(mailer happydns.Mailer) *EmailSender {
|
||||
return &EmailSender{mailer: mailer}
|
||||
}
|
||||
|
||||
func (s *EmailSender) Send(channel *happydns.NotificationChannel, payload *NotificationPayload) error {
|
||||
addr := channel.Config.EmailAddress
|
||||
if addr == "" && payload.User != nil {
|
||||
addr = payload.User.Email
|
||||
}
|
||||
if addr == "" {
|
||||
return fmt.Errorf("no email address configured for channel %s", channel.Id)
|
||||
}
|
||||
|
||||
to := &mail.Address{Address: addr}
|
||||
if payload.User != nil {
|
||||
to.Name = payload.User.Email
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("[happyDomain] %s: %s", payload.DomainName, payload.NewStatus)
|
||||
|
||||
var body strings.Builder
|
||||
fmt.Fprintf(&body, "## Status Change: %s -> %s\n\n", payload.OldStatus, payload.NewStatus)
|
||||
fmt.Fprintf(&body, "**Domain:** %s\n\n", payload.DomainName)
|
||||
fmt.Fprintf(&body, "**Checker:** %s\n\n", payload.CheckerID)
|
||||
|
||||
if len(payload.States) > 0 {
|
||||
body.WriteString("### Rule Results\n\n")
|
||||
for _, state := range payload.States {
|
||||
fmt.Fprintf(&body, "- **%s** (%s): %s\n", state.Code, state.Status, state.Message)
|
||||
}
|
||||
body.WriteString("\n")
|
||||
}
|
||||
|
||||
if payload.Annotation != "" {
|
||||
fmt.Fprintf(&body, "**Note:** %s\n\n", payload.Annotation)
|
||||
}
|
||||
|
||||
if payload.BaseURL != "" {
|
||||
fmt.Fprintf(&body, "[View in happyDomain](%s)\n", payload.BaseURL)
|
||||
}
|
||||
|
||||
return s.mailer.SendMail(to, subject, body.String())
|
||||
}
|
||||
44
internal/notification/sender.go
Normal file
44
internal/notification/sender.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// NotificationPayload holds the data passed to channel senders.
|
||||
type NotificationPayload struct {
|
||||
User *happydns.User
|
||||
CheckerID string
|
||||
Target happydns.CheckTarget
|
||||
DomainName string
|
||||
OldStatus happydns.Status
|
||||
NewStatus happydns.Status
|
||||
States []happydns.CheckState
|
||||
Annotation string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// ChannelSender sends a notification through a specific channel type.
|
||||
type ChannelSender interface {
|
||||
Send(channel *happydns.NotificationChannel, payload *NotificationPayload) error
|
||||
}
|
||||
90
internal/notification/unifiedpush_sender.go
Normal file
90
internal/notification/unifiedpush_sender.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// UnifiedPushSender sends notifications via the UnifiedPush protocol.
|
||||
type UnifiedPushSender struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewUnifiedPushSender creates a new UnifiedPushSender.
|
||||
func NewUnifiedPushSender() *UnifiedPushSender {
|
||||
return &UnifiedPushSender{
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UnifiedPushSender) Send(channel *happydns.NotificationChannel, payload *NotificationPayload) error {
|
||||
if channel.Config.UnifiedPushEndpoint == "" {
|
||||
return fmt.Errorf("no UnifiedPush endpoint configured for channel %s", channel.Id)
|
||||
}
|
||||
|
||||
msg := WebhookPayload{
|
||||
Event: "status_change",
|
||||
Checker: payload.CheckerID,
|
||||
Domain: payload.DomainName,
|
||||
Target: payload.Target,
|
||||
OldStatus: payload.OldStatus,
|
||||
NewStatus: payload.NewStatus,
|
||||
States: payload.States,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if payload.BaseURL != "" {
|
||||
msg.DashboardURL = payload.BaseURL
|
||||
}
|
||||
|
||||
body, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling UnifiedPush payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, channel.Config.UnifiedPushEndpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating UnifiedPush request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending UnifiedPush notification: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("UnifiedPush endpoint returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
118
internal/notification/webhook_sender.go
Normal file
118
internal/notification/webhook_sender.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// WebhookPayload is the JSON body sent to webhook endpoints.
|
||||
type WebhookPayload struct {
|
||||
Event string `json:"event"`
|
||||
Checker string `json:"checker"`
|
||||
Domain string `json:"domain"`
|
||||
Target happydns.CheckTarget `json:"target"`
|
||||
OldStatus happydns.Status `json:"oldStatus"`
|
||||
NewStatus happydns.Status `json:"newStatus"`
|
||||
States []happydns.CheckState `json:"states,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
DashboardURL string `json:"dashboardUrl,omitempty"`
|
||||
}
|
||||
|
||||
// WebhookSender sends notifications via HTTP POST to a configured URL.
|
||||
type WebhookSender struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewWebhookSender creates a new WebhookSender.
|
||||
func NewWebhookSender() *WebhookSender {
|
||||
return &WebhookSender{
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebhookSender) Send(channel *happydns.NotificationChannel, payload *NotificationPayload) error {
|
||||
if channel.Config.WebhookURL == "" {
|
||||
return fmt.Errorf("no webhook URL configured for channel %s", channel.Id)
|
||||
}
|
||||
|
||||
whPayload := WebhookPayload{
|
||||
Event: "status_change",
|
||||
Checker: payload.CheckerID,
|
||||
Domain: payload.DomainName,
|
||||
Target: payload.Target,
|
||||
OldStatus: payload.OldStatus,
|
||||
NewStatus: payload.NewStatus,
|
||||
States: payload.States,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if payload.BaseURL != "" {
|
||||
whPayload.DashboardURL = payload.BaseURL
|
||||
}
|
||||
|
||||
body, err := json.Marshal(whPayload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling webhook payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, channel.Config.WebhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating webhook request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "happyDomain-Notification/1.0")
|
||||
|
||||
for k, v := range channel.Config.WebhookHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
if channel.Config.WebhookSecret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(channel.Config.WebhookSecret))
|
||||
mac.Write(body)
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
req.Header.Set("X-Happydomain-Signature", "sha256="+sig)
|
||||
}
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending webhook: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import (
|
|||
"git.happydns.org/happyDomain/internal/usecase/domain"
|
||||
"git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||
"git.happydns.org/happyDomain/internal/usecase/insight"
|
||||
"git.happydns.org/happyDomain/internal/usecase/notification"
|
||||
"git.happydns.org/happyDomain/internal/usecase/provider"
|
||||
"git.happydns.org/happyDomain/internal/usecase/session"
|
||||
"git.happydns.org/happyDomain/internal/usecase/user"
|
||||
|
|
@ -51,6 +52,10 @@ type Storage interface {
|
|||
domain.DomainStorage
|
||||
domainlog.DomainLogStorage
|
||||
insight.InsightStorage
|
||||
notification.NotificationChannelStorage
|
||||
notification.NotificationPreferenceStorage
|
||||
notification.NotificationStateStorage
|
||||
notification.NotificationRecordStorage
|
||||
provider.ProviderStorage
|
||||
session.SessionStorage
|
||||
user.UserStorage
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ func (s *KVStorage) ListEvaluationsByChecker(checkerID string, target happydns.C
|
|||
s.GetEvaluation,
|
||||
func(a, b *happydns.CheckEvaluation) bool { return a.EvaluatedAt.After(b.EvaluatedAt) },
|
||||
limit,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +106,24 @@ func (s *KVStorage) CreateEvaluation(eval *happydns.CheckEvaluation) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// RestoreEvaluation writes an evaluation at its existing Id and rebuilds
|
||||
// its secondary indexes. Used by the backup restore path.
|
||||
func (s *KVStorage) RestoreEvaluation(eval *happydns.CheckEvaluation) error {
|
||||
if err := s.db.Put(fmt.Sprintf("chckeval|%s", eval.Id.String()), eval); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if eval.PlanID != nil {
|
||||
indexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String())
|
||||
if err := s.db.Put(indexKey, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
checkerIndexKey := fmt.Sprintf("chckeval-chkr|%s|%s|%s", eval.CheckerID, eval.Target.String(), eval.Id.String())
|
||||
return s.db.Put(checkerIndexKey, true)
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteEvaluation(evalID happydns.Identifier) error {
|
||||
// Load first to find plan ID for index cleanup.
|
||||
eval, err := s.GetEvaluation(evalID)
|
||||
|
|
|
|||
|
|
@ -138,6 +138,16 @@ func (s *KVStorage) putCheckPlanIndexes(plan *happydns.CheckPlan) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// RestoreCheckPlan writes a plan at its existing Id and (re)builds its
|
||||
// secondary indexes. Used by the backup restore path, which must preserve
|
||||
// the original identifier instead of generating a new one.
|
||||
func (s *KVStorage) RestoreCheckPlan(plan *happydns.CheckPlan) error {
|
||||
if err := s.db.Put(fmt.Sprintf("chckpln|%s", plan.Id.String()), plan); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.putCheckPlanIndexes(plan)
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteCheckPlan(planID happydns.Identifier) error {
|
||||
plan, err := s.GetCheckPlan(planID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -43,27 +43,28 @@ func (s *KVStorage) ListExecutionsByPlan(planID happydns.Identifier) ([]*happydn
|
|||
}
|
||||
|
||||
// listRecentExecutions scans a prefix, decodes executions, sorts by most
|
||||
// recent first, and applies an optional limit.
|
||||
func (s *KVStorage) listRecentExecutions(prefix string, limit int) ([]*happydns.Execution, error) {
|
||||
// recent first, applies an optional filter predicate, and then applies a limit.
|
||||
func (s *KVStorage) listRecentExecutions(prefix string, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
|
||||
return listByIndexSorted(
|
||||
s,
|
||||
prefix,
|
||||
s.GetExecution,
|
||||
func(a, b *happydns.Execution) bool { return a.StartedAt.After(b.StartedAt) },
|
||||
limit,
|
||||
filter,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) {
|
||||
return s.listRecentExecutions(fmt.Sprintf("chckexec-chkr|%s|%s|", checkerID, target.String()), limit)
|
||||
func (s *KVStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
|
||||
return s.listRecentExecutions(fmt.Sprintf("chckexec-chkr|%s|%s|", checkerID, target.String()), limit, filter)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListExecutionsByUser(userId happydns.Identifier, limit int) ([]*happydns.Execution, error) {
|
||||
return s.listRecentExecutions(fmt.Sprintf("chckexec-user|%s|", userId.String()), limit)
|
||||
func (s *KVStorage) ListExecutionsByUser(userId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
|
||||
return s.listRecentExecutions(fmt.Sprintf("chckexec-user|%s|", userId.String()), limit, filter)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListExecutionsByDomain(domainId happydns.Identifier, limit int) ([]*happydns.Execution, error) {
|
||||
return s.listRecentExecutions(fmt.Sprintf("chckexec-domain|%s|", domainId.String()), limit)
|
||||
func (s *KVStorage) ListExecutionsByDomain(domainId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
|
||||
return s.listRecentExecutions(fmt.Sprintf("chckexec-domain|%s|", domainId.String()), limit, filter)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListAllExecutions() (happydns.Iterator[happydns.Execution], error) {
|
||||
|
|
@ -122,6 +123,40 @@ func (s *KVStorage) CreateExecution(exec *happydns.Execution) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// RestoreExecution writes an execution at its existing Id and rebuilds
|
||||
// its secondary indexes. Used by the backup restore path.
|
||||
func (s *KVStorage) RestoreExecution(exec *happydns.Execution) error {
|
||||
if err := s.db.Put(fmt.Sprintf("chckexec|%s", exec.Id.String()), exec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exec.PlanID != nil {
|
||||
indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String())
|
||||
if err := s.db.Put(indexKey, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
checkerIndexKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), exec.Id.String())
|
||||
if err := s.db.Put(checkerIndexKey, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exec.Target.UserId != "" {
|
||||
if err := s.db.Put(executionUserIndexKey(exec.Target.UserId, exec.Id.String()), true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if exec.Target.DomainId != "" {
|
||||
if err := s.db.Put(executionDomainIndexKey(exec.Target.DomainId, exec.Id.String()), true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) UpdateExecution(exec *happydns.Execution) error {
|
||||
// Load the old record so we can detect changed index keys.
|
||||
old, err := s.GetExecution(exec.Id)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ func (it *KVIterator[T]) NextWithError() bool {
|
|||
}
|
||||
return true
|
||||
}
|
||||
it.err = nil
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
|
|||
92
internal/storage/kvtpl/notification_channel.go
Normal file
92
internal/storage/kvtpl/notification_channel.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func (s *KVStorage) ListChannelsByUser(userId happydns.Identifier) ([]*happydns.NotificationChannel, error) {
|
||||
prefix := fmt.Sprintf("notifch-user|%s|", userId.String())
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var channels []*happydns.NotificationChannel
|
||||
for iter.Next() {
|
||||
var ch happydns.NotificationChannel
|
||||
if err := s.db.DecodeData(iter.Value(), &ch); err != nil {
|
||||
continue
|
||||
}
|
||||
channels = append(channels, &ch)
|
||||
}
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetChannel(channelId happydns.Identifier) (*happydns.NotificationChannel, error) {
|
||||
ch := &happydns.NotificationChannel{}
|
||||
err := s.db.Get(fmt.Sprintf("notifch|%s", channelId.String()), ch)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, happydns.ErrNotificationChannelNotFound
|
||||
}
|
||||
return ch, err
|
||||
}
|
||||
|
||||
func (s *KVStorage) CreateChannel(ch *happydns.NotificationChannel) error {
|
||||
key, id, err := s.db.FindIdentifierKey("notifch|")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch.Id = id
|
||||
|
||||
if err := s.db.Put(key, ch); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexKey := fmt.Sprintf("notifch-user|%s|%s", ch.UserId.String(), ch.Id.String())
|
||||
return s.db.Put(indexKey, ch)
|
||||
}
|
||||
|
||||
func (s *KVStorage) UpdateChannel(ch *happydns.NotificationChannel) error {
|
||||
if err := s.db.Put(fmt.Sprintf("notifch|%s", ch.Id.String()), ch); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexKey := fmt.Sprintf("notifch-user|%s|%s", ch.UserId.String(), ch.Id.String())
|
||||
return s.db.Put(indexKey, ch)
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteChannel(channelId happydns.Identifier) error {
|
||||
ch, err := s.GetChannel(channelId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexKey := fmt.Sprintf("notifch-user|%s|%s", ch.UserId.String(), channelId.String())
|
||||
if err := s.db.Delete(indexKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Delete(fmt.Sprintf("notifch|%s", channelId.String()))
|
||||
}
|
||||
92
internal/storage/kvtpl/notification_preference.go
Normal file
92
internal/storage/kvtpl/notification_preference.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func (s *KVStorage) ListPreferencesByUser(userId happydns.Identifier) ([]*happydns.NotificationPreference, error) {
|
||||
prefix := fmt.Sprintf("notifpref-user|%s|", userId.String())
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var prefs []*happydns.NotificationPreference
|
||||
for iter.Next() {
|
||||
var pref happydns.NotificationPreference
|
||||
if err := s.db.DecodeData(iter.Value(), &pref); err != nil {
|
||||
continue
|
||||
}
|
||||
prefs = append(prefs, &pref)
|
||||
}
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetPreference(prefId happydns.Identifier) (*happydns.NotificationPreference, error) {
|
||||
pref := &happydns.NotificationPreference{}
|
||||
err := s.db.Get(fmt.Sprintf("notifpref|%s", prefId.String()), pref)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, happydns.ErrNotificationPreferenceNotFound
|
||||
}
|
||||
return pref, err
|
||||
}
|
||||
|
||||
func (s *KVStorage) CreatePreference(pref *happydns.NotificationPreference) error {
|
||||
key, id, err := s.db.FindIdentifierKey("notifpref|")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pref.Id = id
|
||||
|
||||
if err := s.db.Put(key, pref); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexKey := fmt.Sprintf("notifpref-user|%s|%s", pref.UserId.String(), pref.Id.String())
|
||||
return s.db.Put(indexKey, pref)
|
||||
}
|
||||
|
||||
func (s *KVStorage) UpdatePreference(pref *happydns.NotificationPreference) error {
|
||||
if err := s.db.Put(fmt.Sprintf("notifpref|%s", pref.Id.String()), pref); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexKey := fmt.Sprintf("notifpref-user|%s|%s", pref.UserId.String(), pref.Id.String())
|
||||
return s.db.Put(indexKey, pref)
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeletePreference(prefId happydns.Identifier) error {
|
||||
pref, err := s.GetPreference(prefId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexKey := fmt.Sprintf("notifpref-user|%s|%s", pref.UserId.String(), prefId.String())
|
||||
if err := s.db.Delete(indexKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Delete(fmt.Sprintf("notifpref|%s", prefId.String()))
|
||||
}
|
||||
87
internal/storage/kvtpl/notification_record.go
Normal file
87
internal/storage/kvtpl/notification_record.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func (s *KVStorage) CreateRecord(rec *happydns.NotificationRecord) error {
|
||||
key, id, err := s.db.FindIdentifierKey("notifrec|")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rec.Id = id
|
||||
|
||||
if err := s.db.Put(key, rec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexKey := fmt.Sprintf("notifrec-user|%s|%s", rec.UserId.String(), rec.Id.String())
|
||||
return s.db.Put(indexKey, rec)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListRecordsByUser(userId happydns.Identifier, limit int) ([]*happydns.NotificationRecord, error) {
|
||||
prefix := fmt.Sprintf("notifrec-user|%s|", userId.String())
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var records []*happydns.NotificationRecord
|
||||
for iter.Next() {
|
||||
var rec happydns.NotificationRecord
|
||||
if err := s.db.DecodeData(iter.Value(), &rec); err != nil {
|
||||
continue
|
||||
}
|
||||
records = append(records, &rec)
|
||||
}
|
||||
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
return records[i].SentAt.After(records[j].SentAt)
|
||||
})
|
||||
|
||||
if limit > 0 && len(records) > limit {
|
||||
records = records[:limit]
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteRecordsOlderThan(before time.Time) error {
|
||||
iter := s.db.Search("notifrec|")
|
||||
defer iter.Release()
|
||||
|
||||
for iter.Next() {
|
||||
var rec happydns.NotificationRecord
|
||||
if err := s.db.DecodeData(iter.Value(), &rec); err != nil {
|
||||
continue
|
||||
}
|
||||
if rec.SentAt.Before(before) {
|
||||
_ = s.db.Delete(iter.Key())
|
||||
userIndexKey := fmt.Sprintf("notifrec-user|%s|%s", rec.UserId.String(), rec.Id.String())
|
||||
_ = s.db.Delete(userIndexKey)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
66
internal/storage/kvtpl/notification_state.go
Normal file
66
internal/storage/kvtpl/notification_state.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func notifStateKey(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) string {
|
||||
return fmt.Sprintf("notifstate|%s|%s|%s", userId.String(), checkerID, target.String())
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (*happydns.NotificationState, error) {
|
||||
state := &happydns.NotificationState{}
|
||||
err := s.db.Get(notifStateKey(checkerID, target, userId), state)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, happydns.ErrNotificationStateNotFound
|
||||
}
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (s *KVStorage) PutState(state *happydns.NotificationState) error {
|
||||
return s.db.Put(notifStateKey(state.CheckerID, state.Target, state.UserId), state)
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) error {
|
||||
return s.db.Delete(notifStateKey(checkerID, target, userId))
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) {
|
||||
prefix := fmt.Sprintf("notifstate|%s|", userId.String())
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var states []*happydns.NotificationState
|
||||
for iter.Next() {
|
||||
var state happydns.NotificationState
|
||||
if err := s.db.DecodeData(iter.Value(), &state); err != nil {
|
||||
continue
|
||||
}
|
||||
states = append(states, &state)
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
|
|
@ -75,8 +75,10 @@ func listByIndex[T any](s *KVStorage, prefix string, getEntity func(happydns.Ide
|
|||
return results, nil
|
||||
}
|
||||
|
||||
// listByIndexSorted is like listByIndex but sorts results and applies a limit.
|
||||
func listByIndexSorted[T any](s *KVStorage, prefix string, getEntity func(happydns.Identifier) (*T, error), less func(*T, *T) bool, limit int) ([]*T, error) {
|
||||
// listByIndexSorted is like listByIndex but sorts results, applies an optional
|
||||
// filter predicate, and then applies a limit. The limit counts only items that
|
||||
// pass the filter; passing nil for filter disables filtering.
|
||||
func listByIndexSorted[T any](s *KVStorage, prefix string, getEntity func(happydns.Identifier) (*T, error), less func(*T, *T) bool, limit int, filter func(*T) bool) ([]*T, error) {
|
||||
results, err := listByIndex(s, prefix, getEntity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -86,10 +88,23 @@ func listByIndexSorted[T any](s *KVStorage, prefix string, getEntity func(happyd
|
|||
return less(results[i], results[j])
|
||||
})
|
||||
|
||||
if limit > 0 && len(results) > limit {
|
||||
results = results[:limit]
|
||||
if filter == nil {
|
||||
if limit > 0 && len(results) > limit {
|
||||
results = results[:limit]
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
return results, nil
|
||||
|
||||
filtered := results[:0]
|
||||
for _, r := range results {
|
||||
if filter(r) {
|
||||
filtered = append(filtered, r)
|
||||
if limit > 0 && len(filtered) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// tidyTwoPartIndex removes stale secondary index entries of the form
|
||||
|
|
|
|||
28
internal/storage/kvtpl/updates-from-10.go
Normal file
28
internal/storage/kvtpl/updates-from-10.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package database
|
||||
|
||||
// migrateFrom10 adds the notification system tables.
|
||||
// KV storage uses prefix-based keys, so no structural migration is needed.
|
||||
func migrateFrom10(s *KVStorage) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -38,12 +38,12 @@ import (
|
|||
// abstract.EMail
|
||||
func explodeAbstractEMail(dn happydns.Subdomain, in *happydns.ServiceMessage) ([]*happydns.ServiceMessage, error) {
|
||||
var val struct {
|
||||
MX []map[string]any `json:"mx,omitempty"`
|
||||
SPF map[string]any `json:"spf,omitempty"`
|
||||
DKIM map[string]*svcs.DKIM `json:"dkim,omitempty"`
|
||||
DMARC *svcs.DMARCFields `json:"dmarc,omitempty"`
|
||||
MTA_STS *svcs.MTASTSFields `json:"mta_sts,omitempty"`
|
||||
TLS_RPT *svcs.TLS_RPTField `json:"tls_rpt,omitempty"`
|
||||
MX []map[string]any `json:"mx,omitempty"`
|
||||
SPF map[string]any `json:"spf,omitempty"`
|
||||
DKIM map[string]*svcs.DKIM `json:"dkim,omitempty"`
|
||||
DMARC *svcs.DMARCFields `json:"dmarc,omitempty"`
|
||||
MTA_STS *svcs.MTASTSFields `json:"mta_sts,omitempty"`
|
||||
TLS_RPT *svcs.TLS_RPTField `json:"tls_rpt,omitempty"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(in.Service, &val)
|
||||
|
|
@ -815,7 +815,12 @@ func migrateFrom7(s *KVStorage) error {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s.zZzZ. 0 IN CNAME %s", val["SubDomain"], helpers.DomainFQDN(val["Target"], "zZzZ.")))
|
||||
var rr dns.RR
|
||||
if strings.Contains(val["Target"], "IN\tCNAME") {
|
||||
rr, err = dns.NewRR(val["Target"])
|
||||
} else {
|
||||
rr, err = dns.NewRR(fmt.Sprintf("%s.zZzZ. 0 IN CNAME %s", val["SubDomain"], helpers.DomainFQDN(val["Target"], "zZzZ.")))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
|
|
@ -33,6 +34,15 @@ func migrateFrom9(s *KVStorage) (err error) {
|
|||
|
||||
for sessions.Next() {
|
||||
session := sessions.Item()
|
||||
if len(session.Id) != 103 {
|
||||
err = sessions.DropItem()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to drop invalid session: %s: %w", session.Id, err)
|
||||
}
|
||||
log.Printf("Drop invalid session identifier: %s", session.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
err := s.UpdateSession(session)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ var migrations []KVMigrationFunc = []KVMigrationFunc{
|
|||
migrateFrom7,
|
||||
migrateFrom8,
|
||||
migrateFrom9,
|
||||
migrateFrom10,
|
||||
}
|
||||
|
||||
type Version struct {
|
||||
|
|
|
|||
|
|
@ -374,6 +374,11 @@ func (s *planStore) UpdateCheckPlan(plan *happydns.CheckPlan) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *planStore) RestoreCheckPlan(plan *happydns.CheckPlan) error {
|
||||
s.plans[plan.Id.String()] = plan
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *planStore) DeleteCheckPlan(planID happydns.Identifier) error {
|
||||
delete(s.plans, planID.String())
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ func (u *CheckStatusUsecase) ListCheckerStatuses(target happydns.CheckTarget) ([
|
|||
}
|
||||
status.EnabledRules = enabledRules
|
||||
|
||||
execs, err := u.execStore.ListExecutionsByChecker(def.ID, target, 1)
|
||||
execs, err := u.execStore.ListExecutionsByChecker(def.ID, target, 1, nil)
|
||||
if err != nil {
|
||||
log.Printf("ListCheckerStatuses: failed to fetch latest execution for checker %s: %v", def.ID, err)
|
||||
} else if len(execs) > 0 {
|
||||
|
|
@ -190,7 +190,7 @@ func (u *CheckStatusUsecase) GetExecution(scope happydns.CheckTarget, execID hap
|
|||
|
||||
// ListExecutionsByChecker returns executions for a checker on a target, up to limit.
|
||||
func (u *CheckStatusUsecase) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) {
|
||||
return u.execStore.ListExecutionsByChecker(checkerID, target, limit)
|
||||
return u.execStore.ListExecutionsByChecker(checkerID, target, limit, nil)
|
||||
}
|
||||
|
||||
// GetObservationsByExecution returns the observation snapshot for an execution after verifying scope.
|
||||
|
|
@ -263,7 +263,7 @@ func worstStatuses(execs []*happydns.Execution, keyFn func(*happydns.Execution)
|
|||
// (most critical) status per domain. It keeps only the latest execution per
|
||||
// (domain, checker) pair and reports the worst status among them.
|
||||
func (u *CheckStatusUsecase) GetWorstDomainStatuses(userId happydns.Identifier) (map[string]*happydns.Status, error) {
|
||||
execs, err := u.execStore.ListExecutionsByUser(userId, worstStatusMaxExecs)
|
||||
execs, err := u.execStore.ListExecutionsByUser(userId, worstStatusMaxExecs, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -276,7 +276,7 @@ func (u *CheckStatusUsecase) GetWorstDomainStatuses(userId happydns.Identifier)
|
|||
// It fetches all executions for the domain in a single query, then aggregates
|
||||
// the worst status per service in memory.
|
||||
func (u *CheckStatusUsecase) GetWorstServiceStatuses(userId happydns.Identifier, domainId happydns.Identifier) (map[string]*happydns.Status, error) {
|
||||
execs, err := u.execStore.ListExecutionsByDomain(domainId, worstStatusMaxExecs)
|
||||
execs, err := u.execStore.ListExecutionsByDomain(domainId, worstStatusMaxExecs, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -342,6 +342,12 @@ func (u *CheckStatusUsecase) extractMetricsFromExecutions(execs []*happydns.Exec
|
|||
return allMetrics, nil
|
||||
}
|
||||
|
||||
// doneExecution is a filter predicate for ListExecutionsBy* that keeps only
|
||||
// executions that have completed successfully and can produce metrics.
|
||||
func doneExecution(e *happydns.Execution) bool {
|
||||
return e.Status == happydns.ExecutionDone && e.EvaluationID != nil
|
||||
}
|
||||
|
||||
// GetMetricsByExecution extracts metrics from a single execution's snapshot after verifying scope.
|
||||
func (u *CheckStatusUsecase) GetMetricsByExecution(scope happydns.CheckTarget, execID happydns.Identifier) ([]happydns.CheckMetric, error) {
|
||||
exec, err := u.execStore.GetExecution(execID)
|
||||
|
|
@ -356,7 +362,7 @@ func (u *CheckStatusUsecase) GetMetricsByExecution(scope happydns.CheckTarget, e
|
|||
|
||||
// GetMetricsByChecker extracts metrics from recent executions of a checker on a target.
|
||||
func (u *CheckStatusUsecase) GetMetricsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]happydns.CheckMetric, error) {
|
||||
execs, err := u.execStore.ListExecutionsByChecker(checkerID, target, limit)
|
||||
execs, err := u.execStore.ListExecutionsByChecker(checkerID, target, limit, doneExecution)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -365,7 +371,7 @@ func (u *CheckStatusUsecase) GetMetricsByChecker(checkerID string, target happyd
|
|||
|
||||
// GetMetricsByUser extracts metrics from recent executions for a user across all checkers.
|
||||
func (u *CheckStatusUsecase) GetMetricsByUser(userId happydns.Identifier, limit int) ([]happydns.CheckMetric, error) {
|
||||
execs, err := u.execStore.ListExecutionsByUser(userId, limit)
|
||||
execs, err := u.execStore.ListExecutionsByUser(userId, limit, doneExecution)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -374,7 +380,7 @@ func (u *CheckStatusUsecase) GetMetricsByUser(userId happydns.Identifier, limit
|
|||
|
||||
// GetMetricsByDomain extracts metrics from recent executions for a domain (including services).
|
||||
func (u *CheckStatusUsecase) GetMetricsByDomain(domainId happydns.Identifier, limit int) ([]happydns.CheckMetric, error) {
|
||||
execs, err := u.execStore.ListExecutionsByDomain(domainId, limit)
|
||||
execs, err := u.execStore.ListExecutionsByDomain(domainId, limit, doneExecution)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ import (
|
|||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// ExecutionCallbackSetter is implemented by checker engines that support
|
||||
// notification callbacks after execution completion.
|
||||
type ExecutionCallbackSetter interface {
|
||||
SetExecutionCallback(func(*happydns.Execution, *happydns.CheckEvaluation))
|
||||
}
|
||||
|
||||
// checkerEngine implements the happydns.CheckerEngine interface.
|
||||
type checkerEngine struct {
|
||||
optionsUC *CheckerOptionsUsecase
|
||||
|
|
@ -39,6 +45,13 @@ type checkerEngine struct {
|
|||
execStore ExecutionStorage
|
||||
snapStore ObservationSnapshotStorage
|
||||
cacheStore ObservationCacheStorage
|
||||
|
||||
onComplete func(exec *happydns.Execution, eval *happydns.CheckEvaluation)
|
||||
}
|
||||
|
||||
// SetExecutionCallback registers a callback invoked after each successful execution.
|
||||
func (e *checkerEngine) SetExecutionCallback(cb func(*happydns.Execution, *happydns.CheckEvaluation)) {
|
||||
e.onComplete = cb
|
||||
}
|
||||
|
||||
// NewCheckerEngine creates a new CheckerEngine implementation.
|
||||
|
|
@ -135,6 +148,12 @@ func (e *checkerEngine) RunExecution(ctx context.Context, exec *happydns.Executi
|
|||
log.Printf("CheckerEngine: failed to update execution: %v", err)
|
||||
}
|
||||
|
||||
// Fire notification callback (runs synchronously within the caller's goroutine
|
||||
// so that shutdown can wait for it to complete).
|
||||
if e.onComplete != nil {
|
||||
e.onComplete(exec, eval)
|
||||
}
|
||||
|
||||
return eval, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ func TestCheckerEngine_RunOK(t *testing.T) {
|
|||
}
|
||||
|
||||
// Verify execution was persisted.
|
||||
execs, err := store.ListExecutionsByChecker("test_checker", target, 0)
|
||||
execs, err := store.ListExecutionsByChecker("test_checker", target, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ListExecutionsByChecker() returned error: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,13 +88,13 @@ func (s *mockExecStore) DeleteExecution(execID happydns.Identifier) error {
|
|||
func (s *mockExecStore) ListAllExecutions() (happydns.Iterator[happydns.Execution], error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockExecStore) ListExecutionsByChecker(string, happydns.CheckTarget, int) ([]*happydns.Execution, error) {
|
||||
func (s *mockExecStore) ListExecutionsByChecker(string, happydns.CheckTarget, int, func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockExecStore) ListExecutionsByUser(happydns.Identifier, int) ([]*happydns.Execution, error) {
|
||||
func (s *mockExecStore) ListExecutionsByUser(happydns.Identifier, int, func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockExecStore) ListExecutionsByDomain(happydns.Identifier, int) ([]*happydns.Execution, error) {
|
||||
func (s *mockExecStore) ListExecutionsByDomain(happydns.Identifier, int, func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockExecStore) GetExecution(happydns.Identifier) (*happydns.Execution, error) {
|
||||
|
|
@ -102,6 +102,7 @@ func (s *mockExecStore) GetExecution(happydns.Identifier) (*happydns.Execution,
|
|||
}
|
||||
func (s *mockExecStore) CreateExecution(*happydns.Execution) error { return nil }
|
||||
func (s *mockExecStore) UpdateExecution(*happydns.Execution) error { return nil }
|
||||
func (s *mockExecStore) RestoreExecution(*happydns.Execution) error { return nil }
|
||||
func (s *mockExecStore) DeleteExecutionsByChecker(string, happydns.CheckTarget) error { return nil }
|
||||
func (s *mockExecStore) TidyExecutionIndexes() error { return nil }
|
||||
func (s *mockExecStore) ClearExecutions() error { return nil }
|
||||
|
|
@ -492,6 +493,7 @@ func (s *mockEvalStore) GetLatestEvaluation(happydns.Identifier) (*happydns.Chec
|
|||
return nil, nil
|
||||
}
|
||||
func (s *mockEvalStore) CreateEvaluation(*happydns.CheckEvaluation) error { return nil }
|
||||
func (s *mockEvalStore) RestoreEvaluation(*happydns.CheckEvaluation) error { return nil }
|
||||
func (s *mockEvalStore) DeleteEvaluationsByChecker(string, happydns.CheckTarget) error { return nil }
|
||||
func (s *mockEvalStore) TidyEvaluationIndexes() error { return nil }
|
||||
func (s *mockEvalStore) ClearEvaluations() error { return nil }
|
||||
|
|
|
|||
|
|
@ -492,7 +492,7 @@ func (s *Scheduler) buildQueue() {
|
|||
svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String(), ServiceId: sid.String(), ServiceType: svc.Type}
|
||||
|
||||
for _, c := range serviceCheckers {
|
||||
if len(c.def.Availability.LimitToServices) > 0 && !slices.Contains(c.def.Availability.LimitToServices, svc.Type) {
|
||||
if !serviceCheckerApplies(c.def, svc.Type) {
|
||||
continue
|
||||
}
|
||||
s.enqueueJob(c.id, c.def, svcTarget, disabledSet, planMap, lastRun)
|
||||
|
|
@ -534,7 +534,7 @@ func (s *Scheduler) NotifyDomainChange(domain *happydns.Domain) {
|
|||
}
|
||||
if def.Availability.ApplyToService {
|
||||
for _, svc := range services {
|
||||
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
|
||||
if !serviceCheckerApplies(def, svc.Type) {
|
||||
continue
|
||||
}
|
||||
svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: didStr, ServiceId: svc.Id.String(), ServiceType: svc.Type}
|
||||
|
|
@ -579,7 +579,7 @@ func (s *Scheduler) NotifyDomainChange(domain *happydns.Domain) {
|
|||
|
||||
if def.Availability.ApplyToService {
|
||||
for _, svc := range services {
|
||||
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
|
||||
if !serviceCheckerApplies(def, svc.Type) {
|
||||
continue
|
||||
}
|
||||
sid := svc.Id
|
||||
|
|
@ -627,10 +627,20 @@ func (s *Scheduler) NotifyDomainRemoved(domainID happydns.Identifier) {
|
|||
s.mu.Unlock()
|
||||
|
||||
if n > 0 {
|
||||
log.Printf("Scheduler: NotifyDomainRemoved(%s): removed %d jobs", domainID, n)
|
||||
log.Printf("Scheduler: NotifyDomainRemoved(%s): removed %d jobs", domainID.String(), n)
|
||||
}
|
||||
}
|
||||
|
||||
// serviceCheckerApplies reports whether a service-scoped checker should be
|
||||
// auto-scheduled for the given service type. Auto-scheduling is restricted to
|
||||
// checkers that explicitly declare the service type in LimitToServices; a
|
||||
// checker with an empty LimitToServices must be activated manually via a
|
||||
// CheckPlan.
|
||||
func serviceCheckerApplies(def *happydns.CheckerDefinition, serviceType string) bool {
|
||||
return len(def.Availability.LimitToServices) > 0 &&
|
||||
slices.Contains(def.Availability.LimitToServices, serviceType)
|
||||
}
|
||||
|
||||
// buildPlanIndex builds disabled and plan lookup maps from a slice of plans.
|
||||
func buildPlanIndex(plans []*happydns.CheckPlan) (disabledSet map[string]bool, planMap map[string]*happydns.CheckPlan) {
|
||||
disabledSet = make(map[string]bool)
|
||||
|
|
@ -731,7 +741,7 @@ func (s *Scheduler) loadDomainServices(domain *happydns.Domain) []*happydns.Serv
|
|||
}
|
||||
zone, err := s.zoneStore.GetZone(domain.ZoneHistory[idx])
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: failed to load zone %s for domain %s: %v", domain.ZoneHistory[idx], domain.DomainName, err)
|
||||
log.Printf("Scheduler: failed to load zone %s for domain %s: %v", domain.ZoneHistory[idx].String(), domain.DomainName, err)
|
||||
continue
|
||||
}
|
||||
for _, svcs := range zone.Services {
|
||||
|
|
|
|||
|
|
@ -121,8 +121,9 @@ func (s *mockPlanStore) CreateCheckPlan(plan *happydns.CheckPlan) error {
|
|||
s.plans = append(s.plans, plan)
|
||||
return nil
|
||||
}
|
||||
func (s *mockPlanStore) UpdateCheckPlan(plan *happydns.CheckPlan) error { return nil }
|
||||
func (s *mockPlanStore) DeleteCheckPlan(happydns.Identifier) error { return nil }
|
||||
func (s *mockPlanStore) UpdateCheckPlan(plan *happydns.CheckPlan) error { return nil }
|
||||
func (s *mockPlanStore) RestoreCheckPlan(plan *happydns.CheckPlan) error { return nil }
|
||||
func (s *mockPlanStore) DeleteCheckPlan(happydns.Identifier) error { return nil }
|
||||
func (s *mockPlanStore) TidyCheckPlanIndexes() error { return nil }
|
||||
func (s *mockPlanStore) ClearCheckPlans() error { return nil }
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ type CheckPlanStorage interface {
|
|||
GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error)
|
||||
CreateCheckPlan(plan *happydns.CheckPlan) error
|
||||
UpdateCheckPlan(plan *happydns.CheckPlan) error
|
||||
RestoreCheckPlan(plan *happydns.CheckPlan) error
|
||||
DeleteCheckPlan(planID happydns.Identifier) error
|
||||
TidyCheckPlanIndexes() error
|
||||
ClearCheckPlans() error
|
||||
|
|
@ -84,6 +85,7 @@ type CheckEvaluationStorage interface {
|
|||
GetEvaluation(evalID happydns.Identifier) (*happydns.CheckEvaluation, error)
|
||||
GetLatestEvaluation(planID happydns.Identifier) (*happydns.CheckEvaluation, error)
|
||||
CreateEvaluation(eval *happydns.CheckEvaluation) error
|
||||
RestoreEvaluation(eval *happydns.CheckEvaluation) error
|
||||
DeleteEvaluation(evalID happydns.Identifier) error
|
||||
DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) error
|
||||
TidyEvaluationIndexes() error
|
||||
|
|
@ -94,12 +96,13 @@ type CheckEvaluationStorage interface {
|
|||
type ExecutionStorage interface {
|
||||
ListAllExecutions() (happydns.Iterator[happydns.Execution], error)
|
||||
ListExecutionsByPlan(planID happydns.Identifier) ([]*happydns.Execution, error)
|
||||
ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error)
|
||||
ListExecutionsByUser(userId happydns.Identifier, limit int) ([]*happydns.Execution, error)
|
||||
ListExecutionsByDomain(domainId happydns.Identifier, limit int) ([]*happydns.Execution, error)
|
||||
ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error)
|
||||
ListExecutionsByUser(userId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error)
|
||||
ListExecutionsByDomain(domainId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error)
|
||||
GetExecution(execID happydns.Identifier) (*happydns.Execution, error)
|
||||
CreateExecution(exec *happydns.Execution) error
|
||||
UpdateExecution(exec *happydns.Execution) error
|
||||
RestoreExecution(exec *happydns.Execution) error
|
||||
DeleteExecution(execID happydns.Identifier) error
|
||||
DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error
|
||||
TidyExecutionIndexes() error
|
||||
|
|
|
|||
79
internal/usecase/notification/acknowledge.go
Normal file
79
internal/usecase/notification/acknowledge.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// AcknowledgeIssue marks an issue as acknowledged, suppressing repeat
|
||||
// notifications until the next state change.
|
||||
func (d *Dispatcher) AcknowledgeIssue(userId happydns.Identifier, checkerID string, target happydns.CheckTarget, acknowledgedBy string, annotation string) error {
|
||||
state, err := d.stateStore.GetState(checkerID, target, userId)
|
||||
if errors.Is(err, happydns.ErrNotificationStateNotFound) {
|
||||
// Create a new state if one doesn't exist yet.
|
||||
state = &happydns.NotificationState{
|
||||
CheckerID: checkerID,
|
||||
Target: target,
|
||||
UserId: userId,
|
||||
LastStatus: happydns.StatusUnknown,
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
state.Acknowledged = true
|
||||
state.AcknowledgedAt = &now
|
||||
state.AcknowledgedBy = acknowledgedBy
|
||||
state.Annotation = annotation
|
||||
|
||||
return d.stateStore.PutState(state)
|
||||
}
|
||||
|
||||
// ClearAcknowledgement removes the acknowledgement from an issue.
|
||||
func (d *Dispatcher) ClearAcknowledgement(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) error {
|
||||
state, err := d.stateStore.GetState(checkerID, target, userId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state.Acknowledged = false
|
||||
state.AcknowledgedAt = nil
|
||||
state.AcknowledgedBy = ""
|
||||
state.Annotation = ""
|
||||
|
||||
return d.stateStore.PutState(state)
|
||||
}
|
||||
|
||||
// GetState returns the current notification state for a checker/target/user.
|
||||
func (d *Dispatcher) GetState(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) (*happydns.NotificationState, error) {
|
||||
return d.stateStore.GetState(checkerID, target, userId)
|
||||
}
|
||||
|
||||
// ListStatesByUser returns all notification states for a user.
|
||||
func (d *Dispatcher) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) {
|
||||
return d.stateStore.ListStatesByUser(userId)
|
||||
}
|
||||
312
internal/usecase/notification/dispatcher.go
Normal file
312
internal/usecase/notification/dispatcher.go
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
notifPkg "git.happydns.org/happyDomain/internal/notification"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Dispatcher evaluates notification state transitions after checker executions
|
||||
// and dispatches notifications through configured channels.
|
||||
type Dispatcher struct {
|
||||
channelStore NotificationChannelStorage
|
||||
prefStore NotificationPreferenceStorage
|
||||
stateStore NotificationStateStorage
|
||||
recordStore NotificationRecordStorage
|
||||
userStore UserGetter
|
||||
domainStore DomainGetter
|
||||
senders map[happydns.NotificationChannelType]notifPkg.ChannelSender
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewDispatcher creates a new notification Dispatcher.
|
||||
func NewDispatcher(
|
||||
channelStore NotificationChannelStorage,
|
||||
prefStore NotificationPreferenceStorage,
|
||||
stateStore NotificationStateStorage,
|
||||
recordStore NotificationRecordStorage,
|
||||
userStore UserGetter,
|
||||
domainStore DomainGetter,
|
||||
senders map[happydns.NotificationChannelType]notifPkg.ChannelSender,
|
||||
baseURL string,
|
||||
) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
channelStore: channelStore,
|
||||
prefStore: prefStore,
|
||||
stateStore: stateStore,
|
||||
recordStore: recordStore,
|
||||
userStore: userStore,
|
||||
domainStore: domainStore,
|
||||
senders: senders,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// OnExecutionComplete is the callback invoked after a checker execution finishes.
|
||||
// It determines whether a notification should be sent based on state transitions,
|
||||
// user preferences, acknowledgements, and quiet hours.
|
||||
func (d *Dispatcher) OnExecutionComplete(exec *happydns.Execution, eval *happydns.CheckEvaluation) {
|
||||
if exec == nil || exec.Status != happydns.ExecutionDone {
|
||||
return
|
||||
}
|
||||
|
||||
userId := happydns.TargetIdentifier(exec.Target.UserId)
|
||||
if userId == nil {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := d.userStore.GetUser(*userId)
|
||||
if err != nil {
|
||||
log.Printf("notification: failed to load user %s: %v", exec.Target.UserId, err)
|
||||
return
|
||||
}
|
||||
|
||||
newStatus := exec.Result.Status
|
||||
|
||||
// Load or create notification state.
|
||||
state, err := d.stateStore.GetState(exec.CheckerID, exec.Target, *userId)
|
||||
if errors.Is(err, happydns.ErrNotificationStateNotFound) {
|
||||
state = &happydns.NotificationState{
|
||||
CheckerID: exec.CheckerID,
|
||||
Target: exec.Target,
|
||||
UserId: *userId,
|
||||
LastStatus: happydns.StatusUnknown,
|
||||
}
|
||||
} else if err != nil {
|
||||
log.Printf("notification: failed to load state for %s/%s: %v", exec.CheckerID, exec.Target.String(), err)
|
||||
return
|
||||
}
|
||||
|
||||
oldStatus := state.LastStatus
|
||||
|
||||
// No state transition: skip notification.
|
||||
if oldStatus == newStatus {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear acknowledgement on any state change.
|
||||
state.Acknowledged = false
|
||||
state.AcknowledgedAt = nil
|
||||
state.AcknowledgedBy = ""
|
||||
state.Annotation = ""
|
||||
|
||||
isRecovery := newStatus < happydns.StatusWarn && oldStatus >= happydns.StatusWarn
|
||||
|
||||
// Resolve the effective preference for this target.
|
||||
pref := d.resolvePreference(user, exec.Target)
|
||||
if pref == nil || !pref.Enabled {
|
||||
// No preference or disabled: still update state, but don't notify.
|
||||
d.updateState(state, newStatus)
|
||||
return
|
||||
}
|
||||
|
||||
// Check minimum severity threshold.
|
||||
if !isRecovery && newStatus < pref.MinStatus {
|
||||
d.updateState(state, newStatus)
|
||||
return
|
||||
}
|
||||
|
||||
// Check recovery notification preference.
|
||||
if isRecovery && !pref.NotifyRecovery {
|
||||
d.updateState(state, newStatus)
|
||||
return
|
||||
}
|
||||
|
||||
// Check quiet hours.
|
||||
if d.isQuietHour(pref) {
|
||||
d.updateState(state, newStatus)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve domain name for the notification payload.
|
||||
domainName := exec.Target.DomainId
|
||||
if did := happydns.TargetIdentifier(exec.Target.DomainId); did != nil {
|
||||
if domain, err := d.domainStore.GetDomain(*did); err == nil {
|
||||
domainName = domain.DomainName
|
||||
}
|
||||
}
|
||||
|
||||
var states []happydns.CheckState
|
||||
if eval != nil {
|
||||
states = eval.States
|
||||
}
|
||||
|
||||
payload := ¬ifPkg.NotificationPayload{
|
||||
User: user,
|
||||
CheckerID: exec.CheckerID,
|
||||
Target: exec.Target,
|
||||
DomainName: domainName,
|
||||
OldStatus: oldStatus,
|
||||
NewStatus: newStatus,
|
||||
States: states,
|
||||
BaseURL: d.baseURL,
|
||||
}
|
||||
|
||||
// Resolve channels and send.
|
||||
channels := d.resolveChannels(user, pref)
|
||||
for _, ch := range channels {
|
||||
d.sendAndRecord(ch, payload, user)
|
||||
}
|
||||
|
||||
d.updateState(state, newStatus)
|
||||
}
|
||||
|
||||
// resolvePreference finds the most specific enabled preference for the given target.
|
||||
// Specificity: service > domain > global.
|
||||
func (d *Dispatcher) resolvePreference(user *happydns.User, target happydns.CheckTarget) *happydns.NotificationPreference {
|
||||
prefs, err := d.prefStore.ListPreferencesByUser(user.Id)
|
||||
if err != nil {
|
||||
log.Printf("notification: failed to load preferences for user %s: %v", user.Id, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var global, domainMatch, serviceMatch *happydns.NotificationPreference
|
||||
for _, p := range prefs {
|
||||
if p.ServiceId != nil && p.ServiceId.String() == target.ServiceId {
|
||||
serviceMatch = p
|
||||
} else if p.DomainId != nil && p.DomainId.String() == target.DomainId && p.ServiceId == nil {
|
||||
domainMatch = p
|
||||
} else if p.DomainId == nil && p.ServiceId == nil {
|
||||
global = p
|
||||
}
|
||||
}
|
||||
|
||||
if serviceMatch != nil {
|
||||
return serviceMatch
|
||||
}
|
||||
if domainMatch != nil {
|
||||
return domainMatch
|
||||
}
|
||||
return global
|
||||
}
|
||||
|
||||
// resolveChannels returns the channels to use for a notification.
|
||||
func (d *Dispatcher) resolveChannels(user *happydns.User, pref *happydns.NotificationPreference) []*happydns.NotificationChannel {
|
||||
allChannels, err := d.channelStore.ListChannelsByUser(user.Id)
|
||||
if err != nil {
|
||||
log.Printf("notification: failed to load channels for user %s: %v", user.Id, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build a set of allowed channel IDs from the preference.
|
||||
var allowed map[string]bool
|
||||
if len(pref.ChannelIds) > 0 {
|
||||
allowed = make(map[string]bool, len(pref.ChannelIds))
|
||||
for _, id := range pref.ChannelIds {
|
||||
allowed[id.String()] = true
|
||||
}
|
||||
}
|
||||
|
||||
var result []*happydns.NotificationChannel
|
||||
for _, ch := range allChannels {
|
||||
if !ch.Enabled {
|
||||
continue
|
||||
}
|
||||
if allowed != nil && !allowed[ch.Id.String()] {
|
||||
continue
|
||||
}
|
||||
result = append(result, ch)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// isQuietHour returns true if the current UTC hour falls within the quiet window.
|
||||
func (d *Dispatcher) isQuietHour(pref *happydns.NotificationPreference) bool {
|
||||
if pref.QuietStart == nil || pref.QuietEnd == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
hour := time.Now().UTC().Hour()
|
||||
start := *pref.QuietStart
|
||||
end := *pref.QuietEnd
|
||||
|
||||
if start <= end {
|
||||
return hour >= start && hour < end
|
||||
}
|
||||
// Wraps midnight, e.g. 22:00 - 06:00.
|
||||
return hour >= start || hour < end
|
||||
}
|
||||
|
||||
// sendAndRecord dispatches through the appropriate sender and logs the result.
|
||||
func (d *Dispatcher) sendAndRecord(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User) {
|
||||
sender, ok := d.senders[ch.Type]
|
||||
if !ok {
|
||||
log.Printf("notification: no sender for channel type %q", ch.Type)
|
||||
return
|
||||
}
|
||||
|
||||
rec := &happydns.NotificationRecord{
|
||||
UserId: user.Id,
|
||||
ChannelType: ch.Type,
|
||||
ChannelId: ch.Id,
|
||||
CheckerID: payload.CheckerID,
|
||||
Target: payload.Target,
|
||||
OldStatus: payload.OldStatus,
|
||||
NewStatus: payload.NewStatus,
|
||||
SentAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := sender.Send(ch, payload); err != nil {
|
||||
log.Printf("notification: failed to send via %s channel %s: %v", ch.Type, ch.Id, err)
|
||||
rec.Success = false
|
||||
rec.Error = err.Error()
|
||||
} else {
|
||||
rec.Success = true
|
||||
}
|
||||
|
||||
if err := d.recordStore.CreateRecord(rec); err != nil {
|
||||
log.Printf("notification: failed to log record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// SendTestNotification sends a test notification through the given channel.
|
||||
func (d *Dispatcher) SendTestNotification(ch *happydns.NotificationChannel, user *happydns.User) error {
|
||||
sender, ok := d.senders[ch.Type]
|
||||
if !ok {
|
||||
return fmt.Errorf("no sender for channel type %q", ch.Type)
|
||||
}
|
||||
|
||||
payload := ¬ifPkg.NotificationPayload{
|
||||
User: user,
|
||||
CheckerID: "test",
|
||||
DomainName: "example.com",
|
||||
OldStatus: happydns.StatusOK,
|
||||
NewStatus: happydns.StatusWarn,
|
||||
BaseURL: d.baseURL,
|
||||
}
|
||||
|
||||
return sender.Send(ch, payload)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) updateState(state *happydns.NotificationState, newStatus happydns.Status) {
|
||||
state.LastStatus = newStatus
|
||||
state.LastNotifiedAt = time.Now()
|
||||
if err := d.stateStore.PutState(state); err != nil {
|
||||
log.Printf("notification: failed to update state: %v", err)
|
||||
}
|
||||
}
|
||||
71
internal/usecase/notification/storage.go
Normal file
71
internal/usecase/notification/storage.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// NotificationChannelStorage provides persistence for notification channels.
|
||||
type NotificationChannelStorage interface {
|
||||
ListChannelsByUser(userId happydns.Identifier) ([]*happydns.NotificationChannel, error)
|
||||
GetChannel(channelId happydns.Identifier) (*happydns.NotificationChannel, error)
|
||||
CreateChannel(ch *happydns.NotificationChannel) error
|
||||
UpdateChannel(ch *happydns.NotificationChannel) error
|
||||
DeleteChannel(channelId happydns.Identifier) error
|
||||
}
|
||||
|
||||
// NotificationPreferenceStorage provides persistence for notification preferences.
|
||||
type NotificationPreferenceStorage interface {
|
||||
ListPreferencesByUser(userId happydns.Identifier) ([]*happydns.NotificationPreference, error)
|
||||
GetPreference(prefId happydns.Identifier) (*happydns.NotificationPreference, error)
|
||||
CreatePreference(pref *happydns.NotificationPreference) error
|
||||
UpdatePreference(pref *happydns.NotificationPreference) error
|
||||
DeletePreference(prefId happydns.Identifier) error
|
||||
}
|
||||
|
||||
// NotificationStateStorage provides persistence for notification state tracking.
|
||||
type NotificationStateStorage interface {
|
||||
GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (*happydns.NotificationState, error)
|
||||
PutState(state *happydns.NotificationState) error
|
||||
DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) error
|
||||
ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error)
|
||||
}
|
||||
|
||||
// NotificationRecordStorage provides persistence for notification audit records.
|
||||
type NotificationRecordStorage interface {
|
||||
CreateRecord(rec *happydns.NotificationRecord) error
|
||||
ListRecordsByUser(userId happydns.Identifier, limit int) ([]*happydns.NotificationRecord, error)
|
||||
DeleteRecordsOlderThan(before time.Time) error
|
||||
}
|
||||
|
||||
// UserGetter is a narrow interface for loading users in the notification context.
|
||||
type UserGetter interface {
|
||||
GetUser(id happydns.Identifier) (*happydns.User, error)
|
||||
}
|
||||
|
||||
// DomainGetter is a narrow interface for loading domains in the notification context.
|
||||
type DomainGetter interface {
|
||||
GetDomain(id happydns.Identifier) (*happydns.Domain, error)
|
||||
}
|
||||
|
|
@ -209,7 +209,7 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
|
|||
if len(domain.ZoneHistory) > 1 {
|
||||
prevZone, prevErr := uc.zoneGetter.Get(domain.ZoneHistory[1])
|
||||
if prevErr != nil {
|
||||
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[1], prevErr)
|
||||
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[1].String(), prevErr)
|
||||
} else {
|
||||
zoneUC.ReassociateMetadata(prevZone.Services, services, domain.DomainName, defaultTTL)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ func (uc *ZoneImporterUsecase) Import(user *happydns.User, domain *happydns.Doma
|
|||
if len(domain.ZoneHistory) > 0 {
|
||||
prevZone, err := uc.zoneGetter.Get(domain.ZoneHistory[0])
|
||||
if err != nil {
|
||||
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[0], err)
|
||||
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[0].String(), err)
|
||||
} else {
|
||||
zoneUC.ReassociateMetadata(prevZone.Services, services, domain.DomainName, defaultTTL)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,8 +40,33 @@ func NewTidyUpUsecase(store storage.Storage) happydns.TidyUpUseCase {
|
|||
}
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyAll() error {
|
||||
for _, tidy := range []func() error{
|
||||
// iterateTidy drives an iterator using NextWithError so Tidy can decide
|
||||
// whether to delete undecodable records (via DropItem) or just log them.
|
||||
// handle is only invoked for successfully decoded items.
|
||||
func iterateTidy[T any](iter happydns.Iterator[T], dropInvalid bool, handle func(*T) error) error {
|
||||
for iter.NextWithError() {
|
||||
item := iter.Item()
|
||||
if item == nil {
|
||||
key := iter.Key()
|
||||
log.Printf("KVIterator: error decoding item at key %q: %s", key, iter.Err())
|
||||
if dropInvalid {
|
||||
if err := iter.DropItem(); err != nil {
|
||||
log.Printf("KVIterator: failed to delete invalid item at key %q: %s", key, err)
|
||||
} else {
|
||||
log.Printf("KVIterator: dropped invalid item at key %q", key)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := handle(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return iter.Err()
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyAll(dropInvalid bool) error {
|
||||
for _, tidy := range []func(bool) error{
|
||||
tu.TidySessions,
|
||||
tu.TidyAuthUsers,
|
||||
tu.TidyUsers,
|
||||
|
|
@ -56,24 +81,22 @@ func (tu *tidyUpUsecase) TidyAll() error {
|
|||
tu.TidySnapshots,
|
||||
tu.TidyObservationCache,
|
||||
} {
|
||||
if err := tidy(); err != nil {
|
||||
if err := tidy(dropInvalid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyAuthUsers() error {
|
||||
func (tu *tidyUpUsecase) TidyAuthUsers(dropInvalid bool) error {
|
||||
iter, err := tu.store.ListAllAuthUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
userAuth := iter.Item()
|
||||
|
||||
_, err = tu.store.GetUser(userAuth.Id)
|
||||
return iterateTidy(iter, dropInvalid, func(userAuth *happydns.UserAuth) error {
|
||||
_, err := tu.store.GetUser(userAuth.Id)
|
||||
if errors.Is(err, happydns.ErrUserNotFound) && time.Since(userAuth.CreatedAt) > 24*time.Hour {
|
||||
// Drop providers of unexistant users
|
||||
log.Printf("Deleting orphan authuser (user %s not found): %v\n", userAuth.Id.String(), userAuth)
|
||||
|
|
@ -81,21 +104,18 @@ func (tu *tidyUpUsecase) TidyAuthUsers() error {
|
|||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyCheckEvaluations() error {
|
||||
func (tu *tidyUpUsecase) TidyCheckEvaluations(dropInvalid bool) error {
|
||||
iter, err := tu.store.ListAllEvaluations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
eval := iter.Item()
|
||||
|
||||
err = iterateTidy(iter, dropInvalid, func(eval *happydns.CheckEvaluation) error {
|
||||
drop := false
|
||||
|
||||
if eval.Target.UserId != "" {
|
||||
|
|
@ -119,36 +139,34 @@ func (tu *tidyUpUsecase) TidyCheckEvaluations() error {
|
|||
}
|
||||
|
||||
if !drop && eval.PlanID != nil {
|
||||
if _, err = tu.store.GetCheckPlan(*eval.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
|
||||
if _, err := tu.store.GetCheckPlan(*eval.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
|
||||
log.Printf("Deleting orphan check evaluation (plan %s not found): %s\n", eval.PlanID.String(), eval.Id.String())
|
||||
drop = true
|
||||
}
|
||||
}
|
||||
|
||||
if drop {
|
||||
if err = tu.store.DeleteEvaluation(eval.Id); err != nil {
|
||||
if err := tu.store.DeleteEvaluation(eval.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err = iter.Err(); err != nil {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tu.store.TidyEvaluationIndexes()
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyCheckPlans() error {
|
||||
func (tu *tidyUpUsecase) TidyCheckPlans(dropInvalid bool) error {
|
||||
iter, err := tu.store.ListAllCheckPlans()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
plan := iter.Item()
|
||||
|
||||
err = iterateTidy(iter, dropInvalid, func(plan *happydns.CheckPlan) error {
|
||||
if plan.Target.UserId != "" {
|
||||
userId, err := happydns.NewIdentifierFromString(plan.Target.UserId)
|
||||
if err == nil {
|
||||
|
|
@ -157,10 +175,7 @@ func (tu *tidyUpUsecase) TidyCheckPlans() error {
|
|||
log.Printf("Deleting orphan check plan (user %s not found): %s\n", plan.Target.UserId, plan.Id.String())
|
||||
_ = tu.store.DeleteEvaluationsByChecker(plan.CheckerID, plan.Target)
|
||||
_ = tu.store.DeleteExecutionsByChecker(plan.CheckerID, plan.Target)
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
return iter.DropItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -173,39 +188,31 @@ func (tu *tidyUpUsecase) TidyCheckPlans() error {
|
|||
log.Printf("Deleting orphan check plan (domain %s not found): %s\n", plan.Target.DomainId, plan.Id.String())
|
||||
_ = tu.store.DeleteEvaluationsByChecker(plan.CheckerID, plan.Target)
|
||||
_ = tu.store.DeleteExecutionsByChecker(plan.CheckerID, plan.Target)
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
return iter.DropItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := iter.Err(); err != nil {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tu.store.TidyCheckPlanIndexes()
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyCheckerConfigurations() error {
|
||||
func (tu *tidyUpUsecase) TidyCheckerConfigurations(dropInvalid bool) error {
|
||||
iter, err := tu.store.ListAllCheckerConfigurations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
cfg := iter.Item()
|
||||
|
||||
return iterateTidy(iter, dropInvalid, func(cfg *happydns.CheckerOptionsPositional) error {
|
||||
if cfg.UserId != nil {
|
||||
if _, err = tu.store.GetUser(*cfg.UserId); errors.Is(err, happydns.ErrUserNotFound) {
|
||||
if _, err := tu.store.GetUser(*cfg.UserId); errors.Is(err, happydns.ErrUserNotFound) {
|
||||
log.Printf("Deleting orphan checker configuration (user %s not found): %s\n", cfg.UserId.String(), cfg.CheckName)
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
return iter.DropItem()
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -215,10 +222,7 @@ func (tu *tidyUpUsecase) TidyCheckerConfigurations() error {
|
|||
domain, err := tu.store.GetDomain(*cfg.DomainId)
|
||||
if errors.Is(err, happydns.ErrDomainNotFound) {
|
||||
log.Printf("Deleting orphan checker configuration (domain %s not found): %s\n", cfg.DomainId.String(), cfg.CheckName)
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
return iter.DropItem()
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -253,28 +257,22 @@ func (tu *tidyUpUsecase) TidyCheckerConfigurations() error {
|
|||
}
|
||||
if !found {
|
||||
log.Printf("Deleting orphan checker configuration (service %s not found in domain %s): %s\n", cfg.ServiceId.String(), cfg.DomainId.String(), cfg.CheckName)
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
return iter.DropItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyExecutions() error {
|
||||
func (tu *tidyUpUsecase) TidyExecutions(dropInvalid bool) error {
|
||||
iter, err := tu.store.ListAllExecutions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
exec := iter.Item()
|
||||
|
||||
err = iterateTidy(iter, dropInvalid, func(exec *happydns.Execution) error {
|
||||
drop := false
|
||||
|
||||
if exec.Target.UserId != "" {
|
||||
|
|
@ -298,58 +296,53 @@ func (tu *tidyUpUsecase) TidyExecutions() error {
|
|||
}
|
||||
|
||||
if !drop && exec.PlanID != nil {
|
||||
if _, err = tu.store.GetCheckPlan(*exec.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
|
||||
if _, err := tu.store.GetCheckPlan(*exec.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
|
||||
log.Printf("Deleting orphan execution (plan %s not found): %s\n", exec.PlanID.String(), exec.Id.String())
|
||||
drop = true
|
||||
}
|
||||
}
|
||||
|
||||
if drop {
|
||||
if err = tu.store.DeleteExecution(exec.Id); err != nil {
|
||||
if err := tu.store.DeleteExecution(exec.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err = iter.Err(); err != nil {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tu.store.TidyExecutionIndexes()
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyObservationCache() error {
|
||||
func (tu *tidyUpUsecase) TidyObservationCache(dropInvalid bool) error {
|
||||
iter, err := tu.store.ListAllCachedObservations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
entry := iter.Item()
|
||||
|
||||
if _, err = tu.store.GetSnapshot(entry.SnapshotID); errors.Is(err, happydns.ErrSnapshotNotFound) {
|
||||
return iterateTidy(iter, dropInvalid, func(entry *happydns.ObservationCacheEntry) error {
|
||||
if _, err := tu.store.GetSnapshot(entry.SnapshotID); errors.Is(err, happydns.ErrSnapshotNotFound) {
|
||||
log.Printf("Deleting stale observation cache entry (snapshot %s not found)\n", entry.SnapshotID.String())
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyDomains() error {
|
||||
func (tu *tidyUpUsecase) TidyDomains(dropInvalid bool) error {
|
||||
iter, err := tu.store.ListAllDomains()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
domain := iter.Item()
|
||||
|
||||
if _, err = tu.store.GetUser(domain.Owner); errors.Is(err, happydns.ErrUserNotFound) {
|
||||
return iterateTidy(iter, dropInvalid, func(domain *happydns.Domain) error {
|
||||
if _, err := tu.store.GetUser(domain.Owner); errors.Is(err, happydns.ErrUserNotFound) {
|
||||
// Drop domain of unexistant users
|
||||
log.Printf("Deleting orphan domain (user %s not found): %v\n", domain.Owner.String(), domain)
|
||||
if err = iter.DropItem(); err != nil {
|
||||
|
|
@ -357,51 +350,45 @@ func (tu *tidyUpUsecase) TidyDomains() error {
|
|||
}
|
||||
}
|
||||
|
||||
if _, err = tu.store.GetProvider(domain.ProviderId); errors.Is(err, happydns.ErrProviderNotFound) {
|
||||
if _, err := tu.store.GetProvider(domain.ProviderId); errors.Is(err, happydns.ErrProviderNotFound) {
|
||||
// Drop domain of unexistant provider
|
||||
log.Printf("Deleting orphan domain (provider %s not found): %v\n", domain.ProviderId.String(), domain)
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyDomainLogs() error {
|
||||
func (tu *tidyUpUsecase) TidyDomainLogs(dropInvalid bool) error {
|
||||
iter, err := tu.store.ListAllDomainLogs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
l := iter.Item()
|
||||
|
||||
if _, err = tu.store.GetDomain(l.DomainId); errors.Is(err, happydns.ErrDomainNotFound) {
|
||||
return iterateTidy(iter, dropInvalid, func(l *happydns.DomainLogWithDomainId) error {
|
||||
if _, err := tu.store.GetDomain(l.DomainId); errors.Is(err, happydns.ErrDomainNotFound) {
|
||||
// Drop domain of unexistant provider
|
||||
log.Printf("Deleting orphan domain log (domain %s not found): %v\n", l.DomainId.String(), l)
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyProviders() error {
|
||||
func (tu *tidyUpUsecase) TidyProviders(dropInvalid bool) error {
|
||||
iter, err := tu.store.ListAllProviders()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
prvd := iter.Item()
|
||||
|
||||
_, err = tu.store.GetUser(prvd.Owner)
|
||||
return iterateTidy(iter, dropInvalid, func(prvd *happydns.ProviderMessage) error {
|
||||
_, err := tu.store.GetUser(prvd.Owner)
|
||||
if errors.Is(err, happydns.ErrUserNotFound) {
|
||||
// Drop providers of unexistant users
|
||||
log.Printf("Deleting orphan provider (user %s not found): %v\n", prvd.Owner.String(), prvd)
|
||||
|
|
@ -409,22 +396,19 @@ func (tu *tidyUpUsecase) TidyProviders() error {
|
|||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidySessions() error {
|
||||
func (tu *tidyUpUsecase) TidySessions(dropInvalid bool) error {
|
||||
iter, err := tu.store.ListAllSessions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
session := iter.Item()
|
||||
|
||||
_, err = tu.store.GetUser(session.IdUser)
|
||||
return iterateTidy(iter, dropInvalid, func(session *happydns.Session) error {
|
||||
_, err := tu.store.GetUser(session.IdUser)
|
||||
if errors.Is(err, happydns.ErrUserNotFound) {
|
||||
// Drop session from unexistant users
|
||||
log.Printf("Deleting orphan session (user %s not found): %v\n", session.IdUser.String(), session)
|
||||
|
|
@ -432,12 +416,11 @@ func (tu *tidyUpUsecase) TidySessions() error {
|
|||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidySnapshots() error {
|
||||
func (tu *tidyUpUsecase) TidySnapshots(dropInvalid bool) error {
|
||||
// Collect all snapshot IDs referenced by evaluations.
|
||||
evalIter, err := tu.store.ListAllEvaluations()
|
||||
if err != nil {
|
||||
|
|
@ -446,13 +429,12 @@ func (tu *tidyUpUsecase) TidySnapshots() error {
|
|||
defer evalIter.Close()
|
||||
|
||||
referencedSnapshots := make(map[string]struct{})
|
||||
for evalIter.Next() {
|
||||
eval := evalIter.Item()
|
||||
if err = iterateTidy(evalIter, dropInvalid, func(eval *happydns.CheckEvaluation) error {
|
||||
if !eval.SnapshotID.IsEmpty() {
|
||||
referencedSnapshots[eval.SnapshotID.String()] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err = evalIter.Err(); err != nil {
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -463,44 +445,39 @@ func (tu *tidyUpUsecase) TidySnapshots() error {
|
|||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
snap := iter.Item()
|
||||
return iterateTidy(iter, dropInvalid, func(snap *happydns.ObservationSnapshot) error {
|
||||
if _, ok := referencedSnapshots[snap.Id.String()]; !ok {
|
||||
log.Printf("Deleting orphan snapshot: %s\n", snap.Id.String())
|
||||
if err = iter.DropItem(); err != nil {
|
||||
if err := iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyUsers() error {
|
||||
func (tu *tidyUpUsecase) TidyUsers(dropInvalid bool) error {
|
||||
iter, err := tu.store.ListAllAuthUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
authUser := iter.Item()
|
||||
|
||||
return iterateTidy(iter, dropInvalid, func(authUser *happydns.UserAuth) error {
|
||||
if authUser.EmailVerification == nil && authUser.LastLoggedIn == nil && time.Since(authUser.CreatedAt) > 7*24*time.Hour {
|
||||
log.Printf("Deleting user with unverified email and no login (created %s): %s\n", authUser.CreatedAt.Format(time.RFC3339), authUser.Email)
|
||||
if err = tu.store.DeleteUser(authUser.Id); err != nil && !errors.Is(err, happydns.ErrUserNotFound) {
|
||||
if err := tu.store.DeleteUser(authUser.Id); err != nil && !errors.Is(err, happydns.ErrUserNotFound) {
|
||||
return err
|
||||
}
|
||||
if err = iter.DropItem(); err != nil {
|
||||
if err := iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyZones() error {
|
||||
func (tu *tidyUpUsecase) TidyZones(dropInvalid bool) error {
|
||||
iterdn, err := tu.store.ListAllDomains()
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -508,15 +485,10 @@ func (tu *tidyUpUsecase) TidyZones() error {
|
|||
defer iterdn.Close()
|
||||
|
||||
var referencedZones []happydns.Identifier
|
||||
|
||||
for iterdn.Next() {
|
||||
domain := iterdn.Item()
|
||||
for _, zh := range domain.ZoneHistory {
|
||||
referencedZones = append(referencedZones, zh)
|
||||
}
|
||||
}
|
||||
|
||||
if err = iterdn.Err(); err != nil {
|
||||
if err = iterateTidy(iterdn, dropInvalid, func(domain *happydns.Domain) error {
|
||||
referencedZones = append(referencedZones, domain.ZoneHistory...)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -526,9 +498,7 @@ func (tu *tidyUpUsecase) TidyZones() error {
|
|||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
zone := iter.Item()
|
||||
|
||||
return iterateTidy(iter, dropInvalid, func(zone *happydns.ZoneMessage) error {
|
||||
foundZone := false
|
||||
for _, zid := range referencedZones {
|
||||
if zid.Equals(zone.Id) {
|
||||
|
|
@ -540,11 +510,10 @@ func (tu *tidyUpUsecase) TidyZones() error {
|
|||
if !foundZone {
|
||||
// Drop orphan zones
|
||||
log.Printf("Deleting orphan zone: %s\n", zone.Id.String())
|
||||
if err = iter.DropItem(); err != nil {
|
||||
if err := iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ func TestTidyObservationCache_RemovesStaleEntries(t *testing.T) {
|
|||
|
||||
// Run tidy.
|
||||
tu := usecase.NewTidyUpUsecase(store)
|
||||
if err := tu.TidyObservationCache(); err != nil {
|
||||
if err := tu.TidyObservationCache(true); err != nil {
|
||||
t.Fatalf("TidyObservationCache() error: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ func TestTidyObservationCache_EmptyCache(t *testing.T) {
|
|||
}
|
||||
|
||||
tu := usecase.NewTidyUpUsecase(store)
|
||||
if err := tu.TidyObservationCache(); err != nil {
|
||||
if err := tu.TidyObservationCache(true); err != nil {
|
||||
t.Fatalf("TidyObservationCache() on empty cache error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,13 +22,17 @@
|
|||
package happydns
|
||||
|
||||
type Backup struct {
|
||||
Version int
|
||||
Domains []*Domain
|
||||
DomainsLogs map[string][]*DomainLog
|
||||
Errors []string
|
||||
Providers []*ProviderMessage
|
||||
Sessions []*Session
|
||||
Users []*User
|
||||
UsersAuth UserAuths
|
||||
Zones []*ZoneMessage
|
||||
Version int
|
||||
Domains []*Domain
|
||||
DomainsLogs map[string][]*DomainLog
|
||||
Errors []string
|
||||
Providers []*ProviderMessage
|
||||
Sessions []*Session
|
||||
Users []*User
|
||||
UsersAuth UserAuths
|
||||
Zones []*ZoneMessage
|
||||
CheckerConfigurations []*CheckerOptionsPositional
|
||||
CheckPlans []*CheckPlan
|
||||
CheckEvaluations []*CheckEvaluation
|
||||
Executions []*Execution
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,10 @@ type Options struct {
|
|||
// false, manual triggers bypass the quota entirely.
|
||||
CheckerCountManualTriggers bool
|
||||
|
||||
// NotificationRetentionDays is how many days of notification history
|
||||
// records are kept. Defaults to 90.
|
||||
NotificationRetentionDays int
|
||||
|
||||
// CaptchaProvider selects the captcha provider ("hcaptcha", "recaptchav2", "turnstile", or "").
|
||||
CaptchaProvider string
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ var (
|
|||
ErrUserAlreadyExist = errors.New("user already exists")
|
||||
ErrZoneNotFound = errors.New("zone not found")
|
||||
ErrNotFound = errors.New("not found")
|
||||
|
||||
ErrNotificationChannelNotFound = errors.New("notification channel not found")
|
||||
ErrNotificationPreferenceNotFound = errors.New("notification preference not found")
|
||||
ErrNotificationStateNotFound = errors.New("notification state not found")
|
||||
)
|
||||
|
||||
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."
|
||||
|
|
|
|||
178
model/notification.go
Normal file
178
model/notification.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package happydns
|
||||
|
||||
import "time"
|
||||
|
||||
// NotificationChannelType identifies the transport used to deliver a notification.
|
||||
type NotificationChannelType string
|
||||
|
||||
const (
|
||||
NotificationChannelEmail NotificationChannelType = "email"
|
||||
NotificationChannelWebhook NotificationChannelType = "webhook"
|
||||
NotificationChannelUnifiedPush NotificationChannelType = "unifiedpush"
|
||||
)
|
||||
|
||||
// NotificationChannelConfig holds channel-specific configuration.
|
||||
type NotificationChannelConfig struct {
|
||||
// EmailAddress overrides the user's account email. Empty means use account email.
|
||||
EmailAddress string `json:"emailAddress,omitempty"`
|
||||
|
||||
// WebhookURL is the HTTP endpoint to POST to.
|
||||
WebhookURL string `json:"webhookUrl,omitempty"`
|
||||
|
||||
// WebhookHeaders are extra headers sent with webhook requests.
|
||||
WebhookHeaders map[string]string `json:"webhookHeaders,omitempty"`
|
||||
|
||||
// WebhookSecret is used to compute an HMAC-SHA256 signature header.
|
||||
WebhookSecret string `json:"webhookSecret,omitempty"`
|
||||
|
||||
// UnifiedPushEndpoint is the push server endpoint URL.
|
||||
UnifiedPushEndpoint string `json:"unifiedPushEndpoint,omitempty"`
|
||||
}
|
||||
|
||||
// NotificationChannel represents a single configured notification destination.
|
||||
type NotificationChannel struct {
|
||||
// Id is the channel's unique identifier.
|
||||
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
|
||||
|
||||
// UserId is the owner of the channel.
|
||||
UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"`
|
||||
|
||||
// Type is the transport type (email, webhook, unifiedpush).
|
||||
Type NotificationChannelType `json:"type" binding:"required"`
|
||||
|
||||
// Name is a human-readable label for the channel.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Enabled controls whether notifications are sent through this channel.
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Config holds channel-specific settings.
|
||||
Config NotificationChannelConfig `json:"config"`
|
||||
}
|
||||
|
||||
// NotificationPreference controls what notifications a user receives for a given scope.
|
||||
// Scope resolution: ServiceId set > DomainId set > global (both nil).
|
||||
type NotificationPreference struct {
|
||||
// Id is the preference's unique identifier.
|
||||
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
|
||||
|
||||
// UserId is the owner of the preference.
|
||||
UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"`
|
||||
|
||||
// DomainId, if set, scopes this preference to a specific domain.
|
||||
DomainId *Identifier `json:"domainId,omitempty" swaggertype:"string"`
|
||||
|
||||
// ServiceId, if set, scopes this preference to a specific service.
|
||||
ServiceId *Identifier `json:"serviceId,omitempty" swaggertype:"string"`
|
||||
|
||||
// ChannelIds restricts which channels to use. Empty means all enabled channels.
|
||||
ChannelIds []Identifier `json:"channelIds,omitempty" swaggertype:"array,string"`
|
||||
|
||||
// MinStatus is the minimum severity that triggers a notification.
|
||||
MinStatus Status `json:"minStatus"`
|
||||
|
||||
// NotifyRecovery controls whether recovery (back to OK) notifications are sent.
|
||||
NotifyRecovery bool `json:"notifyRecovery"`
|
||||
|
||||
// QuietStart is the start hour (0-23, UTC) of a quiet window.
|
||||
QuietStart *int `json:"quietStart,omitempty"`
|
||||
|
||||
// QuietEnd is the end hour (0-23, UTC) of a quiet window.
|
||||
QuietEnd *int `json:"quietEnd,omitempty"`
|
||||
|
||||
// Enabled is the master switch for this preference scope.
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// NotificationState tracks the last notified status for a (checker, target, user) tuple.
|
||||
// Used for deduplication: only state transitions trigger notifications.
|
||||
type NotificationState struct {
|
||||
// CheckerID identifies the checker.
|
||||
CheckerID string `json:"checkerId"`
|
||||
|
||||
// Target is the checked scope.
|
||||
Target CheckTarget `json:"target"`
|
||||
|
||||
// UserId is the user who owns the target.
|
||||
UserId Identifier `json:"userId" swaggertype:"string"`
|
||||
|
||||
// LastStatus is the status from the last notification.
|
||||
LastStatus Status `json:"lastStatus"`
|
||||
|
||||
// LastNotifiedAt is when the last notification was sent.
|
||||
LastNotifiedAt time.Time `json:"lastNotifiedAt" format:"date-time"`
|
||||
|
||||
// Acknowledged indicates the user has acknowledged the current issue.
|
||||
Acknowledged bool `json:"acknowledged"`
|
||||
|
||||
// AcknowledgedAt is when the issue was acknowledged.
|
||||
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty" format:"date-time"`
|
||||
|
||||
// AcknowledgedBy describes who acknowledged (user email or "api").
|
||||
AcknowledgedBy string `json:"acknowledgedBy,omitempty"`
|
||||
|
||||
// Annotation is a user-provided note on the acknowledgement.
|
||||
Annotation string `json:"annotation,omitempty"`
|
||||
}
|
||||
|
||||
// NotificationRecord logs a sent notification for audit purposes.
|
||||
type NotificationRecord struct {
|
||||
// Id is the record's unique identifier.
|
||||
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
|
||||
|
||||
// UserId is the recipient user.
|
||||
UserId Identifier `json:"userId" swaggertype:"string"`
|
||||
|
||||
// ChannelType is the transport used.
|
||||
ChannelType NotificationChannelType `json:"channelType"`
|
||||
|
||||
// ChannelId is the channel through which the notification was sent.
|
||||
ChannelId Identifier `json:"channelId" swaggertype:"string"`
|
||||
|
||||
// CheckerID is the checker that triggered the notification.
|
||||
CheckerID string `json:"checkerId"`
|
||||
|
||||
// Target is the checked scope.
|
||||
Target CheckTarget `json:"target"`
|
||||
|
||||
// OldStatus is the previous status before the transition.
|
||||
OldStatus Status `json:"oldStatus"`
|
||||
|
||||
// NewStatus is the new status that triggered the notification.
|
||||
NewStatus Status `json:"newStatus"`
|
||||
|
||||
// SentAt is when the notification was dispatched.
|
||||
SentAt time.Time `json:"sentAt" format:"date-time"`
|
||||
|
||||
// Success indicates whether the send succeeded.
|
||||
Success bool `json:"success"`
|
||||
|
||||
// Error holds the error message if the send failed.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// AcknowledgeRequest is the JSON body for acknowledging a checker issue.
|
||||
type AcknowledgeRequest struct {
|
||||
Annotation string `json:"annotation,omitempty"`
|
||||
}
|
||||
|
|
@ -24,18 +24,21 @@ package happydns
|
|||
import ()
|
||||
|
||||
type TidyUpUseCase interface {
|
||||
TidyAll() error
|
||||
TidyAuthUsers() error
|
||||
TidyCheckEvaluations() error
|
||||
TidyCheckPlans() error
|
||||
TidyCheckerConfigurations() error
|
||||
TidyExecutions() error
|
||||
TidyObservationCache() error
|
||||
TidySnapshots() error
|
||||
TidyDomains() error
|
||||
TidyDomainLogs() error
|
||||
TidyProviders() error
|
||||
TidySessions() error
|
||||
TidyUsers() error
|
||||
TidyZones() error
|
||||
// TidyAll runs every tidy pass. When dropInvalid is true, iterators
|
||||
// that encounter undecodable records (e.g. legacy schema drift) will
|
||||
// delete those records; otherwise they are only logged.
|
||||
TidyAll(dropInvalid bool) error
|
||||
TidyAuthUsers(dropInvalid bool) error
|
||||
TidyCheckEvaluations(dropInvalid bool) error
|
||||
TidyCheckPlans(dropInvalid bool) error
|
||||
TidyCheckerConfigurations(dropInvalid bool) error
|
||||
TidyExecutions(dropInvalid bool) error
|
||||
TidyObservationCache(dropInvalid bool) error
|
||||
TidySnapshots(dropInvalid bool) error
|
||||
TidyDomains(dropInvalid bool) error
|
||||
TidyDomainLogs(dropInvalid bool) error
|
||||
TidyProviders(dropInvalid bool) error
|
||||
TidySessions(dropInvalid bool) error
|
||||
TidyUsers(dropInvalid bool) error
|
||||
TidyZones(dropInvalid bool) error
|
||||
}
|
||||
|
|
|
|||
326
web/package-lock.json → package-lock.json
generated
326
web/package-lock.json → package-lock.json
generated
|
|
@ -1,48 +1,16 @@
|
|||
{
|
||||
"name": "happyDomain",
|
||||
"name": "happydomain-monorepo",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "happyDomain",
|
||||
"name": "happydomain-monorepo",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@hey-api/openapi-ts": "^0.95.0",
|
||||
"@sveltestrap/sveltestrap": "^7.0.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"bootstrap-icons": "^1.13.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html-escaper": "^3.0.0",
|
||||
"sass": "^1.97.0",
|
||||
"sass-loader": "^16.0.0",
|
||||
"sveltekit-i18n": "^2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.0",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.22.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@types/node": "^25.0.10",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"prettier": "^3.4.0",
|
||||
"prettier-plugin-svelte": "^3.3.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-preprocess": "^6.0.0",
|
||||
"tslib": "^2.8.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
"workspaces": [
|
||||
"web",
|
||||
"web-admin"
|
||||
]
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.2",
|
||||
|
|
@ -227,6 +195,14 @@
|
|||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@happydomain/web": {
|
||||
"resolved": "web",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@happydomain/web-admin": {
|
||||
"resolved": "web-admin",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@hey-api/codegen-core": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.4.tgz",
|
||||
|
|
@ -332,29 +308,43 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanfs/types": "^0.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/node": {
|
||||
"version": "0.16.7",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
|
||||
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
|
||||
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanfs/core": "^0.19.1",
|
||||
"@humanfs/core": "^0.19.2",
|
||||
"@humanfs/types": "^0.15.0",
|
||||
"@humanwhocodes/retry": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/types": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
|
||||
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/module-importer": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
||||
|
|
@ -441,9 +431,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
||||
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
|
@ -1269,17 +1259,17 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
|
||||
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz",
|
||||
"integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
"@typescript-eslint/type-utils": "8.58.1",
|
||||
"@typescript-eslint/utils": "8.58.1",
|
||||
"@typescript-eslint/visitor-keys": "8.58.1",
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/type-utils": "8.58.2",
|
||||
"@typescript-eslint/utils": "8.58.2",
|
||||
"@typescript-eslint/visitor-keys": "8.58.2",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
|
|
@ -1292,7 +1282,7 @@
|
|||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.58.1",
|
||||
"@typescript-eslint/parser": "^8.58.2",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
|
|
@ -1308,16 +1298,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz",
|
||||
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz",
|
||||
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/typescript-estree": "8.58.1",
|
||||
"@typescript-eslint/visitor-keys": "8.58.1",
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/typescript-estree": "8.58.2",
|
||||
"@typescript-eslint/visitor-keys": "8.58.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -1333,14 +1323,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz",
|
||||
"integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz",
|
||||
"integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.58.1",
|
||||
"@typescript-eslint/types": "^8.58.1",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.58.2",
|
||||
"@typescript-eslint/types": "^8.58.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -1355,14 +1345,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz",
|
||||
"integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz",
|
||||
"integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/visitor-keys": "8.58.1"
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/visitor-keys": "8.58.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -1373,9 +1363,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz",
|
||||
"integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz",
|
||||
"integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -1390,15 +1380,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz",
|
||||
"integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz",
|
||||
"integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/typescript-estree": "8.58.1",
|
||||
"@typescript-eslint/utils": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/typescript-estree": "8.58.2",
|
||||
"@typescript-eslint/utils": "8.58.2",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
|
|
@ -1415,9 +1405,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz",
|
||||
"integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz",
|
||||
"integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -1429,16 +1419,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz",
|
||||
"integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz",
|
||||
"integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.58.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/visitor-keys": "8.58.1",
|
||||
"@typescript-eslint/project-service": "8.58.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/visitor-keys": "8.58.2",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
|
|
@ -1457,16 +1447,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz",
|
||||
"integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz",
|
||||
"integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/typescript-estree": "8.58.1"
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/typescript-estree": "8.58.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -1481,13 +1471,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz",
|
||||
"integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz",
|
||||
"integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -2082,18 +2072,18 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz",
|
||||
"integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==",
|
||||
"version": "10.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz",
|
||||
"integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@eslint/config-array": "^0.23.4",
|
||||
"@eslint/config-helpers": "^0.5.4",
|
||||
"@eslint/core": "^1.2.0",
|
||||
"@eslint/plugin-kit": "^0.7.0",
|
||||
"@eslint/config-array": "^0.23.5",
|
||||
"@eslint/config-helpers": "^0.5.5",
|
||||
"@eslint/core": "^1.2.1",
|
||||
"@eslint/plugin-kit": "^0.7.1",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
|
|
@ -3302,9 +3292,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
||||
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -3461,9 +3451,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz",
|
||||
"integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==",
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
|
@ -3755,16 +3745,16 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
|
||||
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.55.3",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.3.tgz",
|
||||
"integrity": "sha512-dS1N+i3bA1v+c4UDb750MlN5vCO82G6vxh8HeTsPsTdJ1BLsN1zxSyDlIdBBqUjqZ/BxEwM8UrFf98aaoVnZFQ==",
|
||||
"version": "5.55.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
|
||||
"integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
|
|
@ -4093,16 +4083,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz",
|
||||
"integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz",
|
||||
"integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.58.1",
|
||||
"@typescript-eslint/parser": "8.58.1",
|
||||
"@typescript-eslint/typescript-estree": "8.58.1",
|
||||
"@typescript-eslint/utils": "8.58.1"
|
||||
"@typescript-eslint/eslint-plugin": "8.58.2",
|
||||
"@typescript-eslint/parser": "8.58.2",
|
||||
"@typescript-eslint/typescript-estree": "8.58.2",
|
||||
"@typescript-eslint/utils": "8.58.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -4420,6 +4410,86 @@
|
|||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"web": {
|
||||
"name": "@happydomain/web",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@hey-api/openapi-ts": "^0.95.0",
|
||||
"@sveltestrap/sveltestrap": "^7.0.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"bootstrap-icons": "^1.13.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html-escaper": "^3.0.0",
|
||||
"sass": "^1.97.0",
|
||||
"sass-loader": "^16.0.0",
|
||||
"sveltekit-i18n": "^2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.0",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.22.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@types/node": "^25.0.10",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"prettier": "^3.4.0",
|
||||
"prettier-plugin-svelte": "^3.3.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-preprocess": "^6.0.0",
|
||||
"tslib": "^2.8.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"web-admin": {
|
||||
"name": "@happydomain/web-admin",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@hey-api/openapi-ts": "^0.95.0",
|
||||
"@sveltestrap/sveltestrap": "^7.0.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"bootstrap-icons": "^1.13.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html-escaper": "^3.0.0",
|
||||
"sass": "^1.97.0",
|
||||
"sass-loader": "^16.0.0",
|
||||
"sveltekit-i18n": "^2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.0",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.22.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@types/node": "^25.0.10",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"prettier": "^3.4.0",
|
||||
"prettier-plugin-svelte": "^3.3.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-preprocess": "^6.0.0",
|
||||
"tslib": "^2.8.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
package.json
Normal file
12
package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "happydomain-monorepo",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"web",
|
||||
"web-admin"
|
||||
],
|
||||
"resolutions": {
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,13 @@ func GetDomainWhoisInfo(ctx context.Context, domain happydns.Origin) (*happydns.
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Some registries (e.g. Verisign for .com) return a "No match" response
|
||||
// that the parser accepts without error but produces an empty Domain
|
||||
// field. Treat this as a non-existent domain.
|
||||
if result.Domain == nil || result.Domain.Domain == "" {
|
||||
return nil, happydns.ErrDomainDoesNotExist
|
||||
}
|
||||
|
||||
return mapWhoisResult(&result), nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ var entityMap = map[string]string{
|
|||
"SessionStorage": "session",
|
||||
"UserStorage": "user",
|
||||
"ZoneStorage": "zone",
|
||||
"NotificationChannelStorage": "notification_channel",
|
||||
"NotificationPreferenceStorage": "notification_preference",
|
||||
"NotificationRecordStorage": "notification_record",
|
||||
"NotificationStateStorage": "notification_state",
|
||||
}
|
||||
|
||||
// operationOverrides maps method names that don't follow the prefix convention.
|
||||
|
|
@ -311,6 +315,7 @@ func inferOperation(name string) string {
|
|||
{"Count", "count"},
|
||||
{"Create", "create"},
|
||||
{"Update", "update"},
|
||||
{"Restore", "restore"},
|
||||
{"Delete", "delete"},
|
||||
{"Clear", "delete"},
|
||||
{"Set", "set"},
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
../web/.npmrc
|
||||
|
|
@ -1 +0,0 @@
|
|||
../web/.prettierignore
|
||||
13
web-admin/.prettierignore
Normal file
13
web-admin/.prettierignore
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
|
@ -1 +0,0 @@
|
|||
../web/.prettierrc
|
||||
|
|
@ -1 +0,0 @@
|
|||
../web/eslint.config.js
|
||||
|
|
@ -1 +0,0 @@
|
|||
../web/node_modules/
|
||||
1
web-admin/package-lock.json
generated
1
web-admin/package-lock.json
generated
|
|
@ -1 +0,0 @@
|
|||
../web/package-lock.json
|
||||
|
|
@ -1 +0,0 @@
|
|||
../web/package.json
|
||||
56
web-admin/package.json
Normal file
56
web-admin/package.json
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"name": "@happydomain/web-admin",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||
"format": "prettier --plugin-search-dir . --write .",
|
||||
"generate:api": "openapi-ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.0",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.22.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@types/node": "^25.0.10",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"prettier": "^3.4.0",
|
||||
"prettier-plugin-svelte": "^3.3.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-preprocess": "^6.0.0",
|
||||
"tslib": "^2.8.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@hey-api/openapi-ts": "^0.95.0",
|
||||
"@sveltestrap/sveltestrap": "^7.0.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"bootstrap-icons": "^1.13.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html-escaper": "^3.0.0",
|
||||
"sass": "^1.97.0",
|
||||
"sass-loader": "^16.0.0",
|
||||
"sveltekit-i18n": "^2.4.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -22,86 +22,282 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
Col,
|
||||
Collapse,
|
||||
Container,
|
||||
Icon,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
Row,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { getDomains, getProviders, getUsers } from '$lib/api-admin';
|
||||
import { fetchMetrics, firstLabel, singleValue, sumValues, type Metrics } from "$lib/metrics";
|
||||
import { formatBytes, formatDuration } from "$lib/utils";
|
||||
|
||||
// formatDuration in $lib/utils takes nanoseconds and may emit decimals;
|
||||
// metrics expose seconds and we want whole units, so floor to a unit
|
||||
// boundary before delegating.
|
||||
function fmtSeconds(s: number | undefined): string {
|
||||
if (s === undefined || !Number.isFinite(s)) return formatDuration(undefined);
|
||||
const sec = Math.floor(s);
|
||||
let unitNs: number;
|
||||
if (sec < 60) unitNs = 1e9;
|
||||
else if (sec < 3600) unitNs = 60 * 1e9;
|
||||
else if (sec < 86400) unitNs = 3600 * 1e9;
|
||||
else unitNs = 86400 * 1e9;
|
||||
return formatDuration(Math.floor((sec * 1e9) / unitNs) * unitNs);
|
||||
}
|
||||
import DatabaseBackupCard from "./DatabaseBackupCard.svelte";
|
||||
import TidyCard from "./TidyCard.svelte";
|
||||
|
||||
let totalUsers: number | undefined = $state();
|
||||
getUsers().then((res) => { totalUsers = res.data?.length || 0; });
|
||||
let metrics: Metrics | undefined = $state();
|
||||
let metricsError: string | undefined = $state();
|
||||
let lastUpdated: Date | undefined = $state();
|
||||
let isRefreshing = $state(false);
|
||||
let showMore = $state(false);
|
||||
let now = $state(Date.now() / 1000);
|
||||
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let tickTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
let totalDomains: number | undefined = $state();
|
||||
getDomains().then((res) => { totalDomains = res.data?.length || 0; });
|
||||
async function refresh() {
|
||||
isRefreshing = true;
|
||||
try {
|
||||
metrics = await fetchMetrics();
|
||||
metricsError = undefined;
|
||||
lastUpdated = new Date();
|
||||
} catch (err) {
|
||||
metricsError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
let totalProviders: number | undefined = $state();
|
||||
getProviders().then((res) => { totalProviders = res.data?.length || 0; });
|
||||
onMount(() => {
|
||||
refresh();
|
||||
refreshTimer = setInterval(refresh, 15000);
|
||||
tickTimer = setInterval(() => (now = Date.now() / 1000), 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
if (tickTimer) clearInterval(tickTimer);
|
||||
});
|
||||
|
||||
let totalUsers = $derived(singleValue(metrics ?? {}, "happydomain_registered_users"));
|
||||
let totalDomains = $derived(singleValue(metrics ?? {}, "happydomain_domains"));
|
||||
let totalProviders = $derived(singleValue(metrics ?? {}, "happydomain_providers"));
|
||||
let totalZones = $derived(singleValue(metrics ?? {}, "happydomain_zones"));
|
||||
let schedulerQueue = $derived(singleValue(metrics ?? {}, "happydomain_scheduler_queue_depth"));
|
||||
let schedulerWorkers = $derived(
|
||||
singleValue(metrics ?? {}, "happydomain_scheduler_active_workers"),
|
||||
);
|
||||
let httpInFlight = $derived(singleValue(metrics ?? {}, "happydomain_http_requests_in_flight"));
|
||||
let buildVersion = $derived(firstLabel(metrics ?? {}, "happydomain_build_info", "version"));
|
||||
|
||||
let httpRequestsTotal = $derived(sumValues(metrics ?? {}, "happydomain_http_requests_total"));
|
||||
let checksTotal = $derived(sumValues(metrics ?? {}, "happydomain_scheduler_checks_total"));
|
||||
let providerCallsTotal = $derived(
|
||||
sumValues(metrics ?? {}, "happydomain_provider_api_calls_total"),
|
||||
);
|
||||
let storageOpsTotal = $derived(
|
||||
sumValues(metrics ?? {}, "happydomain_storage_operations_total"),
|
||||
);
|
||||
let storageStatsErrors = $derived(
|
||||
sumValues(metrics ?? {}, "happydomain_storage_stats_errors_total"),
|
||||
);
|
||||
let goRoutines = $derived(singleValue(metrics ?? {}, "go_goroutines"));
|
||||
let goThreads = $derived(singleValue(metrics ?? {}, "go_threads"));
|
||||
let goMemAlloc = $derived(singleValue(metrics ?? {}, "go_memstats_alloc_bytes"));
|
||||
let processRSS = $derived(singleValue(metrics ?? {}, "process_resident_memory_bytes"));
|
||||
let processCPU = $derived(singleValue(metrics ?? {}, "process_cpu_seconds_total"));
|
||||
let processOpenFDs = $derived(singleValue(metrics ?? {}, "process_open_fds"));
|
||||
let processStart = $derived(singleValue(metrics ?? {}, "process_start_time_seconds"));
|
||||
let uptime = $derived(processStart === undefined ? undefined : now - processStart);
|
||||
|
||||
function fmt(v: number | undefined): string {
|
||||
if (v === undefined || !Number.isFinite(v)) return "—";
|
||||
return Math.round(v).toLocaleString();
|
||||
}
|
||||
|
||||
let checksFailed = $derived.by(() => {
|
||||
const samples = metrics?.["happydomain_scheduler_checks_total"];
|
||||
if (!samples) return undefined;
|
||||
return samples
|
||||
.filter((s) => {
|
||||
const st = s.labels["status"];
|
||||
return st && st !== "ok" && st !== "success";
|
||||
})
|
||||
.reduce((acc, s) => acc + s.value, 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet tile(label: string, value: string, sub: string | null, icon: string, color: string)}
|
||||
<Col>
|
||||
<Card body class="h-100 border-0 shadow-sm">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h6 class="text-muted text-uppercase small mb-0">{label}</h6>
|
||||
<i class="bi bi-{icon} text-{color}" style="font-size: 1.25rem;"></i>
|
||||
</div>
|
||||
<div class="fs-2 fw-semibold font-monospace">{value}</div>
|
||||
{#if sub}<div class="small text-muted">{sub}</div>{/if}
|
||||
</Card>
|
||||
</Col>
|
||||
{/snippet}
|
||||
|
||||
{#snippet row(label: string, value: string, badge: string | null)}
|
||||
<ListGroupItem class="d-flex justify-content-between align-items-center bg-transparent">
|
||||
<span class="text-muted small">{label}</span>
|
||||
<span class="font-monospace">
|
||||
{value}
|
||||
{#if badge}<Badge color="warning" class="text-dark ms-2">{badge}</Badge>{/if}
|
||||
</span>
|
||||
</ListGroupItem>
|
||||
{/snippet}
|
||||
|
||||
<Container class="flex-fill my-5">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1 class="display-4">
|
||||
<i class="bi bi-speedometer2"></i>
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="display-5 mb-1">
|
||||
<Icon name="speedometer2" class="text-primary"></Icon>
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<p class="text-muted">System overview and management</p>
|
||||
<p class="text-muted mb-0">
|
||||
Live telemetry from <code>/metrics</code>, refreshed every 15s.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" color="secondary" outline disabled={isRefreshing} on:click={refresh}>
|
||||
<Icon name="arrow-repeat" class="me-1 {isRefreshing ? 'spin' : ''}"></Icon>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="row row-cols-sm-2 row-cols-lg-3 g-4">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Total Users</h6>
|
||||
<h2 class="mb-0">{totalUsers}</h2>
|
||||
</div>
|
||||
<div class="text-primary">
|
||||
<i class="bi bi-people-fill" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Total Domains</h6>
|
||||
<h2 class="mb-0">{totalDomains}</h2>
|
||||
</div>
|
||||
<div class="text-primary">
|
||||
<i class="bi bi-globe" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Providers</h6>
|
||||
<h2 class="mb-0">{totalProviders}</h2>
|
||||
</div>
|
||||
<div class="text-primary">
|
||||
<i class="bi bi-hdd-network-fill" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
{#if buildVersion}
|
||||
<Badge class="bg-secondary-subtle text-secondary-emphasis border">
|
||||
<Icon name="tag" class="me-1"></Icon>v{buildVersion}
|
||||
</Badge>
|
||||
{/if}
|
||||
<Badge class="bg-secondary-subtle text-secondary-emphasis border tnum">
|
||||
<i class="bi bi-clock me-1"></i>uptime {fmtSeconds(uptime)}
|
||||
</Badge>
|
||||
<Badge class="bg-success-subtle text-success-emphasis border tnum">
|
||||
<Icon name="broadcast" class="me-1"></Icon>
|
||||
{lastUpdated ? `updated ${lastUpdated.toLocaleTimeString()}` : "connecting…"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{#if metricsError}
|
||||
<Alert color="warning" class="d-flex align-items-center">
|
||||
<Icon name="exclamation-triangle" class="me-2"></Icon>
|
||||
<div>Failed to load metrics: {metricsError}</div>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<h2 class="h5 text-muted text-uppercase small fw-bold mt-4 mb-3">Inventory</h2>
|
||||
<div class="row row-cols-2 row-cols-lg-4 g-3 mb-4">
|
||||
{@render tile("Users", fmt(totalUsers), "registered", "people-fill", "primary")}
|
||||
{@render tile("Domains", fmt(totalDomains), "managed", "globe2", "primary")}
|
||||
{@render tile(
|
||||
"Providers",
|
||||
fmt(totalProviders),
|
||||
"DNS backends",
|
||||
"hdd-network-fill",
|
||||
"primary",
|
||||
)}
|
||||
{@render tile("Zone snapshots", fmt(totalZones), "stored", "clock-history", "primary")}
|
||||
</div>
|
||||
|
||||
<h2 class="h5 text-muted text-uppercase small fw-bold mt-4 mb-3">Runtime</h2>
|
||||
<div class="row row-cols-2 row-cols-lg-4 g-3 mb-4">
|
||||
{@render tile("Checker queue", fmt(schedulerQueue), "queued", "list-task", "info")}
|
||||
{@render tile("Active workers", fmt(schedulerWorkers), "running", "cpu", "info")}
|
||||
{@render tile("HTTP in flight", fmt(httpInFlight), "serving", "arrow-left-right", "info")}
|
||||
{@render tile("Memory RSS", formatBytes(processRSS), "resident", "memory", "info")}
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-3">
|
||||
<Button
|
||||
color="link"
|
||||
class="text-decoration-none"
|
||||
onclick={() => (showMore = !showMore)}
|
||||
aria-expanded={showMore}
|
||||
>
|
||||
{showMore ? "Hide" : "Show"} detailed metrics
|
||||
<i class="bi bi-chevron-{showMore ? 'up' : 'down'} ms-1"></i>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Collapse isOpen={showMore}>
|
||||
<Card class="border-0 shadow-sm mb-4">
|
||||
<CardHeader class="bg-transparent">
|
||||
<h3 class="h6 mb-0">Detailed metrics</h3>
|
||||
</CardHeader>
|
||||
<Row class="g-0">
|
||||
<Col md={6}>
|
||||
<div class="px-3 pt-2 small text-uppercase text-muted fw-bold">
|
||||
Traffic & work
|
||||
</div>
|
||||
<ListGroup flush>
|
||||
{@render row("HTTP requests served", fmt(httpRequestsTotal), null)}
|
||||
{@render row(
|
||||
"Checks executed",
|
||||
fmt(checksTotal),
|
||||
checksFailed !== undefined && checksFailed > 0
|
||||
? `${fmt(checksFailed)} failed`
|
||||
: null,
|
||||
)}
|
||||
{@render row("Provider API calls", fmt(providerCallsTotal), null)}
|
||||
{@render row("Storage operations", fmt(storageOpsTotal), null)}
|
||||
{@render row(
|
||||
"Storage stats errors",
|
||||
fmt(storageStatsErrors),
|
||||
storageStatsErrors && storageStatsErrors > 0 ? "warn" : null,
|
||||
)}
|
||||
</ListGroup>
|
||||
</Col>
|
||||
<Col md={6}>
|
||||
<div class="px-3 pt-2 small text-uppercase text-muted fw-bold">
|
||||
Runtime & process
|
||||
</div>
|
||||
<ListGroup flush>
|
||||
{@render row("CPU time", fmtSeconds(processCPU), null)}
|
||||
{@render row("Heap allocated", formatBytes(goMemAlloc), null)}
|
||||
{@render row("Goroutines", fmt(goRoutines), null)}
|
||||
{@render row("OS threads", fmt(goThreads), null)}
|
||||
{@render row("Open file descriptors", fmt(processOpenFDs), null)}
|
||||
</ListGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
<CardFooter class="bg-transparent text-muted small">
|
||||
Source: <a href="/metrics" target="_blank" rel="noopener">/metrics</a> (Prometheus).
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Collapse>
|
||||
|
||||
<TidyCard class="my-4" />
|
||||
|
||||
<DatabaseBackupCard class="my-4" />
|
||||
</Container>
|
||||
|
||||
<style>
|
||||
.tnum {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.spin {
|
||||
display: inline-block;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "happyDomain",
|
||||
"name": "@happydomain/web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
import { Alert, Button, Card, Col, Icon, Row } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import { base } from "$lib/stores/config";
|
||||
import { checkers } from "$lib/stores/checkers";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import type {
|
||||
|
|
@ -66,6 +67,11 @@
|
|||
|
||||
let checkerDef = $derived($checkers?.[checkerId]);
|
||||
let intervalSpec = $derived(checkerDef?.interval);
|
||||
let metricsApiUrl = $derived(
|
||||
scope.zoneId && scope.subdomain !== undefined && scope.serviceId
|
||||
? `${base}/api/domains/${scope.domainId}/zone/${scope.zoneId}/${scope.subdomain}/services/${scope.serviceId}/checkers/${encodeURIComponent(checkerId)}/metrics`
|
||||
: `${base}/api/domains/${scope.domainId}/checkers/${encodeURIComponent(checkerId)}/metrics`
|
||||
);
|
||||
|
||||
let plan = $state<HappydnsCheckPlanWritable>({
|
||||
enabled: {},
|
||||
|
|
@ -92,6 +98,22 @@
|
|||
});
|
||||
});
|
||||
|
||||
async function copyMetricsUrl() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(metricsApiUrl);
|
||||
toasts.addToast({
|
||||
message: $t("checkers.list.prometheus-metrics-copied"),
|
||||
type: "success",
|
||||
timeout: 3000,
|
||||
});
|
||||
} catch (error) {
|
||||
toasts.addErrorToast({
|
||||
message: $t("checkers.list.prometheus-metrics-copy-failed", { error: String(error) }),
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOptions() {
|
||||
savingOptions = true;
|
||||
try {
|
||||
|
|
@ -128,6 +150,17 @@
|
|||
{$t("checkers.list.view-results")}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if checkerDef?.has_metrics}
|
||||
<Button
|
||||
color="secondary"
|
||||
outline
|
||||
onclick={copyMetricsUrl}
|
||||
title={metricsApiUrl}
|
||||
>
|
||||
<Icon name="graph-up-arrow"></Icon>
|
||||
{$t("checkers.list.prometheus-metrics")}
|
||||
</Button>
|
||||
{/if}
|
||||
</PageTitle>
|
||||
|
||||
{#await checkStatusPromise}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { Alert, Badge, Card, Icon, Table } from "@sveltestrap/sveltestrap";
|
||||
import { Alert, Badge, Button, Card, Icon, Table } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import { base } from "$lib/stores/config";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import type { CheckerScope } from "$lib/api/checkers";
|
||||
import { listScopedCheckers } from "$lib/api/checkers";
|
||||
import { checkers } from "$lib/stores/checkers";
|
||||
|
|
@ -45,6 +47,28 @@
|
|||
|
||||
let checkersPromise = $derived(listScopedCheckers(scope));
|
||||
|
||||
let metricsApiUrl = $derived(
|
||||
scope.zoneId && scope.subdomain !== undefined && scope.serviceId
|
||||
? `${base}/api/domains/${scope.domainId}/zone/${scope.zoneId}/${scope.subdomain}/services/${scope.serviceId}/checkers/metrics`
|
||||
: `${base}/api/domains/${scope.domainId}/checkers/metrics`
|
||||
);
|
||||
|
||||
async function copyMetricsUrl() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(metricsApiUrl);
|
||||
toasts.addToast({
|
||||
message: $t("checkers.list.prometheus-metrics-copied"),
|
||||
type: "success",
|
||||
timeout: 3000,
|
||||
});
|
||||
} catch (error) {
|
||||
toasts.addErrorToast({
|
||||
message: $t("checkers.list.prometheus-metrics-copy-failed", { error: String(error) }),
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getConfiguredCheckerIds(statuses: HappydnsCheckerStatus[]): Set<string> {
|
||||
return new Set(statuses.map((s) => s.id).filter((id): id is string => !!id));
|
||||
}
|
||||
|
|
@ -72,7 +96,17 @@
|
|||
</svelte:head>
|
||||
|
||||
<div class="flex-fill mt-1 mb-5">
|
||||
<PageTitle {title} domain={domainName}></PageTitle>
|
||||
<PageTitle {title} domain={domainName}>
|
||||
<Button
|
||||
color="secondary"
|
||||
outline
|
||||
onclick={copyMetricsUrl}
|
||||
title={metricsApiUrl}
|
||||
>
|
||||
<Icon name="graph-up-arrow"></Icon>
|
||||
{$t("checkers.list.prometheus-metrics")}
|
||||
</Button>
|
||||
</PageTitle>
|
||||
|
||||
{#await checkersPromise}
|
||||
<Card body>
|
||||
|
|
|
|||
|
|
@ -620,6 +620,9 @@
|
|||
"loading": "Loading checkers...",
|
||||
"no-checks": "No checks available for this domain.",
|
||||
"view-results": "View Results",
|
||||
"prometheus-metrics": "Prometheus Metrics",
|
||||
"prometheus-metrics-copied": "Prometheus metrics URL copied to clipboard",
|
||||
"prometheus-metrics-copy-failed": "Failed to copy Prometheus metrics URL: {{error}}",
|
||||
"configure": "Configure",
|
||||
"error-loading": "Error loading checkers: {{error}}",
|
||||
"table": {
|
||||
|
|
|
|||
94
web/src/lib/metrics.ts
Normal file
94
web/src/lib/metrics.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2022-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
|
||||
export type MetricSample = {
|
||||
name: string;
|
||||
labels: Record<string, string>;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type Metrics = Record<string, MetricSample[]>;
|
||||
|
||||
// Minimal Prometheus text format parser. Handles standard lines of the form
|
||||
// `metric_name{label="value",...} number` and ignores HELP/TYPE/comment lines.
|
||||
export function parsePrometheusText(text: string): Metrics {
|
||||
const out: Metrics = {};
|
||||
for (const rawLine of text.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
|
||||
// Split name(+labels) from value (last whitespace-separated token before optional timestamp).
|
||||
const braceEnd = line.indexOf("}");
|
||||
let head: string;
|
||||
let rest: string;
|
||||
if (braceEnd >= 0) {
|
||||
head = line.slice(0, braceEnd + 1);
|
||||
rest = line.slice(braceEnd + 1).trim();
|
||||
} else {
|
||||
const sp = line.indexOf(" ");
|
||||
if (sp < 0) continue;
|
||||
head = line.slice(0, sp);
|
||||
rest = line.slice(sp + 1).trim();
|
||||
}
|
||||
|
||||
const valueToken = rest.split(/\s+/)[0];
|
||||
const value = Number(valueToken);
|
||||
if (!Number.isFinite(value)) continue;
|
||||
|
||||
let name = head;
|
||||
const labels: Record<string, string> = {};
|
||||
const lb = head.indexOf("{");
|
||||
if (lb >= 0) {
|
||||
name = head.slice(0, lb);
|
||||
const labelStr = head.slice(lb + 1, head.lastIndexOf("}"));
|
||||
// Naive label parser; sufficient for values without escaped quotes/commas
|
||||
const re = /([a-zA-Z_][a-zA-Z0-9_]*)="((?:[^"\\]|\\.)*)"/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(labelStr)) !== null) {
|
||||
labels[m[1]] = m[2].replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||
}
|
||||
}
|
||||
|
||||
(out[name] ||= []).push({ name, labels, value });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function fetchMetrics(): Promise<Metrics> {
|
||||
const res = await fetch("/metrics", { headers: { Accept: "text/plain" } });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch /metrics: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return parsePrometheusText(await res.text());
|
||||
}
|
||||
|
||||
// Returns the single value of a metric, or undefined if absent.
|
||||
export function singleValue(metrics: Metrics, name: string): number | undefined {
|
||||
const samples = metrics[name];
|
||||
if (!samples || samples.length === 0) return undefined;
|
||||
return samples[0].value;
|
||||
}
|
||||
|
||||
// Sums all samples of a metric (useful for *_total counters with labels).
|
||||
export function sumValues(metrics: Metrics, name: string): number | undefined {
|
||||
const samples = metrics[name];
|
||||
if (!samples || samples.length === 0) return undefined;
|
||||
return samples.reduce((acc, s) => acc + s.value, 0);
|
||||
}
|
||||
|
||||
// Returns the first label value found for a metric (e.g. build version).
|
||||
export function firstLabel(metrics: Metrics, name: string, label: string): string | undefined {
|
||||
const samples = metrics[name];
|
||||
if (!samples || samples.length === 0) return undefined;
|
||||
return samples[0].labels[label];
|
||||
}
|
||||
16
web/src/lib/utils/format.ts
Normal file
16
web/src/lib/utils/format.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Format a byte count into a human-readable IEC string (KiB, MiB, ...).
|
||||
* @param n Number of bytes
|
||||
* @returns Human-readable string such as "1.4 MiB", or "—" if undefined
|
||||
*/
|
||||
export function formatBytes(n: number | undefined): string {
|
||||
if (n === undefined || !Number.isFinite(n)) return "—";
|
||||
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let i = 0;
|
||||
let v = n;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
|
@ -3,4 +3,5 @@
|
|||
*/
|
||||
|
||||
export { toDatetimeLocal, fromDatetimeLocal, formatDuration } from './datetime';
|
||||
export { formatBytes } from './format';
|
||||
export { getStatusColor, getStatusIcon, getStatusI18nKey, getExecutionStatusColor, getExecutionStatusI18nKey, formatCheckDate, withInheritedPlaceholders, splitPositionalOptions, downloadBlob, collectAllOptionDocs, availabilityBadges, getOrphanedOptionKeys, filterValidOptions } from './checkers';
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@
|
|||
</div>
|
||||
{:then sessions}
|
||||
<ListGroup>
|
||||
{#each sessions as session (session.id)}
|
||||
{#each [...sessions].sort((a, b) => new Date(b.upd || b.time).getTime() - new Date(a.upd || a.time).getTime()) as session (session.id)}
|
||||
<ListGroupItem class="d-flex align-items-center justify-content-between">
|
||||
<div class="flex-fill" style="max-width:90%">
|
||||
<div class="text-truncate">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue