Compare commits

..

11 commits

Author SHA1 Message Date
c7fe67c65b notification: wire notification system into app lifecycle
Some checks failed
continuous-integration/drone/push Build is failing
Configure notification senders (email, webhook, UnifiedPush) and
dispatcher in initUsecases(). Connect the execution callback from the
checker engine to the dispatcher. Pass notification dependencies
through to the API router.

Add NotificationRetentionDays config option (default 90 days).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:14:07 +07:00
8a65bf1af2 notification: add API endpoints for channels, preferences, history, and acknowledgement
New endpoints under /api/notifications:
- CRUD for notification channels (email, webhook, UnifiedPush)
- CRUD for notification preferences (global, per-domain, per-service)
- Notification history listing
- Test notification endpoint

Acknowledgement endpoints added to scoped checker routes:
- POST /api/domains/:domain/checkers/:checkerId/acknowledge
- DELETE /api/domains/:domain/checkers/:checkerId/acknowledge

Thread NotificationController through route declarations for scoped
checker routes (domain and service level).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:09:29 +07:00
196fd1a061 checker: add execution callback for notification integration
Add ExecutionCallbackSetter interface and onComplete field to the
checker engine. After a successful execution, the callback is fired
asynchronously so it never blocks the checker pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:08:13 +07:00
29c1d2502e notification: add dispatcher with state tracking and acknowledgement
The Dispatcher is the core notification logic: it receives execution
callbacks, detects status transitions via persisted NotificationState,
resolves user preferences by specificity (service > domain > global),
respects quiet hours, and dispatches through configured channels.

Acknowledgement support allows users to suppress repeat notifications
until the next state change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:08:13 +07:00
c2225ddc5e notification: add channel senders for email, webhook, and UnifiedPush
Implement ChannelSender interface with three backends:
- EmailSender: reuses existing Mailer, sends Markdown-formatted alerts
- WebhookSender: HTTP POST with JSON payload and optional HMAC signature
- UnifiedPushSender: HTTP POST following the UnifiedPush protocol

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:08:13 +07:00
48455218d5 notification: add storage interfaces and KV implementations
Introduce NotificationChannelStorage, NotificationPreferenceStorage,
NotificationStateStorage, and NotificationRecordStorage interfaces
with KV-based implementations. Extend the Storage composite interface
and add migration 10 (no-op for KV prefix-based storage).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:08:13 +07:00
263df02e4c notification: add notification models and error sentinels
Add NotificationChannel, NotificationPreference, NotificationState,
and NotificationRecord models for the upcoming notification system.
These models support email, webhook, and UnifiedPush channels with
per-user/domain/service preference scoping and state deduplication.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:08:13 +07:00
7a51b2e540 Add Prometheus export documentation
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-04-16 17:02:31 +07:00
195509f48a web: add Prometheus metrics URL link to checker config page 2026-04-16 17:02:31 +07:00
92df4a58b4 web-admin: wire dashboard to /metrics with collapsible details
Replaces the three REST count calls with a single Prometheus scrape that
auto-refreshes every 15s, surfaces queue/worker/in-flight/RSS/version/uptime
as featured cards, and tucks counters and Go runtime stats under a
"Show more metrics" Collapse.
2026-04-16 17:02:31 +07:00
6fd9d08195 checkers: add filter predicate to ListExecutionsBy* storage methods
Metrics endpoints now skip incomplete/planned executions by passing a
`doneExecution` filter so only fully-evaluated runs contribute to the
Prometheus output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:02:31 +07:00
43 changed files with 357 additions and 695 deletions

View file

@ -1,14 +0,0 @@
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 }}

View file

@ -28,8 +28,8 @@ steps:
commands:
- apk --no-cache add tar
- yarn config set network-timeout 100000
- 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/
- yarn --cwd web install
- tar --transform="s@.@./happydomain-${DRONE_COMMIT}@" --exclude-vcs --exclude=./web/node_modules/.cache -czf /dev/shm/happydomain-src.tar.gz .
- 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,25 +37,14 @@ 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 -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 -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/
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
environment:
CGO_ENABLED: 0
GOFLAGS: "-tags=netgo,swagger,web"
when:
event:
exclude:
@ -65,16 +54,10 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- 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/
- 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/
- 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
@ -171,7 +154,11 @@ steps:
from_secret: git_nemunaire_token
base_url: https://git.nemunai.re
draft: true
files: deploy/*
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
when:
event:
- tag
@ -183,7 +170,11 @@ steps:
from_secret: codeberg_token
base_url: https://codeberg.org
draft: true
files: deploy/*
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
when:
event:
- tag
@ -195,7 +186,11 @@ steps:
from_secret: github_release_token
draft: true
github_url: https://github.com
files: deploy/*
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
when:
event:
- tag
@ -237,8 +232,8 @@ steps:
- name: frontend
image: node:24-alpine
commands:
- npm install --network-timeout=100000
- cd web
- npm install --network-timeout=100000
- npx svelte-kit sync && npm run generate:api
- npm test
- npm run build
@ -367,7 +362,9 @@ steps:
base_url: https://git.nemunai.re
draft: true
prerelease: true
files: deploy/*
files:
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
when:
event:
- tag
@ -380,7 +377,9 @@ steps:
base_url: https://codeberg.org
draft: true
prerelease: true
files: deploy/*
files:
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
when:
event:
- tag
@ -393,7 +392,9 @@ steps:
draft: true
prerelease: true
github_url: https://github.com
files: deploy/*
files:
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
when:
event:
- tag

1
.gitignore vendored
View file

@ -1,3 +1,2 @@
./happydomain
./happyDomain
node_modules

2
go.mod
View file

@ -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.8
github.com/oracle/nosql-go-sdk v1.4.7
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
View file

@ -135,10 +135,6 @@ 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=
@ -476,8 +472,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.8 h1:eMzz+yNLHvB0GCPAWxe0qYttBJF7Fh0Aup+zectpgc4=
github.com/oracle/nosql-go-sdk v1.4.8/go.mod h1:xgJE9wxADDbk7vR4FGA4NOt4RNAaIsQOj4sCATmCVXM=
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/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=
@ -633,8 +629,6 @@ 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=
@ -654,6 +648,7 @@ 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=

View file

@ -120,46 +120,6 @@ 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
}
@ -215,29 +175,6 @@ 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
}

View file

@ -22,9 +22,6 @@
package controller
import (
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/model"
@ -44,25 +41,14 @@ 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. 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.
// @Description Performs cleanup and maintenance operations on the database, removing orphaned records and optimizing storage.
// @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) {
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))
happydns.ApiResponse(c, true, tc.tidyUpService.TidyAll())
}

View file

@ -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 %s: %v", cname, exec.Id.String(), err)
log.Printf("async RunExecution error for checker %q execution %v: %v", cname, exec.Id, err)
}
}()
c.JSON(http.StatusAccepted, exec)

View file

@ -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.String(), user.Id.String())
log.Printf("%s: tries to do action as %s (logged %s)", c.ClientIP(), myuser.Id, user.Id)
c.AbortWithStatusJSON(http.StatusForbidden, happydns.ErrorResponse{Message: "Not authorized"})
return
}

View file

@ -559,21 +559,6 @@ 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)

View file

@ -106,24 +106,6 @@ 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)

View file

@ -138,16 +138,6 @@ 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 {

View file

@ -123,40 +123,6 @@ 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)

View file

@ -76,7 +76,6 @@ func (it *KVIterator[T]) NextWithError() bool {
}
return true
}
it.err = nil
return false
}

View file

@ -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,12 +815,7 @@ func migrateFrom7(s *KVStorage) error {
return nil, err
}
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.")))
}
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
}

View file

@ -22,7 +22,6 @@
package database
import (
"fmt"
"log"
)
@ -34,15 +33,6 @@ 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

View file

@ -374,11 +374,6 @@ 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

View file

@ -102,7 +102,6 @@ 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 }
@ -493,7 +492,6 @@ 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 }

View file

@ -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 !serviceCheckerApplies(c.def, svc.Type) {
if len(c.def.Availability.LimitToServices) > 0 && !slices.Contains(c.def.Availability.LimitToServices, 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 !serviceCheckerApplies(def, svc.Type) {
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, 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 !serviceCheckerApplies(def, svc.Type) {
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
continue
}
sid := svc.Id
@ -627,20 +627,10 @@ func (s *Scheduler) NotifyDomainRemoved(domainID happydns.Identifier) {
s.mu.Unlock()
if n > 0 {
log.Printf("Scheduler: NotifyDomainRemoved(%s): removed %d jobs", domainID.String(), n)
log.Printf("Scheduler: NotifyDomainRemoved(%s): removed %d jobs", domainID, 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)
@ -741,7 +731,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].String(), domain.DomainName, err)
log.Printf("Scheduler: failed to load zone %s for domain %s: %v", domain.ZoneHistory[idx], domain.DomainName, err)
continue
}
for _, svcs := range zone.Services {

View file

@ -121,9 +121,8 @@ 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) RestoreCheckPlan(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) DeleteCheckPlan(happydns.Identifier) error { return nil }
func (s *mockPlanStore) TidyCheckPlanIndexes() error { return nil }
func (s *mockPlanStore) ClearCheckPlans() error { return nil }

View file

@ -61,7 +61,6 @@ 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
@ -85,7 +84,6 @@ 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
@ -102,7 +100,6 @@ type ExecutionStorage interface {
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

View file

@ -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].String(), prevErr)
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[1], prevErr)
} else {
zoneUC.ReassociateMetadata(prevZone.Services, services, domain.DomainName, defaultTTL)
}

View file

@ -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].String(), err)
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[0], err)
} else {
zoneUC.ReassociateMetadata(prevZone.Services, services, domain.DomainName, defaultTTL)
}

View file

@ -40,33 +40,8 @@ func NewTidyUpUsecase(store storage.Storage) happydns.TidyUpUseCase {
}
}
// 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{
func (tu *tidyUpUsecase) TidyAll() error {
for _, tidy := range []func() error{
tu.TidySessions,
tu.TidyAuthUsers,
tu.TidyUsers,
@ -81,22 +56,24 @@ func (tu *tidyUpUsecase) TidyAll(dropInvalid bool) error {
tu.TidySnapshots,
tu.TidyObservationCache,
} {
if err := tidy(dropInvalid); err != nil {
if err := tidy(); err != nil {
return err
}
}
return nil
}
func (tu *tidyUpUsecase) TidyAuthUsers(dropInvalid bool) error {
func (tu *tidyUpUsecase) TidyAuthUsers() error {
iter, err := tu.store.ListAllAuthUsers()
if err != nil {
return err
}
defer iter.Close()
return iterateTidy(iter, dropInvalid, func(userAuth *happydns.UserAuth) error {
_, err := tu.store.GetUser(userAuth.Id)
for iter.Next() {
userAuth := iter.Item()
_, 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)
@ -104,18 +81,21 @@ func (tu *tidyUpUsecase) TidyAuthUsers(dropInvalid bool) error {
return err
}
}
return nil
})
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidyCheckEvaluations(dropInvalid bool) error {
func (tu *tidyUpUsecase) TidyCheckEvaluations() error {
iter, err := tu.store.ListAllEvaluations()
if err != nil {
return err
}
defer iter.Close()
err = iterateTidy(iter, dropInvalid, func(eval *happydns.CheckEvaluation) error {
for iter.Next() {
eval := iter.Item()
drop := false
if eval.Target.UserId != "" {
@ -139,34 +119,36 @@ func (tu *tidyUpUsecase) TidyCheckEvaluations(dropInvalid bool) 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
}
}
return nil
})
if err != nil {
}
if err = iter.Err(); err != nil {
return err
}
return tu.store.TidyEvaluationIndexes()
}
func (tu *tidyUpUsecase) TidyCheckPlans(dropInvalid bool) error {
func (tu *tidyUpUsecase) TidyCheckPlans() error {
iter, err := tu.store.ListAllCheckPlans()
if err != nil {
return err
}
defer iter.Close()
err = iterateTidy(iter, dropInvalid, func(plan *happydns.CheckPlan) error {
for iter.Next() {
plan := iter.Item()
if plan.Target.UserId != "" {
userId, err := happydns.NewIdentifierFromString(plan.Target.UserId)
if err == nil {
@ -175,7 +157,10 @@ func (tu *tidyUpUsecase) TidyCheckPlans(dropInvalid bool) 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)
return iter.DropItem()
if err = iter.DropItem(); err != nil {
return err
}
continue
}
}
}
@ -188,31 +173,39 @@ func (tu *tidyUpUsecase) TidyCheckPlans(dropInvalid bool) 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)
return iter.DropItem()
if err = iter.DropItem(); err != nil {
return err
}
continue
}
}
}
return nil
})
if err != nil {
}
if err := iter.Err(); err != nil {
return err
}
return tu.store.TidyCheckPlanIndexes()
}
func (tu *tidyUpUsecase) TidyCheckerConfigurations(dropInvalid bool) error {
func (tu *tidyUpUsecase) TidyCheckerConfigurations() error {
iter, err := tu.store.ListAllCheckerConfigurations()
if err != nil {
return err
}
defer iter.Close()
return iterateTidy(iter, dropInvalid, func(cfg *happydns.CheckerOptionsPositional) error {
for iter.Next() {
cfg := iter.Item()
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)
return iter.DropItem()
if err = iter.DropItem(); err != nil {
return err
}
continue
} else if err != nil {
return err
}
@ -222,7 +215,10 @@ func (tu *tidyUpUsecase) TidyCheckerConfigurations(dropInvalid bool) 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)
return iter.DropItem()
if err = iter.DropItem(); err != nil {
return err
}
continue
} else if err != nil {
return err
}
@ -257,22 +253,28 @@ func (tu *tidyUpUsecase) TidyCheckerConfigurations(dropInvalid bool) 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)
return iter.DropItem()
if err = iter.DropItem(); err != nil {
return err
}
continue
}
}
}
return nil
})
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidyExecutions(dropInvalid bool) error {
func (tu *tidyUpUsecase) TidyExecutions() error {
iter, err := tu.store.ListAllExecutions()
if err != nil {
return err
}
defer iter.Close()
err = iterateTidy(iter, dropInvalid, func(exec *happydns.Execution) error {
for iter.Next() {
exec := iter.Item()
drop := false
if exec.Target.UserId != "" {
@ -296,53 +298,58 @@ func (tu *tidyUpUsecase) TidyExecutions(dropInvalid bool) 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
}
}
return nil
})
if err != nil {
}
if err = iter.Err(); err != nil {
return err
}
return tu.store.TidyExecutionIndexes()
}
func (tu *tidyUpUsecase) TidyObservationCache(dropInvalid bool) error {
func (tu *tidyUpUsecase) TidyObservationCache() error {
iter, err := tu.store.ListAllCachedObservations()
if err != nil {
return err
}
defer iter.Close()
return iterateTidy(iter, dropInvalid, func(entry *happydns.ObservationCacheEntry) error {
if _, err := tu.store.GetSnapshot(entry.SnapshotID); errors.Is(err, happydns.ErrSnapshotNotFound) {
for iter.Next() {
entry := iter.Item()
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 nil
})
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidyDomains(dropInvalid bool) error {
func (tu *tidyUpUsecase) TidyDomains() error {
iter, err := tu.store.ListAllDomains()
if err != nil {
return err
}
defer iter.Close()
return iterateTidy(iter, dropInvalid, func(domain *happydns.Domain) error {
if _, err := tu.store.GetUser(domain.Owner); errors.Is(err, happydns.ErrUserNotFound) {
for iter.Next() {
domain := iter.Item()
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 {
@ -350,45 +357,51 @@ func (tu *tidyUpUsecase) TidyDomains(dropInvalid bool) 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 nil
})
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidyDomainLogs(dropInvalid bool) error {
func (tu *tidyUpUsecase) TidyDomainLogs() error {
iter, err := tu.store.ListAllDomainLogs()
if err != nil {
return err
}
defer iter.Close()
return iterateTidy(iter, dropInvalid, func(l *happydns.DomainLogWithDomainId) error {
if _, err := tu.store.GetDomain(l.DomainId); errors.Is(err, happydns.ErrDomainNotFound) {
for iter.Next() {
l := iter.Item()
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 nil
})
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidyProviders(dropInvalid bool) error {
func (tu *tidyUpUsecase) TidyProviders() error {
iter, err := tu.store.ListAllProviders()
if err != nil {
return err
}
defer iter.Close()
return iterateTidy(iter, dropInvalid, func(prvd *happydns.ProviderMessage) error {
_, err := tu.store.GetUser(prvd.Owner)
for iter.Next() {
prvd := iter.Item()
_, 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)
@ -396,19 +409,22 @@ func (tu *tidyUpUsecase) TidyProviders(dropInvalid bool) error {
return err
}
}
return nil
})
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidySessions(dropInvalid bool) error {
func (tu *tidyUpUsecase) TidySessions() error {
iter, err := tu.store.ListAllSessions()
if err != nil {
return err
}
defer iter.Close()
return iterateTidy(iter, dropInvalid, func(session *happydns.Session) error {
_, err := tu.store.GetUser(session.IdUser)
for iter.Next() {
session := iter.Item()
_, 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)
@ -416,11 +432,12 @@ func (tu *tidyUpUsecase) TidySessions(dropInvalid bool) error {
return err
}
}
return nil
})
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidySnapshots(dropInvalid bool) error {
func (tu *tidyUpUsecase) TidySnapshots() error {
// Collect all snapshot IDs referenced by evaluations.
evalIter, err := tu.store.ListAllEvaluations()
if err != nil {
@ -429,12 +446,13 @@ func (tu *tidyUpUsecase) TidySnapshots(dropInvalid bool) error {
defer evalIter.Close()
referencedSnapshots := make(map[string]struct{})
if err = iterateTidy(evalIter, dropInvalid, func(eval *happydns.CheckEvaluation) error {
for evalIter.Next() {
eval := evalIter.Item()
if !eval.SnapshotID.IsEmpty() {
referencedSnapshots[eval.SnapshotID.String()] = struct{}{}
}
return nil
}); err != nil {
}
if err = evalIter.Err(); err != nil {
return err
}
@ -445,39 +463,44 @@ func (tu *tidyUpUsecase) TidySnapshots(dropInvalid bool) error {
}
defer iter.Close()
return iterateTidy(iter, dropInvalid, func(snap *happydns.ObservationSnapshot) error {
for iter.Next() {
snap := iter.Item()
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 nil
})
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidyUsers(dropInvalid bool) error {
func (tu *tidyUpUsecase) TidyUsers() error {
iter, err := tu.store.ListAllAuthUsers()
if err != nil {
return err
}
defer iter.Close()
return iterateTidy(iter, dropInvalid, func(authUser *happydns.UserAuth) error {
for iter.Next() {
authUser := iter.Item()
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 nil
})
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidyZones(dropInvalid bool) error {
func (tu *tidyUpUsecase) TidyZones() error {
iterdn, err := tu.store.ListAllDomains()
if err != nil {
return err
@ -485,10 +508,15 @@ func (tu *tidyUpUsecase) TidyZones(dropInvalid bool) error {
defer iterdn.Close()
var referencedZones []happydns.Identifier
if err = iterateTidy(iterdn, dropInvalid, func(domain *happydns.Domain) error {
referencedZones = append(referencedZones, domain.ZoneHistory...)
return nil
}); err != nil {
for iterdn.Next() {
domain := iterdn.Item()
for _, zh := range domain.ZoneHistory {
referencedZones = append(referencedZones, zh)
}
}
if err = iterdn.Err(); err != nil {
return err
}
@ -498,7 +526,9 @@ func (tu *tidyUpUsecase) TidyZones(dropInvalid bool) error {
}
defer iter.Close()
return iterateTidy(iter, dropInvalid, func(zone *happydns.ZoneMessage) error {
for iter.Next() {
zone := iter.Item()
foundZone := false
for _, zid := range referencedZones {
if zid.Equals(zone.Id) {
@ -510,10 +540,11 @@ func (tu *tidyUpUsecase) TidyZones(dropInvalid bool) 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 nil
})
}
return iter.Err()
}

View file

@ -80,7 +80,7 @@ func TestTidyObservationCache_RemovesStaleEntries(t *testing.T) {
// Run tidy.
tu := usecase.NewTidyUpUsecase(store)
if err := tu.TidyObservationCache(true); err != nil {
if err := tu.TidyObservationCache(); 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(true); err != nil {
if err := tu.TidyObservationCache(); err != nil {
t.Fatalf("TidyObservationCache() on empty cache error: %v", err)
}
}

View file

@ -22,17 +22,13 @@
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
CheckerConfigurations []*CheckerOptionsPositional
CheckPlans []*CheckPlan
CheckEvaluations []*CheckEvaluation
Executions []*Execution
Version int
Domains []*Domain
DomainsLogs map[string][]*DomainLog
Errors []string
Providers []*ProviderMessage
Sessions []*Session
Users []*User
UsersAuth UserAuths
Zones []*ZoneMessage
}

View file

@ -24,21 +24,18 @@ package happydns
import ()
type TidyUpUseCase interface {
// 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
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
}

View file

@ -1,12 +0,0 @@
{
"name": "happydomain-monorepo",
"version": "0.0.1",
"private": true,
"workspaces": [
"web",
"web-admin"
],
"resolutions": {
"vite": "^8.0.0"
}
}

View file

@ -56,13 +56,6 @@ 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
}

View file

@ -315,7 +315,6 @@ func inferOperation(name string) string {
{"Count", "count"},
{"Create", "create"},
{"Update", "update"},
{"Restore", "restore"},
{"Delete", "delete"},
{"Clear", "delete"},
{"Set", "set"},

1
web-admin/.npmrc Symbolic link
View file

@ -0,0 +1 @@
../web/.npmrc

View file

@ -1,13 +0,0 @@
.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
web-admin/.prettierignore Symbolic link
View file

@ -0,0 +1 @@
../web/.prettierignore

1
web-admin/.prettierrc Symbolic link
View file

@ -0,0 +1 @@
../web/.prettierrc

1
web-admin/eslint.config.js Symbolic link
View file

@ -0,0 +1 @@
../web/eslint.config.js

1
web-admin/node_modules Symbolic link
View file

@ -0,0 +1 @@
../web/node_modules/

1
web-admin/package-lock.json generated Symbolic link
View file

@ -0,0 +1 @@
../web/package-lock.json

View file

@ -1,56 +0,0 @@
{
"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"
}
}

1
web-admin/package.json Symbolic link
View file

@ -0,0 +1 @@
../web/package.json

View file

View file

@ -1,16 +1,48 @@
{
"name": "happydomain-monorepo",
"name": "happyDomain",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "happydomain-monorepo",
"name": "happyDomain",
"version": "0.0.1",
"workspaces": [
"web",
"web-admin"
]
"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"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
@ -195,14 +227,6 @@
"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",
@ -308,43 +332,29 @@
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanfs/core": "^0.19.1",
"@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",
@ -431,9 +441,9 @@
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"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==",
"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==",
"dev": true,
"license": "MIT",
"optional": true,
@ -1259,17 +1269,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz",
"integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==",
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@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",
"@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",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@ -1282,7 +1292,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.58.2",
"@typescript-eslint/parser": "^8.58.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@ -1298,16 +1308,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz",
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz",
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@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",
"@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",
"debug": "^4.4.3"
},
"engines": {
@ -1323,14 +1333,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"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==",
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.58.2",
"@typescript-eslint/types": "^8.58.2",
"@typescript-eslint/tsconfig-utils": "^8.58.1",
"@typescript-eslint/types": "^8.58.1",
"debug": "^4.4.3"
},
"engines": {
@ -1345,14 +1355,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"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==",
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz",
"integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/visitor-keys": "8.58.2"
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/visitor-keys": "8.58.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1363,9 +1373,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"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==",
"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==",
"dev": true,
"license": "MIT",
"engines": {
@ -1380,15 +1390,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz",
"integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==",
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/typescript-estree": "8.58.2",
"@typescript-eslint/utils": "8.58.2",
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/typescript-estree": "8.58.1",
"@typescript-eslint/utils": "8.58.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@ -1405,9 +1415,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz",
"integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==",
"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==",
"devOptional": true,
"license": "MIT",
"engines": {
@ -1419,16 +1429,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz",
"integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==",
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz",
"integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@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",
"@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",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@ -1447,16 +1457,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz",
"integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==",
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz",
"integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/typescript-estree": "8.58.2"
"@typescript-eslint/scope-manager": "8.58.1",
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/typescript-estree": "8.58.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1471,13 +1481,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz",
"integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==",
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz",
"integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/types": "8.58.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@ -2072,18 +2082,18 @@
}
},
"node_modules/eslint": {
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz",
"integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz",
"integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.5",
"@eslint/config-helpers": "^0.5.5",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.1",
"@eslint/config-array": "^0.23.4",
"@eslint/config-helpers": "^0.5.4",
"@eslint/core": "^1.2.0",
"@eslint/plugin-kit": "^0.7.0",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@ -3292,9 +3302,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
"dev": true,
"funding": [
{
@ -3451,9 +3461,9 @@
}
},
"node_modules/prettier": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz",
"integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==",
"dev": true,
"license": "MIT",
"bin": {
@ -3745,16 +3755,16 @@
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
"dev": true,
"license": "MIT"
},
"node_modules/svelte": {
"version": "5.55.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
"integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==",
"version": "5.55.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.3.tgz",
"integrity": "sha512-dS1N+i3bA1v+c4UDb750MlN5vCO82G6vxh8HeTsPsTdJ1BLsN1zxSyDlIdBBqUjqZ/BxEwM8UrFf98aaoVnZFQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@ -4083,16 +4093,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz",
"integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==",
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz",
"integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@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"
"@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"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -4410,86 +4420,6 @@
"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"
}
}
}
}

View file

@ -1,5 +1,5 @@
{
"name": "@happydomain/web",
"name": "happyDomain",
"version": "0.0.1",
"private": true,
"scripts": {

View file

@ -132,7 +132,7 @@
</div>
{:then sessions}
<ListGroup>
{#each [...sessions].sort((a, b) => new Date(b.upd || b.time).getTime() - new Date(a.upd || a.time).getTime()) as session (session.id)}
{#each sessions 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">