Merge branch 'view-ui'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ca0f148b78
29 files changed
+620
-52
No files matched your search
@@ -52,6 +52,10 @@ jobs:
|
|||||||
run: make test
|
run: make test
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
|
env:
|
||||||
|
# Checked against VERSION above; don't depend on the checkout
|
||||||
|
# having the tag.
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
export DEB_MAINTAINER="$(git log -1 --format='%an <%ae>')"
|
export DEB_MAINTAINER="$(git log -1 --format='%an <%ae>')"
|
||||||
make dist
|
make dist
|
||||||
|
|||||||
@@ -71,9 +71,32 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
zone (`example.com` or `JARL_TEST_ZONE`) and a reverse zone, starts named
|
zone (`example.com` or `JARL_TEST_ZONE`) and a reverse zone, starts named
|
||||||
and prints the steps to install Jarl and adopt the configuration
|
and prints the steps to install Jarl and adopt the configuration
|
||||||
(docs/development.md).
|
(docs/development.md).
|
||||||
|
- View colours. Give a view a colour (Views → Edit, `jarl view color`, or
|
||||||
|
`PUT /views/{id}/color`) and the zone list, the zone page and the views
|
||||||
|
list show its name in that colour. The colours are muted, and pastel in
|
||||||
|
dark mode. The default is the link colour. A colour isn't configuration,
|
||||||
|
so setting one needs no changeset or deploy.
|
||||||
|
- A "← Zones" link above a zone, back to the zone list, and "← History"
|
||||||
|
above a changeset. The zone list keeps its filter, view and type in the
|
||||||
|
address, so going back to it (with that link or the browser's back
|
||||||
|
button) restores them. A view opened from a zone or the zone list has a
|
||||||
|
"← example.com (external)" or "← Zones" link back.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- The version moved from the header to a footer: "Powered by Jarl v0.1.0",
|
||||||
|
linking to the release on Gitea. A build that isn't of a release tag
|
||||||
|
shows its commit instead, linked to the commit and marked `-modified`
|
||||||
|
with uncommitted changes. Before, every build of a commit after a
|
||||||
|
release still showed the release's version. `jarld version`,
|
||||||
|
`jarl --version`, `GET /status` and `/auth/whoami` report the same, the
|
||||||
|
latter two as `build` (docs/development.md).
|
||||||
|
- The view in the zone list, and on the zone page, links to that view,
|
||||||
|
and the views page highlights it. It used to be plain text, and the zone
|
||||||
|
page linked to the views page as a whole.
|
||||||
|
- Switching a zone's tabs (Records, Settings, …) no longer blanks the page
|
||||||
|
while the tab loads: the page changes once the tab is ready, and
|
||||||
|
"Loading…" appears only if a page takes a while.
|
||||||
- `jarl zone serials` compares the serial named serves, which for a signed
|
- `jarl zone serials` compares the serial named serves, which for a signed
|
||||||
zone is the signed zone's (ahead of Jarl's).
|
zone is the signed zone's (ahead of Jarl's).
|
||||||
- `jarld import` turns `dnssec-policy` in an existing configuration into
|
- `jarld import` turns `dnssec-policy` in an existing configuration into
|
||||||
|
|||||||
@@ -14,7 +14,11 @@
|
|||||||
# (jarl-cli-<version>-<os>-<arch>[.exe])
|
# (jarl-cli-<version>-<os>-<arch>[.exe])
|
||||||
|
|
||||||
VERSION ?= $(shell cat VERSION 2>/dev/null || echo 0.0.0-dev)
|
VERSION ?= $(shell cat VERSION 2>/dev/null || echo 0.0.0-dev)
|
||||||
LDFLAGS := -s -w -X jarl/internal/version.Version=$(VERSION)
|
# RELEASE_TAG marks a build of a release (the web UI then shows and links
|
||||||
|
# the release rather than the commit): set when HEAD is tagged v$(VERSION)
|
||||||
|
# and no tracked file is changed.
|
||||||
|
RELEASE_TAG ?= $(shell t=v$(VERSION); git tag --points-at HEAD 2>/dev/null | grep -qx "$$t" && git diff --quiet HEAD 2>/dev/null && echo $$t)
|
||||||
|
LDFLAGS := -s -w -X jarl/internal/version.Version=$(VERSION) -X jarl/internal/version.Tag=$(RELEASE_TAG)
|
||||||
GOFLAGS := -trimpath
|
GOFLAGS := -trimpath
|
||||||
export CGO_ENABLED := 0
|
export CGO_ENABLED := 0
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"jarl/internal/api"
|
"jarl/internal/api"
|
||||||
|
"jarl/internal/model"
|
||||||
"jarl/internal/rr"
|
"jarl/internal/rr"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,6 +72,8 @@ func argCompletion(c *cobra.Command) cobra.CompletionFunc {
|
|||||||
return lookup(cmd, "zone")
|
return lookup(cmd, "zone")
|
||||||
case p == "VIEW":
|
case p == "VIEW":
|
||||||
return lookup(cmd, "view")
|
return lookup(cmd, "view")
|
||||||
|
case p == "COLOR":
|
||||||
|
return append([]string{"default"}, model.ViewColors...), cobra.ShellCompDirectiveNoFileComp
|
||||||
case p == "ACL":
|
case p == "ACL":
|
||||||
return lookup(cmd, "acl")
|
return lookup(cmd, "acl")
|
||||||
case p == "USER":
|
case p == "USER":
|
||||||
|
|||||||
+39
-3
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -27,9 +28,9 @@ func viewCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(views, func(w io.Writer) {
|
return output(views, func(w io.Writer) {
|
||||||
fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES")
|
fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES\tCOLOR")
|
||||||
for i, v := range views {
|
for i, v := range views {
|
||||||
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\n", i+1, v.ID, v.Name, orAny(v.MatchClients), orAny(v.MatchDestinations), v.Zones)
|
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\t%s\n", i+1, v.ID, v.Name, orAny(v.MatchClients), orAny(v.MatchDestinations), v.Zones, orDefault(v.Color))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -172,10 +173,45 @@ func viewCmd() *cobra.Command {
|
|||||||
return report(resp)
|
return report(resp)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.AddCommand(list, create, update, del, order)
|
color := &cobra.Command{
|
||||||
|
Use: "color VIEW COLOR",
|
||||||
|
Short: "Set the colour the web UI shows a view in",
|
||||||
|
Long: "Set the colour the web UI shows a view in, so zones of the same view are easy to spot.\n" +
|
||||||
|
"Colours: default (the link colour), " + strings.Join(model.ViewColors, ", ") + ".\n" +
|
||||||
|
"This only changes the web UI: it applies at once, with no changeset or deploy.",
|
||||||
|
Example: ` jarl view color internal green
|
||||||
|
jarl view color internal default`,
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
col := strings.ToLower(args[1])
|
||||||
|
if col == "default" {
|
||||||
|
col = ""
|
||||||
|
} else if !slices.Contains(model.ViewColors, col) {
|
||||||
|
return fmt.Errorf("unknown colour %q: use default, %s", args[1], strings.Join(model.ViewColors, ", "))
|
||||||
|
}
|
||||||
|
var v api.View
|
||||||
|
if err := c.Do(ctx(), "PUT", "/views/"+url.PathEscape(args[0])+"/color", nil, api.ViewColor{Color: col}, &v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("View %s: colour %s\n", v.Name, orDefault(v.Color))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(list, create, update, del, order, color)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func orDefault(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func orAny(list []string) string {
|
func orAny(list []string) string {
|
||||||
if len(list) == 0 {
|
if len(list) == 0 {
|
||||||
return "any"
|
return "any"
|
||||||
|
|||||||
+1
-1
@@ -225,7 +225,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
|||||||
5 conflict, 6 invalid or rejected by validation, 7 server or BIND error.`,
|
5 conflict, 6 invalid or rejected by validation, 7 server or BIND error.`,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
Version: version.Version,
|
Version: version.String(),
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return checkFanOut(cmd)
|
return checkFanOut(cmd)
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -383,7 +383,7 @@ func versionCmd() *cobra.Command {
|
|||||||
Use: "version",
|
Use: "version",
|
||||||
Short: "Print the version",
|
Short: "Print the version",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
fmt.Println("jarld", version.Version)
|
fmt.Println("jarld", version.String())
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -51,7 +51,7 @@ func rootCmd() *cobra.Command {
|
|||||||
Short: "Jarl: manage BIND (named) through a web UI, a REST API and a CLI",
|
Short: "Jarl: manage BIND (named) through a web UI, a REST API and a CLI",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
Version: version.Version,
|
Version: version.String(),
|
||||||
}
|
}
|
||||||
def := os.Getenv("JARL_CONFIG")
|
def := os.Getenv("JARL_CONFIG")
|
||||||
if def == "" {
|
if def == "" {
|
||||||
@@ -303,7 +303,7 @@ func serveCmd() *cobra.Command {
|
|||||||
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile,
|
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile,
|
||||||
TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
|
TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
|
||||||
SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure,
|
SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure,
|
||||||
Version: version.Version, Stats: collector, Socket: a.cfg.Socket,
|
Version: version.String(), Stats: collector, Socket: a.cfg.Socket,
|
||||||
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
|
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
|
||||||
Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
|
Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
|
||||||
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
|
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
|
||||||
@@ -323,7 +323,7 @@ func serveCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
go auditRetention(ctx, a)
|
go auditRetention(ctx, a)
|
||||||
a.log.Info("jarld starting", "version", version.Version, "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir)
|
a.log.Info("jarld starting", "version", version.String(), "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir)
|
||||||
err = srv.Serve(ctx, func() { sdNotify("READY=1\nSTATUS=serving") })
|
err = srv.Serve(ctx, func() { sdNotify("READY=1\nSTATUS=serving") })
|
||||||
sdNotify("STOPPING=1")
|
sdNotify("STOPPING=1")
|
||||||
a.log.Info("jarld stopped")
|
a.log.Info("jarld stopped")
|
||||||
|
|||||||
+10
@@ -354,10 +354,20 @@ Responses add `zone`, `view`, `relative_name`, `effective_ttl`, and `text`
|
|||||||
| POST | `/views` | `{"name": "internal", "match_clients": ["homelab", "localhost"], "match_destinations": [], "match_recursive_only": null, "comment": "", "adopt_zones": true}` |
|
| POST | `/views` | `{"name": "internal", "match_clients": ["homelab", "localhost"], "match_destinations": [], "match_recursive_only": null, "comment": "", "adopt_zones": true}` |
|
||||||
| GET / PATCH / DELETE | `/views/{id}` | DELETE `?cascade=1` also deletes the view's zones |
|
| GET / PATCH / DELETE | `/views/{id}` | DELETE `?cascade=1` also deletes the view's zones |
|
||||||
| POST | `/views/order` | `{"ids": [3, 1, 2]}` |
|
| POST | `/views/order` | `{"ids": [3, 1, 2]}` |
|
||||||
|
| PUT | `/views/{id}/color` | `{"color": "green"}`; `""` for the default |
|
||||||
|
|
||||||
`adopt_zones` moves zones that are not in a view into the new view. named
|
`adopt_zones` moves zones that are not in a view into the new view. named
|
||||||
requires every zone to be in a view as soon as one view exists.
|
requires every zone to be in a view as soon as one view exists.
|
||||||
|
|
||||||
|
`color` is how the web UI shows the view: one of `green`, `teal`,
|
||||||
|
`purple`, `pink`, `orange`, `yellow`, `red` and `grey`. When it's left out,
|
||||||
|
the view uses the link colour. It isn't part of the BIND configuration, so
|
||||||
|
`PUT /views/{id}/color` applies at once and creates no changeset. It needs
|
||||||
|
write access to the view, and it is logged in the audit log as
|
||||||
|
`view.color`. To colour a view that so far exists only in a changeset,
|
||||||
|
send the request with that changeset (`?changeset=` or
|
||||||
|
`X-Jarl-Changeset`).
|
||||||
|
|
||||||
### ACLs and TSIG keys
|
### ACLs and TSIG keys
|
||||||
|
|
||||||
| Method | Path | Body |
|
| Method | Path | Body |
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ jarl view create external --match-clients any
|
|||||||
jarl view order internal external
|
jarl view order internal external
|
||||||
jarl view update external --match-clients "!homelab,any"
|
jarl view update external --match-clients "!homelab,any"
|
||||||
jarl view delete old [--cascade]
|
jarl view delete old [--cascade]
|
||||||
|
jarl view color internal green # web UI colour; "default" resets it
|
||||||
|
|
||||||
jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost
|
jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost
|
||||||
jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16
|
jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16
|
||||||
|
|||||||
@@ -230,6 +230,22 @@ pre-release:
|
|||||||
(`VERSION=0.2.0-rc.1 scripts/gitea-release.sh notes`), and the workflow
|
(`VERSION=0.2.0-rc.1 scripts/gitea-release.sh notes`), and the workflow
|
||||||
runs it as a check before building.
|
runs it as a check before building.
|
||||||
|
|
||||||
|
### Which version a build reports
|
||||||
|
|
||||||
|
The footer of the web UI, `jarld version` and `jarl --version` say what a
|
||||||
|
binary was built from, linking to it on Gitea:
|
||||||
|
|
||||||
|
* **A release**: `make` run on the commit tagged `v<VERSION>` with no
|
||||||
|
tracked file changed. The Makefile then sets `RELEASE_TAG`, and the
|
||||||
|
footer reads "Powered by Jarl v0.2.0", linking to the release. The
|
||||||
|
release workflow sets `RELEASE_TAG` itself.
|
||||||
|
* **Any other build**: the commit, which Go records from git: "Powered by
|
||||||
|
Jarl 1a2b3c4d5e6f", linking to the commit. With uncommitted changes it
|
||||||
|
is marked `-modified`. The version commands print `0.2.0 (1a2b3c4d5e6f)`.
|
||||||
|
* **No git** (a build from a source tarball): just `VERSION`.
|
||||||
|
|
||||||
|
`RELEASE_TAG=v0.2.0 make` forces the first case.
|
||||||
|
|
||||||
Pushing the same tag again (`git tag -f`, `git push -f`) re-runs it, and
|
Pushing the same tag again (`git tag -f`, `git push -f`) re-runs it, and
|
||||||
the script replaces the attachments and packages, so a failed release can
|
the script replaces the attachments and packages, so a failed release can
|
||||||
simply be retried.
|
simply be retried.
|
||||||
|
|||||||
@@ -130,5 +130,10 @@ On the server, `dig -b <source address>` picks which view you test.
|
|||||||
`jarl view list` shows it.
|
`jarl view list` shows it.
|
||||||
* Deleting a view is refused while it still has zones, unless you pass
|
* Deleting a view is refused while it still has zones, unless you pass
|
||||||
`--cascade`.
|
`--cascade`.
|
||||||
|
* Give each view a colour (Views → Edit → Colour, or
|
||||||
|
`jarl view color internal green`) to tell the zones of each view apart
|
||||||
|
in the zone list. The colour only affects the web UI, so it applies at
|
||||||
|
once, with no changeset or deploy. `jarl view color internal default`
|
||||||
|
goes back to the link colour.
|
||||||
* Zone files are stored per view: `/var/lib/bind/jarl/internal/adbraun.lol.zone`,
|
* Zone files are stored per view: `/var/lib/bind/jarl/internal/adbraun.lol.zone`,
|
||||||
`/var/lib/bind/jarl/external/adbraun.lol.zone`.
|
`/var/lib/bind/jarl/external/adbraun.lol.zone`.
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
"jarl/internal/nscheck"
|
"jarl/internal/nscheck"
|
||||||
"jarl/internal/porkbun"
|
"jarl/internal/porkbun"
|
||||||
|
"jarl/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is the API path prefix.
|
// Version is the API path prefix.
|
||||||
@@ -47,6 +48,12 @@ type Record struct {
|
|||||||
type View struct {
|
type View struct {
|
||||||
model.View
|
model.View
|
||||||
Zones int `json:"zone_count"`
|
Zones int `json:"zone_count"`
|
||||||
|
Color string `json:"color,omitempty"` // web UI colour, see model.ViewColors
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewColor sets the colour the web UI shows a view in.
|
||||||
|
type ViewColor struct {
|
||||||
|
Color string `json:"color"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ACL is an ACL with the objects that use it.
|
// ACL is an ACL with the objects that use it.
|
||||||
@@ -99,6 +106,8 @@ type Whoami struct {
|
|||||||
// TOTPSetupRequired: the user must set up two-factor login before
|
// TOTPSetupRequired: the user must set up two-factor login before
|
||||||
// doing anything else (totp.require).
|
// doing anything else (totp.require).
|
||||||
TOTPSetupRequired bool `json:"totp_setup_required,omitempty"`
|
TOTPSetupRequired bool `json:"totp_setup_required,omitempty"`
|
||||||
|
// Build is what the server was built from (release or commit).
|
||||||
|
Build version.Build `json:"build"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginChallenge answers a correct password of a user with two-factor
|
// LoginChallenge answers a correct password of a user with two-factor
|
||||||
@@ -219,6 +228,7 @@ type Changeset struct {
|
|||||||
// StatusResponse is GET /status.
|
// StatusResponse is GET /status.
|
||||||
type StatusResponse struct {
|
type StatusResponse struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
Build version.Build `json:"build"`
|
||||||
Engine json.RawMessage `json:"engine"`
|
Engine json.RawMessage `json:"engine"`
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
// ExpiringTokens are tokens that expire within TokenExpiryWarning or
|
// ExpiringTokens are tokens that expire within TokenExpiryWarning or
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ func (m *Meta) Touch(user string, now time.Time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// View is a BIND view.
|
// View is a BIND view.
|
||||||
|
// ViewColors are the colours the web UI can show a view in, besides the
|
||||||
|
// default (the link colour).
|
||||||
|
var ViewColors = []string{"green", "teal", "purple", "pink", "orange", "yellow", "red", "grey"}
|
||||||
|
|
||||||
type View struct {
|
type View struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
"jarl/internal/store"
|
"jarl/internal/store"
|
||||||
|
"jarl/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func toRequests(in []api.ChangeRequest) []engine.Request {
|
func toRequests(in []api.ChangeRequest) []engine.Request {
|
||||||
@@ -273,7 +274,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
resp := api.StatusResponse{Version: s.cfg.Version, Engine: raw(st), Time: now, ExpiringTokens: s.expiringTokens(r.Context(), p, now)}
|
resp := api.StatusResponse{Version: s.cfg.Version, Build: version.Info(), Engine: raw(st), Time: now, ExpiringTokens: s.expiringTokens(r.Context(), p, now)}
|
||||||
if p.IsAdmin() {
|
if p.IsAdmin() {
|
||||||
resp.Backup = s.backupStatus(r.Context(), now)
|
resp.Backup = s.backupStatus(r.Context(), now)
|
||||||
resp.TOTP = s.totpOverview(r.Context())
|
resp.TOTP = s.totpOverview(r.Context())
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -46,10 +47,14 @@ func (s *Server) listViews(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
colors, err := s.store.ViewColors(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
out := []api.View{}
|
out := []api.View{}
|
||||||
for _, v := range st.OrderedViews() {
|
for _, v := range st.OrderedViews() {
|
||||||
if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) {
|
if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) {
|
||||||
out = append(out, presentView(st, v))
|
out = append(out, presentView(st, v, colors))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
@@ -79,23 +84,67 @@ func (s *Server) getView(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, presentView(st, v))
|
colors, err := s.store.ViewColors(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, presentView(st, v, colors))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func viewObject(st *model.State, id int64) any {
|
func (s *Server) viewObject(r *http.Request) func(st *model.State, id int64) any {
|
||||||
|
return func(st *model.State, id int64) any {
|
||||||
if v := st.Views[id]; v != nil {
|
if v := st.Views[id]; v != nil {
|
||||||
return presentView(st, v)
|
colors, _ := s.store.ViewColors(r.Context())
|
||||||
|
return presentView(st, v, colors)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setViewColor sets the colour the web UI shows a view in. It is not part
|
||||||
|
// of the configuration, so it applies at once, outside changesets.
|
||||||
|
func (s *Server) setViewColor(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v, err := s.viewByPath(r, st, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := require(p, auth.Action{Resource: auth.ResViews, Write: true, View: v.Name}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in api.ViewColor
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.Color != "" && !slices.Contains(model.ViewColors, in.Color) {
|
||||||
|
return badRequest("unknown colour %q: use one of %s, or \"\" for the default", in.Color, strings.Join(model.ViewColors, ", "))
|
||||||
|
}
|
||||||
|
if err := s.store.SetViewColor(r.Context(), v.ID, in.Color); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
detail := in.Color
|
||||||
|
if detail == "" {
|
||||||
|
detail = "default"
|
||||||
|
}
|
||||||
|
s.eng.Audit(r.Context(), p, "view.color", "view", v.ID, v.Name, "success", detail)
|
||||||
|
colors, err := s.store.ViewColors(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, presentView(st, v, colors))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) createView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) createView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
body, err := readBody(r)
|
body, err := readBody(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewCreate, 0, body)}, viewObject)
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewCreate, 0, body)}, s.viewObject(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) updateView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) updateView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
@@ -112,7 +161,7 @@ func (s *Server) updateView(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
id := v.ID
|
id := v.ID
|
||||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewUpdate, id, body)}, func(st *model.State, _ int64) any { return viewObject(st, id) })
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewUpdate, id, body)}, func(st *model.State, _ int64) any { return s.viewObject(r)(st, id) })
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) deleteView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) deleteView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
"jarl/internal/render"
|
"jarl/internal/render"
|
||||||
"jarl/internal/rr"
|
"jarl/internal/rr"
|
||||||
|
"jarl/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]int) api.Zone {
|
func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]int) api.Zone {
|
||||||
@@ -44,8 +45,8 @@ func canReadRecord(p *auth.Principal, st *model.State, r *model.Record) bool {
|
|||||||
return z != nil && p.Can(auth.Action{Resource: auth.ResRecords, Zone: z.Name, View: st.ViewName(z), Name: r.Name, Type: r.Type})
|
return z != nil && p.Can(auth.Action{Resource: auth.ResRecords, Zone: z.Name, View: st.ViewName(z), Name: r.Name, Type: r.Type})
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentView(st *model.State, v *model.View) api.View {
|
func presentView(st *model.State, v *model.View, colors map[int64]string) api.View {
|
||||||
return api.View{View: *v, Zones: len(st.ZonesInView(v.ID))}
|
return api.View{View: *v, Zones: len(st.ZonesInView(v.ID)), Color: colors[v.ID]}
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentACL(st *model.State, a *model.ACL) api.ACL {
|
func presentACL(st *model.State, a *model.ACL) api.ACL {
|
||||||
@@ -142,7 +143,7 @@ func (s *Server) userOut(u *model.User) api.User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) whoamiOf(ctx context.Context, p *auth.Principal, se *model.Session) api.Whoami {
|
func (s *Server) whoamiOf(ctx context.Context, p *auth.Principal, se *model.Session) api.Whoami {
|
||||||
out := api.Whoami{User: s.userOut(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version}
|
out := api.Whoami{User: s.userOut(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version, Build: version.Info()}
|
||||||
if p.Method == "session" {
|
if p.Method == "session" {
|
||||||
st := s.totpStatus(ctx, p.User)
|
st := s.totpStatus(ctx, p.User)
|
||||||
out.TOTP = &st
|
out.TOTP = &st
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("GET "+p+"/views/{id}", s.authed(s.getView))
|
m.HandleFunc("GET "+p+"/views/{id}", s.authed(s.getView))
|
||||||
m.HandleFunc("PATCH "+p+"/views/{id}", s.authed(s.updateView))
|
m.HandleFunc("PATCH "+p+"/views/{id}", s.authed(s.updateView))
|
||||||
m.HandleFunc("DELETE "+p+"/views/{id}", s.authed(s.deleteView))
|
m.HandleFunc("DELETE "+p+"/views/{id}", s.authed(s.deleteView))
|
||||||
|
m.HandleFunc("PUT "+p+"/views/{id}/color", s.authed(s.setViewColor))
|
||||||
// ACLs
|
// ACLs
|
||||||
m.HandleFunc("GET "+p+"/acls", s.authed(s.listACLs))
|
m.HandleFunc("GET "+p+"/acls", s.authed(s.listACLs))
|
||||||
m.HandleFunc("POST "+p+"/acls", s.authed(s.createACL))
|
m.HandleFunc("POST "+p+"/acls", s.authed(s.createACL))
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestViewColors(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
in := e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)["object"].(map[string]any)
|
||||||
|
ex := e.do("POST", "/views", e.token, map[string]any{"name": "external"}, 201)["object"].(map[string]any)
|
||||||
|
inID, exID := int64(in["id"].(float64)), int64(ex["id"].(float64))
|
||||||
|
applied := len(e.list("/changesets?status=applied", e.token))
|
||||||
|
|
||||||
|
v := e.do("PUT", "/views/internal/color", e.token, map[string]any{"color": "green"}, 200)
|
||||||
|
if v["color"] != "green" || v["name"] != "internal" {
|
||||||
|
t.Errorf("set colour = %v", v)
|
||||||
|
}
|
||||||
|
e.do("PUT", "/views/"+itoa(exID)+"/color", e.token, map[string]any{"color": "purple"}, 200)
|
||||||
|
got := map[string]any{}
|
||||||
|
for _, v := range e.list("/views", e.token) {
|
||||||
|
got[v["name"].(string)] = v["color"]
|
||||||
|
}
|
||||||
|
if got["internal"] != "green" || got["external"] != "purple" {
|
||||||
|
t.Errorf("listed colours = %v", got)
|
||||||
|
}
|
||||||
|
if v := e.do("GET", "/views/internal", e.token, nil, 200); v["color"] != "green" {
|
||||||
|
t.Errorf("get view colour = %v", v["color"])
|
||||||
|
}
|
||||||
|
// A colour is not configuration: no changeset.
|
||||||
|
if n := len(e.list("/changesets?status=applied", e.token)); n != applied {
|
||||||
|
t.Errorf("colour changes made changesets: %d -> %d", applied, n)
|
||||||
|
}
|
||||||
|
if v := e.do("PATCH", "/views/internal", e.token, map[string]any{"comment": "LAN"}, 200)["object"].(map[string]any); v["color"] != "green" {
|
||||||
|
t.Errorf("colour after update = %v", v["color"])
|
||||||
|
}
|
||||||
|
if _, status := e.req("PUT", "/views/internal/color", e.token, map[string]any{"color": "#ff0000"}); status != 400 {
|
||||||
|
t.Errorf("bad colour = %d", status)
|
||||||
|
}
|
||||||
|
if _, status := e.req("PUT", "/views/nope/color", e.token, map[string]any{"color": "red"}); status != 404 {
|
||||||
|
t.Errorf("unknown view = %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Readers of views can't change colours.
|
||||||
|
e.do("POST", "/users", e.token, map[string]any{"username": "reader", "password": "reader-password-123", "grant_strings": []string{"views:read"}}, 201)
|
||||||
|
var readerID int64
|
||||||
|
for _, u := range e.list("/users", e.token) {
|
||||||
|
if u["username"] == "reader" {
|
||||||
|
readerID = int64(u["id"].(float64))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rt := e.do("POST", "/users/"+itoa(readerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
|
||||||
|
if _, status := e.req("PUT", "/views/internal/color", rt, map[string]any{"color": "red"}); status != 403 {
|
||||||
|
t.Errorf("reader set a colour: %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A view created in a draft can be given a colour before it is applied.
|
||||||
|
cs := itoa(int64(e.do("POST", "/changesets", e.token, map[string]any{"description": "dmz"}, 201)["id"].(float64)))
|
||||||
|
e.do("POST", "/views?changeset="+cs, e.token, map[string]any{"name": "dmz"}, 202)
|
||||||
|
if _, status := e.req("PUT", "/views/dmz/color", e.token, map[string]any{"color": "orange"}); status != 404 {
|
||||||
|
t.Errorf("draft view found outside its changeset: %d", status)
|
||||||
|
}
|
||||||
|
e.do("PUT", "/views/dmz/color?changeset="+cs, e.token, map[string]any{"color": "orange"}, 200)
|
||||||
|
e.do("PUT", "/views/internal/color", e.token, map[string]any{"color": "teal"}, 200)
|
||||||
|
e.do("POST", "/changesets/"+cs+"/apply", e.token, nil, 200)
|
||||||
|
if v := e.do("GET", "/views/dmz", e.token, nil, 200); v["color"] != "orange" {
|
||||||
|
t.Errorf("draft view colour after apply = %v", v["color"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// "" goes back to the default.
|
||||||
|
if v := e.do("PUT", "/views/internal/color", e.token, map[string]any{"color": ""}, 200); v["color"] != nil {
|
||||||
|
t.Errorf("reset colour = %v", v["color"])
|
||||||
|
}
|
||||||
|
colors, err := e.st.ViewColors(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := colors[inID]; ok {
|
||||||
|
t.Errorf("stored colours after reset = %v", colors)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// View colours only change how the web UI shows a view, so they are kept
|
||||||
|
// outside the configuration: setting one needs no changeset or deploy.
|
||||||
|
const viewColorsKey = "view_colors"
|
||||||
|
|
||||||
|
// ViewColors returns the colour of each view that has one, by view ID.
|
||||||
|
func (s *Store) ViewColors(ctx context.Context) (map[int64]string, error) {
|
||||||
|
out := map[int64]string{}
|
||||||
|
raw, err := s.GetMeta(ctx, viewColorsKey)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for k, v := range m {
|
||||||
|
if id, err := strconv.ParseInt(k, 10, 64); err == nil {
|
||||||
|
out[id] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetViewColor sets a view's colour; "" removes it. View IDs are never
|
||||||
|
// reused, so the colour of a deleted view is harmless and is left alone
|
||||||
|
// (the view may still exist in a changeset).
|
||||||
|
func (s *Store) SetViewColor(ctx context.Context, id int64, color string) error {
|
||||||
|
m, err := s.ViewColors(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if color == "" {
|
||||||
|
delete(m, id)
|
||||||
|
} else {
|
||||||
|
m[id] = color
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for k, v := range m {
|
||||||
|
out[strconv.FormatInt(k, 10)] = v
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(out)
|
||||||
|
return s.SetMeta(ctx, viewColorsKey, string(b))
|
||||||
|
}
|
||||||
@@ -1,7 +1,94 @@
|
|||||||
// Package version holds Jarl's version, set at build time with
|
// Package version holds Jarl's version, set at build time with
|
||||||
//
|
//
|
||||||
// go build -ldflags "-X jarl/internal/version.Version=1.2.3"
|
// go build -ldflags "-X jarl/internal/version.Version=1.2.3 -X jarl/internal/version.Tag=v1.2.3"
|
||||||
|
//
|
||||||
|
// The Makefile sets Version from the VERSION file, and Tag only when the
|
||||||
|
// build is of that release: HEAD is the tag v<VERSION> and no tracked file
|
||||||
|
// is changed. Other builds are identified by their commit, which Go
|
||||||
|
// records from git by itself.
|
||||||
package version
|
package version
|
||||||
|
|
||||||
// Version is the Jarl version.
|
import (
|
||||||
|
"runtime/debug"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the Jarl version (the VERSION file).
|
||||||
var Version = "0.1.0-dev"
|
var Version = "0.1.0-dev"
|
||||||
|
|
||||||
|
// Tag is the release tag the build is of ("" when it isn't a release).
|
||||||
|
var Tag = ""
|
||||||
|
|
||||||
|
// Commit is the git commit the build is of. It is read from the build
|
||||||
|
// information when not set at build time.
|
||||||
|
var Commit = ""
|
||||||
|
|
||||||
|
// Repo is Jarl's source repository.
|
||||||
|
const Repo = "https://gitea.adbraun.lol/adam/jarl"
|
||||||
|
|
||||||
|
// Build describes what a binary was built from.
|
||||||
|
type Build struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
|
Commit string `json:"commit,omitempty"`
|
||||||
|
Modified bool `json:"modified,omitempty"` // built with uncommitted changes
|
||||||
|
// Label is the short form to show: the tag of a release, otherwise
|
||||||
|
// the abbreviated commit (or the version when the commit is unknown).
|
||||||
|
Label string `json:"label"`
|
||||||
|
// URL is the release or commit in the repository.
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = sync.OnceValue(func() Build {
|
||||||
|
commit, modified := Commit, false
|
||||||
|
if bi, ok := debug.ReadBuildInfo(); ok {
|
||||||
|
for _, s := range bi.Settings {
|
||||||
|
switch s.Key {
|
||||||
|
case "vcs.revision":
|
||||||
|
if commit == "" {
|
||||||
|
commit = s.Value
|
||||||
|
}
|
||||||
|
case "vcs.modified":
|
||||||
|
modified = s.Value == "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return describe(Version, Tag, commit, modified)
|
||||||
|
})
|
||||||
|
|
||||||
|
func describe(version, tag, commit string, modified bool) Build {
|
||||||
|
b := Build{Version: version, Tag: tag, Commit: commit, Modified: modified}
|
||||||
|
switch {
|
||||||
|
case tag != "":
|
||||||
|
b.Modified = false // the Makefile only sets Tag for clean trees
|
||||||
|
b.Label, b.URL = tag, Repo+"/releases/tag/"+tag
|
||||||
|
case commit != "":
|
||||||
|
b.Label, b.URL = short(commit), Repo+"/commit/"+commit
|
||||||
|
if modified {
|
||||||
|
b.Label += "-modified"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
b.Label, b.URL = version, Repo
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info describes this build.
|
||||||
|
func Info() Build { return info() }
|
||||||
|
|
||||||
|
// String is the version for logs and 'version' commands: "0.2.0" for a
|
||||||
|
// release, otherwise the version and the commit ("0.2.0-dev (a1b2c3d4e5f6)").
|
||||||
|
func String() string {
|
||||||
|
b := Info()
|
||||||
|
if b.Tag != "" || b.Commit == "" {
|
||||||
|
return b.Version
|
||||||
|
}
|
||||||
|
return b.Version + " (" + b.Label + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func short(commit string) string {
|
||||||
|
if len(commit) > 12 {
|
||||||
|
return commit[:12]
|
||||||
|
}
|
||||||
|
return commit
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDescribe(t *testing.T) {
|
||||||
|
const c = "4d00be5e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c"
|
||||||
|
for _, tc := range []struct {
|
||||||
|
tag, commit string
|
||||||
|
modified bool
|
||||||
|
label, url string
|
||||||
|
}{
|
||||||
|
{"v0.1.0", c, true, "v0.1.0", Repo + "/releases/tag/v0.1.0"},
|
||||||
|
{"", c, false, "4d00be5e1f0a", Repo + "/commit/" + c},
|
||||||
|
{"", c, true, "4d00be5e1f0a-modified", Repo + "/commit/" + c},
|
||||||
|
{"", "", false, "0.1.0", Repo},
|
||||||
|
} {
|
||||||
|
b := describe("0.1.0", tc.tag, tc.commit, tc.modified)
|
||||||
|
if b.Label != tc.label || b.URL != tc.url {
|
||||||
|
t.Errorf("describe(%q, %q, %v) = %q %q, want %q %q", tc.tag, tc.commit, tc.modified, b.Label, b.URL, tc.label, tc.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Jarl web UI: router, layout and login.
|
// Jarl web UI: router, layout and login.
|
||||||
import { h, mount, toast, errorBox, plural } from "./ui.js";
|
import { h, mount, toast, errorBox, plural, lastList } from "./ui.js";
|
||||||
import { wordmark, logo } from "./brand.js";
|
import { wordmark, logo } from "./brand.js";
|
||||||
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate, setCodePrompt } from "./api.js";
|
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate, setCodePrompt } from "./api.js";
|
||||||
import * as totp from "./totp.js";
|
import * as totp from "./totp.js";
|
||||||
@@ -50,7 +50,7 @@ const navItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
let mainEl, navEl, barEl, current = { key: "" };
|
let mainEl, navEl, barEl, current = { key: "" }, routeGen = 0;
|
||||||
|
|
||||||
function parseHash() {
|
function parseHash() {
|
||||||
const raw = location.hash.replace(/^#\/?/, "");
|
const raw = location.hash.replace(/^#\/?/, "");
|
||||||
@@ -153,8 +153,7 @@ function layout() {
|
|||||||
const header = h("header", { class: "top" },
|
const header = h("header", { class: "top" },
|
||||||
h("a", { class: "brand", href: "#/", title: "Dashboard" },
|
h("a", { class: "brand", href: "#/", title: "Dashboard" },
|
||||||
logo(30),
|
logo(30),
|
||||||
wordmark({ rule: false, height: 19, cls: "brand-name" }),
|
wordmark({ rule: false, height: 19, cls: "brand-name" })),
|
||||||
h("span", { class: "brand-version" }, me.version)),
|
|
||||||
navEl,
|
navEl,
|
||||||
h("div", { class: "spacer" }),
|
h("div", { class: "spacer" }),
|
||||||
modeEl = h("span"),
|
modeEl = h("span"),
|
||||||
@@ -162,7 +161,7 @@ function layout() {
|
|||||||
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
|
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
|
||||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||||
h("button", { class: "small", onclick: logout }, "Log out"));
|
h("button", { class: "small", onclick: logout }, "Log out"));
|
||||||
mount(app, header, barEl, mainEl);
|
mount(app, h("div", { class: "shell" }, header, barEl, mainEl, footer(me.build)));
|
||||||
modeHolder = modeEl;
|
modeHolder = modeEl;
|
||||||
renderNav();
|
renderNav();
|
||||||
renderBar();
|
renderBar();
|
||||||
@@ -170,6 +169,16 @@ function layout() {
|
|||||||
|
|
||||||
let modeHolder;
|
let modeHolder;
|
||||||
|
|
||||||
|
// footer names the Jarl build: the release, or the commit for other
|
||||||
|
// builds, linked to it in the repository.
|
||||||
|
function footer(b) {
|
||||||
|
if (!b) return null;
|
||||||
|
const what = b.tag ? "release " + b.tag
|
||||||
|
: b.commit ? "commit " + b.commit + (b.modified ? ", with uncommitted changes" : "") : "version " + b.version;
|
||||||
|
return h("footer", { class: "site" }, "Powered by ",
|
||||||
|
h("a", { href: b.url, target: "_blank", rel: "noopener", title: "Jarl " + b.version + ", built from " + what }, "Jarl " + b.label));
|
||||||
|
}
|
||||||
|
|
||||||
function renderNav() {
|
function renderNav() {
|
||||||
mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
|
mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
|
||||||
}
|
}
|
||||||
@@ -211,12 +220,21 @@ async function route() {
|
|||||||
const params = {};
|
const params = {};
|
||||||
(r.params || []).forEach((p, i) => { params[p] = m[i + 1]; });
|
(r.params || []).forEach((p, i) => { params[p] = m[i + 1]; });
|
||||||
current = { key: path, nav: r.nav, page: r.page, params, query };
|
current = { key: path, nav: r.nav, page: r.page, params, query };
|
||||||
|
if (!r.params && r.nav) lastList[r.nav] = location.hash;
|
||||||
renderNav();
|
renderNav();
|
||||||
mount(mainEl, h("div", { class: "muted" }, "Loading…"));
|
// Keep the old page until the new one is drawn (switching a zone's
|
||||||
|
// tabs shouldn't blank the page); say "Loading…" only when it's slow.
|
||||||
|
// (Only while the new page has drawn nothing yet: pages that draw
|
||||||
|
// their frame first fill it in afterwards.)
|
||||||
|
const gen = ++routeGen;
|
||||||
|
const old = mainEl.firstChild;
|
||||||
|
const slow = setTimeout(() => { if (gen === routeGen && mainEl.firstChild === old) mount(mainEl, h("div", { class: "muted" }, "Loading…")); }, 300);
|
||||||
try {
|
try {
|
||||||
await r.page.render(mainEl, params, query);
|
await r.page.render(mainEl, params, query);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e));
|
if (gen === routeGen) mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e));
|
||||||
|
} finally {
|
||||||
|
clearTimeout(slow);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { h, mount, table, toast, confirmDialog, diffView, time, fmtTime, badge, plural } from "../ui.js";
|
import { h, mount, table, toast, confirmDialog, diffView, time, fmtTime, badge, plural, backLink, lastList } from "../ui.js";
|
||||||
import { api, get, useDraft } from "../api.js";
|
import { api, get, useDraft } from "../api.js";
|
||||||
|
|
||||||
export async function render(el, params) {
|
export async function render(el, params) {
|
||||||
@@ -30,10 +30,10 @@ async function detail(el, id) {
|
|||||||
mount(el,
|
mount(el,
|
||||||
h("div", { class: "page-head" },
|
h("div", { class: "page-head" },
|
||||||
h("div", { class: "grow" },
|
h("div", { class: "grow" },
|
||||||
|
backLink("History", lastList.history || "#/history"),
|
||||||
h("h1", null, "Changeset #" + c.id),
|
h("h1", null, "Changeset #" + c.id),
|
||||||
h("div", { class: "muted" }, c.description || "(no description)")),
|
h("div", { class: "muted" }, c.description || "(no description)")),
|
||||||
h("div", { class: "actions" },
|
h("div", { class: "actions" },
|
||||||
h("a", { class: "button", href: "#/history" }, "All changes"),
|
|
||||||
c.status === "applied" && c.undoable ? h("button", { onclick: () => revert(c) }, "Revert…") : null)),
|
c.status === "applied" && c.undoable ? h("button", { onclick: () => revert(c) }, "Revert…") : null)),
|
||||||
h("div", { class: "panel" }, h("dl", { class: "kv" },
|
h("div", { class: "panel" }, h("dl", { class: "kv" },
|
||||||
h("dt", null, "Status"), h("dd", null, badge(c.status, c.status === "applied" ? "ok" : "")),
|
h("dt", null, "Status"), h("dd", null, badge(c.status, c.status === "applied" ? "ok" : "")),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox } from "../ui.js";
|
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, colorPicker, backLink, safeBack, viewHref } from "../ui.js";
|
||||||
import { amlContext, suggestions } from "../aml.js";
|
import { amlContext, suggestions } from "../aml.js";
|
||||||
import { get, change, describeResult } from "../api.js";
|
import { api, get, change, describeResult } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { optionsEditor } from "./options.js";
|
import { optionsEditor } from "./options.js";
|
||||||
|
|
||||||
@@ -9,6 +9,9 @@ export async function render(el, params, query) {
|
|||||||
const viewless = zones.filter((z) => !z.view_id);
|
const viewless = zones.filter((z) => !z.view_id);
|
||||||
const optsEl = h("div");
|
const optsEl = h("div");
|
||||||
const selected = query.get("view") ? views.find((v) => String(v.id) === query.get("view")) : null;
|
const selected = query.get("view") ? views.find((v) => String(v.id) === query.get("view")) : null;
|
||||||
|
// Opened from a view link on a zone or the zone list: offer the way back
|
||||||
|
// (kept while picking other views here).
|
||||||
|
const back = safeBack(query.get("back"));
|
||||||
const move = async (i, d) => {
|
const move = async (i, d) => {
|
||||||
const ids = views.map((v) => v.id);
|
const ids = views.map((v) => v.id);
|
||||||
[ids[i], ids[i + d]] = [ids[i + d], ids[i]];
|
[ids[i], ids[i + d]] = [ids[i + d], ids[i]];
|
||||||
@@ -20,14 +23,14 @@ export async function render(el, params, query) {
|
|||||||
};
|
};
|
||||||
mount(el,
|
mount(el,
|
||||||
h("div", { class: "page-head" },
|
h("div", { class: "page-head" },
|
||||||
h("div", { class: "grow" }, h("h1", null, "Views"),
|
h("div", { class: "grow" }, back ? backLink(backLabel(back, zones), back) : null, h("h1", null, "Views"),
|
||||||
h("div", { class: "muted" }, "A client sees the first view it matches (by source address, destination address or TSIG key). Order matters.")),
|
h("div", { class: "muted" }, "A client sees the first view it matches (by source address, destination address or TSIG key). Order matters.")),
|
||||||
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => viewDialog(null, viewless) }, "New view"))),
|
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => viewDialog(null, viewless) }, "New view"))),
|
||||||
views.length === 0 ? h("div", { class: "msg info" },
|
views.length === 0 ? h("div", { class: "msg info" },
|
||||||
"No views: every client sees the same zones. Views let you serve different answers to different clients, for example an internal view with private records and recursion for your LAN, and an external view with only public records.") : null,
|
"No views: every client sees the same zones. Views let you serve different answers to different clients, for example an internal view with private records and recursion for your LAN, and an external view with only public records.") : null,
|
||||||
h("div", { class: "panel" }, table([
|
h("div", { class: "panel" }, table([
|
||||||
{ label: "#", render: (v, i) => String(i + 1) },
|
{ label: "#", render: (v, i) => String(i + 1) },
|
||||||
{ label: "View", render: (v) => h("a", { href: "#/views?view=" + v.id }, h("strong", null, v.name)) },
|
{ label: "View", render: (v) => h("a", { href: viewHref(v.id, back), class: v.color ? "vc-" + v.color : null }, h("strong", null, v.name)) },
|
||||||
{ label: "Match clients", render: (v) => (v.match_clients || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" },
|
{ label: "Match clients", render: (v) => (v.match_clients || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" },
|
||||||
{ label: "Match destinations", render: (v) => (v.match_destinations || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" },
|
{ label: "Match destinations", render: (v) => (v.match_destinations || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" },
|
||||||
{ label: "Zones", render: (v) => h("a", { href: "#/zones?view=" + encodeURIComponent(v.name) }, String(v.zone_count)) },
|
{ label: "Zones", render: (v) => h("a", { href: "#/zones?view=" + encodeURIComponent(v.name) }, String(v.zone_count)) },
|
||||||
@@ -37,10 +40,24 @@ export async function render(el, params, query) {
|
|||||||
h("button", { class: "small", disabled: i === views.length - 1, title: "Move down", onclick: () => move(i, 1) }, "↓"), " ",
|
h("button", { class: "small", disabled: i === views.length - 1, title: "Move down", onclick: () => move(i, 1) }, "↓"), " ",
|
||||||
h("button", { class: "small", onclick: () => viewDialog(v, viewless) }, "Edit"), " ",
|
h("button", { class: "small", onclick: () => viewDialog(v, viewless) }, "Edit"), " ",
|
||||||
h("button", { class: "small danger", onclick: () => deleteView(v) }, "Delete")), tdClass: "actions-cell" },
|
h("button", { class: "small danger", onclick: () => deleteView(v) }, "Delete")), tdClass: "actions-cell" },
|
||||||
], views, { empty: "No views." })),
|
], views, { empty: "No views.", rowClass: (v) => selected && v.id === selected.id ? "selected" : "" })),
|
||||||
selected ? optsEl : (views.length ? h("p", { class: "muted" }, "Select a view to see its options.") : null));
|
selected ? optsEl : (views.length ? h("p", { class: "muted" }, "Select a view to see its options.") : null));
|
||||||
if (selected) await optionsEditor(optsEl, { scope: "view", scope_id: selected.id, catalogScope: "view", title: "Options of view " + selected.name,
|
if (selected) {
|
||||||
|
await optionsEditor(optsEl, { scope: "view", scope_id: selected.id, catalogScope: "view", title: "Options of view " + selected.name,
|
||||||
intro: "View options override the server options for clients in this view (for example recursion and allow-recursion)." });
|
intro: "View options override the server options for clients in this view (for example recursion and allow-recursion)." });
|
||||||
|
// With a long list, scroll to the selected view's options.
|
||||||
|
if (views.length > 3) optsEl.scrollIntoView({ block: "start" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backLabel names the page a back address leads to.
|
||||||
|
function backLabel(back, zones) {
|
||||||
|
const m = back.match(/^#\/zones\/(\d+)/);
|
||||||
|
if (m) {
|
||||||
|
const z = zones.find((z) => String(z.id) === m[1]);
|
||||||
|
return z ? z.display_name + (z.view ? " (" + z.view + ")" : "") : "Zone";
|
||||||
|
}
|
||||||
|
return /^#\/zones(\?|$)/.test(back) ? "Zones" : "Back";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function viewDialog(v, viewless) {
|
async function viewDialog(v, viewless) {
|
||||||
@@ -51,13 +68,15 @@ async function viewDialog(v, viewless) {
|
|||||||
const md = textarea(v ? (v.match_destinations || []).join("\n") : "", { class: "mono", rows: 2, placeholder: "(empty = any)" });
|
const md = textarea(v ? (v.match_destinations || []).join("\n") : "", { class: "mono", rows: 2, placeholder: "(empty = any)" });
|
||||||
const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : "");
|
const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : "");
|
||||||
const comment = input({ value: v ? v.comment : "" });
|
const comment = input({ value: v ? v.comment : "" });
|
||||||
|
const color = colorPicker(v ? v.color : "");
|
||||||
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
|
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
|
||||||
const body = h("div", null, h("div", { class: "form-grid" },
|
const body = h("div", null, h("div", { class: "form-grid" },
|
||||||
field("Name", name),
|
field("Name", name),
|
||||||
field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
|
field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
|
||||||
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
|
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
|
||||||
field("Recursive only", mro, "If yes, only recursive queries match this view."),
|
field("Recursive only", mro, "If yes, only recursive queries match this view."),
|
||||||
field("Comment", comment)),
|
field("Comment", comment),
|
||||||
|
field("Colour", color, "How the web UI shows this view, so zones of the same view are easy to spot. Only changes the web UI: it applies at once, with no deploy.")),
|
||||||
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
|
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
|
||||||
err);
|
err);
|
||||||
await modal(v ? "Edit view " + v.name : "New view", body, [
|
await modal(v ? "Edit view " + v.name : "New view", body, [
|
||||||
@@ -66,9 +85,16 @@ async function viewDialog(v, viewless) {
|
|||||||
const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value };
|
const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value };
|
||||||
data.match_recursive_only = mro.value === "" ? null : mro.value === "true";
|
data.match_recursive_only = mro.value === "" ? null : mro.value === "true";
|
||||||
if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true;
|
if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true;
|
||||||
|
const colorChanged = color.value !== ((v && v.color) || "");
|
||||||
try {
|
try {
|
||||||
const r = v ? await change("PATCH", "/views/" + v.id, data) : await change("POST", "/views", data);
|
let r = null;
|
||||||
toast(describeResult(r));
|
if (!v || configChanged(v, data)) r = v ? await change("PATCH", "/views/" + v.id, data) : await change("POST", "/views", data);
|
||||||
|
const id = v ? v.id : (r.object && r.object.id) || ((r.added || [])[0] || {}).id;
|
||||||
|
if (colorChanged && id) {
|
||||||
|
// A view created in a draft only exists in that draft so far.
|
||||||
|
await api("PUT", "/views/" + id + "/color", { body: { color: color.value }, stage: r && r.staged ? r.changeset_id : undefined });
|
||||||
|
}
|
||||||
|
toast(r ? describeResult(r) : "Colour saved");
|
||||||
rerender();
|
rerender();
|
||||||
return true;
|
return true;
|
||||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||||
@@ -76,6 +102,15 @@ async function viewDialog(v, viewless) {
|
|||||||
], { wide: true, sticky: true });
|
], { wide: true, sticky: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configChanged tells whether the dialog changed the view's configuration
|
||||||
|
// (not just its colour), so a colour change alone stages nothing.
|
||||||
|
function configChanged(v, data) {
|
||||||
|
const same = (a, b) => JSON.stringify(a || []) === JSON.stringify(b || []);
|
||||||
|
const mro = v.match_recursive_only === undefined ? null : v.match_recursive_only;
|
||||||
|
return data.name !== v.name || !same(data.match_clients, v.match_clients) || !same(data.match_destinations, v.match_destinations) ||
|
||||||
|
data.match_recursive_only !== mro || data.comment !== (v.comment || "");
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteView(v) {
|
async function deleteView(v) {
|
||||||
let cascade = false;
|
let cascade = false;
|
||||||
if (v.zone_count > 0) {
|
if (v.zone_count > 0) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView } from "../ui.js";
|
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList } from "../ui.js";
|
||||||
import { api, get, change, describeResult, rrtypes, state, draftQuery } from "../api.js";
|
import { api, get, change, describeResult, rrtypes, state, draftQuery } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { optionsEditor } from "./options.js";
|
import { optionsEditor } from "./options.js";
|
||||||
@@ -7,9 +7,12 @@ import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.js";
|
|||||||
import { dnssecTab } from "./dnssec.js";
|
import { dnssecTab } from "./dnssec.js";
|
||||||
import { registrarTab } from "./registrar.js";
|
import { registrarTab } from "./registrar.js";
|
||||||
|
|
||||||
|
let renderSeq = 0;
|
||||||
|
|
||||||
export async function render(el, params, query) {
|
export async function render(el, params, query) {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
const zone = await get("/zones/" + id, draftQuery());
|
const [zone, views] = await Promise.all([get("/zones/" + id, draftQuery()), get("/views")]);
|
||||||
|
const view = views.find((v) => v.id === zone.view_id);
|
||||||
const tab = params.tab || (zone.type === "primary" ? "records" : "settings");
|
const tab = params.tab || (zone.type === "primary" ? "records" : "settings");
|
||||||
const tabs = [
|
const tabs = [
|
||||||
zone.type === "primary" ? ["records", "Records (" + zone.record_count + ")"] : null,
|
zone.type === "primary" ? ["records", "Records (" + zone.record_count + ")"] : null,
|
||||||
@@ -21,21 +24,30 @@ export async function render(el, params, query) {
|
|||||||
zone.registrar && state.me && state.me.admin ? ["registrar", "Registrar"] : null,
|
zone.registrar && state.me && state.me.admin ? ["registrar", "Registrar"] : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const body = h("div");
|
const body = h("div");
|
||||||
mount(el,
|
const head = h("div", { class: "page-head" },
|
||||||
h("div", { class: "page-head" },
|
|
||||||
h("div", { class: "grow" },
|
h("div", { class: "grow" },
|
||||||
|
backLink("Zones", lastList.zones || "#/zones"),
|
||||||
h("h1", { class: "mono" }, zone.display_name),
|
h("h1", { class: "mono" }, zone.display_name),
|
||||||
h("div", { class: "muted" },
|
h("div", { class: "muted" },
|
||||||
badge(zone.type, "accent"), " ",
|
badge(zone.type, "accent"), " ",
|
||||||
zone.pending ? [pendingBadge(zone.pending), " "] : null,
|
zone.pending ? [pendingBadge(zone.pending), " "] : null,
|
||||||
zone.view ? ["view ", h("a", { href: "#/views" }, zone.view), " · "] : null,
|
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
|
||||||
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
|
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
|
||||||
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
|
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
|
||||||
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
|
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
|
||||||
h("div", { class: "actions" },
|
h("div", { class: "actions" },
|
||||||
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone"))),
|
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
|
||||||
h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label))),
|
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
|
||||||
body);
|
// The header and tabs show at once. Below them, switching tabs keeps the
|
||||||
|
// previous tab's content (faded) until the new one is loaded, rather
|
||||||
|
// than blanking the page; opening the zone shows "Loading…" there.
|
||||||
|
const seq = ++renderSeq;
|
||||||
|
const prev = el.querySelector(`:scope > .zone-body[data-zone="${id}"]`);
|
||||||
|
const holder = prev || h("div", { class: "zone-body" }, h("div", { class: "muted" }, "Loading…"));
|
||||||
|
holder.classList.add("stale");
|
||||||
|
body.className = "zone-body";
|
||||||
|
body.dataset.zone = id;
|
||||||
|
mount(el, head, tabBar, holder);
|
||||||
if (tab === "records") await recordsTab(body, zone, query);
|
if (tab === "records") await recordsTab(body, zone, query);
|
||||||
else if (tab === "settings") await settingsTab(body, zone);
|
else if (tab === "settings") await settingsTab(body, zone);
|
||||||
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
|
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
|
||||||
@@ -43,6 +55,11 @@ export async function render(el, params, query) {
|
|||||||
else if (tab === "servers") await serversTab(body, zone);
|
else if (tab === "servers") await serversTab(body, zone);
|
||||||
else if (tab === "dnssec") await dnssecTab(body, zone);
|
else if (tab === "dnssec") await dnssecTab(body, zone);
|
||||||
else if (tab === "registrar") await registrarTab(body, zone);
|
else if (tab === "registrar") await registrarTab(body, zone);
|
||||||
|
// A later click (another tab) has taken over the page.
|
||||||
|
if (seq !== renderSeq || !holder.isConnected) return;
|
||||||
|
holder.replaceWith(body);
|
||||||
|
const first = body.querySelector("[data-autofocus]");
|
||||||
|
if (first) first.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- name servers: serials on the other servers, and the delegation
|
// ---- name servers: serials on the other servers, and the delegation
|
||||||
@@ -166,7 +183,7 @@ async function recordsTab(el, zone, query) {
|
|||||||
const reverse = isReverseZone(zone.name);
|
const reverse = isReverseZone(zone.name);
|
||||||
const rinfo = reverse ? reverseInfo(zone.name) : null;
|
const rinfo = reverse ? reverseInfo(zone.name) : null;
|
||||||
// Add form. In reverse zones records are named by address.
|
// Add form. In reverse zones records are named by address.
|
||||||
const fName = input({ placeholder: reverse && rinfo ? rinfo.example : "@ or www", style: { width: reverse ? "220px" : "180px" }, class: "mono", autocomplete: "off", spellcheck: "false" });
|
const fName = input({ placeholder: reverse && rinfo ? rinfo.example : "@ or www", style: { width: reverse ? "220px" : "180px" }, class: "mono", autocomplete: "off", spellcheck: "false", "data-autofocus": true });
|
||||||
const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), reverse ? "PTR" : "A", { style: { width: "110px" } });
|
const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), reverse ? "PTR" : "A", { style: { width: "110px" } });
|
||||||
const fTTL = input({ type: "number", placeholder: "default", min: 0, style: { width: "100px" } });
|
const fTTL = input({ type: "number", placeholder: "default", min: 0, style: { width: "100px" } });
|
||||||
const fData = input({ class: "mono", placeholder: typeInfo.A.example });
|
const fData = input({ class: "mono", placeholder: typeInfo.A.example });
|
||||||
@@ -388,7 +405,6 @@ async function recordsTab(el, zone, query) {
|
|||||||
(reverse ? "Names are the last part of the address within " + zone.display_name + "." : "Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute."))),
|
(reverse ? "Names are the last part of the address within " + zone.display_name + "." : "Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute."))),
|
||||||
list));
|
list));
|
||||||
draw();
|
draw();
|
||||||
fName.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- settings
|
// ---- settings
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge } from "../ui.js";
|
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList } from "../ui.js";
|
||||||
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
|
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { checkDomain, reverseZoneFor } from "../names.js";
|
import { checkDomain, reverseZoneFor } from "../names.js";
|
||||||
@@ -8,13 +8,23 @@ export async function render(el, params, query) {
|
|||||||
const filter = input({ type: "search", placeholder: "Filter zones…", value: query.get("q") || "" });
|
const filter = input({ type: "search", placeholder: "Filter zones…", value: query.get("q") || "" });
|
||||||
const viewSel = select([["", "All views"], ...views.map((v) => [v.name, v.name])], query.get("view") || "");
|
const viewSel = select([["", "All views"], ...views.map((v) => [v.name, v.name])], query.get("view") || "");
|
||||||
const typeSel = select([["", "All types"], "primary", "secondary", "forward", "stub", "static-stub", "mirror", "hint", "redirect", "in-view"], query.get("type") || "");
|
const typeSel = select([["", "All types"], "primary", "secondary", "forward", "stub", "static-stub", "mirror", "hint", "redirect", "in-view"], query.get("type") || "");
|
||||||
|
const viewsById = new Map(views.map((v) => [v.id, v]));
|
||||||
const list = h("div");
|
const list = h("div");
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
const q = filter.value.trim().toLowerCase();
|
const q = filter.value.trim().toLowerCase();
|
||||||
|
// Keep the filters in the address, so going back to the list (or a
|
||||||
|
// breadcrumb) restores them.
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filter.value.trim()) qs.set("q", filter.value.trim());
|
||||||
|
if (viewSel.value) qs.set("view", viewSel.value);
|
||||||
|
if (typeSel.value) qs.set("type", typeSel.value);
|
||||||
|
const hash = "#/zones" + (qs.toString() ? "?" + qs : "");
|
||||||
|
if (location.hash !== hash && !query.get("new")) history.replaceState(null, "", hash);
|
||||||
|
lastList.zones = hash;
|
||||||
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
|
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
|
||||||
mount(list, table([
|
mount(list, table([
|
||||||
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
|
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
|
||||||
views.length ? { label: "View", render: (z) => z.view || h("span", { class: "muted" }, "–") } : null,
|
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
|
||||||
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
|
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
|
||||||
{ label: "Records", render: (z) => z.type === "primary" ? String(z.record_count) : "", class: "right", tdClass: "right" },
|
{ label: "Records", render: (z) => z.type === "primary" ? String(z.record_count) : "", class: "right", tdClass: "right" },
|
||||||
{ label: "Serial", render: (z) => z.type === "primary" ? String(z.soa.serial) : "", tdClass: "mono" },
|
{ label: "Serial", render: (z) => z.type === "primary" ? String(z.soa.serial) : "", tdClass: "mono" },
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
--hunk: #e8eefb;
|
--hunk: #e8eefb;
|
||||||
--shadow: 0 1px 2px rgba(20, 30, 50, 0.08);
|
--shadow: 0 1px 2px rgba(20, 30, 50, 0.08);
|
||||||
--radius: 6px;
|
--radius: 6px;
|
||||||
|
--vc-green: #3d8160; --vc-teal: #2f7c80; --vc-purple: #7a5ba8; --vc-pink: #a8557f;
|
||||||
|
--vc-orange: #a8652f; --vc-yellow: #8a7424; --vc-red: #b0534c; --vc-grey: #6b7280;
|
||||||
--mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
|
--mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
@@ -50,6 +52,8 @@
|
|||||||
--del-text: #f7b2ac;
|
--del-text: #f7b2ac;
|
||||||
--hunk: #23314a;
|
--hunk: #23314a;
|
||||||
--shadow: none;
|
--shadow: none;
|
||||||
|
--vc-green: #8fcfa9; --vc-teal: #87cdd0; --vc-purple: #bca6e6; --vc-pink: #e6a6c6;
|
||||||
|
--vc-orange: #ebbb8f; --vc-yellow: #e3d08e; --vc-red: #eba29c; --vc-grey: #aab2bf;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +63,8 @@
|
|||||||
--warn: #e5b450; --warn-soft: #3a2f15; --err: #f08077; --err-soft: #3c1f1d; --info-soft: #232833;
|
--warn: #e5b450; --warn-soft: #3a2f15; --err: #f08077; --err-soft: #3c1f1d; --info-soft: #232833;
|
||||||
--code-bg: #242933; --add: #183a25; --add-text: #9fe3b5; --del: #43201e; --del-text: #f7b2ac; --hunk: #23314a;
|
--code-bg: #242933; --add: #183a25; --add-text: #9fe3b5; --del: #43201e; --del-text: #f7b2ac; --hunk: #23314a;
|
||||||
--shadow: none; color-scheme: dark;
|
--shadow: none; color-scheme: dark;
|
||||||
|
--vc-green: #8fcfa9; --vc-teal: #87cdd0; --vc-purple: #bca6e6; --vc-pink: #e6a6c6;
|
||||||
|
--vc-orange: #ebbb8f; --vc-yellow: #e3d08e; --vc-red: #eba29c; --vc-grey: #aab2bf;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -91,7 +97,6 @@ header.top {
|
|||||||
header.top .brand { display: inline-flex; align-items: center; gap: 8px; color: var(--text); text-decoration: none; }
|
header.top .brand { display: inline-flex; align-items: center; gap: 8px; color: var(--text); text-decoration: none; }
|
||||||
header.top .brand .logo { display: block; transition: transform 0.15s; }
|
header.top .brand .logo { display: block; transition: transform 0.15s; }
|
||||||
header.top .brand .brand-name { display: block; color: var(--text); }
|
header.top .brand .brand-name { display: block; color: var(--text); }
|
||||||
header.top .brand .brand-version { color: var(--muted); font-size: 12px; align-self: flex-end; padding-bottom: 2px; }
|
|
||||||
header.top .brand:hover { text-decoration: none; }
|
header.top .brand:hover { text-decoration: none; }
|
||||||
header.top .brand:hover .brand-name { color: var(--accent); }
|
header.top .brand:hover .brand-name { color: var(--accent); }
|
||||||
header.top .brand:hover .logo { transform: scale(1.06); }
|
header.top .brand:hover .logo { transform: scale(1.06); }
|
||||||
@@ -397,3 +402,27 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent);
|
|||||||
header.top .brand .logo { transition: none; }
|
header.top .brand .logo { transition: none; }
|
||||||
header.top .brand:hover .logo { transform: none; }
|
header.top .brand:hover .logo { transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a zone tab's previous content while the next tab loads */
|
||||||
|
.zone-body.stale { opacity: 0.5; transition: opacity 0.15s 0.1s; pointer-events: none; }
|
||||||
|
|
||||||
|
/* the "← Zones" link above a page's heading */
|
||||||
|
.back-link { font-size: 13px; margin-bottom: 2px; }
|
||||||
|
|
||||||
|
/* view colours (the default is the link colour) */
|
||||||
|
.vc-green { color: var(--vc-green); } .vc-teal { color: var(--vc-teal); } .vc-purple { color: var(--vc-purple); }
|
||||||
|
.vc-pink { color: var(--vc-pink); } .vc-orange { color: var(--vc-orange); } .vc-yellow { color: var(--vc-yellow); }
|
||||||
|
.vc-red { color: var(--vc-red); } .vc-grey { color: var(--vc-grey); } .vc-default { color: var(--accent); }
|
||||||
|
.swatches { display: flex; flex-wrap: wrap; gap: 8px; padding: 2px 0; }
|
||||||
|
.swatch { width: 24px; height: 24px; min-width: 0; padding: 0; border-radius: 50%; background: currentColor; border: 2px solid var(--panel); box-shadow: 0 0 0 1px var(--border); cursor: pointer; }
|
||||||
|
.swatch:hover { background: currentColor; }
|
||||||
|
.swatch.selected { box-shadow: 0 0 0 2px currentColor; }
|
||||||
|
.swatch:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
|
||||||
|
tr.selected td { background: var(--accent-soft); }
|
||||||
|
|
||||||
|
/* the page footer with the Jarl build */
|
||||||
|
.shell { min-height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
.shell > main { flex: 1; width: 100%; }
|
||||||
|
footer.site { padding: 12px 16px 16px; text-align: center; font-size: 12px; color: var(--muted); }
|
||||||
|
footer.site a { color: var(--muted); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 2px; }
|
||||||
|
footer.site a:hover { color: var(--accent); text-decoration-color: currentColor; }
|
||||||
@@ -247,3 +247,50 @@ export function debounce(fn, ms = 250) {
|
|||||||
let t;
|
let t;
|
||||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where each list page was last left (with its filters), so a breadcrumb
|
||||||
|
// back to it restores them. The router and list pages fill it in.
|
||||||
|
export const lastList = {};
|
||||||
|
|
||||||
|
// backLink is the "← Zones" link above a page's heading, back to the
|
||||||
|
// page it was opened from.
|
||||||
|
export function backLink(label, href) {
|
||||||
|
return h("nav", { class: "back-link", "aria-label": "Back" }, h("a", { href }, "← ", label));
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeBack returns a "back" address taken from the URL if it is one of
|
||||||
|
// the UI's own (#/...), else null: it ends up in an href.
|
||||||
|
export function safeBack(v) {
|
||||||
|
return v && /^#\/[A-Za-z0-9\/?=&%._~+-]*$/.test(v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The colours a view can be shown in (besides the default, the link
|
||||||
|
// colour). Keep in step with model.ViewColors.
|
||||||
|
export const viewColors = ["green", "teal", "purple", "pink", "orange", "yellow", "red", "grey"];
|
||||||
|
|
||||||
|
// viewLink links to a view's page, in the view's colour. v is a view
|
||||||
|
// from /views; name is shown when the view is unknown. back is the
|
||||||
|
// address the views page offers to go back to (the current page).
|
||||||
|
export function viewLink(v, name, back) {
|
||||||
|
if (!v) return name ? h("span", null, name) : h("span", { class: "muted" }, "–");
|
||||||
|
return h("a", { href: viewHref(v.id, back), class: v.color ? "vc-" + v.color : null, title: "View " + v.name }, v.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewHref is the address of a view on the views page.
|
||||||
|
export function viewHref(id, back) {
|
||||||
|
return "#/views?view=" + id + (back ? "&back=" + encodeURIComponent(back) : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// colorPicker picks a view colour; its value is "" for the default.
|
||||||
|
export function colorPicker(value) {
|
||||||
|
const el = h("div", { class: "swatches", role: "radiogroup" });
|
||||||
|
const draw = () => mount(el, ["", ...viewColors].map((c) => h("button", {
|
||||||
|
type: "button", role: "radio", "aria-checked": String(c === el.value),
|
||||||
|
class: "swatch" + (c ? " vc-" + c : " vc-default") + (c === el.value ? " selected" : ""),
|
||||||
|
title: c || "default (link colour)", "aria-label": c || "default",
|
||||||
|
onclick: () => { el.value = c; draw(); },
|
||||||
|
})));
|
||||||
|
el.value = value || "";
|
||||||
|
draw();
|
||||||
|
return el;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user