Disable reservations; subnet enable/disable buttons; page-head CSS fix
release / release (push) Successful in 5m53s

- A disabled reservation is kept but ignored: the client gets a pool
  address like any other (steward reservation disable|enable, buttons and
  a checkbox in the web UI). Schema migration 3.
- Subnets get Disable/Enable buttons on their page and in the list.
- A rule left without its selector in style.css broke the page heading's
  layout, so the New ... buttons sat cramped under the description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-14 16:02:15 +01:00
1 parent 7b3f0f764c
commit 6ad1bb9a0d
21 files changed
+153 -25

No files matched your search

+3
View File
@@ -102,6 +102,9 @@ and a CLI, built the way Jarl is.
- **HTTPS and plain HTTP on one port:** with a certificate, plain HTTP is - **HTTPS and plain HTTP on one port:** with a certificate, plain HTTP is
redirected to HTTPS, except from the server itself (also for redirected to HTTPS, except from the server itself (also for
`tls.cert_file`). `tls.cert_file`).
- **Disabled reservations** (`steward reservation disable|enable`, Disable
in the web UI): kept but ignored, so the client gets a pool address like
any other. Subnets get Disable/Enable buttons in the web UI too.
- **Token rotation** (`steward token rotate`, `POST /tokens/{id}/rotate`, - **Token rotation** (`steward token rotate`, `POST /tokens/{id}/rotate`,
Rotate in the web UI): a new secret for a token, with an optional grace Rotate in the web UI): a new secret for a token, with an optional grace
period for the old one, as in Jarl. period for the old one, as in Jarl.
+31 -2
View File
@@ -330,7 +330,7 @@ func reservationCmd() *cobra.Command {
return err return err
} }
return output(rs, func(w io.Writer) { return output(rs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tIP\tMAC / CLIENT ID\tHOSTNAME\tSUBNET\tLEASE\tCOMMENT") fmt.Fprintln(w, "ID\tIP\tMAC / CLIENT ID\tHOSTNAME\tSUBNET\tLEASE\tSTATE\tCOMMENT")
for _, r := range rs { for _, r := range rs {
who := r.MAC who := r.MAC
if r.ClientID != "" { if r.ClientID != "" {
@@ -340,7 +340,11 @@ func reservationCmd() *cobra.Command {
if r.Lease != nil { if r.Lease != nil {
lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04") lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04")
} }
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.IP, who, orDash(r.Hostname), orDash(r.SubnetName), lease, r.Comment) state := "on"
if r.Disabled {
state = "disabled"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.IP, who, orDash(r.Hostname), orDash(r.SubnetName), lease, state, r.Comment)
} }
}) })
}, },
@@ -374,12 +378,37 @@ func reservationCmd() *cobra.Command {
} else { } else {
fmt.Fprintln(w, "Lease:\tnone now") fmt.Fprintln(w, "Lease:\tnone now")
} }
if r.Disabled {
fmt.Fprintln(w, "State:\tdisabled (ignored: the client gets a pool address like any other)")
}
if r.Comment != "" { if r.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", r.Comment) fmt.Fprintf(w, "Comment:\t%s\n", r.Comment)
} }
}) })
}, },
}) })
for _, on := range []bool{true, false} {
on := on
verb, short := "disable", "Ignore a reservation, keeping it (the client gets a pool address like any other)"
if on {
verb, short = "enable", "Use a disabled reservation again"
}
cmd.AddCommand(&cobra.Command{
Use: verb + " RESERVATION", Short: short, Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
disabled := !on
var resp api.MutationResponse
if err := c.Do(ctx(), "PATCH", "/reservations/"+url.PathEscape(args[0]), nil, api.ReservationInput{Disabled: &disabled}, &resp); err != nil {
return err
}
return report(&resp)
},
})
}
var hostname, clientID, comment, ip, mac string var hostname, clientID, comment, ip, mac string
var opts, unsetOpts []string var opts, unsetOpts []string
+1 -1
View File
@@ -102,7 +102,7 @@ settings'.
| | | | | |
|---|---| |---|---|
| `GET /reservations[?subnet=REF]` | each with `subnet_id`, `subnet` and its current `lease` | | `GET /reservations[?subnet=REF]` | each with `subnet_id`, `subnet` and its current `lease` |
| `POST /reservations` | `{"ip", "mac", "client_id", "hostname", "options", "comment"}`; `ip` and `mac` or `client_id` needed | | `POST /reservations` | `{"ip", "mac", "client_id", "hostname", "options", "disabled", "comment"}`; `ip` and `mac` or `client_id` needed |
| `GET`, `PATCH`, `DELETE /reservations/{ref}` | by ID, address, or MAC address (when it has one reservation) | | `GET`, `PATCH`, `DELETE /reservations/{ref}` | by ID, address, or MAC address (when it has one reservation) |
### Leases and activity ### Leases and activity
+1
View File
@@ -79,6 +79,7 @@ one. An option Steward doesn't know: `--option 224:string=hello`.
| `reservation show RES` | RES is its ID, address or MAC address | | `reservation show RES` | RES is its ID, address or MAC address |
| `reservation add IP MAC [--hostname H] [--client-id HEX] [--option NAME=VALUE]... [--comment C]` | `-` for MAC with `--client-id` | | `reservation add IP MAC [--hostname H] [--client-id HEX] [--option NAME=VALUE]... [--comment C]` | `-` for MAC with `--client-id` |
| `reservation update RES [--ip] [--mac] [--client-id] [--hostname] [--option]... [--unset-option]... [--comment]` | | | `reservation update RES [--ip] [--mac] [--client-id] [--hostname] [--option]... [--unset-option]... [--comment]` | |
| `reservation enable\|disable RES` | use a reservation, or keep it but ignore it |
| `reservation delete RES` | | | `reservation delete RES` | |
### lease ### lease
+10 -1
View File
@@ -34,7 +34,8 @@ answered from the interface's address in their own subnet, or its first
address. address.
A subnet can be **disabled**: its configuration and leases stay, but its A subnet can be **disabled**: its configuration and leases stay, but its
clients get no answers (`steward subnet disable NAME`). clients get no answers (`steward subnet disable NAME`, or Disable in the
web UI).
## Pools ## Pools
@@ -67,6 +68,14 @@ steward lease reserve 10.0.10.57 # the address a client has now
* **A device on two VLANs** can have a reservation in each subnet. Within * **A device on two VLANs** can have a reservation in each subnet. Within
one subnet, a client has at most one. one subnet, a client has at most one.
A reservation can be **disabled** (`steward reservation disable RES`, or
Disable in the web UI). It is kept but ignored: the client gets a pool
address like any other, and the reserved address, if it is inside a pool,
may go to someone else. The client keeps the address it has until its
lease runs out. While it is disabled, another reservation may use its
address or client; enabling it again is then refused until one of them
moves.
A client with a reservation always gets that address, not a pool address. A client with a reservation always gets that address, not a pool address.
If it asks for another one, for example after the reservation was added, If it asks for another one, for example after the reservation was added,
it gets a NAK that names its reserved address, and then the reservation. it gets a NAK that names its reserved address, and then the reservation.
+1
View File
@@ -90,6 +90,7 @@ type ReservationInput struct {
IP *string `json:"ip,omitempty"` IP *string `json:"ip,omitempty"`
Hostname *string `json:"hostname,omitempty"` Hostname *string `json:"hostname,omitempty"`
Options *[]OptionInput `json:"options,omitempty"` Options *[]OptionInput `json:"options,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"` Comment *string `json:"comment,omitempty"`
} }
+3
View File
@@ -52,6 +52,9 @@ func compile(st *model.State) *config {
c.byIface[s.Interface] = append(c.byIface[s.Interface], sc) c.byIface[s.Interface] = append(c.byIface[s.Interface], sc)
} }
for _, r := range st.Reservations { for _, r := range st.Reservations {
if r.Disabled {
continue
}
c.resByIP[r.IP] = r c.resByIP[r.IP] = r
s := st.SubnetFor(r.IP) s := st.SubnetFor(r.IP)
if s == nil { if s == nil {
+17
View File
@@ -444,3 +444,20 @@ func TestPrune(t *testing.T) {
t.Errorf("old lease still saved: %+v", ls) t.Errorf("old lease still saved: %+v", ls)
} }
} }
func TestDisabledReservation(t *testing.T) {
// The printer's reservation (10.0.10.101, inside the pool) is
// disabled: the printer gets a pool address like anyone, and its
// reserved address can go to others.
h := newHarness(t, func(st *model.State) { st.Reservations[1].Disabled = true })
got := map[string]bool{}
for i := byte(1); i <= 4; i++ {
got[h.lease("eth0", mac(i)).YIAddr.String()] = true
}
if !got["10.0.10.101"] {
t.Errorf("the disabled reservation's address wasn't handed out: %v", got)
}
if out := h.discover("eth0", mac(0x98)); out != nil {
t.Errorf("the printer got %s from a full pool", out.Packet.YIAddr)
}
}
+3
View File
@@ -195,6 +195,9 @@ type Reservation struct {
// Hostname is sent to the client (option 12). // Hostname is sent to the client (option 12).
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
Options []OptionValue `json:"options"` Options []OptionValue `json:"options"`
// Disabled reservations are kept but ignored: the client is treated
// like any other, and an address inside a pool may go to others.
Disabled bool `json:"disabled"`
Comment string `json:"comment"` Comment string `json:"comment"`
} }
+4 -1
View File
@@ -132,7 +132,7 @@ func (s *Server) presentSubnet(sn *model.Subnet, st *model.State, leases []*mode
} }
reserved := map[netip.Addr]bool{} reserved := map[netip.Addr]bool{}
for _, res := range st.Reservations { for _, res := range st.Reservations {
if sn.Prefix.Contains(res.IP) { if sn.Prefix.Contains(res.IP) && !res.Disabled {
out.Usage.Reservations++ out.Usage.Reservations++
reserved[res.IP] = true reserved[res.IP] = true
for _, p := range sn.Pools { for _, p := range sn.Pools {
@@ -421,6 +421,9 @@ func applyReservationInput(res *model.Reservation, in *api.ReservationInput) err
} }
res.Options = opts res.Options = opts
} }
if in.Disabled != nil {
res.Disabled = *in.Disabled
}
if in.Comment != nil { if in.Comment != nil {
res.Comment = *in.Comment res.Comment = *in.Comment
} }
+1 -1
View File
@@ -29,7 +29,7 @@ func (s *Server) presentLease(l *model.Lease, st *model.State) *api.Lease {
} }
} }
for _, r := range st.Reservations { for _, r := range st.Reservations {
if r.IP == l.IP { if r.IP == l.IP && !r.Disabled {
out.Reservation, out.ReservedHostname = r.ID, r.Hostname out.Reservation, out.ReservedHostname = r.ID, r.Hostname
} }
} }
+7 -5
View File
@@ -50,7 +50,7 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err return nil, err
} }
rows, err = s.db.QueryContext(ctx, `SELECT id, mac, client_id, ip, hostname, options, comment FROM reservations ORDER BY id`) rows, err = s.db.QueryContext(ctx, `SELECT id, mac, client_id, ip, hostname, options, disabled, comment FROM reservations ORDER BY id`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -58,10 +58,12 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
for rows.Next() { for rows.Next() {
r := &model.Reservation{} r := &model.Reservation{}
var ip, opts string var ip, opts string
if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &r.Comment); err != nil { var disabled int
if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &disabled, &r.Comment); err != nil {
return nil, err return nil, err
} }
r.IP, _ = netip.ParseAddr(ip) r.IP, _ = netip.ParseAddr(ip)
r.Disabled = disabled != 0
if err := json.Unmarshal([]byte(opts), &r.Options); err != nil { if err := json.Unmarshal([]byte(opts), &r.Options); err != nil {
return nil, fmt.Errorf("reservation %d options: %w", r.ID, err) return nil, fmt.Errorf("reservation %d options: %w", r.ID, err)
} }
@@ -158,10 +160,10 @@ func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error {
if err := json.Unmarshal(c.After, &r); err != nil { if err := json.Unmarshal(c.After, &r); err != nil {
return err return err
} }
_, err := tx.ExecContext(ctx, `INSERT INTO reservations (id, mac, client_id, ip, hostname, options, comment) VALUES (?,?,?,?,?,?,?) _, err := tx.ExecContext(ctx, `INSERT INTO reservations (id, mac, client_id, ip, hostname, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET mac = excluded.mac, client_id = excluded.client_id, ip = excluded.ip, hostname = excluded.hostname, ON CONFLICT(id) DO UPDATE SET mac = excluded.mac, client_id = excluded.client_id, ip = excluded.ip, hostname = excluded.hostname,
options = excluded.options, comment = excluded.comment`, options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`,
r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), r.Comment) r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), boolInt(r.Disabled), r.Comment)
return err return err
} }
return fmt.Errorf("unknown kind %q", c.Kind) return fmt.Errorf("unknown kind %q", c.Kind)
+4
View File
@@ -193,6 +193,10 @@ CREATE INDEX audit_time ON audit (time);
ALTER TABLE tokens ADD COLUMN rotated_at TEXT; ALTER TABLE tokens ADD COLUMN rotated_at TEXT;
ALTER TABLE tokens ADD COLUMN prev_secret_hash TEXT NOT NULL DEFAULT ''; ALTER TABLE tokens ADD COLUMN prev_secret_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT; ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
`,
// 3: disabled reservations.
`
ALTER TABLE reservations ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
`, `,
} }
+2 -2
View File
@@ -35,7 +35,7 @@ func TestStateHistoryAndLeases(t *testing.T) {
sn := &model.Subnet{ID: ids[0], Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"), sn := &model.Subnet{ID: ids[0], Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"),
Pools: []model.Pool{{Start: netip.MustParseAddr("10.0.10.100"), End: netip.MustParseAddr("10.0.10.199")}}, Pools: []model.Pool{{Start: netip.MustParseAddr("10.0.10.100"), End: netip.MustParseAddr("10.0.10.199")}},
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}}} Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}}}
r := &model.Reservation{ID: ids[1], MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas"} r := &model.Reservation{ID: ids[1], MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas", Disabled: true}
set := st.Settings set := st.Settings
set.LeaseTime = 3600 set.LeaseTime = 3600
e := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "cli", Description: "add office", e := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "cli", Description: "add office",
@@ -51,7 +51,7 @@ func TestStateHistoryAndLeases(t *testing.T) {
if len(st.Subnets) != 1 || st.Subnets[0].Pools[0].Size() != 100 || st.Subnets[0].Options[0].Value != "10.0.10.1" { if len(st.Subnets) != 1 || st.Subnets[0].Pools[0].Size() != 100 || st.Subnets[0].Options[0].Value != "10.0.10.1" {
t.Errorf("subnets %+v", st.Subnets) t.Errorf("subnets %+v", st.Subnets)
} }
if len(st.Reservations) != 1 || st.Reservations[0].Hostname != "nas" || st.Settings.LeaseTime != 3600 { if len(st.Reservations) != 1 || st.Reservations[0].Hostname != "nas" || !st.Reservations[0].Disabled || st.Settings.LeaseTime != 3600 {
t.Errorf("reservations %+v settings %+v", st.Reservations, st.Settings) t.Errorf("reservations %+v settings %+v", st.Reservations, st.Settings)
} }
// Undo: the inverse changes, marked as a revert. // Undo: the inverse changes, marked as a revert.
+4
View File
@@ -275,6 +275,9 @@ func (c *checker) reservations(st *model.State, env *Env) {
if r.IP == s.Prefix.Addr() || r.IP == model.Last(s.Prefix) { if r.IP == s.Prefix.Addr() || r.IP == model.Last(s.Prefix) {
c.add(Error, "reservation_network_address", obj, "%s is the network or broadcast address of %s", r.IP, s.Prefix) c.add(Error, "reservation_network_address", obj, "%s is the network or broadcast address of %s", r.IP, s.Prefix)
} }
// A disabled reservation is ignored, so it takes no address or
// client from another; enabling it again is checked.
if !r.Disabled {
if o := byIP[r.IP]; o != nil { if o := byIP[r.IP]; o != nil {
c.add(Error, "reservation_ip_taken", obj, "%s is already reserved for %s", r.IP, o.Label()) c.add(Error, "reservation_ip_taken", obj, "%s is already reserved for %s", r.IP, o.Label())
} }
@@ -287,6 +290,7 @@ func (c *checker) reservations(st *model.State, env *Env) {
c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label()) c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label())
} }
byClient[k] = r byClient[k] = r
}
if r.Hostname != "" && !validHostname(r.Hostname) { if r.Hostname != "" && !validHostname(r.Hostname) {
c.add(Error, "reservation_hostname", obj, "%q isn't a valid host name (letters, digits and '-', labels up to 63 characters, separated by dots)", r.Hostname) c.add(Error, "reservation_hostname", obj, "%q isn't a valid host name (letters, digits and '-', labels up to 63 characters, separated by dots)", r.Hostname)
} }
+16
View File
@@ -98,6 +98,22 @@ func TestProblems(t *testing.T) {
} }
} }
func TestDisabledReservationsDontConflict(t *testing.T) {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
st.Reservations[0].Disabled = true
// The same client and address again, enabled: fine while the first is
// disabled.
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20")})
if ps := Check(st, nil); len(ps) != 0 {
t.Errorf("problems: %v", ps)
}
st.Reservations[0].Disabled = false
if got := codes(Check(st, nil)); !strings.Contains(got, "reservation_ip_taken") {
t.Errorf("enabling it again: %q", got)
}
}
func TestSameMACInTwoSubnets(t *testing.T) { func TestSameMACInTwoSubnets(t *testing.T) {
st := good() st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")} st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
@@ -1,5 +1,5 @@
// Reservations: fixed addresses for clients. // Reservations: fixed addresses for clients.
import { h, mount, table, input, modal, errorBox, field, confirmDialog, select, debounce, badge } from "../ui.js"; import { h, mount, table, input, modal, errorBox, field, confirmDialog, select, debounce, badge, checkbox } from "../ui.js";
import { get, change, can } from "../api.js"; import { get, change, can } from "../api.js";
import { optionsEditor, subnetLabel } from "../widgets.js"; import { optionsEditor, subnetLabel } from "../widgets.js";
@@ -7,7 +7,7 @@ import { optionsEditor, subnetLabel } from "../widgets.js";
export function reservationsTable(rs, done, opts = {}) { export function reservationsTable(rs, done, opts = {}) {
const write = can("config", true); const write = can("config", true);
return table([ return table([
{ label: "Address", render: (r) => h("span", { class: "mono" }, r.ip) }, { label: "Address", render: (r) => [h("span", { class: "mono" }, r.ip), r.disabled ? [" ", badge("disabled", "off")] : null] },
{ label: "Client", render: (r) => h("span", { class: "mono" }, r.client_id ? "id " + r.client_id : r.mac) }, { label: "Client", render: (r) => h("span", { class: "mono" }, r.client_id ? "id " + r.client_id : r.mac) },
{ label: "Host name", render: (r) => r.hostname || h("span", { class: "muted" }, "–") }, { label: "Host name", render: (r) => r.hostname || h("span", { class: "muted" }, "–") },
opts.subnet ? null : { label: "Subnet", render: (r) => r.subnet_id ? h("a", { href: "#/subnets/" + r.subnet_id }, r.subnet) : "–" }, opts.subnet ? null : { label: "Subnet", render: (r) => r.subnet_id ? h("a", { href: "#/subnets/" + r.subnet_id }, r.subnet) : "–" },
@@ -18,9 +18,18 @@ export function reservationsTable(rs, done, opts = {}) {
{ label: "Comment", render: (r) => r.comment }, { label: "Comment", render: (r) => r.comment },
write ? { label: "", tdClass: "actions-cell", render: (r) => [ write ? { label: "", tdClass: "actions-cell", render: (r) => [
h("button", { class: "small reveal", onclick: () => reservationDialog(r, done) }, "Edit"), " ", h("button", { class: "small reveal", onclick: () => reservationDialog(r, done) }, "Edit"), " ",
h("button", { class: "small reveal", title: r.disabled ? "Use this reservation again" : "Keep it, but ignore it: the client gets a pool address like any other",
onclick: () => toggle(r, done) }, r.disabled ? "Enable" : "Disable"), " ",
h("button", { class: "small reveal", onclick: () => del(r, done) }, "Delete"), h("button", { class: "small reveal", onclick: () => del(r, done) }, "Delete"),
] } : null, ] } : null,
].filter(Boolean), rs, { empty: opts.empty || "No reservations." }); ].filter(Boolean), rs, { empty: opts.empty || "No reservations.", rowClass: (r) => r.disabled ? "disabled" : "" });
}
async function toggle(r, done) {
try {
await change("PATCH", "/reservations/" + r.id, { disabled: !r.disabled });
done();
} catch (e) { modal(r.disabled ? "Not enabled" : "Not disabled", errorBox(e)); }
} }
async function del(r, done) { async function del(r, done) {
@@ -44,6 +53,7 @@ export async function reservationDialog(r, done, preset = {}) {
const host = input({ value: r ? r.hostname : preset.hostname || "", placeholder: "nas" }); const host = input({ value: r ? r.hostname : preset.hostname || "", placeholder: "nas" });
const opts = await optionsEditor(r ? r.options : [], inherited); const opts = await optionsEditor(r ? r.options : [], inherited);
const comment = input({ value: r ? r.comment : "" }); const comment = input({ value: r ? r.comment : "" });
const disabled = checkbox("Disabled: keep it, but ignore it (the client gets a pool address like any other)", r ? r.disabled : false);
const err = h("div"); const err = h("div");
const body = h("div", null, h("div", { class: "form-grid" }, const body = h("div", null, h("div", { class: "form-grid" },
field("Address", ip, "Its subnet is the one whose network holds it. Inside a pool is fine: nobody else gets it."), field("Address", ip, "Its subnet is the one whose network holds it. Inside a pool is fine: nobody else gets it."),
@@ -51,12 +61,14 @@ export async function reservationDialog(r, done, preset = {}) {
field("Client identifier", cid, "Optional: match the client's identifier (option 61, hex) instead of its MAC address."), field("Client identifier", cid, "Optional: match the client's identifier (option 61, hex) instead of its MAC address."),
field("Host name", host, "Sent to the client."), field("Host name", host, "Sent to the client."),
field("Options", opts, "Only for this client; they win over the subnet's."), field("Options", opts, "Only for this client; they win over the subnet's."),
field("Comment", comment)), err); field("Comment", comment),
r ? [h("label"), disabled] : null), err);
await modal(r ? "Edit reservation " + r.ip : "New reservation", body, [ await modal(r ? "Edit reservation " + r.ip : "New reservation", body, [
{ label: "Cancel", value: null }, { label: "Cancel", value: null },
{ label: r ? "Save" : "Add reservation", kind: "primary", onclick: async () => { { label: r ? "Save" : "Add reservation", kind: "primary", onclick: async () => {
mount(err); mount(err);
const v = { ip: ip.value.trim(), mac: mac.value.trim(), client_id: cid.value.trim(), hostname: host.value.trim(), options: opts.value(), comment: comment.value }; const v = { ip: ip.value.trim(), mac: mac.value.trim(), client_id: cid.value.trim(), hostname: host.value.trim(), options: opts.value(), comment: comment.value };
if (r) v.disabled = disabled.input.checked;
try { try {
await change(r ? "PATCH" : "POST", r ? "/reservations/" + r.id : "/reservations", v); await change(r ? "PATCH" : "POST", r ? "/reservations/" + r.id : "/reservations", v);
done(); done();
+13 -1
View File
@@ -21,7 +21,11 @@ export async function render(el, params) {
h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ", h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ",
s.pools.length ? h("span", { class: "mono" }, s.pools.map((p) => p.start + "-" + p.end).join(", ")) : "none", s.pools.length ? h("span", { class: "mono" }, s.pools.map((p) => p.start + "-" + p.end).join(", ")) : "none",
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")), " · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")),
h("div", { class: "subnet-usage" }, usageBar(s.usage))), h("div", { class: "subnet-usage" }, usageBar(s.usage)),
can("config", true) ? h("div", { class: "actions" }, h("button", {
title: s.disabled ? "Answer this subnet's clients again" : "Stop answering this subnet's clients; its configuration and leases stay",
onclick: () => toggleSubnet(s) }, s.disabled ? "Enable subnet" : "Disable subnet")) : null),
s.disabled ? h("div", { class: "msg warn" }, "This subnet is disabled: its clients get no answers. Its configuration and leases are kept.") : null,
h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/subnets/" + id + "/" + k, class: tab === k ? "active" : "" }, label))), h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/subnets/" + id + "/" + k, class: tab === k ? "active" : "" }, label))),
body); body);
if (tab === "leases") await leasesTab(body, s); if (tab === "leases") await leasesTab(body, s);
@@ -29,6 +33,14 @@ export async function render(el, params) {
else await settingsTab(body, s); else await settingsTab(body, s);
} }
export async function toggleSubnet(s) {
if (!s.disabled && !await confirmDialog("Disable subnet", "Stop answering the clients of " + subnetLabel(s) + "? They keep their addresses until their leases run out, then have none. The configuration and leases stay; Enable brings it back.", "Disable", "danger")) return;
try {
await change("PATCH", "/subnets/" + s.id, { disabled: !s.disabled });
rerender();
} catch (e) { modal(s.disabled ? "Not enabled" : "Not disabled", errorBox(e)); }
}
async function leasesTab(el, s) { async function leasesTab(el, s) {
const search = input({ type: "search", placeholder: "address, MAC or host name" }); const search = input({ type: "search", placeholder: "address, MAC or host name" });
const out = h("div"); const out = h("div");
+4 -2
View File
@@ -2,6 +2,7 @@
import { h, mount, table, modal, errorBox, badge } from "../ui.js"; import { h, mount, table, modal, errorBox, badge } from "../ui.js";
import { get, change, can } from "../api.js"; import { get, change, can } from "../api.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js"; import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js";
import { toggleSubnet } from "./subnet.js";
export async function render(el, params, query) { export async function render(el, params, query) {
const subnets = await get("/subnets"); const subnets = await get("/subnets");
@@ -18,8 +19,9 @@ export async function render(el, params, query) {
{ label: "In use", render: (s) => usageBar(s.usage) }, { label: "In use", render: (s) => usageBar(s.usage) },
{ label: "Reserved", class: "num", tdClass: "num", render: (s) => String(s.usage.reservations) }, { label: "Reserved", class: "num", tdClass: "num", render: (s) => String(s.usage.reservations) },
{ label: "Lease time", render: (s) => s.lease_time ? fmtSeconds(s.lease_time) : h("span", { class: "muted" }, "default") }, { label: "Lease time", render: (s) => s.lease_time ? fmtSeconds(s.lease_time) : h("span", { class: "muted" }, "default") },
{ label: "", render: (s) => s.disabled ? badge("disabled", "off") : null }, { label: "", tdClass: "actions-cell", render: (s) => [s.disabled ? [badge("disabled", "off"), " "] : null,
], subnets, { empty: "No subnets yet. Add one for each network Steward should serve." }))); can("config", true) ? h("button", { class: "small reveal", onclick: () => toggleSubnet(s) }, s.disabled ? "Enable" : "Disable") : null] },
], subnets, { empty: "No subnets yet. Add one for each network Steward should serve.", rowClass: (s) => s.disabled ? "disabled" : "" })));
if (query.get("new") === "1" && can("config", true)) newSubnet(); if (query.get("new") === "1" && can("config", true)) newSubnet();
} }
+3 -5
View File
@@ -110,11 +110,9 @@ header.top .spacer { flex: 1; }
header.top form.search input { width: 220px; } header.top form.search input { width: 220px; }
header.top .user { color: var(--muted); } header.top .user { color: var(--muted); }
main { padding: 16px; max-width: 1400px; margin: 0 auto; } main { padding: 16px; max-width: 1400px; margin: 0 auto; }
display: flex; align-items: center; gap: 12px; flex-wrap: wrap; .page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
background: var(--warn-soft); color: var(--text); border-bottom: 1px solid var(--border); .page-head > .actions { padding: 4px 0; }
padding: 8px 16px; .filter-row + .panel, .actions + .panel { margin-top: 4px; }
}
.page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.page-head .grow { flex: 1; min-width: 200px; } .page-head .grow { flex: 1; min-width: 200px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } .actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
+9
View File
@@ -106,6 +106,15 @@ run "client on eth1 gets the other subnet" 0 udhcpc b 02:00:00:00:00:02 -x hostn
contains "BOUND ip=$NB.100 mask=255.255.255.0 router=$NB.1 dns= domain=lan lease=3600 serverid=$NB.2" contains "BOUND ip=$NB.100 mask=255.255.255.0 router=$NB.1 dns= domain=lan lease=3600 serverid=$NB.2"
run "reserved client gets its address, host name and option" 0 udhcpc a 02:00:00:00:00:20 run "reserved client gets its address, host name and option" 0 udhcpc a 02:00:00:00:00:20
contains "BOUND ip=$NA.20 .*hostname=printer ntp=$NA.2" contains "BOUND ip=$NA.20 .*hostname=printer ntp=$NA.2"
run "disable the reservation" 0 S reservation disable $NA.20
run "a disabled reservation's client gets a pool address" 0 udhcpc a 02:00:00:00:00:20
contains "BOUND ip=$NA.10"
not_contains "hostname=printer"
POOLIP=$(echo "$OUT" | sed -n 's/^BOUND ip=\([0-9.]*\) .*/\1/p')
run "enable it again" 0 S reservation enable $NA.20
run "the reserved address again" 0 udhcpc a 02:00:00:00:00:20
contains "BOUND ip=$NA.20 .*hostname=printer"
run "free the pool address it had meanwhile" 0 S lease delete "$POOLIP" --yes
run "the same device on the other VLAN gets that VLAN's address" 0 udhcpc b 02:00:00:00:00:20 run "the same device on the other VLAN gets that VLAN's address" 0 udhcpc b 02:00:00:00:00:20
contains "BOUND ip=$NB.101 " contains "BOUND ip=$NB.101 "
run "leases" 0 S lease list run "leases" 0 S lease list