v0.5.43-beta - van-tot omrekenen naar een CIDR-blok
Some checks failed
Deploy server-up (dev) / deploy (push) Has been cancelled

"Ik wil van 10.0.20.200 tot .254" bestaat niet als CIDR-notatie: 55 adressen,
terwijl Docker alleen uitgelijnde blokken van een macht van twee accepteert.
Dat is niet iets wat een gebruiker zelf hoort uit te rekenen.

- Nieuwe range_options(): vertaalt een gewenst begin- en eindadres naar twee
  bruikbare blokken. "Ruim" is het kleinste blok dat de hele wens omvat,
  "krap" het grootste blok dat er volledig binnen valt. Met per optie hoeveel
  adressen erbij komen of ontbreken.
- Endpoint POST /api/networks/range en een uitklapbare van-tot-hulp onder het
  IP-range-veld; klikken op een optie vult het veld.
- Voor 10.0.20.200-254 levert dat 10.0.20.192/26 op: 62 adressen, .193 t/m .254.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
Ramon 2026-07-27 00:13:53 +02:00
parent cbb232d63f
commit b30f83866f
8 changed files with 219 additions and 3 deletions

View file

@ -1,3 +1,21 @@
# v0.5.43-beta — Van-tot omrekenen naar een CIDR-blok
"Ik wil van 10.0.20.200 tot .254" is een volstrekt redelijke wens, maar bestaat
niet als CIDR-notatie: dat zijn 55 adressen, en Docker accepteert alleen
uitgelijnde blokken van 2, 4, 8, 16 … adressen.
Onder het IP-range-veld zit nu een **van-tot-hulp**. Vul de eerste en laatste
gewenste adressen in en Server Up rekent uit welke blokken in de buurt komen:
* **dekt alles** — het kleinste blok dat je hele wens omvat (pakt er onderaan
wat bij)
* **past binnen** — het grootste blok dat volledig binnen je wens valt
Bij t/m levert dat op: 62 adressen, t/m
. Klikken vult het veld in.
---
# v0.5.42-beta — Netwerk toevoegen: twee blokkades weg # v0.5.42-beta — Netwerk toevoegen: twee blokkades weg
Een netwerk aanmaken lukte niet, met wisselend gedrag: soms een foutmelding dat Een netwerk aanmaken lukte niet, met wisselend gedrag: soms een foutmelding dat

View file

@ -1 +1 @@
0.5.42-beta 0.5.43-beta

View file

@ -1402,6 +1402,14 @@ def api_networks():
drivers=list(networks.DRIVERS)) drivers=list(networks.DRIVERS))
@app.route("/api/networks/range", methods=["POST"])
def api_networks_range():
"""Zet "van adres X tot adres Y" om in bruikbare CIDR-blokken."""
d = request.json or {}
return jsonify(networks.range_options(
d.get("first", ""), d.get("last", ""), d.get("subnet", "")))
@app.route("/api/networks/review", methods=["POST"]) @app.route("/api/networks/review", methods=["POST"])
def api_networks_review(): def api_networks_review():
"""Beoordeel een definitie zonder iets aan te maken (live in het formulier).""" """Beoordeel een definitie zonder iets aan te maken (live in het formulier)."""

View file

@ -390,6 +390,74 @@ def _bruikbaar_bereik(net, gateway: str = ""):
return eerste, laatste, max(aantal, 0) return eerste, laatste, max(aantal, 0)
def range_options(eerste: str, laatste: str, subnet: str = "") -> dict:
"""Vertaal "ik wil van X tot Y" naar bruikbare CIDR-blokken.
Docker accepteert één `--ip-range`, en dat moet een uitgelijnd blok zijn met
een macht van twee als grootte. Een wens als `.200 t/m .254` (55 adressen)
past daar niet in. Deze functie geeft daarom twee bruikbare antwoorden:
* **ruim** het kleinste blok dat je hele wens omvat; pakt er wat adressen
onderaan bij
* **krap** het grootste blok dat volledig bínnen je wens valt; nooit te
veel, maar mogelijk minder dan gevraagd
Bijna altijd is "ruim" wat je wilt, mits je DHCP-bereik eronder blijft.
"""
try:
f = ipaddress.ip_address(eerste.strip())
l = ipaddress.ip_address(laatste.strip())
except (ValueError, AttributeError):
return {"ok": False, "msg": "Vul twee geldige IP-adressen in.", "options": []}
if f.version != 4 or l.version != 4:
return {"ok": False, "msg": "Alleen IPv4 wordt ondersteund.", "options": []}
if int(l) < int(f):
f, l = l, f
net = None
if subnet:
try:
net = ipaddress.ip_network(subnet, strict=False)
if f not in net or l not in net:
return {"ok": False, "options": [],
"msg": f"Die adressen liggen niet allebei binnen {net}."}
except ValueError:
net = None
gewenst = int(l) - int(f) + 1
opties = []
# Ruim: opklimmen tot beide adressen erin passen.
omvattend = ipaddress.ip_network(f"{f}/32")
for _ in range(32):
if l in omvattend:
break
omvattend = omvattend.supernet()
if l in omvattend and (net is None or omvattend.subnet_of(net)):
opties.append(_range_optie("ruim", omvattend, gewenst))
# Krap: het grootste blok uit de exacte opdeling van het bereik.
try:
stukken = list(ipaddress.summarize_address_range(f, l))
except Exception:
stukken = []
if stukken:
grootste = max(stukken, key=lambda n: n.num_addresses)
if not opties or str(grootste) != opties[0]["cidr"]:
opties.append(_range_optie("krap", grootste, gewenst))
return {"ok": bool(opties), "options": opties, "wanted": gewenst,
"msg": "" if opties else "Geen passend blok gevonden."}
def _range_optie(soort: str, blok, gewenst: int) -> dict:
eerste, laatste, aantal = _bruikbaar_bereik(blok)
return {"kind": soort, "cidr": str(blok), "first": str(eerste),
"last": str(laatste), "count": aantal,
"extra": max(blok.num_addresses - gewenst, 0),
"short": max(gewenst - blok.num_addresses, 0)}
def normalize(net: dict) -> dict: def normalize(net: dict) -> dict:
"""Zet subnet en ip_range om naar hun canonieke vorm. """Zet subnet en ip_range om naar hun canonieke vorm.

View file

@ -972,6 +972,41 @@ tailwind.config = {
</button> </button>
</div> </div>
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('ip_range_hint')"></p> <p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('ip_range_hint')"></p>
<!-- Van-tot omrekenen. CIDR kan alleen uitgelijnde blokken van een
macht van twee, dus '.200 tot .254' bestaat niet als notatie. -->
<details class="mt-2 rounded-xl border border-bd dark:border-bd-dark overflow-hidden">
<summary class="px-3 py-2 cursor-pointer text-[11px] font-bold text-wh dark:text-wh-dark bg-s2/60 dark:bg-s2-dark/60 flex items-center gap-1.5">
<span class="mdi mdi-calculator-variant-outline"></span>
<span x-text="t('range_helper')">Ik wil een bepaald bereik</span>
</summary>
<div class="p-3 space-y-2">
<div class="flex items-center gap-2">
<span class="text-[11px] text-t2 dark:text-t2-dark shrink-0" x-text="t('from')">van</span>
<input class="form-input !h-9 font-mono !text-xs" x-model="rangeCalc.first"
@input="calcRange()" placeholder="10.0.20.200">
<span class="text-[11px] text-t2 dark:text-t2-dark shrink-0" x-text="t('to')">tot</span>
<input class="form-input !h-9 font-mono !text-xs" x-model="rangeCalc.last"
@input="calcRange()" placeholder="10.0.20.254">
</div>
<p class="text-[11px] text-dm dark:text-dm-dark" x-text="t('range_helper_hint')"></p>
<p x-show="rangeCalc.msg" class="text-[11px] text-wr" x-text="rangeCalc.msg"></p>
<template x-for="o in (rangeCalc.options || [])" :key="o.cidr">
<button type="button" @click="newNet.ip_range = o.cidr; reviewNet()"
class="w-full text-left px-3 py-2 rounded-lg border border-bd dark:border-bd-dark hover:border-ac transition">
<div class="flex items-center gap-2">
<span class="font-mono text-xs font-semibold text-wh dark:text-wh-dark" x-text="o.cidr"></span>
<span class="badge" :class="o.kind === 'ruim' ? 'badge-ok' : 'badge-dim'"
x-text="t('range_' + o.kind)"></span>
<span class="text-[11px] text-t2 dark:text-t2-dark ml-auto"
x-text="t('range_count', {count: o.count})"></span>
</div>
<div class="text-[10px] font-mono text-dm dark:text-dm-dark mt-0.5"
x-text="o.first + ' … ' + o.last"></div>
</button>
</template>
</div>
</details>
</div> </div>
</div> </div>
@ -1309,6 +1344,7 @@ function app() {
// Macvlan/ipvlan-netwerken waarmee stacks een eigen IP krijgen // Macvlan/ipvlan-netwerken waarmee stacks een eigen IP krijgen
netList: [], netIfaces: [], netDetected: [], netDetectError: '', netList: [], netIfaces: [], netDetected: [], netDetectError: '',
netReview: {}, netReviewTimer: null, netReviewSeq: 0, netReview: {}, netReviewTimer: null, netReviewSeq: 0,
rangeCalc: { first: '', last: '', options: [], msg: '' }, rangeCalcTimer: null,
newNet: { name: '', driver: 'macvlan', parent: '', subnet: '', gateway: '', ip_range: '' }, newNet: { name: '', driver: 'macvlan', parent: '', subnet: '', gateway: '', ip_range: '' },
docker: { running: 0, containers: 0, images: 0 }, docker: { running: 0, containers: 0, images: 0 },
@ -1760,6 +1796,19 @@ function app() {
} }
this.reviewNet(); this.reviewNet();
}, },
// 'Van adres X tot Y' omrekenen naar een CIDR-blok dat Docker accepteert.
calcRange() {
if (this.rangeCalcTimer) clearTimeout(this.rangeCalcTimer);
this.rangeCalcTimer = setTimeout(async () => {
const { first, last } = this.rangeCalc;
if (!first || !last) { this.rangeCalc.options = []; this.rangeCalc.msg = ''; return; }
const d = await this.rpc('/api/networks/range',
{ first, last, subnet: this.newNet.subnet });
this.rangeCalc.options = d.options || [];
this.rangeCalc.msg = d.ok ? '' : (d.msg || '');
}, 350);
},
// Live meelezen terwijl je typt; even wachten zodat niet elke toetsaanslag // Live meelezen terwijl je typt; even wachten zodat niet elke toetsaanslag
// een verzoek stuurt. // een verzoek stuurt.
reviewNet() { reviewNet() {

View file

@ -232,5 +232,12 @@
"tip_example_title": "Example with a router at 192.168.1.1", "tip_example_title": "Example with a router at 192.168.1.1",
"tip_example": "Subnet 192.168.1.0/24 ← same as your router\nGateway 192.168.1.1 ← your router\nIP range 192.168.1.240/28 ← .240 through .255\n\nSet your router's DHCP pool to 192.168.1.20 192.168.1.239 so that\nthose last sixteen addresses are reserved for your containers.", "tip_example": "Subnet 192.168.1.0/24 ← same as your router\nGateway 192.168.1.1 ← your router\nIP range 192.168.1.240/28 ← .240 through .255\n\nSet your router's DHCP pool to 192.168.1.20 192.168.1.239 so that\nthose last sixteen addresses are reserved for your containers.",
"suggest_range": "Suggest a free block", "suggest_range": "Suggest a free block",
"range_result": "{count} addresses available: {first} through {last}" "range_result": "{count} addresses available: {first} through {last}",
"range_helper": "I want a specific range",
"range_helper_hint": "Docker works with blocks of 2, 4, 8, 16 … addresses starting on a fixed boundary. A wish like '.200 to .254' does not exist as a notation; the closest blocks are listed below.",
"from": "from",
"to": "to",
"range_ruim": "covers all",
"range_krap": "fits inside",
"range_count": "{count} addresses"
} }

View file

@ -232,5 +232,12 @@
"tip_example_title": "Voorbeeld bij een router op 192.168.1.1", "tip_example_title": "Voorbeeld bij een router op 192.168.1.1",
"tip_example": "Subnet 192.168.1.0/24 ← hetzelfde als je router\nGateway 192.168.1.1 ← je router\nIP-range 192.168.1.240/28 ← .240 t/m .255\n\nStel je DHCP-bereik in je router in op 192.168.1.20 192.168.1.239,\nzodat die laatste zestien adressen alleen voor je containers zijn.", "tip_example": "Subnet 192.168.1.0/24 ← hetzelfde als je router\nGateway 192.168.1.1 ← je router\nIP-range 192.168.1.240/28 ← .240 t/m .255\n\nStel je DHCP-bereik in je router in op 192.168.1.20 192.168.1.239,\nzodat die laatste zestien adressen alleen voor je containers zijn.",
"suggest_range": "Stel een vrij blok voor", "suggest_range": "Stel een vrij blok voor",
"range_result": "{count} adressen beschikbaar: {first} tot en met {last}" "range_result": "{count} adressen beschikbaar: {first} tot en met {last}",
"range_helper": "Ik wil een bepaald bereik",
"range_helper_hint": "Docker werkt met blokken van 2, 4, 8, 16 … adressen die op een vaste grens beginnen. Een wens als '.200 tot .254' bestaat niet als notatie; hieronder staan de dichtstbijzijnde blokken.",
"from": "van",
"to": "tot",
"range_ruim": "dekt alles",
"range_krap": "past binnen",
"range_count": "{count} adressen"
} }

View file

@ -290,3 +290,62 @@ def test_detectiefout_wordt_doorgegeven(client, monkeypatch):
d = client.get("/api/networks").get_json() d = client.get("/api/networks").get_json()
assert d["detect_error"] == "docker niet bereikbaar" assert d["detect_error"] == "docker niet bereikbaar"
assert d["detected"] == [] assert d["detected"] == []
# ── Van-tot omrekenen ────────────────────────────────────────────────────────
def test_range_options_voor_200_tot_254():
"""De vraag uit de praktijk: 55 adressen, wat geen geldig CIDR-blok is."""
r = networks.range_options("10.0.20.200", "10.0.20.254", "10.0.20.0/24")
assert r["ok"] is True
ruim = next(o for o in r["options"] if o["kind"] == "ruim")
assert ruim["cidr"] == "10.0.20.192/26"
assert ruim["count"] == 62
assert (ruim["first"], ruim["last"]) == ("10.0.20.193", "10.0.20.254")
krap = next(o for o in r["options"] if o["kind"] == "krap")
assert krap["cidr"] == "10.0.20.208/28"
def test_range_options_exact_passend_blok():
"""Valt de wens toevallig samen met een blok, dan is er maar één antwoord."""
r = networks.range_options("10.0.20.240", "10.0.20.255", "10.0.20.0/24")
assert [o["cidr"] for o in r["options"]] == ["10.0.20.240/28"]
def test_range_options_draait_omgekeerde_invoer_om():
r = networks.range_options("10.0.20.254", "10.0.20.200")
assert r["ok"] is True
assert r["options"][0]["cidr"] == "10.0.20.192/26"
def test_range_options_buiten_het_subnet():
r = networks.range_options("192.168.9.10", "192.168.9.20", "10.0.20.0/24")
assert r["ok"] is False
assert "niet allebei" in r["msg"]
@pytest.mark.parametrize("eerste,laatste", [
("geen-ip", "10.0.20.254"), ("10.0.20.200", ""), ("", ""),
])
def test_range_options_ongeldige_invoer(eerste, laatste):
r = networks.range_options(eerste, laatste)
assert r["ok"] is False
assert r["options"] == []
def test_range_options_meldt_hoeveel_er_bij_komt():
r = networks.range_options("10.0.20.200", "10.0.20.254", "10.0.20.0/24")
ruim = next(o for o in r["options"] if o["kind"] == "ruim")
assert ruim["extra"] == 9 # 64 adressen in het blok, 55 gevraagd
def test_range_endpoint(client):
from conftest import login
csrf = login(client)
r = client.post("/api/networks/range",
json={"first": "10.0.20.200", "last": "10.0.20.254",
"subnet": "10.0.20.0/24"},
headers={"X-CSRF-Token": csrf})
d = r.get_json()
assert d["ok"] is True
assert d["options"][0]["cidr"] == "10.0.20.192/26"