PUT /panels/ carries a version and refuses a stale one with 409

The Panels dialog re-reads the list on 409. A panels.json without a
version reads as 0.
This commit is contained in:
2026-10-15 10:07:50 +02:00
parent 589ddecbb1
commit 43498037be
10 changed files with 108 additions and 10 deletions
+22 -3
View File
@@ -269,6 +269,8 @@ class PanelsPublic(BaseModel):
"""
panels: list[PanelDef] = Field(default_factory=list)
#: What a save of this list has to send back.
version: int = 0
#: Whatever this instance was told it is reachable at. The same setting
#: the password-reset links are built from, so an instance that has it
#: wrong has it wrong in both places.
@@ -277,7 +279,9 @@ class PanelsPublic(BaseModel):
def _public(config: PanelsConfig) -> PanelsPublic:
return PanelsPublic(
panels=config.panels, frontend_host=settings.FRONTEND_HOST.rstrip("/")
panels=config.panels,
version=config.version,
frontend_host=settings.FRONTEND_HOST.rstrip("/"),
)
@@ -298,6 +302,11 @@ async def save_panels(body: PanelsConfig) -> Any:
A panel that disappears here takes its credential with it, so this is also
how a device is unpaired along with its assignment. Re-pairing one screen
and keeping the assignment is ``/{panel_id}/unpair`` below.
The body carries the ``version`` it was based on, and a save someone else
has moved past is refused with 409, as a dashboard's is. Two clients each
writing back the whole list would otherwise drop each other's panels, and
a dropped panel revokes its screen at once.
"""
seen = set()
for panel in body.panels:
@@ -314,9 +323,19 @@ async def save_panels(body: PanelsConfig) -> Any:
# saving an arrangement — and reading it in a separate step from
# writing it is exactly how an unpair in between was undone.
with edit_lock:
stored = {p.id: p.nonce for p in read_config().panels}
stored = read_config()
if body.version != stored.version:
raise HTTPException(
status_code=409,
detail={
"message": "Someone else saved the panels first",
"current_version": stored.version,
},
)
nonces = {p.id: p.nonce for p in stored.panels}
for panel in body.panels:
panel.nonce = stored.get(panel.id, 0)
panel.nonce = nonces.get(panel.id, 0)
body.version = stored.version + 1
write_config(body)
await run_in_threadpool(_save)
+3
View File
@@ -96,6 +96,9 @@ class PanelsConfig(BaseModel):
"""Every panel this instance knows about."""
panels: list[PanelDef] = Field(default_factory=list)
#: Bumped on every save; a save based on an older one is refused, as a
#: dashboard's is. A file written before there was one reads as 0.
version: int = 0
#: Held across a read-modify-write of the panels file.
+38 -1
View File
@@ -11,7 +11,11 @@ DASHBOARDS = f"{settings.API_V1_STR}/dashboards"
def _panels(client: TestClient, headers: dict[str, str], config: dict) -> None:
response = client.put(f"{PREFIX}/", headers=headers, json=config)
"""Replace the panels, based on whatever is stored now."""
version = client.get(f"{PREFIX}/", headers=headers).json()["version"]
response = client.put(
f"{PREFIX}/", headers=headers, json={**config, "version": version}
)
assert response.status_code == 200, response.text
@@ -130,6 +134,39 @@ def test_duplicate_panel_is_refused(
assert response.status_code == 422
def test_a_stale_save_is_refused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Two clients writing back the whole list must not drop each other's panels."""
read = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
base = read["version"]
saved = client.put(
f"{PREFIX}/",
headers=superuser_token_headers,
json={"panels": [*read["panels"], {"id": "mine"}], "version": base},
)
assert saved.status_code == 200, saved.text
assert saved.json()["version"] == base + 1
stale = client.put(
f"{PREFIX}/",
headers=superuser_token_headers,
json={"panels": [*read["panels"], {"id": "theirs"}], "version": base},
)
assert stale.status_code == 409
assert stale.json()["detail"]["current_version"] == base + 1
ids = [
p["id"]
for p in client.get(f"{PREFIX}/", headers=superuser_token_headers).json()[
"panels"
]
]
assert "mine" in ids
assert "theirs" not in ids
def test_polling_needs_the_secret(client: TestClient) -> None:
started = client.post(f"{PREFIX}/pair").json()
assert len(started["code"]) == 6