APIs & Devices NotesRohit’s interview study guide
Chapter 08

Admin Web UIs for Hardware

The web UI an administrator uses to run a device: what it must do well, forms and validation generated from one settings schema, apply and restart flows that can't lock you out, software updates with honest progress and rollback, behaviour while the device is rebooting or unreachable, configuration import and provisioning, and accessibility and localisation.

8 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What device admin UIs must do well

Added

A device admin UI is the control panel for a machine the operator usually can't see. A video bar on a meeting-room wall, a camera, a phone, a gateway in a rack: each runs a small web server, and an IT admin, an AV technician or a support engineer opens the device's address in a browser to see whether it's healthy, change its settings, read its logs and update its software. The same jobs are often also done from a cloud management service for a whole fleet, but the on-device UI is the one that works when everything else is broken: no cloud account, no internet, just a laptop on the same network.

The device, paths (/api/v1/…) and firmware numbers in this chapter's models are made up to teach the patterns; Poly's own REST API, for comparison, uses /rest/… paths with no version segment (see Versioning strategies).

The mental model that explains most of the design rules is a pilot's cockpit for a remote aircraft. The admin is often not in the room; people may be in a meeting in that room right now; a wrong change can take the room offline, and the admin may then have no way back in. So the UI's first duty is to tell the truth about the device's state (including "I don't know, I can't reach it"), its second is to make changes safe (validate, preview, apply in one step, never lock the admin out), and its third is to keep working while the device is only half alive: rebooting, updating, or with a peripheral missing.

Poly's public documentation shows the shape on a real product. The Poly Video Mode Administrator Guide 4.7.0 (HP; its document-information page says "Last update: July 2025", although its copyright line reads 2021–2026) says that through the system web interface you can "configure every setting", while the local interface in the room "is intended only for the initial setup"; the same web interface also controls some calling functions, manages contacts, and is where software updates are configured and run. The 4.4.0 guide (November 2024) recommends restarting through the web interface, Poly Lens, the REST API, Telnet or SSH rather than pulling the power, and both say not to restart or power off during a software update. When a provisioning service manages the device, the 4.7.0 guide says provisioned settings become read-only in the web interface. Every one of those behaviours appears in this chapter as a general pattern; the guides don't describe how Poly built them, so nothing here claims to.

Most device admin UIs have the same five areas. Each answers a question the operator walks in with:

AreaThe operator's questionWhat it must show or do
Dashboard / status"Is this room OK? If not, what do I do?"Health summary, what needs attention with a link to fix it, firmware version, uptime, pending restart
Settings"Change X without breaking anything."Grouped, validated forms; which changes need a restart; who manages a setting (local or provisioned)
Diagnostics"Why is the camera black?"Connected peripherals, network tests, logs to download, call statistics, a restart button
Maintenance"Update it, back it up, reset it."Software update with real progress, config export and import, factory reset with clear consequences
Security"Who can get in, and how?"Accounts and roles, certificates and their expiry, which services (Telnet, SSH, REST) are enabled
Admin's browser Dashboard Settings Diagnostics Maintenance Security the device HTTPS On-device web server serves the UI files REST /api/v1/… auth, validation Settings store (saved) Running services (active) Update agent (A/B slots) Peripherals: camera, mics Logs and diagnostics Provisioning or cloud management service same API
The browser only ever talks to the device's web server; the server fronts the settings store, the running services, the update agent, peripherals and logs. A management service uses the same API.

The dashboard is the page an admin sees first, so it carries the first rule: turn the raw status into "is this room OK, and if not, what do I do?" This model takes a status document like the one a device's GET /api/v1/status might return and reduces it to a sorted list of problems, each with an action and a link to the page that fixes it:

status-summary.mjs
// The dashboard's first job: turn a raw status document into "is this room OK, and if not, what do I do?"
const now = Date.parse("2026-10-27T15:00:00Z"); // fixed clock so the output never changes
const status = {
  system: { name: "Board Room", firmware: "4.2.1", uptimeSeconds: 3 * 86400, pendingRestart: ["provider"] },
  network: { link: "up", ip: "10.0.4.20", dnsOk: true },
  registration: { provider: "teams", state: "registered" },
  peripherals: [
    { kind: "camera", name: "Main camera", connected: true },
    { kind: "microphone", name: "Ceiling mic 2", connected: false, lastSeen: "2026-10-27T14:12:00Z" },
    { kind: "display", name: "Left display", connected: true, resolution: "1080p" },
  ],
  certificates: [{ name: "Web UI certificate", notAfter: "2026-11-08T00:00:00Z" }],
  update: { available: "5.0.1", channel: "stable" },
};

const days = (iso) => Math.round((Date.parse(iso) - now) / 86400000);
const minutesAgo = (iso) => Math.round((now - Date.parse(iso)) / 60000);
const rules = [
  (s) => s.network.link !== "up" && { level: "critical", text: "No network link", action: "Check the cable or Wi-Fi", page: "/network" },
  (s) => s.registration.state !== "registered" && { level: "critical", text: `Not signed in to ${s.registration.provider}`, action: "Re-enter the room account", page: "/provider" },
  (s) => s.peripherals.filter((p) => !p.connected).map((p) => ({ level: "critical", text: `${p.name} disconnected for ${minutesAgo(p.lastSeen)} min`, action: "Check its cable and power", page: "/devices" })),
  (s) => s.system.pendingRestart.length && { level: "warning", text: `Restart needed to apply: ${s.system.pendingRestart.join(", ")}`, action: "Restart when the room is free", page: "/maintenance" },
  (s) => s.certificates.filter((c) => days(c.notAfter) < 30).map((c) => ({ level: days(c.notAfter) < 0 ? "critical" : "warning", text: `${c.name} expires in ${days(c.notAfter)} days`, action: "Install a renewed certificate", page: "/security/certificates" })),
  (s) => s.update.available && { level: "info", text: `Software ${s.update.available} available (running ${s.system.firmware})`, action: "Review release notes, schedule the update", page: "/maintenance/update" },
];

const order = { critical: 0, warning: 1, info: 2 };
const issues = rules.flatMap((rule) => rule(status) || []).sort((a, b) => order[a.level] - order[b.level]);
const overall = issues[0]?.level ?? "ok";
console.log(`${status.system.name}: ${overall.toUpperCase()} (${issues.length} items)`);
for (const i of issues) console.log(`  ${i.level.padEnd(8)} ${i.text.padEnd(52)} → ${i.action} [${i.page}]`);
Result
Board Room: CRITICAL (4 items)
  critical Ceiling mic 2 disconnected for 48 min                → Check its cable and power [/devices]
  warning  Restart needed to apply: provider                    → Restart when the room is free [/maintenance]
  warning  Web UI certificate expires in 11 days                → Install a renewed certificate [/security/certificates]
  info     Software 5.0.1 available (running 4.2.1)             → Review release notes, schedule the update [/maintenance/update]
What’s happening
  1. The status document is what the device knows: firmware, uptime, a pendingRestart list, network, registration with the conferencing provider, peripherals, certificates and an available update. Shown raw (a JSON viewer, or ten panels of equal weight), the admin has to work out what matters. The clock is fixed at 15:00Z so the "minutes ago" and "days left" values are reproducible.
  2. Each rule looks at one part of the status and returns nothing, one issue or a list of issues. Rules that find nothing return false (network up, registered), and rule(status) || [] drops them, so adding a check is adding one line, not editing a page.
  3. The ceiling microphone has been gone since 14:12, so it becomes a critical issue "disconnected for 48 min" with "Check its cable and power" and a link to /devices. A missing mic is critical because the room still looks fine and meetings quietly fail; the dashboard is the only place anyone will notice.
  4. pendingRestart: ["provider"] becomes a warning: a saved change isn't running yet (the apply and restart topic builds this). The web certificate's notAfter is 11 days away, under the 30-day threshold, so it's a warning now and would turn critical once expired.
  5. The available update is only info: "available" isn't a problem, and showing it in red trains admins to ignore red. Sorting by level puts the mic first; the overall state is the worst level, CRITICAL, with "4 items".
  6. What this demonstrates: the dashboard is a function of device state, not a page of widgets. Because it's computed, the same function can run in a fleet view over a thousand devices, and you can unit-test "a disconnected mic is critical" without a browser.
AdvancedWhat "doing it well" means

The qualities interviewers and operators care about, each of which gets its own topic below:

  • Truthful state. Every value shown says how fresh it is. If the device stopped answering, the page says so and shows the last-known values as stale, rather than freezing silently (unreachable states).
  • Safe change. Settings are validated before they're sent and again on the device, applied as one batch, and changes that need a restart or could cut the admin off are flagged and protected (validation, apply flows).
  • Recoverable maintenance. Updates verify the package before installing, keep the old version to roll back to, and report the real outcome after the reboot (update flows).
  • Repeatable configuration. One device's setup can be exported, reviewed and applied to fifty others, and a provisioning server can own settings centrally (import and provisioning).
  • Usable by everyone. Keyboard and screen-reader friendly, readable at the phone width an installer on a ladder uses, and translated (accessibility and localisation).
  • The UI is the API's first client. Everything the UI does goes through the same public REST API that scripts, SDKs and management services use (see Modelling a device as resources). A setting that only the UI can change is a setting no customer can automate.
AdvancedA UI that hides the device's real state
The provider change at 9:58

At 9:58 an admin changes the conferencing provider on a room device. Changing the provider needs a restart, and the device's status says it's in a call that's scheduled until 10:30. What should the UI do when the admin presses Save?

Show answer

Save the setting, don't restart, and say so: "Saved. The new provider starts after a restart. This room is in a call; restart now (ends the call) or later?"

What’s happening
  1. The device knows two facts the admin may not: the change needs a restart (a schema flag, see the next topic), and the room is busy (its status). The UI has both, so it shouldn't make the admin discover them by surprise.
  2. Silently restarting would drop a live meeting; that's the worst outcome, because people in the room had no say. Silently not restarting is the second worst: the admin walks away believing Teams is active.
  3. So the save goes through (the setting is stored), the device reports pendingRestart: ["provider"], and the UI offers a choice: restart now, with a clear warning that the call ends, or later. "Later" can be a scheduled restart in a maintenance window: Poly's 4.7.0 guide documents a scheduled auto restart (daily or weekly) that would pick it up.
  4. Until then the dashboard keeps a "Restart needed to apply: provider" warning, as in the model above, so whoever looks next sees the gap between saved and active.

#Forms generated from a settings schema

Added

A real device has hundreds of settings, and the set changes with the model (a camera without pan-tilt has no tracking settings) and the firmware version (5.0 adds a setting, 5.1 widens a range). If every setting is a hand-written form field, a hand-written validator in the browser, another on the device and a paragraph in the API docs, those four copies drift: the UI allows a value the device rejects, or the docs describe a range the firmware changed two releases ago. The fix is to describe each setting once, as data, in a JSON Schema: its type, limits, default, label, group, whether it needs a restart, and when it applies. Everything else is generated from that description.

The mental model is one blueprint, many buildings. The schema is the blueprint; the form, the browser-side validator, the device-side validator, the OpenAPI documentation and the SDK's types are buildings put up from it. When the blueprint changes, you rebuild, you don't renovate four buildings by hand. And because the device can serve its own schema, a UI talking to old firmware can build exactly the form that firmware supports.

JSON Schema (draft 2020-12, the version OpenAPI 3.1 aligns with; see OpenAPI 3.1 in an interview) already has most of what a form needs: type, enum, minimum/maximum, minLength/maxLength, pattern, default, title, description, readOnly and writeOnly, and if/then for conditional rules. What it doesn't have (which group a field is in, whether changing it needs a restart) you add as your own annotation keywords, conventionally prefixed x-.

Settings schema JSON Schema 2020-12 + x-group, x-restart served per firmware Form description → React or Angular form Ajv in the browser: instant feedback Ajv on the device: the authority OpenAPI docs for partners Generated SDK types
Describe each setting once; generate the form, both validators, the docs and the types from it.

The first building is the form description: a flat list of fields that any UI framework can render. This model walks a small settings schema and produces one:

form-from-schema.mjs
// One JSON Schema describes the device's settings. This turns it into a form description that any UI
// framework can render: React, Angular or a plain template. No UI code here, only data.
const schema = {
  type: "object",
  additionalProperties: false,
  properties: {
    systemName: { type: "string", title: "System name", minLength: 1, maxLength: 40, "x-group": "General" },
    language: { enum: ["en-US", "fr-FR", "ja-JP"], title: "Language", default: "en-US", "x-group": "General" },
    provider: { enum: ["poly", "teams", "zoom"], title: "Conferencing provider", "x-group": "General", "x-restart": true },
    network: {
      type: "object", title: "Network", "x-group": "Network", "x-restart": true,
      properties: {
        dhcp: { type: "boolean", title: "Use DHCP", default: true },
        ipAddress: { type: "string", title: "IP address", pattern: "^(\\d{1,3}\\.){3}\\d{1,3}$" },
        prefixLength: { type: "integer", title: "Prefix length", minimum: 8, maximum: 30 },
      },
      if: { properties: { dhcp: { const: false } } },
      then: { required: ["ipAddress", "prefixLength"] },
    },
    adminPassword: { type: "string", title: "Admin password", minLength: 12, writeOnly: true, "x-group": "Security" },
  },
  required: ["systemName", "language", "provider"],
};

function widgetFor(s) {
  if (s.enum) return "select";
  if (s.type === "boolean") return "toggle";
  if (s.type === "integer" || s.type === "number") return "number";
  if (s.writeOnly) return "password";
  return "text";
}

function describe(node, path = [], inherited = {}) {
  const fields = [];
  // A "dhcp is false → these are required" rule becomes "show and require these when dhcp is false".
  const cond = node.if?.properties && Object.entries(node.if.properties)[0];
  for (const [key, s] of Object.entries(node.properties)) {
    const at = [...path, key];
    const ctx = { group: s["x-group"] ?? inherited.group, restart: s["x-restart"] ?? inherited.restart ?? false };
    if (s.type === "object") { fields.push(...describe(s, at, ctx)); continue; }
    const field = { path: at.join("."), label: s.title, widget: widgetFor(s), group: ctx.group, restart: ctx.restart };
    if (s.enum) field.options = s.enum;
    if (s.default !== undefined) field.default = s.default;
    if (node.required?.includes(key)) field.required = true;
    if (cond && node.then?.required?.includes(key)) {
      field.showWhen = { path: [...path, cond[0]].join("."), equals: cond[1].const };
      field.required = "when shown";
    }
    for (const k of ["minLength", "maxLength", "minimum", "maximum", "pattern"]) if (k in s) field[k] = s[k];
    fields.push(field);
  }
  return fields;
}

for (const f of describe(schema)) {
  const { path, widget, group, restart, ...rest } = f;
  console.log(`${group.padEnd(8)} ${path.padEnd(22)} ${widget.padEnd(8)} ${restart ? "restart " : "        "}${JSON.stringify(rest)}`);
}
Result
General  systemName             text             {"label":"System name","required":true,"minLength":1,"maxLength":40}
General  language               select           {"label":"Language","options":["en-US","fr-FR","ja-JP"],"default":"en-US","required":true}
General  provider               select   restart {"label":"Conferencing provider","options":["poly","teams","zoom"],"required":true}
Network  network.dhcp           toggle   restart {"label":"Use DHCP","default":true}
Network  network.ipAddress      text     restart {"label":"IP address","showWhen":{"path":"network.dhcp","equals":false},"required":"when shown","pattern":"^(\\d{1,3}\\.){3}\\d{1,3}$"}
Network  network.prefixLength   number   restart {"label":"Prefix length","showWhen":{"path":"network.dhcp","equals":false},"required":"when shown","minimum":8,"maximum":30}
Security adminPassword          password         {"label":"Admin password","minLength":12}
What’s happening
  1. The schema is ordinary JSON Schema plus two annotations: x-group (which section of the page) and x-restart (changing it needs a restart). network sets both once, and its children inherit them, so network.dhcp, network.ipAddress and network.prefixLength all print restart without repeating the flag.
  2. widgetFor picks a control from the schema alone: an enum becomes a select (language, provider), a boolean a toggle, an integer a number input, a writeOnly string a password field, everything else text. That's the whole trick: the widget is derived, not configured, so a new boolean setting appears as a toggle without any UI code.
  3. describe recurses into objects and builds a dotted path (network.ipAddress) for each leaf. The path is what ties a field to its value, to its validation errors and to the merge patch the form will send, so it's the one identifier everything else uses.
  4. The if/then rule ("if dhcp is false, ipAddress and prefixLength are required") becomes showWhen: { path: "network.dhcp", equals: false } and required: "when shown". The same rule that the validator enforces drives visibility, so the UI can never show a field the device ignores or hide one it requires.
  5. Constraints (minLength, maxLength, minimum, maximum, pattern) are copied onto the field so the renderer can set maxlength, min and max attributes. They're hints for the browser; the real check is the validator in the next topic.
  6. adminPassword is writeOnly: it becomes a password field, and in export it's left out, because a GET of the settings must never return it. One annotation, two behaviours, both from the schema.
AdvancedRendering it in React

The form description is plain data, so the component that renders it is small and knows nothing about any one setting. This SchemaForm groups fields into <fieldset>s, picks the control from widget, hides fields whose showWhen doesn't match, links each error and restart note to its input with aria-describedby, and on Save runs the shared validator and moves focus to the first problem:

SchemaForm.jsx
// SchemaForm.jsx: renders a form description (from the settings schema). It knows nothing about any one setting.
import { useState } from "react";

const get = (obj, path) => path.split(".").reduce((o, k) => o?.[k], obj);
const set = (obj, path, value) => {
  const copy = structuredClone(obj);
  const keys = path.split(".");
  keys.slice(0, -1).reduce((o, k) => (o[k] ??= {}), copy)[keys.at(-1)] = value;
  return copy;
};

function Field({ field, value, error, onChange }) {
  const id = `f-${field.path}`;
  const hintId = `${id}-hint`;
  const errorId = `${id}-error`;
  const describedBy = [error && errorId, field.restart && hintId].filter(Boolean).join(" ") || undefined;
  const common = { id, name: field.path, "aria-invalid": error ? true : undefined, "aria-describedby": describedBy };
  let control;
  if (field.widget === "select") {
    control = (
      <select {...common} value={value ?? ""} onChange={(e) => onChange(e.target.value)}>
        {field.options.map((o) => <option key={o} value={o}>{o}</option>)}
      </select>
    );
  } else if (field.widget === "toggle") {
    control = <input type="checkbox" {...common} checked={!!value} onChange={(e) => onChange(e.target.checked)} />;
  } else if (field.widget === "number") {
    control = <input type="number" {...common} min={field.minimum} max={field.maximum} value={value ?? ""}
      onChange={(e) => onChange(e.target.value === "" ? undefined : Number(e.target.value))} />;
  } else {
    control = <input type={field.widget === "password" ? "password" : "text"} {...common} maxLength={field.maxLength}
      autoComplete={field.widget === "password" ? "new-password" : "off"} value={value ?? ""} onChange={(e) => onChange(e.target.value)} />;
  }
  return (
    <div className="field">
      <label htmlFor={id}>{field.label}</label>
      {control}
      {error && <p id={errorId} className="error">{error}</p>}
      {field.restart && <p id={hintId} className="hint">Takes effect after a restart.</p>}
    </div>
  );
}

export function SchemaForm({ fields, initial, validate, onSave }) {
  const [values, setValues] = useState(initial);
  const [errors, setErrors] = useState({});
  const visible = fields.filter((f) => !f.showWhen || get(values, f.showWhen.path) === f.showWhen.equals);
  const groups = Map.groupBy(visible, (f) => f.group);

  function submit(event) {
    event.preventDefault();
    const found = validate(values); // the same Ajv-compiled schema the device runs
    setErrors(found);
    const first = visible.find((f) => found[f.path]);
    if (first) document.getElementById(`f-${first.path}`).focus(); // take the admin to the first problem
    else onSave(values);
  }

  return (
    <form onSubmit={submit} noValidate>
      {[...groups].map(([group, groupFields]) => (
        <fieldset key={group}>
          <legend>{group}</legend>
          {groupFields.map((f) => (
            <Field key={f.path} field={f} value={get(values, f.path)} error={errors[f.path]?.join(" ")}
              onChange={(v) => setValues((old) => set(old, f.path, v))} />
          ))}
        </fieldset>
      ))}
      <button type="submit">Save</button>
    </form>
  );
}
What’s happening
  1. get and set read and write a value by dotted path; set copies the object first (structuredClone), so React sees a new object and re-renders. Every field uses the same two functions, which is why the component has no per-setting code.
  2. visible filters out fields whose showWhen doesn't match the current values. When the admin unticks "Use DHCP", network.dhcp becomes false, the filter now includes ipAddress and prefixLength, and they appear on the next render.
  3. Map.groupBy (ES2024) turns the field list into General, Network and Security groups in schema order; each becomes a <fieldset> with a <legend>, which screen readers announce when focus enters the group.
  4. Each input gets aria-describedby pointing at its error (when there is one) and its restart note (when the field has restart), so the IP field's accessible description, which screen readers read after its name, is "This field is required. Takes effect after a restart." The label itself stays "IP address", so tests and voice control can find the field by its plain name.
  5. submit runs validate(values): the same Ajv-compiled schema the device runs, passed in as a prop. If anything fails it stores the errors and focuses the first failing field in form order (not the order Ajv reported them), so the admin lands on the first problem they'd meet reading down the page. Only a clean form calls onSave.

The component was run in the React sandbox (React 19.3, Vitest 5, React Testing Library 16, jsdom), with the real Ajv 8.20 validator compiled from the same schema as the model above. These are the tests and what they printed (the start-time and duration lines are left out):

SchemaForm.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Ajv2020 from "../../../deviceapis/node_modules/ajv/dist/2020.js";
import { SchemaForm } from "./SchemaForm.jsx";
import { schema, describe as describeForm } from "./settings.js";

const ajv = new Ajv2020({ allErrors: true });
ajv.addVocabulary(["x-group", "x-restart"]);
const check = ajv.compile(schema);
function validate(values) {
  if (check(values)) return {};
  const out = {};
  for (const e of check.errors) {
    if (e.keyword === "if") continue;
    let path = e.instancePath.slice(1).replaceAll("/", ".");
    if (e.keyword === "required") path = [path, e.params.missingProperty].filter(Boolean).join(".");
    (out[path] ??= []).push(e.keyword === "required" ? "This field is required." : `Must ${e.message.replace(/^must /, "")}.`);
  }
  return out;
}

const fields = describeForm(schema);
const initial = { systemName: "Board Room", language: "en-US", provider: "poly", network: { dhcp: true } };

test("renders one fieldset per group, with static-IP fields hidden while DHCP is on", () => {
  render(<SchemaForm fields={fields} initial={initial} validate={validate} onSave={() => {}} />);
  expect(screen.getAllByRole("group").map((g) => g.querySelector("legend").textContent)).toEqual(["General", "Network", "Security"]);
  expect(screen.getByLabelText("System name")).toHaveValue("Board Room");
  const provider = screen.getByRole("combobox", { name: "Conferencing provider" });
  expect(provider).toHaveValue("poly");
  expect(provider).toHaveAccessibleDescription("Takes effect after a restart.");
  expect(screen.getByLabelText("System name")).not.toHaveAttribute("aria-describedby");
  expect(screen.queryByLabelText(/IP address/)).toBeNull();
});

test("turning DHCP off reveals the static fields; saving empty ones shows linked errors and moves focus", async () => {
  const user = userEvent.setup();
  const onSave = vi.fn();
  render(<SchemaForm fields={fields} initial={initial} validate={validate} onSave={onSave} />);
  await user.click(screen.getByRole("checkbox", { name: "Use DHCP" }));
  const ip = screen.getByRole("textbox", { name: "IP address" });
  await user.click(screen.getByRole("button", { name: "Save" }));
  expect(onSave).not.toHaveBeenCalled();
  expect(ip).toHaveAttribute("aria-invalid", "true");
  expect(ip).toHaveAccessibleDescription("This field is required. Takes effect after a restart.");
  expect(screen.getByRole("spinbutton", { name: "Prefix length" })).toHaveAttribute("aria-invalid", "true");
  expect(ip).toHaveFocus();
  console.log("errors shown:", [...document.querySelectorAll(".error")].map((p) => `${p.id}: ${p.textContent}`).join(" | "));
});

test("valid values are saved as one object", async () => {
  const user = userEvent.setup();
  const onSave = vi.fn();
  render(<SchemaForm fields={fields} initial={initial} validate={validate} onSave={onSave} />);
  await user.click(screen.getByRole("checkbox", { name: "Use DHCP" }));
  await user.type(screen.getByRole("textbox", { name: "IP address" }), "10.0.4.20");
  await user.type(screen.getByRole("spinbutton", { name: "Prefix length" }), "24");
  await user.click(screen.getByRole("button", { name: "Save" }));
  expect(onSave).toHaveBeenCalledWith({ ...initial, network: { dhcp: false, ipAddress: "10.0.4.20", prefixLength: 24 } });
  console.log("saved:", JSON.stringify(onSave.mock.calls[0][0]));
});
npx vitest run --reporter=verbose --silent=false
stdout | src/SchemaForm.test.jsx > turning DHCP off reveals the static fields; saving empty ones shows linked errors and moves focus
errors shown: f-network.ipAddress-error: This field is required. | f-network.prefixLength-error: This field is required.
 ✓ src/SchemaForm.test.jsx > renders one fieldset per group, with static-IP fields hidden while DHCP is on 159ms
 ✓ src/SchemaForm.test.jsx > turning DHCP off reveals the static fields; saving empty ones shows linked errors and moves focus 100ms
stdout | src/SchemaForm.test.jsx > valid values are saved as one object
saved: {"systemName":"Board Room","language":"en-US","provider":"poly","network":{"dhcp":false,"ipAddress":"10.0.4.20","prefixLength":24}}
 ✓ src/SchemaForm.test.jsx > valid values are saved as one object 134ms
 Test Files  1 passed (1)
      Tests  3 passed (3)
What’s happening
  1. The first test checks the generated structure: three groups whose legends are General, Network and Security (schema order), the system name pre-filled, the provider select found by its plain name with the accessible description "Takes effect after a restart.", and no IP field while DHCP is on. It also checks that System name has no aria-describedby at all, because a field with no restart flag and no error has nothing to describe.
  2. The second test unticks "Use DHCP": the IP and prefix fields appear. Pressing Save with them empty doesn't call onSave; both inputs get aria-invalid="true", the IP field's accessible description becomes "This field is required. Takes effect after a restart." (error first, then the hint), and focus moves to the IP field. The printed line shows both error paragraphs and their ids.
  3. The third test fills in 10.0.4.20 and 24 and saves: onSave receives one object with network: { dhcp: false, ipAddress: "10.0.4.20", prefixLength: 24 }. The number input produced the number 24, not the string "24", because the number widget converts on change; otherwise the schema's type: "integer" would reject it.
  4. The validator in the test uses the same path mapping as the next topic's model (with friendlier message wording, copied into the test rather than imported), and the schema and describe are imported from settings.js, which holds exactly the schema and function from form-from-schema.mjs. That's the point of the exercise: the UI's rules are the device's rules, imported, not re-typed.
AdvancedBuild it or use a library

Schema-driven form libraries exist and are maintained (versions checked on npm, 27 October 2026): react-jsonschema-form (@rjsf/core 6.11.0), which renders a JSON Schema with an optional uiSchema for presentation and validates with an Ajv 8 plugin; JSON Forms (@jsonforms/core 3.8.0), which has React, Angular and Vue renderers and keeps layout in a separate UI schema; and ngx-formly (@ngx-formly/core 11.0.0, peer dependency @angular/forms 22 or later) for Angular, which renders its own field config and ships a json-schema entry point that converts a schema into that config.

LibraryYour own renderer (as above)
Time to first formHoursDays
Matches your design systemThrough themes or custom widgets; can fight youExactly, it's your components
Device-specific widgets (IP + prefix, "restart required", "managed by provisioning")Custom widgets per library APIPlain components
UpgradesFollow the library's major versionsYours to maintain
Good fitInternal tools, many simple settingsA product UI with a strong design system and device-specific behaviour

Either way, keep the schema as the source of truth and treat the form description as generated output. The common mistake isn't choosing the wrong library; it's letting hand-edited form config become a second source of truth.

#Validation shared between UI and device

Added

Every setting is checked twice. The UI checks it so the admin gets instant, field-by-field feedback without a round trip; the device checks it because it's the only check that can't be skipped: scripts, SDKs, management servers and curl all reach the API without going through your form. The failure mode to design out is two different rulebooks: the UI allows prefixLength: 31, the device rejects it with a generic 400, and the admin has no idea which field was wrong. The fix is that both sides run the same compiled schema, and the device adds only the checks the UI can't make.

The mental model is a receptionist and a bank teller with the same rulebook. The receptionist (the UI) checks your form is complete and sensible before you queue, so most mistakes are caught early and kindly. The teller (the device) checks everything again, because people can walk straight to the counter, and also checks things the receptionist can't know: your balance, today's limits. For a device, "things only the device knows" are its hardware (which display is connected, which camera), its live network (is that address already in use) and its licences.

So there are three layers, and each belongs in one place:

LayerExampleWhere it's definedWho runs it
Field rulesprefixLength is an integer from 8 to 30the schemaUI and device
Cross-field rulesstatic IP needs an address and a prefixthe schema (if/then, dependentRequired)UI and device
Device-state rules4K needs a 4K display connected nowdevice codedevice only; the UI shows its answer on the right field

The first two layers are one Ajv compile of the schema. Here it is, including the error you'll hit the first time you add your own annotations:

shared-validator.mjs
// The same schema, compiled with Ajv, validates in the browser and on the device.
import Ajv2020 from "ajv/dist/2020.js";

const schema = {
  type: "object",
  additionalProperties: false,
  properties: {
    systemName: { type: "string", minLength: 1, maxLength: 40, "x-group": "General" },
    provider: { enum: ["poly", "teams", "zoom"], "x-restart": true },
    network: {
      type: "object",
      additionalProperties: false,
      properties: {
        dhcp: { type: "boolean" },
        ipAddress: { type: "string", pattern: "^(\\d{1,3}\\.){3}\\d{1,3}$" },
        prefixLength: { type: "integer", minimum: 8, maximum: 30 },
      },
      if: { properties: { dhcp: { const: false } } },
      then: { required: ["ipAddress", "prefixLength"] },
    },
  },
};

try {
  new Ajv2020().compile(schema); // strict mode is on by default
} catch (e) {
  console.log("default Ajv:", e.message);
}

const ajv = new Ajv2020({ allErrors: true });
ajv.addVocabulary(["x-group", "x-restart"]); // our annotations: known, but they don't validate anything
const validate = ajv.compile(schema);

// Ajv reports JSON Pointers; the form wants a message per field path.
function fieldErrors(errors) {
  const out = {};
  for (const e of errors ?? []) {
    if (e.keyword === "if") continue; // "must match then schema" repeats the real error below it
    let path = e.instancePath.slice(1).replaceAll("/", ".");
    if (e.keyword === "required") path = [path, e.params.missingProperty].filter(Boolean).join(".");
    if (e.keyword === "additionalProperties") path = [path, e.params.additionalProperty].filter(Boolean).join(".");
    (out[path] ??= []).push(e.keyword === "required" ? "is required" : e.message);
  }
  return out;
}

const cases = {
  "valid static IP": { systemName: "Board Room", network: { dhcp: false, ipAddress: "10.0.4.20", prefixLength: 24 } },
  "static, no address": { systemName: "Board Room", network: { dhcp: false } },
  "three mistakes": { systemName: "", provider: "skype", network: { dhcp: true, prefixLength: 64 } },
  "unknown key": { systemName: "Lab", volume: 11 },
};
for (const [name, body] of Object.entries(cases)) {
  const ok = validate(body);
  console.log(`${name.padEnd(19)} ${ok ? "valid" : JSON.stringify(fieldErrors(validate.errors))}`);
}
Result
default Ajv: strict mode: unknown keyword: "x-group"
valid static IP     valid
static, no address  {"network.ipAddress":["is required"],"network.prefixLength":["is required"]}
three mistakes      {"systemName":["must NOT have fewer than 1 characters"],"provider":["must be equal to one of the allowed values"],"network.prefixLength":["must be <= 30"]}
unknown key         {"volume":["must NOT have additional properties"]}
What’s happening
  1. Compiling with Ajv's defaults throws strict mode: unknown keyword: "x-group". Ajv 8 is strict by default: a keyword it doesn't know is treated as a probable typo (minLenght), not silently ignored. That's a good default; the fix is to declare your annotations, not to turn strict mode off.
  2. ajv.addVocabulary(["x-group", "x-restart"]) registers them as known keywords with no validation behaviour. allErrors: true makes Ajv report every failure instead of stopping at the first, which is what a form needs to mark all bad fields at once (the device side makes a different choice; see the gotcha below).
  3. "static, no address": dhcp is false, so the if matches and then requires ipAddress and prefixLength. Ajv reports two required errors at instancePath /network with params.missingProperty, plus an if error ("must match then schema"). fieldErrors skips the if error, which only repeats the real ones, and moves required errors onto the missing field's path, so the form marks network.ipAddress, not the whole network group.
  4. "three mistakes" shows allErrors at work: an empty systemName, a provider that isn't in the enum, and prefixLength: 64, all reported together. dhcp: true means the then doesn't apply, so no "required" errors appear even though there's no address.
  5. "unknown key": volume isn't in the schema and additionalProperties: false rejects it, mapped onto the volume path. On a device this matters: a typo in a script (speakervolume) is an error, not a silently ignored write.
  6. What this demonstrates: the rules live in one schema; the only code is a small mapping from Ajv's JSON-Pointer errors to form paths. The React tests in the previous topic use the same mapping (copied into the test file; in a product you'd export it from one shared module so the UI and device can't drift).
AdvancedThe device decides, and the UI uses the device's own schema

The model below runs two kinds of device: firmware 5.0.0, which supports 4K, and an older 4.2.1, which doesn't. The UI downloads the schema from the device it's talking to, pre-validates with it, and only then sends the change. The device validates again and adds a rule no schema can express:

device-validation.mjs
// The device validates with the same schema the UI uses, then adds checks only the device can make.
// The UI downloads the schema from the device it's talking to, so old firmware gets old rules.
import express from "express";
import request from "supertest";
import Ajv2020 from "ajv/dist/2020.js";

const schemaFor = (resolutions) => ({
  type: "object",
  additionalProperties: false,
  properties: {
    systemName: { type: "string", minLength: 1, maxLength: 40 },
    video: { type: "object", additionalProperties: false, properties: { maxResolution: { enum: resolutions } } },
  },
});

const merge = (cur, patch) => ({ ...cur, ...patch, video: { ...cur.video, ...patch.video } });

function deviceApp({ firmware, resolutions, display }) {
  const schema = schemaFor(resolutions);
  const validate = new Ajv2020().compile(schema); // device: stop at the first error (see the gotcha below)
  let settings = { systemName: "Board Room", video: { maxResolution: "1080p" } };
  const app = express().use(express.json({ limit: "16kb", type: ["application/json", "application/merge-patch+json"] }));
  app.get("/api/v1/settings/schema", (req, res) => res.json({ firmware, schema }));
  app.get("/api/v1/settings", (req, res) => res.json(settings));
  app.patch("/api/v1/settings", (req, res) => {
    const next = merge(settings, req.body);
    const errors = validate(next) ? [] : validate.errors.map((e) => ({ pointer: `#${e.instancePath}`, detail: e.message }));
    // A rule no schema can express: it depends on the hardware plugged in right now.
    if (!errors.length && next.video.maxResolution === "4K" && display !== "4K")
      errors.push({ pointer: "#/video/maxResolution", detail: `connected display supports up to ${display}` });
    if (errors.length)
      return res.status(422).type("application/problem+json").json({ type: "urn:device:invalid-settings", title: "Settings are not valid", errors });
    settings = next;
    res.json(settings);
  });
  return app;
}

// The UI side: fetch the device's own schema, pre-validate, then send and map the device's answer to fields.
async function save(app, label, patch) {
  const { body } = await request(app).get("/api/v1/settings/schema");
  const check = new Ajv2020({ allErrors: true }).compile(body.schema); // UI: every error, for the form
  const current = (await request(app).get("/api/v1/settings")).body;
  if (!check(merge(current, patch))) {
    const where = check.errors.map((e) => `${e.instancePath} ${e.message}`).join("; ");
    return console.log(`${label.padEnd(26)} blocked in the UI   ${where}`);
  }
  const res = await request(app).patch("/api/v1/settings").set("Content-Type", "application/merge-patch+json").send(patch);
  const detail = res.status === 200 ? JSON.stringify(res.body.video) : res.body.errors.map((e) => `${e.pointer} ${e.detail}`).join("; ");
  console.log(`${label.padEnd(26)} device said ${res.status}     ${detail}`);
}

const newUnit = deviceApp({ firmware: "5.0.0", resolutions: ["720p", "1080p", "4K"], display: "4K" });
const roomWith1080 = deviceApp({ firmware: "5.0.0", resolutions: ["720p", "1080p", "4K"], display: "1080p" });
const oldUnit = deviceApp({ firmware: "4.2.1", resolutions: ["720p", "1080p"], display: "4K" });

await save(newUnit, "5.0.0, 4K display", { video: { maxResolution: "4K" } });
await save(roomWith1080, "5.0.0, 1080p display", { video: { maxResolution: "4K" } });
await save(oldUnit, "4.2.1 firmware", { video: { maxResolution: "4K" } });
await save(newUnit, "empty name", { systemName: "" });

// A script that skips the UI still meets the device's validator.
const direct = await request(oldUnit).patch("/api/v1/settings").send({ video: { maxResolution: "4K" } });
console.log(`${"4.2.1, script, no UI".padEnd(26)} device said ${direct.status}     ${direct.body.errors.map((e) => `${e.pointer} ${e.detail}`)}`);
Result
5.0.0, 4K display          device said 200     {"maxResolution":"4K"}
5.0.0, 1080p display       device said 422     #/video/maxResolution connected display supports up to 1080p
4.2.1 firmware             blocked in the UI   /video/maxResolution must be equal to one of the allowed values
empty name                 blocked in the UI   /systemName must NOT have fewer than 1 characters
4.2.1, script, no UI       device said 422     #/video/maxResolution must be equal to one of the allowed values
What’s happening
  1. Each device serves GET /api/v1/settings/schema with its firmware version and its own schema; 4.2.1's maxResolution enum is ["720p", "1080p"], 5.0.0's adds "4K". The UI compiles whichever it gets, so its rules always match the firmware in front of it. A fleet UI talking to devices on five firmware versions needs exactly this (see Capability discovery).
  2. "5.0.0, 4K display": the UI's check passes, the PATCH (a JSON Merge Patch, application/merge-patch+json) reaches the device, the device's validator passes, and the hardware rule passes because a 4K display is connected. 200 with the new video object.
  3. "5.0.0, 1080p display": the schema allows 4K, so the UI can't object, but the device knows the connected display tops out at 1080p. It answers 422 with a problem-details body (RFC 9457; see Errors with problem details) whose errors entry has pointer: "#/video/maxResolution" (a JSON Pointer written as a URI fragment, the form RFC 9457 uses in its example). The pointer is the contract: the UI drops the #, turns the rest into video.maxResolution and shows "connected display supports up to 1080p" under that select, not in a generic banner.
  4. "4.2.1 firmware": the UI compiled 4.2.1's schema, so 4K is caught before any request is sent: "must be equal to one of the allowed values". In a real form the select wouldn't even offer 4K, because the form description is built from the same enum.
  5. "empty name" is caught by minLength: 1 in the UI. The last line skips the UI entirely, as a script would: the device's own validator rejects the 4K write with 422 and the same pointer. The device never trusts that the UI ran.
  6. The device compiles with new Ajv2020() (stop at the first error) and caps bodies with express.json({ limit: "16kb" }); the UI uses allErrors: true. That asymmetry is deliberate, for the security reason in the gotcha below.
AdvancedMessages people can read, in their language

Ajv's message is developer English ("must NOT have more than 12 characters"). For an admin UI, build the message from the error's keyword and params instead, so it can name the field, suggest a fix and be translated. The same model shows a proper IPv4 check as a named format:

error-messages.mjs
// Ajv's messages are English developer text. Build user messages from the error's keyword and params instead,
// so they can be translated and can name the field. Also: a real IPv4 check as a named format.
import Ajv2020 from "ajv/dist/2020.js";

const schema = {
  type: "object",
  properties: {
    systemName: { type: "string", maxLength: 12, title: "System name" },
    ipAddress: { type: "string", format: "ipv4", title: "IP address" },
    prefixLength: { type: "integer", minimum: 8, maximum: 30, title: "Prefix length" },
  },
};

try {
  new Ajv2020().compile(schema);
} catch (e) {
  console.log("without addFormat:", e.message);
}

const ajv = new Ajv2020({ allErrors: true });
const octet = "(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)";
ajv.addFormat("ipv4", new RegExp(`^${octet}(\\.${octet}){3}$`));
const validate = ajv.compile(schema);

const catalog = {
  "en-US": {
    maxLength: "{field} can be at most {limit} characters.",
    format: "{field} must be a valid IPv4 address, for example 192.168.1.20.",
    maximum: "{field} can't be more than {limit}.",
  },
  "fr-FR": {
    maxLength: "{field} : {limit} caractères au maximum.",
    format: "{field} : saisissez une adresse IPv4 valide, par exemple 192.168.1.20.",
    maximum: "{field} : la valeur maximale est {limit}.",
  },
};
const titles = { "fr-FR": { "System name": "Nom du système", "IP address": "Adresse IP", "Prefix length": "Longueur du préfixe" } };

function messagesFor(errors, locale) {
  return errors.map((e) => {
    const key = e.instancePath.slice(1);
    const title = schema.properties[key].title;
    const field = titles[locale]?.[title] ?? title;
    const text = catalog[locale][e.keyword].replace(/\{(\w+)\}/g, (_, k) => (k === "field" ? field : e.params[k]));
    return `${key}: ${text}`;
  });
}

validate({ systemName: "Main Boardroom 3", ipAddress: "10.0.300.4", prefixLength: 31 });
console.log("Ajv says:", validate.errors.map((e) => e.message).join(" | "));
for (const locale of ["en-US", "fr-FR"]) for (const m of messagesFor(validate.errors, locale)) console.log(`${locale} ${m}`);
console.log("10.0.30.4 valid?", validate({ ipAddress: "10.0.30.4" }), "| 999.1.1.1 valid?", validate({ ipAddress: "999.1.1.1" }));
Result
without addFormat: unknown format "ipv4" ignored in schema at path "#/properties/ipAddress"
Ajv says: must NOT have more than 12 characters | must match format "ipv4" | must be <= 30
en-US systemName: System name can be at most 12 characters.
en-US ipAddress: IP address must be a valid IPv4 address, for example 192.168.1.20.
en-US prefixLength: Prefix length can't be more than 30.
fr-FR systemName: Nom du système : 12 caractères au maximum.
fr-FR ipAddress: Adresse IP : saisissez une adresse IPv4 valide, par exemple 192.168.1.20.
fr-FR prefixLength: Longueur du préfixe : la valeur maximale est 30.
10.0.30.4 valid? true | 999.1.1.1 valid? false
What’s happening
  1. Without addFormat, strict mode refuses format: "ipv4": unknown format "ipv4" ignored in schema at path "#/properties/ipAddress". The word "ignored" is from the message text, but in strict mode it's thrown, not ignored. In Ajv 8 the standard formats come from the separate ajv-formats plugin (not installed here); this model defines just the one it needs.
  2. The regex built from octet accepts 0–255 per part, so 10.0.300.4 and 999.1.1.1 fail and 10.0.30.4 passes (last line). The earlier models' ^(\d{1,3}\.){3}\d{1,3}$ pattern would have accepted 999.1.1.1: "looks like an IP" isn't "is an IP".
  3. Each Ajv error carries a machine-readable keyword (maxLength, format, maximum) and params ({ limit: 12 }). messagesFor picks a template by keyword from a per-locale catalog and fills {field} and {limit}. The English and French lines come from the same three errors.
  4. Field names are translated too (IP address → Adresse IP), and the French templates follow French punctuation, with a space before the colon. Templates are whole sentences per language, never English fragments glued together (more in the localisation topic).
  5. WCAG 2.2's success criterion 3.3.3 Error Suggestion (level AA) asks for a suggested correction when one is known; "for example 192.168.1.20" is that suggestion. Ajv's generic message can't do it, because it doesn't know what the field means.
AdvancedCompiling the validator at build time

On an on-device web UI you'll want a strict Content Security Policy (see Securing the on-device web UI). Ajv compiles schemas by generating JavaScript and running it with the Function constructor, which a CSP without 'unsafe-eval' blocks. Ajv's answer is standalone code: compile at build time, ship the generated validator as an ordinary module. This model does it, and runs into a real snag along the way:

standalone.mjs
// Compile the settings validator at build time, so the browser runs plain generated code:
// no Ajv compiler in the bundle, no compiling at startup, no `new Function` under a strict CSP.
import Ajv2020 from "ajv/dist/2020.js";
import standaloneCode from "ajv/dist/standalone/index.js";
import { writeFileSync } from "node:fs";

const schema = {
  type: "object",
  additionalProperties: false,
  properties: {
    systemName: { type: "string", minLength: 1, maxLength: 13 },
    speakerVolume: { type: "integer", minimum: 0, maximum: 100 },
  },
};

async function build(file, esm) {
  const ajv = new Ajv2020({ code: { source: true, esm }, allErrors: true });
  const source = standaloneCode(ajv, ajv.compile(schema));
  writeFileSync(file, source); // in a real build: written into the UI's source tree, then bundled
  console.log(`${file}: ${source.length} chars, new Function: ${/new Function/.test(source)}, needs: ${source.match(/require\("[^"]+"\)/g)}`);
  return (await import(`./${file}`)).default;
}

try {
  await build("validate-settings.mjs", true);
} catch (e) {
  console.log("  loading it:", e.constructor.name, "-", e.message.split(",")[0]);
}
const validate = await build("validate-settings.cjs", false);
console.log("  loaded:", typeof validate);

console.log(validate({ systemName: "Lab", speakerVolume: 40 }), validate({ systemName: "", speakerVolume: 140, mode: 1 }));
console.log(validate.errors.map((e) => `${e.instancePath || "/"} ${e.message}`).join(" | "));
// Ajv counts characters (code points), not UTF-16 units like String.length (and Java/Kotlin's length()).
const name = "Huddle 🎥 Room";
console.log(`"${name}": .length ${name.length}, code points ${[...name].length}, maxLength 13 valid? ${validate({ systemName: name })}`);
Result
validate-settings.mjs: 3304 chars, new Function: false, needs: require("ajv/dist/runtime/ucs2length")
  loading it: ReferenceError - require is not defined in ES module scope
validate-settings.cjs: 3307 chars, new Function: false, needs: require("ajv/dist/runtime/ucs2length")
  loaded: function
true false
/ must NOT have additional properties | /systemName must NOT have fewer than 1 characters | /speakerVolume must be <= 100
"Huddle 🎥 Room": .length 14, code points 13, maxLength 13 valid? true
What’s happening
  1. new Ajv2020({ code: { source: true, esm } }) keeps the generated source, and standaloneCode(ajv, validate) returns it as a module. The output has no new Function: the browser just runs plain JavaScript, Ajv's compiler isn't in the bundle, and nothing is compiled at startup.
  2. With esm: true, Ajv 8.20 switched the exports to export const validate / export default, but still emitted require("ajv/dist/runtime/ucs2length") (a helper for minLength/maxLength), so loading the .mjs file in Node 26 failed with ReferenceError - require is not defined in ES module scope. The cause is in Ajv itself: each runtime helper carries its standalone code as a fixed string (ucs2length.code = 'require("ajv/dist/runtime/ucs2length").default' in ajv/lib/runtime/ucs2length.ts), and code.esm changes only the export syntax, not those lines. Other helpers are pulled in the same way (uniqueItems or an object const adds require("ajv/dist/runtime/equal")), and formats in standalone code come through the code.formats option, whose example in Ajv's docs is also a require. This is the kind of thing to catch in CI by importing the generated file, not in the field.
  3. The CommonJS build (esm: false, written as .cjs) has the same require, but CommonJS can resolve it, and it loads: loaded: function. Ajv's standalone docs say the generated functions "still has a dependency on the Ajv" and suggest running them through a bundler for fully isolated code; in a separate check (not part of this model), bundling the failing .mjs with rolldown 1.2.13 (the bundler Vite 8.3 uses) inlined the helper and the bundle loaded in Node and counted code points correctly. So: generate, bundle, then import the bundle in CI. (Ajv's unicode: false option also removes the helper, but it's deprecated and switches maxLength to UTF-16 units, which is the mismatch step 5 warns about.)
  4. The validator behaves exactly like the compiled one: true for a good object, and three errors for { systemName: "", speakerVolume: 140, mode: 1 }, including the unknown mode.
  5. The last line is why that helper exists. "Huddle 🎥 Room".length is 14 in JavaScript because the emoji is two UTF-16 code units; Ajv counts code points, 13, so maxLength: 13 passes. Kotlin and Java's String.length also count UTF-16 units, so a device that re-implemented the rule by hand would reject a name the UI accepted. One more reason to run the same validator on both sides, or at least the same definition of "length".
Advanced"Valid" isn't "safe to apply"

#Apply, restart and rollback flows

Added

Changing a setting on a device isn't one event, it's a journey with up to three stops. The admin edits it in the browser (pending, nothing has happened yet); the device saves it (stored, survives a reboot); and the device makes it active (actually running). For most settings, saved and active happen together. For some, such as the conferencing provider, the network mode or a video pipeline setting, the device needs a restart before the saved value takes effect; Poly's 4.7.0 admin guide has several such steps ("Select Save, then select Restart" when changing the provider; disabling Device Mode "requires a system restart"). And a few settings can lock the admin out if they're wrong: the IP address, the VLAN, the HTTPS certificate, the admin's own role.

The mental model is a change order on a building site. The admin writes the order (pending changes, with a clear list of what will change). The site manager accepts it all at once or not at all (one atomic apply). Some work only happens at the next shutdown (restart-required). And for work on the only door into the building, you leave the old door in place until someone has walked through the new one (confirm or revert).

The UI's job is to make each stop visible: what's pending and what it will cost, what's saved but not yet active, and for the dangerous ones, a timer that undoes the change if the admin can't confirm it worked.

Pending in the browser Save Saved stored on device live setting at restart Active running now Saved, restart pending pendingRestart: ["provider"] restart can cut you off On probation revert timer: 60 s confirmed: kept timeout: reverted IP, VLAN, certificate, own role
Pending → saved → active, with a restart in between for some settings and a confirm-or-revert probation for the ones that can lock the admin out.

The first piece is the pending-changes model behind the page: what the admin edited, what it will take to apply, and the device's answer about what's active now and what waits for a restart:

pending-changes.mjs
// Pending changes in an admin UI: what the admin edited, what it will take to apply, and what the device
// reports back (saved now vs active only after a restart).
const restartRequired = new Set(["provider", "network.dhcp", "network.ipAddress"]);
const get = (obj, path) => path.split(".").reduce((o, k) => o?.[k], obj);
const set = (obj, path, v) => { const ks = path.split("."); ks.slice(0, -1).reduce((o, k) => (o[k] ??= {}), obj)[ks.at(-1)] = v; };
const leaves = (o, pre = "") => Object.entries(o).flatMap(([k, v]) => (v && typeof v === "object" ? leaves(v, `${pre}${k}.`) : [`${pre}${k}`]));

function pendingChanges(baseline) {
  const edits = new Map(); // path -> new value
  return {
    edit(path, value) {
      if (JSON.stringify(value) === JSON.stringify(get(baseline, path))) edits.delete(path); // edited back
      else edits.set(path, value);
    },
    list: () => [...edits].map(([path, to]) => ({ path, from: get(baseline, path), to, restart: restartRequired.has(path) })),
    // One JSON Merge Patch (RFC 7396) for the whole batch, so the device applies it all or nothing.
    patch() {
      const out = {};
      for (const [path, value] of edits) set(out, path, value);
      return out;
    },
    needsRestart: () => [...edits.keys()].some((p) => restartRequired.has(p)),
  };
}

// The device: "saved" is what's stored, "active" is what's running. Restart-required settings differ until reboot.
const device = {
  saved: { systemName: "Board Room", provider: "poly", network: { dhcp: true, ipAddress: null } },
  active: null,
  apply(patch) {
    const changed = leaves(patch); // (validation omitted here: see the previous topic)
    for (const p of changed) set(this.saved, p, get(patch, p));
    const appliedNow = changed.filter((p) => !restartRequired.has(p));
    for (const p of appliedNow) set(this.active, p, get(patch, p)); // live settings take effect at once
    return { status: 200, appliedNow, pendingRestart: changed.filter((p) => restartRequired.has(p)) };
  },
  restart() { this.active = structuredClone(this.saved); },
};
device.active = structuredClone(device.saved);

const ui = pendingChanges(structuredClone(device.saved));
ui.edit("systemName", "Board Room East");
ui.edit("provider", "teams");
ui.edit("network.dhcp", false);
ui.edit("network.dhcp", true); // changed their mind: back to the saved value, so no longer pending
for (const c of ui.list()) console.log(`pending  ${c.path.padEnd(18)} ${JSON.stringify(c.from)} -> ${JSON.stringify(c.to)}${c.restart ? "  (restart)" : ""}`);
console.log("button:", ui.needsRestart() ? "Save and restart…" : "Save");
console.log("PATCH body:", JSON.stringify(ui.patch()));

const result = device.apply(ui.patch());
console.log("device:", JSON.stringify(result));
console.log("active provider before restart:", device.active.provider, "| saved:", device.saved.provider);
device.restart();
console.log("active provider after restart: ", device.active.provider, "| name:", device.active.systemName);
Result
pending  systemName         "Board Room" -> "Board Room East"
pending  provider           "poly" -> "teams"  (restart)
button: Save and restart…
PATCH body: {"systemName":"Board Room East","provider":"teams"}
device: {"status":200,"appliedNow":["systemName"],"pendingRestart":["provider"]}
active provider before restart: poly | saved: teams
active provider after restart:  teams | name: Board Room East
What’s happening
  1. pendingChanges(baseline) remembers the values the page loaded and records each edit by path. In a real UI restartRequired comes from the schema's x-restart annotation (form generation); it's a Set here to keep the model short.
  2. The admin renames the room, picks Teams, unticks DHCP and then ticks it again. edit("network.dhcp", true) compares with the baseline, sees it's back to the saved value and deletes the pending entry. So the list shows two changes, not three, and the button doesn't claim a network restart that isn't needed. Without that check, "edited back" changes would trigger needless restarts.
  3. needsRestart() is true because provider is in the set, so the button reads "Save and restart…" rather than "Save". The ellipsis is a convention for "this opens a confirmation", where the UI says what the restart interrupts (a call in progress, as in the quiz in the first topic).
  4. patch() turns the pending edits into one JSON Merge Patch: {"systemName":"Board Room East","provider":"teams"}. One request means the device applies the batch atomically: if provider fails validation, systemName isn't half-applied either (see Bulk and transactional configuration).
  5. The device stores both, applies systemName at once and answers appliedNow: ["systemName"], pendingRestart: ["provider"]. That answer comes from the device, so the UI doesn't guess. After this, active.provider is still poly while saved.provider is teams, which is exactly the gap the dashboard shows as "Restart needed to apply: provider".
  6. After restart(), active equals saved: provider teams, name Board Room East. Because pendingRestart is device state (it'd be in GET /api/v1/status), the warning survives a page reload and is visible to a second admin, which a flag held only in the first admin's browser wouldn't be.
AdvancedWhat the pending-changes bar looks like

The model's output becomes a bar that stays on screen while the admin edits (in a real page, position: sticky; bottom: 0). Changed fields are marked by more than colour, and the bar says what Save will cost before the admin presses it:

Pending changes, with the restart cost up front
CSS
.page { max-width: 340px; font: 14px/1.4 system-ui, sans-serif; color: #0f172a; }
.field {
  display: flex; justify-content: space-between; align-items: center; height: 40px;
  padding: 0 10px 0 12px; border-left: 4px solid transparent; border-bottom: 1px solid #e2e8f0; background: #fff;
}
.field.changed { border-left-color: #2563eb; background: #eff6ff; }
.field.changed .label::after { content: " (changed)"; color: #1e40af; font-size: 12px; }
.badge { font-size: 12px; padding: 2px 6px; border-radius: 999px; background: #fef3c7; color: #78350f; }
.bar { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; padding: 8px 10px; margin-top: 8px; background: #0f172a; color: #f8fafc; }
.bar .count { flex: 1 1 100%; font-size: 13px; }
.bar button { height: 32px; padding: 0 10px; border-radius: 6px; border: 1px solid #94a3b8; background: #1e293b; color: #f8fafc; font: inherit; font-size: 13px; }
.bar button.primary { background: #2563eb; border-color: #2563eb; }
HTML
<div class="page">
  <div class="field changed"><span class="label">System name</span><span>Board Room East</span></div>
  <div class="field changed"><span class="label">Provider</span><span><span class="badge">restart</span> Teams</span></div>
  <div class="field"><span class="label">Language</span><span>English (US)</span></div>
  <section class="bar" aria-label="Unsaved changes">
    <span class="count">2 changes · 1 needs a restart</span>
    <button type="button">Discard</button>
    <button type="button" class="primary">Save and restart…</button>
  </section>
</div>
Resultlive, in your browser
What’s happening
  1. The two changed rows get a 4px blue left border (rgb(37, 99, 235)) and a pale blue background; "Language" keeps a transparent border (rgba(0, 0, 0, 0)), so the rows still line up.
  2. Colour alone isn't enough: WCAG 2.2's 1.4.1 Use of Color (level A) says colour mustn't be the only way information is conveyed. Each changed label also gets " (changed)" from ::after (the test reads the pseudo-element's content); unchanged rows have content: none. Generated text is a convenient demo; a production version would put the word in the DOM (or a visually hidden span), where translation tools, copy and paste and every assistive technology reliably get it.
  3. The provider row carries a "restart" badge from the same restart flag the model computed, so the cost is visible next to the change that causes it, not only in the bar.
  4. The bar is a <section> with aria-label, which gives it the role region and the name "Unsaved changes", so a screen-reader user can jump to it from the landmarks list. Its text, "2 changes · 1 needs a restart", is the model's list() and needsRestart() turned into words, on its own line above the buttons (flex: 1 1 100%), so it never gets squeezed into a sliver on a phone; the buttons wrap to the right edge, the primary one last.
  5. The buttons are 32px tall, above WCAG 2.2's 2.5.8 Target Size (Minimum) of 24 by 24 CSS pixels (level AA), and the primary button's accessible name is the full "Save and restart…", so nobody presses it thinking it's a plain Save. You can see the rendered result above; the test measured these values in Chrome.
AdvancedWhen another admin saved first

Two admins, or an admin and a provisioning server, can edit the same device. The device protects itself with an ETag and If-Match (see Optimistic concurrency with ETags): a save based on stale data gets 412 Precondition Failed. What the UI does next decides whether the admin loses their work. This model rebases the pending edits onto the device's current settings:

rebase-pending.mjs
// Two admins edit the same device. The second save fails with 412 (stale ETag). Instead of throwing the
// admin's edits away, the UI rebases them onto the device's current settings and shows only real conflicts.
let version = 7;
let settings = { systemName: "Board Room", provider: "poly", language: "en-US", volume: 60 };
const device = {
  get: () => ({ etag: `"${version}"`, body: structuredClone(settings) }),
  patch(ifMatch, patch) {
    if (ifMatch !== `"${version}"`) return { status: 412 };
    settings = { ...settings, ...patch };
    version++;
    return { status: 200, etag: `"${version}"` };
  },
};

// Admin A loads the page and starts editing.
const a = device.get();
const aEdits = { systemName: "Board Room East", provider: "teams", volume: 70 };

// Meanwhile admin B (or a provisioning server) saves first.
const b = device.get();
console.log("B saves:", device.patch(b.etag, { provider: "zoom", volume: 70 }).status);

// A saves with the ETag A loaded.
console.log("A saves:", device.patch(a.etag, aEdits).status);

// Rebase: compare each of A's edits with A's baseline and with the device's current value.
const now = device.get();
const keep = {}, conflicts = [], alreadyThere = [];
for (const [key, mine] of Object.entries(aEdits)) {
  const base = a.body[key], theirs = now.body[key];
  if (theirs === base) keep[key] = mine; // nobody else touched it: still safe to apply
  else if (theirs === mine) alreadyThere.push(key); // someone made the same change
  else conflicts.push(`${key}: you chose ${JSON.stringify(mine)}, it's now ${JSON.stringify(theirs)} (was ${JSON.stringify(base)})`);
}
console.log("still pending:", JSON.stringify(keep));
console.log("already applied by someone else:", alreadyThere.join(", "));
for (const c of conflicts) console.log("conflict →", c);
console.log("A saves the non-conflicting part:", device.patch(now.etag, keep).status, JSON.stringify(device.get().body));
Result
B saves: 200
A saves: 412
still pending: {"systemName":"Board Room East"}
already applied by someone else: volume
conflict → provider: you chose "teams", it's now "zoom" (was "poly")
A saves the non-conflicting part: 200 {"systemName":"Board Room East","provider":"zoom","language":"en-US","volume":70}
What’s happening
  1. Admin A loads the settings at version "7" and edits three of them. Before A saves, admin B saves provider: "zoom" and volume: 70; the device's version goes to "8".
  2. A's save carries If-Match: "7", which no longer matches, so the device answers 412 and changes nothing. Without the ETag, A's save would silently overwrite B's provider choice: the classic lost update.
  3. The UI fetches the current settings and compares each of A's edits three ways: A's baseline (what A started from), A's value, and the device's current value. systemName is still "Board Room" on the device, the same as A's baseline, so nobody else touched it: it stays pending.
  4. volume is already 70 on the device, the value A wanted, so it's dropped as "already applied by someone else", not reported as a conflict. provider is the real conflict: A chose "teams", B set "zoom", from a baseline of "poly". The UI shows exactly that sentence and lets A decide, instead of a generic "settings changed, reload".
  5. A saves only the non-conflicting part with the new ETag "8": 200, and the device now has A's name, B's provider and the shared volume. This is a three-way merge, the same idea as Git's: the baseline is what makes "someone else changed it" detectable at all.
AdvancedConfirm or revert for settings that can cut you off

Validation can prove an IP address is well-formed, not that the admin will still reach the device on it. A wrong gateway, a VLAN the admin's laptop isn't on, or a certificate the browser won't accept, and the web UI is gone; the only fix might be a person in the room with a paperclip for the factory reset. The defence is the pattern operating systems use when you change the screen resolution: apply on probation, keep it only if someone confirms from the new state:

safe-apply.mjs
// "Apply, then confirm or it reverts": the safe way to change a setting that can cut you off from the device
// (IP address, VLAN, HTTPS certificate, firewall). Fake clock; times are seconds after Apply.
function clock() {
  let now = 0;
  const timers = [];
  return {
    now: () => now,
    at: (t, fn) => timers.push({ t, fn }),
    cancel: (fn) => timers.splice(timers.findIndex((x) => x.fn === fn), 1),
    runUntil(end) {
      timers.sort((x, y) => x.t - y.t);
      while (timers.length && timers[0].t <= end) { const x = timers.shift(); now = x.t; x.fn(); timers.sort((p, q) => p.t - q.t); }
      now = end;
    },
  };
}

function device(c, log) {
  let address = "10.0.4.20", previous = null, revert = null, upAt = 0;
  return {
    // The network interface takes 8 s to come back up on a new address.
    reachableAt: (addr, newNetworkWorks) => addr === address && c.now() >= upAt && (addr === "10.0.4.20" || newNetworkWorks),
    confirmed: () => previous !== null && revert === null && address !== previous,
    applyNetwork(newAddress, confirmWithin) {
      previous = address;
      address = newAddress;
      upAt = c.now() + 8;
      log(`device: now on ${address}, reverting to ${previous} at t=${c.now() + confirmWithin}s unless confirmed`);
      revert = () => { log(`device: no confirmation, back on ${previous}`); address = previous; upAt = c.now() + 8; revert = null; };
      c.at(c.now() + confirmWithin, revert);
    },
    confirm() { c.cancel(revert); revert = null; log(`device: confirmed at t=${c.now()}s, keeping ${address}`); },
  };
}

function scenario(title, newNetworkWorks) {
  console.log(`— ${title}`);
  const c = clock();
  const log = (m) => console.log(`  t=${String(c.now()).padStart(2)}  ${m}`);
  const d = device(c, log);
  d.applyNetwork("10.0.9.20", 60);
  // The UI polls the NEW address every 5 s; reaching it from the browser proves the new setting works.
  for (let t = 5; t <= 55; t += 5) c.at(t, () => {
    if (!d.confirmed() && d.reachableAt("10.0.9.20", newNetworkWorks)) { log("ui: reached the new address"); d.confirm(); }
  });
  c.runUntil(70);
  log(`ui: 10.0.4.20 reachable? ${d.reachableAt("10.0.4.20", newNetworkWorks)}, 10.0.9.20 reachable? ${d.reachableAt("10.0.9.20", newNetworkWorks)}`);
}

scenario("new address routes from the admin's network", true);
scenario("typo in the gateway: new address unreachable", false);
Result
— new address routes from the admin's network
  t= 0  device: now on 10.0.9.20, reverting to 10.0.4.20 at t=60s unless confirmed
  t=10  ui: reached the new address
  t=10  device: confirmed at t=10s, keeping 10.0.9.20
  t=70  ui: 10.0.4.20 reachable? false, 10.0.9.20 reachable? true
— typo in the gateway: new address unreachable
  t= 0  device: now on 10.0.9.20, reverting to 10.0.4.20 at t=60s unless confirmed
  t=60  device: no confirmation, back on 10.0.4.20
  t=70  ui: 10.0.4.20 reachable? true, 10.0.9.20 reachable? false
What’s happening
  1. applyNetwork("10.0.9.20", 60) switches the device to the new address and, in the same step, arms a timer on the device: at t=60s it reverts to 10.0.4.20 unless confirmed. The timer must live on the device; a timer in the browser is useless once the browser can't reach it.
  2. The UI then polls the new address every 5 s. The interface needs 8 s to come up, so the poll at t=5 fails and the one at t=10 succeeds. Reaching the device at the new address from the admin's browser is the proof that matters; the device can't prove it on its own.
  3. First scenario: the UI confirms at t=10, the device cancels the revert timer and keeps 10.0.9.20. At t=70 only the new address answers.
  4. Second scenario: a typo in the gateway, so the new address is never reachable from the admin's network. No confirmation arrives; at t=60 the device reverts on its own and, 8 s later, 10.0.4.20 answers again. The admin sees "the change didn't work and was undone" instead of a dead device.
  5. The cost is a minute of uncertainty and a more complex API (POST …/network/confirm, a revert deadline in the response). For settings that can't lock anyone out, it's not worth it; for the handful that can, it's the difference between a bad afternoon and a site visit.
AdvancedRestart orchestration

Some practical rules for the restart itself:

  • Collect, then restart once. Five restart-required changes cost one restart, not five. The pending list and the device's pendingRestart make that natural.
  • Know what a restart interrupts. The device knows whether it's in a call or a content share; the confirmation says so and offers "later". Poly's guides also document a scheduled auto restart, daily or weekly, which is a natural place for pending restarts to land.
  • Restart through the API, never by power. Poly's 4.4.0 guide recommends the web interface, Poly Lens, the REST API, Telnet or SSH over pulling the power, and warns against restarting during maintenance such as an update. A restart through the API can finish writing settings and close files first.
  • Show the restart as a state, not an error. Once the admin presses Restart, the UI expects the device to disappear, so it shows "Restarting…" and waits for it to come back rather than flashing connection errors (the unreachable states topic builds this).

#Software update flows

Added

A software update is the riskiest thing an admin UI does: a large package travels over the network, the device rewrites its own system, switches itself off and has to come back as something new. Each step can fail differently (the upload drops, the package is damaged or not genuine, the install fails, the new version doesn't boot, it boots but something important is broken, or the device simply never answers again) and the UI must end every one of those paths in a clear, true state: done, rejected, rolled back, or "not responding, go and look".

The mental model is a relay race with a baton you can't drop. The baton is "a working system". Upload and verification happen while the old system keeps running; installation writes to a spare copy, not the running one; the reboot is the hand-off; and the new runner only gets the baton after a post-boot check passes. If anything goes wrong before that, the old runner still has it. On Android devices built with A/B updates, that spare copy is the A/B slot scheme (the public Poly docs don't say which update scheme PolyOS uses): the Android Open Source Project's "A/B (seamless) system updates" page (updated October 2026) describes update_engine writing the update to the unused slot while the system runs, the device rebooting into the old slot if the new one won't boot, and the new slot only counting as good once it's "marked as successful by calling markBootSuccessful()". The same page says virtual A/B updates replaced legacy A/B in Android 10.

The UI sees all of this as a state machine. Writing it down as one, with every failure state named, is most of the design:

Upload Verify Install Reboot Confirm Done old version keeps running device away drop: resume from offset rejected: nothing installed failed: running slot untouched not back by deadline: go and look check failed: rolled back to old slot every path ends in a named state the UI can show and log
Upload, verify and install happen while the old version runs; only a passing post-boot check makes the new version stick.

This model drives that state machine with real SHA-256 and Ed25519 checks against a fake device, through the happy path and five failures:

update-flow.mjs
// A software update as the admin UI sees it: upload → verify → install → reboot → verify → done,
// with every failure path ending in a clear state. Real SHA-256 and Ed25519 checks; fake device and clock.
import { createHash, generateKeyPairSync, sign, verify } from "node:crypto";

const vendor = generateKeyPairSync("ed25519"); // the vendor's signing key; devices ship with the public half
const attacker = generateKeyPairSync("ed25519");
const sha256 = (b) => createHash("sha256").update(b).digest("hex");
const makePackage = (bytes, key) => ({ bytes, manifest: { version: "5.0.1", sha256: sha256(bytes), sig: sign(null, bytes, key.privateKey) } });
const genuine = makePackage(Buffer.alloc(64 * 1024, 7), vendor); // stands in for a 1 GB update package
const tampered = makePackage(Buffer.alloc(64 * 1024, 6), attacker); // its own checksum, its own signature

function fakeDevice({ pkg = genuine, corrupt = false, boots = true, healthy = true, comesBack = true }) {
  const dev = { version: "4.2.1", slot: "a", received: Buffer.alloc(0) };
  return {
    pkg,
    upload: (chunk, i) => { dev.received = Buffer.concat([dev.received, corrupt && i === 2 ? Buffer.from(chunk).fill(0) : chunk]); },
    verifyPackage() {
      if (sha256(dev.received) !== pkg.manifest.sha256) return "checksum mismatch: damaged in transit";
      if (!verify(null, dev.received, vendor.publicKey, pkg.manifest.sig)) return "checksum fine, signature not the vendor's";
      return "ok";
    },
    install: () => "b", // A/B: write the inactive slot; the running system is untouched
    reboot() {
      if (!comesBack) return null; // never answers again (power cut, stuck boot)
      if (!boots) return { version: dev.version, slot: dev.slot, bootloaderFellBack: true };
      if (!healthy) return { version: dev.version, slot: dev.slot, healthCheckFailed: "camera service" };
      Object.assign(dev, { version: pkg.manifest.version, slot: "b" });
      return { version: dev.version, slot: dev.slot };
    },
  };
}

function runUpdate(title, device) {
  const steps = [];
  const end = (state, why) => console.log(`${title.padEnd(22)} ${steps.join(" → ")} → ${state}${why ? ` (${why})` : ""}`);
  const { bytes, manifest } = device.pkg;
  const chunks = 4, size = bytes.length / chunks;
  for (let i = 0; i < chunks; i++) device.upload(bytes.subarray(i * size, (i + 1) * size), i);
  steps.push("upload 100%");
  const check = device.verifyPackage();
  if (check !== "ok") return end("REJECTED, nothing installed", check);
  steps.push("verified");
  steps.push(`installed to slot ${device.install()}`);
  steps.push("rebooting");
  const back = device.reboot(); // in real life: poll until it answers, with a deadline (next topic)
  if (!back) return end("NOT RESPONDING after 15 min", "don't say failed: say check the room");
  if (back.bootloaderFellBack) return end(`ROLLED BACK, running ${back.version} slot ${back.slot}`, "new slot didn't boot");
  if (back.healthCheckFailed) return end(`ROLLED BACK, running ${back.version} slot ${back.slot}`, `${back.healthCheckFailed} failed after boot`);
  if (back.version !== manifest.version) return end("FAILED", `expected ${manifest.version}, got ${back.version}`);
  end(`DONE, running ${back.version} slot ${back.slot}`);
}

runUpdate("happy path", fakeDevice({}));
runUpdate("corrupted upload", fakeDevice({ corrupt: true }));
runUpdate("tampered package", fakeDevice({ pkg: tampered }));
runUpdate("new slot won't boot", fakeDevice({ boots: false }));
runUpdate("boots, but unhealthy", fakeDevice({ healthy: false }));
runUpdate("never comes back", fakeDevice({ comesBack: false }));
Result
happy path             upload 100% → verified → installed to slot b → rebooting → DONE, running 5.0.1 slot b
corrupted upload       upload 100% → REJECTED, nothing installed (checksum mismatch: damaged in transit)
tampered package       upload 100% → REJECTED, nothing installed (checksum fine, signature not the vendor's)
new slot won't boot    upload 100% → verified → installed to slot b → rebooting → ROLLED BACK, running 4.2.1 slot a (new slot didn't boot)
boots, but unhealthy   upload 100% → verified → installed to slot b → rebooting → ROLLED BACK, running 4.2.1 slot a (camera service failed after boot)
never comes back       upload 100% → verified → installed to slot b → rebooting → NOT RESPONDING after 15 min (don't say failed: say check the room)
What’s happening
  1. The vendor signs each package with its private key; the device ships with the matching public key. A package carries a manifest with its version, its sha256 and the sig. (Real packages and update protocols are more elaborate; this keeps the two checks that matter. Signing and key handling are in Supply chain and update security.)
  2. "happy path": four chunks arrive, the checksum matches, the signature verifies against the vendor key, the install goes to slot b while slot a keeps running, and after the reboot the device reports 5.0.1 on slot b. Only then does the UI say DONE, and it says it from the device's own report, not from "the upload finished".
  3. "corrupted upload": the third chunk arrives zeroed (a flaky proxy, a truncated USB copy). The SHA-256 differs from the manifest, so the device rejects it before touching anything: "damaged in transit". The checksum catches accidents.
  4. "tampered package": an attacker ships different bytes with a matching checksum they computed themselves and a signature from their own key. The checksum passes, as it would for any self-consistent file; the signature check against the vendor's public key fails. The signature catches forgeries; that's why a checksum alone is never enough.
  5. "new slot won't boot" and "boots, but unhealthy" both end in ROLLED BACK, running 4.2.1 slot a: in the first the bootloader falls back on its own; in the second the new system boots, its post-boot check finds the camera service failing, it never marks itself successful, and the device returns to the old slot. The UI's message names the reason, because "rolled back" without a reason sends the admin straight to support.
  6. "never comes back" is deliberately not "FAILED". After a deadline the UI knows only that it can't reach the device; it might be rolling back, stuck at a boot screen, or unplugged. "Not responding after 15 min, check the room" is true and actionable; "Update failed" is a guess. The deadline polling itself is the next topic.
AdvancedUploading a large package over a bad network

Update packages are large (the model's 256 KiB stands in for one), and admins upload from home connections, hotel Wi-Fi and VPNs. Restarting the upload from zero after every drop is unusable. The pattern, being standardised as the IETF's Resumable Uploads for HTTP (draft-ietf-httpbis-resumable-upload-14, 18 October 2026, still an Internet-Draft, not an RFC), is that the server tells you how much it has and you continue from there:

resumable-upload.mjs
// Uploading a large update package over a flaky link: ask the device how much it has, carry on from there.
// Header names follow the IETF draft "Resumable Uploads for HTTP" (draft-ietf-httpbis-resumable-upload-14);
// the flow is simplified (no 104 responses, no upload-creation-with-data).
import express from "express";
import request from "supertest";

const uploads = new Map();
const app = express();
app.post("/api/v1/updates", (req, res) => {
  uploads.set("u1", { chunks: [], size: 0, complete: false });
  res.status(201).location("/api/v1/updates/u1").end();
});
app.head("/api/v1/updates/:id", (req, res) => {
  const u = uploads.get(req.params.id);
  res.set({ "Upload-Offset": String(u.size), "Upload-Complete": u.complete ? "?1" : "?0" }).end();
});
app.patch("/api/v1/updates/:id", express.raw({ type: "application/partial-upload", limit: "1mb" }), (req, res) => {
  const u = uploads.get(req.params.id);
  const offset = Number(req.get("Upload-Offset"));
  if (offset !== u.size)
    return res.status(409).set("Upload-Offset", String(u.size)).type("application/problem+json")
      .json({ type: "https://iana.org/assignments/http-problem-types#mismatching-upload-offset", "expected-offset": u.size, "provided-offset": offset });
  u.chunks.push(req.body);
  u.size += req.body.length;
  u.complete = req.get("Upload-Complete") === "?1";
  res.status(204).set("Upload-Offset", String(u.size)).end();
});

const pkg = Buffer.alloc(256 * 1024, 1);
const CHUNK = 64 * 1024;
const send = (url, offset, bytes, last) => request(app).patch(url).set({ "Content-Type": "application/partial-upload", "Upload-Offset": String(offset), "Upload-Complete": last ? "?1" : "?0" }).send(bytes);

const url = (await request(app).post("/api/v1/updates")).headers.location;
let offset = 0, dropped = false;
while (offset < pkg.length) {
  const bytes = pkg.subarray(offset, offset + CHUNK);
  if (offset === CHUNK && !dropped) {
    // Wi-Fi drops halfway through the second chunk: the device kept what arrived, the browser saw an error.
    dropped = true;
    await send(url, offset, bytes.subarray(0, CHUNK / 2), false);
    console.log(`chunk at ${String(offset).padStart(6)}: network error`);
    const head = await request(app).head(url);
    console.log(`HEAD: device has ${head.headers["upload-offset"]} bytes, resume from there`);
    offset = Number(head.headers["upload-offset"]);
    continue;
  }
  const res = await send(url, offset, bytes, offset + bytes.length === pkg.length);
  const has = Number(res.headers["upload-offset"]);
  console.log(`chunk at ${String(offset).padStart(6)}: ${res.status}, device has ${has} (${Math.round((100 * has) / pkg.length)}%)`);
  offset = has;
}
const stale = await send(url, 0, pkg.subarray(0, 10), false); // a confused retry of the very first chunk
console.log(`stale retry: ${stale.status}, expected-offset ${stale.body["expected-offset"]}`);
const done = await request(app).head(url);
console.log(`complete: ${done.headers["upload-complete"]}, size ${done.headers["upload-offset"]} of ${pkg.length}, intact: ${Buffer.concat(uploads.get("u1").chunks).equals(pkg)}`);
Result
chunk at      0: 204, device has 65536 (25%)
chunk at  65536: network error
HEAD: device has 98304 bytes, resume from there
chunk at  98304: 204, device has 163840 (63%)
chunk at 163840: 204, device has 229376 (88%)
chunk at 229376: 204, device has 262144 (100%)
stale retry: 409, expected-offset 262144
complete: ?1, size 262144 of 262144, intact: true
What’s happening
  1. POST /api/v1/updates creates an upload resource and returns its URL in Location. Every chunk is then a PATCH to that URL with Content-Type: application/partial-upload and an Upload-Offset header saying where the chunk starts: the draft's append request, simplified.
  2. The first 64 KiB chunk lands; the device answers 204 with Upload-Offset: 65536 (25%). The progress the UI shows is the device's count, not the browser's "bytes sent", which counts bytes that may still be in a buffer somewhere.
  3. Halfway through the second chunk the connection drops. The device kept the 32 KiB that arrived; the browser only saw a network error and can't know how much got through. So it asks: HEAD on the upload URL returns Upload-Offset: 98304 (the draft recommends HEAD for this), and the next chunk starts at byte 98304, not 65536 and not 0.
  4. The remaining chunks follow, and the last one carries Upload-Complete: ?1 (a Structured Fields boolean). The final line confirms the reassembled bytes equal the original package.
  5. A confused retry of the very first chunk, with Upload-Offset: 0, gets 409 Conflict with a problem-details body whose expected-offset is 262144: the draft's mismatching-upload-offset problem type. The offset check is what makes retries safe: a duplicate chunk can never be appended twice.
  6. Verification still happens after the upload is complete. Resumability is about transport; the checksum and signature checks above are about content, and they run on the whole assembled file.
AdvancedProgress that doesn't lie

The UI can only show a real percentage when something is really being counted. Upload has bytes; install may report its own percentage; reboot has none. During the reboot, show an indeterminate progress bar and a message, never a bar creeping to 99% on a timer:

Update steps with determinate and indeterminate progress
CSS
.updater { max-width: 340px; font: 14px/1.4 system-ui, sans-serif; color: #0f172a; }
.steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(60px, 1fr)); gap: 6px; padding: 0; margin: 0 0 12px; list-style: none; }
.steps li { height: 30px; line-height: 30px; padding: 0 4px; text-align: center; white-space: nowrap; font-size: 12px; border-radius: 6px; background: #e2e8f0; color: #334155; }
.steps li.done { background: #dcfce7; color: #14532d; }
.steps li.done::before { content: "✓ "; }
.steps li[aria-current="step"] { background: #2563eb; color: #fff; font-weight: 700; }
.phase { display: grid; gap: 4px; margin-bottom: 10px; font-size: 13px; }
.phase progress { width: 100%; height: 12px; }
.note { margin: 0; padding: 8px 10px; border-radius: 6px; background: #fef3c7; color: #78350f; font-size: 13px; }
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
HTML
<div class="updater">
  <ol class="steps" aria-label="Software update">
    <li class="done">Upload<span class="vh"> (done)</span></li>
    <li class="done">Verify<span class="vh"> (done)</span></li>
    <li class="done">Install<span class="vh"> (done)</span></li>
    <li aria-current="step">Restart</li>
    <li>Check</li>
  </ol>
  <div class="phase"><label for="up">Upload: 812.5 MB of 1,024 MB</label><progress id="up" max="100" value="79">79%</progress></div>
  <div class="phase"><label for="boot">Restarting: waiting for the device</label><progress id="boot"></progress></div>
  <p class="note" role="status">Restarting into 5.0.1. Don't power off the device; this page updates when it's back.</p>
</div>
Resultlive, in your browser
What’s happening
  1. The step list is an <ol> named "Software update"; "Restart" carries aria-current="step", and the test confirms that's the item styled blue (rgb(37, 99, 235)). Finished steps show a ✓ from CSS and contain the text " (done)" in a visually hidden span (measured 1px wide), so their state isn't conveyed by colour alone.
  2. The upload bar is a <progress max="100" value="79">, role progressbar, named by its <label> "Upload: 812.5 MB of 1,024 MB"; its position is 0.79. Those numbers would come from the device's Upload-Offset, as in the previous model.
  3. The restart bar has no value, which makes it indeterminate: position is -1 and it matches :indeterminate. The browser draws its own "busy" animation; you can see it in the live result above. That's the honest display for a phase with nothing to count.
  4. The message is a role="status" live region, which has an implicit aria-live="polite" (MDN, "ARIA: status role", June 2025), so when the UI later replaces the text with "Running 5.0.1" or "Rolled back to 4.2.1", screen readers announce it without moving focus. That's what WCAG 2.2's 4.1.3 Status Messages (level AA) asks for. Keep the same element and change its text, rather than replacing the element, so it stays one live region for the whole update.
AdvancedHow real products expose updates

Poly's Video Mode Administrator Guide 4.7.0 (HP, July 2025) is a good public example of the choices an update UI has to surface. It lists four sources for system software: the Poly update server, a custom server URL holding packages downloaded from Poly Lens, a USB flash drive, and a provisioning service. With automatic updates enabled and no maintenance window, the system tries "1 minute after you enable this setting" and, if nothing is available, "every 4 hours"; with a maintenance window it picks a random time inside it, which has the effect of spreading a fleet's checks across the window instead of every room hitting the server at once. If a provisioning service manages the system, the update methods in the web interface are unavailable. The 4.4.0 guide adds limits worth designing for: downgrading from VideoOS 4.x to 3.x isn't supported, and updating to 4.0 from a version earlier than 3.14.1 isn't a supported path and can leave the system needing a reset; the 4.7.0 guide says a system on software earlier than 3.14.2 must update to 3.14.2 before the latest release. Each of those is a rule the update UI should enforce or explain before the admin starts, not discover afterwards.

Upload complete at 100%

The progress bar reaches 100% when the last chunk is accepted, and a developer wants to show "Update complete ✓" at that moment, because "the device has the file". What's wrong, and what should the UI show instead?

Show answer

At 100% upload, nothing has been installed, and the hardest steps haven't started. Show "Upload complete, verifying…", then each phase, and "Complete" only when the device reports the new version after its post-boot check.

What’s happening
  1. After the upload, the package still has to pass the checksum and signature checks; in the model above, two of the six scenarios end right there with REJECTED, after a 100% upload.
  2. Then install, reboot and the post-boot check; two more scenarios end in ROLLED BACK and one in NOT RESPONDING, all after a 100% upload. "Complete" at upload time would be wrong in five of six cases.
  3. Admins act on "complete": they leave the room, tell the meeting organiser it's ready, or start updating the next fifty devices. A false "complete" doesn't just mislead one person; it can start a fleet rollout of a broken version.
  4. The rule: the word "complete" (or "done") belongs to the state the device reports after it's running the new version and has marked it good; everything before that is a named in-progress phase.

#Device unreachable, rebooting and offline states

Added

A web app for a cloud service can mostly assume its server is up. An admin UI for a device can't: the device restarts after a settings change, reboots twice during an update, loses its network when someone unplugs a switch, and changes its address when DHCP hands out a new one. The page in the admin's browser keeps running through all of it, so it has to know which of those is happening and behave accordingly: keep showing what it last knew (marked as stale), stop offering actions that can't work, wait patiently for a planned restart, and notice when the device that comes back is not the same boot, or the same version, as the one that left.

The mental model is a ship-to-shore radio. Static on one call isn't a sinking; you note the time of the last clear message and keep calling. Several missed calls means "not reachable": you stop sending orders, because an order sent into silence may or may not arrive. A ship that said "going quiet for maintenance" isn't missing until it's overdue. And when it comes back on air, you check whether it's the same voyage (a blip) or a fresh start (it restarted, so your earlier assumptions about its state are void).

The signal for "fresh start" is a boot identifier: a random id the device generates at every boot and includes in its status. Uptime works too, but the id is unambiguous. The UI compares it on every successful poll.

Online poll every few s Checking 1 failure Unreachable 3 failures, back off Restarting failures expected Lost past the deadline poll fails reply Restart pressed new bootId any reply → Online new bootId → reload, log in again
Unplanned failures escalate from checking to unreachable; a planned restart expects silence and only gives up at a deadline. The boot id tells a blip from a restart.

This model is the UI's connection monitor, driven by a fake clock and three timelines: a network blip, a planned restart, and a restart from which the device never returns:

connection-monitor.mjs
// What the admin UI believes about the device, driven only by what its status polls see.
// Fake clock in seconds. The device's bootId changes on every boot, which is how the UI tells
// "the network blipped" from "the device restarted".
function monitor({ expectRebootAt = null, rebootDeadline = 300 } = {}) {
  let state = "online", failures = 0, delay = 2, bootId = "b-41", since = 0;
  const log = (t, s, why) => { if (s !== state) console.log(`  t=${String(t).padStart(3)}s  ${state.padEnd(11)} → ${s.padEnd(11)} ${why}`); state = s; };
  return {
    get delay() { return delay; },
    restartRequested(t) { since = t; log(t, "restarting", "admin pressed Restart; controls locked, no polling errors shown"); },
    poll(t, reply) {
      if (reply) {
        const rebooted = reply.bootId !== bootId;
        if (state === "restarting" && !rebooted) return; // still up: it hasn't gone down yet
        bootId = reply.bootId;
        failures = 0;
        delay = 2;
        if (rebooted) log(t, "online", `new bootId ${reply.bootId}: reload settings and version, ask for login again`);
        else log(t, "online", "same bootId: network blip, refresh data only");
        return;
      }
      failures++;
      // Unplanned outage: back off (4, 8, 15, 15…). Planned restart: we expect it back, so poll steadily.
      delay = state === "restarting" ? 5 : Math.min(delay * 2, 15);
      if (state === "restarting") {
        if (t - since > rebootDeadline) log(t, "lost", `not back ${rebootDeadline}s after restart: check power and network`);
      } else if (failures === 1) log(t, "checking", "one poll failed: keep showing data, mark it 'as of' last success");
      else if (failures === 3) log(t, "unreachable", "3 failures: disable Save, show last-seen time, keep retrying");
    },
  };
}

function simulate(title, { device, restartAt = null, until }) {
  console.log(`— ${title}`);
  const m = monitor();
  let t = 0;
  while (t <= until) {
    if (restartAt !== null && t >= restartAt) { m.restartRequested(t); restartAt = null; }
    m.poll(t, device(t));
    t += m.delay;
  }
}

// A: the switch port flaps for ~40 s; the device never restarts.
simulate("network blip", { device: (t) => (t >= 10 && t < 50 ? null : { bootId: "b-41" }), until: 120 });
// B: the admin restarts the device; it is down from t=21 and back at t=110 with a new bootId.
simulate("planned restart", { restartAt: 20, device: (t) => (t > 20 && t < 110 ? null : { bootId: t <= 20 ? "b-41" : "b-42" }), until: 160 });
// C: the same restart, but the device never answers again.
simulate("restart, never back", { restartAt: 20, device: (t) => (t <= 20 ? { bootId: "b-41" } : null), until: 400 });
Result
— network blip
  t= 10s  online      → checking    one poll failed: keep showing data, mark it 'as of' last success
  t= 22s  checking    → unreachable 3 failures: disable Save, show last-seen time, keep retrying
  t= 52s  unreachable → online      same bootId: network blip, refresh data only
— planned restart
  t= 20s  online      → restarting  admin pressed Restart; controls locked, no polling errors shown
  t=112s  restarting  → online      new bootId b-42: reload settings and version, ask for login again
— restart, never back
  t= 20s  online      → restarting  admin pressed Restart; controls locked, no polling errors shown
  t=322s  restarting  → lost        not back 300s after restart: check power and network
What’s happening
  1. The monitor holds a state, a failure count, the next poll delay and the last bootId it saw (b-41). Each poll either gets a reply ({ bootId }) or null, and simulate schedules the next poll after m.delay seconds, so backing off really does slow the polling.
  2. "network blip": the first failure at t=10 moves to checking. One failure is noise (a dropped packet, a slow response), so the page keeps the data and only marks it "as of" the last success. The third failure at t=22 moves to unreachable: now Save is disabled and the last-seen time is shown. The delays double, 4, 8, then cap at 15 s, so polls land at 10, 14, 22, 37 and 52.
  3. At t=52 a reply arrives with the same bootId, so this was a network blip: refresh the data, nothing else. The device had been back since t=50; the 15-second cap is the trade-off between noticing quickly and not hammering a device that may be struggling.
  4. "planned restart": the admin presses Restart at t=20, and the state goes straight to restarting. The device still answers at t=20 with the old bootId (it hasn't gone down yet), which the monitor ignores rather than treating as "back". Failures are expected now, so there's no "unreachable" alarm, and polling is a steady 5 s instead of backing off, because we expect it back soon and want to see it promptly.
  5. At t=112 a reply carries bootId: b-42: a different boot. The UI reloads everything (settings, firmware version, pending-restart list) because any of it may have changed, and asks for login again, because in-memory sessions don't survive a reboot. It noticed 2 s after the device came back at t=110; the steady 5 s polling is why.
  6. "restart, never back": silence after the restart, until the 300 s deadline passes at t=322, and the state becomes lost with "check power and network". Before the deadline, silence is normal and the UI says "Restarting…"; after it, silence is news. The deadline is a product decision based on the device's measured boot time, not a constant to copy.
AdvancedWhat the page looks like while the device is away

When the monitor says unreachable, three things change on screen: a status message explains it, actions that write to the device are disabled, and the values still shown are marked with the time they were true:

Unreachable: say so, keep the data, disable the writes
CSS
.console { max-width: 340px; font: 14px/1.4 system-ui, sans-serif; color: #0f172a; }
.banner { margin: 0 0 10px; padding: 8px 10px; border-left: 4px solid #b45309; background: #fffbeb; color: #78350f; font-size: 13px; }
fieldset { margin: 0 0 10px; padding: 8px 10px; border: 1px solid #cbd5e1; border-radius: 6px; }
fieldset:disabled { background: #f1f5f9; }
label { display: block; font-size: 13px; }
input { width: 80px; height: 28px; font: inherit; }
button { height: 32px; margin-top: 6px; font: inherit; }
.stale { display: grid; grid-template-columns: 90px 1fr; gap: 2px 8px; margin: 0; font-size: 13px; }
.stale dd { margin: 0; color: #475569; }
.as-of { font-size: 12px; color: #64748b; }
HTML
<div class="console">
  <p class="banner" role="status"><strong>Device not responding.</strong> Last update 14:02:10. Trying again every 15 s.</p>
  <form>
    <fieldset disabled>
      <legend>Audio</legend>
      <label for="vol">Speaker volume</label>
      <input id="vol" type="number" value="65">
      <button type="submit">Save</button>
    </fieldset>
  </form>
  <dl class="stale">
    <dt>Firmware</dt><dd>4.2.1 <span class="as-of">(as of 14:02)</span></dd>
    <dt>Uptime</dt><dd>3 days <span class="as-of">(as of 14:02)</span></dd>
  </dl>
</div>
Resultlive, in your browser
What’s happening
  1. The banner is a role="status" live region, so when the monitor changes its text ("Device not responding" → "Reconnected"), screen readers announce it without stealing focus. It says what's wrong, when the UI last heard from the device, and what the UI is doing about it.
  2. One disabled attribute on the <fieldset> disables every control inside it: the test confirms both the input and Save match :disabled. That's simpler and safer than disabling controls one by one, and it can't miss a control added later.
  3. The input still shows 65 and keeps its label "Speaker volume"; the fieldset keeps its name "Audio". Disabled isn't hidden: the admin can still read the last-known configuration while the device is away, which is often exactly what they need to troubleshoot.
  4. Each status value in the <dl> carries "(as of 14:02)". A value without a time looks current; that's how a dashboard ends up showing "camera connected" for a device that's been off for an hour.
  5. You can see the greyed fieldset in the live result above; colours were measured by the test in Chrome (rgb(241, 245, 249) for the disabled group, rgb(180, 83, 9) for the banner's marker).
AdvancedRules for a device that comes and goes
  • Never queue writes for later. An admin who pressed Save while the device was away, and then left, didn't agree to that change being applied an hour later in a different context. Disable writes while unreachable; keep the unsaved edits in the form (or in sessionStorage) so nothing is lost; let the admin press Save again when it's back.
  • Don't retry writes blindly. If a PATCH timed out, you don't know whether the device applied it. Re-read the settings (or use an idempotency key, see Methods, status codes and idempotency for device APIs) before deciding to send it again.
  • A new boot means a new session. After a reboot, the first request typically gets 401, because sessions kept in memory are gone. Treat a bootId change as "log in again, then reload", not as an error, and keep the admin's place in the page.
  • The device may come back somewhere else. With DHCP, a rebooted device can get a different address, and the page polling the old one waits forever. Show the device's hostname and MAC address on the "lost" screen, and recommend DHCP reservations for room devices; a fleet manager avoids the problem because devices connect out to it.
  • navigator.onLine is about the admin, not the device. It says whether the admin's own machine thinks it has a network. It's useful for "you're offline" messages, never for "the device is down".
  • One tab or many, one device or a thousand. A single page polling one device can use simple backoff. A fleet tool polling thousands of devices after a site-wide power cut needs jitter as well, so they don't all retry in lockstep; the reconnection patterns are in Reconnection and back-off.
Save timed out

The admin presses Save on a static IP change. The PATCH request times out after 10 s with no response, and the next status poll also fails. Should the UI retry the PATCH automatically when the device comes back?

Show answer

No. It should re-read the device's settings when the device is reachable again (at whichever address it's actually on), show the admin what is now saved, and let them decide.

What’s happening
  1. A timeout means "no answer", not "not applied". The device may have applied the new IP and then become unreachable because its address changed, which is exactly what this change does; the failed poll to the old address is consistent with success.
  2. Retrying automatically would at best do nothing (it's already applied) and at worst re-apply a change the admin no longer wants, possibly after another admin has fixed things. A PATCH to set a value is idempotent in effect, but the context isn't.
  3. So the UI moves to an "unknown outcome" state: "We didn't get an answer. The device may now be at 10.0.9.20." It polls both the old and the new address, if it knows the new one.
  4. This is also the case for the confirm-or-revert pattern: with it, the device itself reverts if nobody confirms within a minute, so "unknown" can't last.

#Import, export and provisioning of configuration

Added

Nobody configures fifty meeting rooms by hand twice. Admins set one device up well, export its configuration, and import it into the others; or they skip the per-device UI entirely and let a provisioning service push configuration to every device it manages. All three move settings between devices, and that's what makes them harder than editing: the file may come from another model, an older firmware, or a year ago; it may contain things that must never be copied (passwords, certificates, the room's own name and address); and an import touches dozens of settings at once.

The mental model is moving house with a packing list. Export is packing: you list what's in each box, you don't pack the house keys (secrets) or the address plate on the door (per-device identity), and you write which house it came from (model, firmware, schema version). Import is unpacking in a different house: you check the list against the new rooms (does this model support 4K?), convert old box labels to new ones (schema migrations), and walk through the list with the owner before anything goes in a cupboard (a dry run, then apply). Provisioning is a removals company that keeps furnishing the house to a standard plan; anything they own, the occupant can't rearrange.

The import pipeline, in order:

StepWhat it doesWhy it's there
Parse and identifyCheck the file's format and schemaVersionReject a random JSON file or a contacts export early, with a clear message
MigrateRun migrations v2 → v3 → …Exports from old firmware stay importable for years
FilterDrop settings the target can't take: unsupported by the model, per-device identity, secretsCopying the room name or IP to fifty rooms breaks all fifty
ValidateThe same schema validator as the formsAn import is just a big settings change
Diff (dry run)List exactly what would changeThe admin approves the real effect, not a file they can't read
ApplyOne atomic merge patch, restart flags as usualSame path as the apply flow

Here is that pipeline, with an export from one model imported into another, an old v2 file, and a file with a bad value:

config-import.mjs
// Export a configuration from one device and import it into another: secrets stay behind, per-device
// identity stays behind, old files are migrated, and the import is a dry run until the admin confirms.
import Ajv2020 from "ajv/dist/2020.js";

const schemaV3 = {
  type: "object",
  additionalProperties: false,
  properties: {
    systemName: { type: "string", maxLength: 40, "x-perDevice": true },
    adminPassword: { type: "string", minLength: 12, writeOnly: true },
    language: { enum: ["en-US", "fr-FR", "ja-JP"] },
    audio: { type: "object", additionalProperties: false, properties: { speakerVolume: { type: "integer", minimum: 0, maximum: 100 } } },
    video: { type: "object", additionalProperties: false, properties: { maxResolution: { enum: ["720p", "1080p", "4K"] } } },
  },
};
const ajv = new Ajv2020({ allErrors: true });
ajv.addVocabulary(["x-perDevice"]);
const validate = ajv.compile(schemaV3);

function exportConfig(settings, schema) {
  const out = {}, omitted = [];
  for (const [k, v] of Object.entries(settings)) {
    if (schema.properties[k].writeOnly) omitted.push(`${k} (secret)`);
    else if (schema.properties[k]["x-perDevice"]) omitted.push(`${k} (per-device)`);
    else out[k] = v;
  }
  return { format: "device-config", schemaVersion: 3, model: "model-a", firmware: "5.0.1", settings: out, omitted };
}

// Each migration takes a file one schema version forward. Old exports stay importable for years.
const migrations = {
  2: (s) => {
    const { volume, resolution, ...rest } = s;
    return { ...rest, audio: { speakerVolume: volume }, video: { maxResolution: { HD: "1080p", UHD: "4K" }[resolution] ?? resolution } };
  },
};

const resolutions = { "model-a": ["720p", "1080p", "4K"], "model-b": ["720p", "1080p"] }; // from each model's capabilities

function planImport(file, current, targetModel) {
  if (file.format !== "device-config") return { ok: false, report: ["not a device configuration file"] };
  let s = file.settings, v = file.schemaVersion;
  const report = [];
  while (v < 3) { s = migrations[v](s); report.push(`migrated schema v${v} → v${v + 1}`); v++; }
  const res = s.video?.maxResolution;
  if (res && !resolutions[targetModel].includes(res)) {
    report.push(`skip video.maxResolution ${JSON.stringify(res)}: ${targetModel} supports ${resolutions[targetModel].join(", ")}`);
    const { maxResolution, ...otherVideo } = s.video;
    s = { ...s, video: otherVideo };
  }
  const next = { ...current, ...s, audio: { ...current.audio, ...s.audio }, video: { ...current.video, ...s.video } };
  if (!validate(next)) return { ok: false, report: [...report, ...validate.errors.map((e) => `invalid: ${e.instancePath} ${e.message}`)] };
  for (const [k, sub] of Object.entries(s)) for (const [kk, val] of Object.entries(typeof sub === "object" ? sub : { "": sub })) {
    const path = kk ? `${k}.${kk}` : k, was = kk ? current[k]?.[kk] : current[k];
    if (was !== val) report.push(`change ${path}: ${JSON.stringify(was)} → ${JSON.stringify(val)}`);
  }
  return { ok: true, report, next };
}

const source = { systemName: "Board Room", adminPassword: "correct-horse-battery", language: "fr-FR", audio: { speakerVolume: 70 }, video: { maxResolution: "4K" } };
const file = exportConfig(source, schemaV3);
console.log("exported:", JSON.stringify(file));

const target = { systemName: "Huddle 2", adminPassword: "another-long-secret", language: "en-US", audio: { speakerVolume: 70 }, video: { maxResolution: "1080p" } };
for (const [label, f] of [
  ["v3 file from model-a", file],
  ["v2 file from 2024", { format: "device-config", schemaVersion: 2, model: "model-b", settings: { language: "ja-JP", volume: 55, resolution: "HD" } }],
  ["bad file", { format: "device-config", schemaVersion: 3, model: "model-b", settings: { audio: { speakerVolume: 140 } } }],
]) {
  const plan = planImport(f, target, "model-b");
  console.log(`\n${label}: ${plan.ok ? "dry run OK, waiting for Apply" : "REJECTED"}`);
  for (const line of plan.report) console.log(`  ${line}`);
}
Result
exported: {"format":"device-config","schemaVersion":3,"model":"model-a","firmware":"5.0.1","settings":{"language":"fr-FR","audio":{"speakerVolume":70},"video":{"maxResolution":"4K"}},"omitted":["systemName (per-device)","adminPassword (secret)"]}

v3 file from model-a: dry run OK, waiting for Apply
  skip video.maxResolution "4K": model-b supports 720p, 1080p
  change language: "en-US" → "fr-FR"

v2 file from 2024: dry run OK, waiting for Apply
  migrated schema v2 → v3
  change language: "en-US" → "ja-JP"
  change audio.speakerVolume: 70 → 55

bad file: REJECTED
  invalid: /audio/speakerVolume must be <= 100
What’s happening
  1. exportConfig walks the settings with the schema beside it. adminPassword is writeOnly, so it's omitted as a secret; systemName is marked x-perDevice, so it's omitted too. The file says so in omitted, so whoever imports it knows those must be set separately, and it records format, schemaVersion: 3, the model and firmware it came from.
  2. "v3 file from model-a" into model-b: the 4K resolution is skipped because model-b supports only 720p, 1080p (the capability lookup stands in for the target's capability discovery). Skipping one setting with a reason is better than rejecting the whole file: the language change still applies.
  3. The dry run lists only real changes: language "en-US" → "fr-FR". The speaker volume is 70 in both, so it isn't listed. A diff that lists unchanged values buries the two lines that matter under thirty that don't.
  4. "v2 file from 2024": schemaVersion: 2 triggers the 2 migration, which turns the old flat volume into audio.speakerVolume and the old resolution: "HD" into video.maxResolution: "1080p". After migration it's an ordinary v3 file, validated and diffed like any other. Migrations are small, pure functions, one per version step, and they're kept forever.
  5. "bad file": speakerVolume: 140 fails the same schema the forms use, so the whole import is rejected with the pointer /audio/speakerVolume and nothing changes. An import is all or nothing: half-applying a configuration leaves a device in a state nobody designed.
  6. Nothing is applied in any of these cases: "dry run OK, waiting for Apply" is the end state. The admin reads the list, then applies it as one merge patch through the normal apply flow, with the usual restart-required handling and If-Match.
AdvancedWho owns a setting: local, provisioned or default

When a provisioning service manages a device, a setting can have three sources, and the UI must show which one won. Poly's Video Mode Administrator Guide 4.7.0 (July 2025) describes the behaviour on its systems: "Provisioned settings are read-only in the system web interface", settings that depend on provisioned values are read-only or unavailable, and if the system loses its registration "it continues to use the most recent configuration it received". This model shows the general pattern:

setting-sources.mjs
// Where does each value come from? Factory default < set locally < pushed by a provisioning server.
// The UI shows the winner, says where it came from, and locks what a provisioning server owns.
const defaults = { language: "en-US", timeZone: "UTC", speakerVolume: 50, autoUpdate: true };
const local = { timeZone: "America/Toronto", speakerVolume: 65 }; // set in this device's web UI
const provisioned = { autoUpdate: false, timeZone: "America/New_York" }; // from the management server

function effective() {
  return Object.keys(defaults).map((key) => {
    if (key in provisioned) return { key, value: provisioned[key], source: "provisioning", editable: false };
    if (key in local) return { key, value: local[key], source: "local", editable: true };
    return { key, value: defaults[key], source: "default", editable: true };
  });
}

for (const s of effective())
  console.log(`${s.key.padEnd(14)} ${JSON.stringify(s.value).padEnd(20)} ${s.source.padEnd(13)} ${s.editable ? "editable" : "read-only: managed by provisioning"}`);

// A local PUT to a provisioned key must be refused by the device, not just greyed out in the UI.
function localWrite(key, value) {
  if (key in provisioned) return { status: 409, detail: `${key} is managed by the provisioning server` };
  local[key] = value;
  return { status: 200 };
}
console.log(JSON.stringify(localWrite("timeZone", "Europe/London")));
console.log(JSON.stringify(localWrite("speakerVolume", 70)));
Result
language       "en-US"              default       editable
timeZone       "America/New_York"   provisioning  read-only: managed by provisioning
speakerVolume  65                   local         editable
autoUpdate     false                provisioning  read-only: managed by provisioning
{"status":409,"detail":"timeZone is managed by the provisioning server"}
{"status":200}
What’s happening
  1. Three layers: factory defaults, local values set in this device's web UI, and provisioned values from the management server. For each setting, effective() takes the highest layer that has it: provisioning beats local beats default.
  2. timeZone was set locally to Toronto, but provisioning says New York, so the effective value is "America/New_York" and the row says read-only: managed by provisioning. Showing the source is what stops an admin from changing a value, seeing it snap back an hour later and filing a bug.
  3. speakerVolume is local (65) and editable; language is still the factory default. A good settings page shows the default too, with a "reset to default" action, because "who changed this?" is a common support question.
  4. A local write to a provisioned setting gets 409 with "timeZone is managed by the provisioning server" from the device, not just a greyed-out control in the UI: a script calling the API must get the same answer as the form.
  5. A local write to an unmanaged setting succeeds (200). In a real product, local changes to managed devices are also worth reporting back to the management service, so the central view isn't wrong about the room.
AdvancedProvisioning in practice

Provisioning is how fleets are configured at scale: the device finds its management service, registers, and receives configuration and software from it. Public details from Poly's 4.7.0 guide illustrate the moving parts:

  • Discovery order. During initial setup the system looks for provisioning in the order Zero Touch Onboarding, Poly Lens, then DHCP, moving to the next if one has no information. Zero Touch Onboarding (zto.poly.com) is also how a new system determines its mode of operation, Poly Video Mode or Partner Mode, and changing the conferencing provider updates the provider setting in the system's ZTO profile.
  • Templates. In the web interface, Servers > Provisioning Server > Download Profile Template saves a .cfg template showing how parameters are set, which you edit and import into the provisioning server. The guide's example parameters include sec.auth.admin.password, device.local.country and device.local.timezone. A provisioning file can therefore carry the admin password, so it has to be stored and transferred like a secret, not like a document.
  • Updates come with it. A provisioned system checks for and runs software updates when it restarts and at an interval the service sets; the update methods in the web UI are then unavailable.
  • Not everything is "configuration". Contacts have their own import and export, an XML file of at most 3 MB in the 4.7.0 guide; certificates, logs and directory entries have their own handling during resets. A "configuration export" should say clearly which of these it includes.

#Accessibility and localisation for admin UIs

Added

Admin UIs are often treated as internal tools that can skip accessibility and translation. For device products that's wrong twice over. The people who administer devices include screen-reader users, keyboard-only users and people with low vision, like any other profession; the standard to aim for is WCAG (currently WCAG 2.2, a W3C Recommendation, republished 12 December 2024); and devices sell worldwide, so the admin in Lyon, the installer in Osaka and the support engineer in Bengaluru all use the same pages. An admin UI also has a few problems that consumer apps rarely have: session timeouts on a security-sensitive page, status that changes on its own (a device going offline), dense forms with hundreds of fields, and two clocks, the admin's and the device's.

The mental model: the UI is an interpreter between the device and a person, and a good interpreter adapts to the listener. Accessibility is adapting to how someone perceives and operates the page (eyes or ears, mouse or keyboard, large text or small); localisation is adapting to which conventions they read in (language, plural rules, date order, decimal separator, writing direction). Neither changes what the device is; both change how its state reaches a human.

The WCAG 2.2 success criteria that bite hardest on device admin UIs (numbers and levels from the W3C Recommendation):

CriterionLevelWhere it shows up in an admin UI
1.4.1 Use of ColorAStatus dots (green/red), changed-field markers: add text or icons too
2.2.1 Timing AdjustableAIdle session timeout: warn before logging out and let the admin extend it
3.3.1 Error Identification / 3.3.3 Error SuggestionA / AAField errors in text, on the field, with a fix ("for example 192.168.1.20")
4.1.3 Status MessagesAA"Saved", "Restarting…", "Device not responding" announced without moving focus
2.4.11 Focus Not Obscured (Minimum)AAA sticky pending-changes bar must not cover the field that has focus
2.5.8 Target Size (Minimum)AAButtons at least 24 by 24 CSS pixels, for the installer on a tablet
3.3.8 Accessible Authentication (Minimum)AALet password managers fill the login form and allow paste
1.4.10 ReflowAAUsable at 320 CSS pixels wide without scrolling in two directions

2.4.11, 2.5.8 and 3.3.8 are new in WCAG 2.2; it also removed 4.1.1 Parsing. The W3C's explanation of 3.3.8 mentions support for password managers and copy and paste as ways to reduce the memory burden, which rules out the "disable paste on the password field" habit some device UIs still have.

Here's one field done properly, in a left-to-right English form and a right-to-left Arabic one, sharing the same CSS:

A field error that every user can find, in both directions
CSS
.form { max-width: 320px; margin-bottom: 14px; font: 14px/1.4 system-ui, sans-serif; color: #0f172a; }
.field { padding-inline-start: 10px; border-inline-start: 4px solid transparent; }
.field.invalid { border-inline-start-color: #b91c1c; }
label { display: block; font-weight: 600; }
input { box-sizing: border-box; width: 100%; height: 36px; padding-inline: 8px; font: inherit; border: 1px solid #64748b; border-radius: 4px; }
input:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
.error { margin: 4px 0 0; color: #b91c1c; font-size: 13px; }
HTML
<form class="form" lang="en" dir="ltr">
  <div class="field invalid">
    <label for="ip-en">IP address</label>
    <input id="ip-en" value="10.0.300.4" aria-invalid="true" aria-describedby="ip-en-error" dir="ltr">
    <p class="error" id="ip-en-error">Error: enter a valid IPv4 address, for example 192.168.1.20.</p>
  </div>
</form>
<form class="form" lang="ar" dir="rtl">
  <div class="field invalid">
    <label for="ip-ar">عنوان IP</label>
    <input id="ip-ar" value="10.0.300.4" aria-invalid="true" aria-describedby="ip-ar-error" dir="ltr">
    <p class="error" id="ip-ar-error">خطأ: أدخل عنوان IPv4 صالحًا، مثل 192.168.1.20.</p>
  </div>
</form>
Resultlive, in your browser
What’s happening
  1. The marker uses logical properties: border-inline-start and padding-inline-start. In the English form (dir="ltr") the test measured the 4px border on the left; in the Arabic form (dir="rtl") the very same rule put it on the right. Written with border-left, the Arabic form would have its error marker on the wrong side, next to the end of the text instead of its start.
  2. Each input is named by its <label> ("IP address", "عنوان IP"), has aria-invalid="true", and points at its error paragraph with aria-describedby, so the error is read after the field's name and not only seen in red. The text starts with "Error:" / "خطأ:" for the same reason: WCAG's 1.4.1 says colour can't be the only signal.
  3. The message includes a correction ("for example 192.168.1.20"), which is what 3.3.3 Error Suggestion asks for, and it's a whole sentence per language, not English fragments with a translated word inserted.
  4. The Arabic form's direction is rtl, but the input carries dir="ltr", so its direction is ltr: an IP address, a MAC address or a hostname is always written left to right, even inside right-to-left text. Admin UIs are full of such values (IPs, serial numbers, firmware versions, log lines); mark them explicitly.
  5. input:focus-visible has a 3px blue outline with an offset. The test reads the rule from the stylesheet because a test can't press Tab; you can Tab into the live result above to see it. Keyboard users have to see where focus is, and a sticky bar mustn't hide it (2.4.11).
AdvancedFormatting with Intl, not string concatenation

Dates, numbers, units, relative times, plurals and language names all have locale rules. JavaScript's built-in Intl APIs know them; hand-written formatting doesn't. This model formats the same facts for three locales and adds the device-specific twist, two time zones:

admin-intl.mjs
// Localising an admin UI with the built-in Intl APIs: no string concatenation for dates, numbers or plurals.
const event = new Date("2026-10-27T14:05:00Z"); // from the device's log, always stored in UTC
const deviceZone = "Asia/Kolkata"; // the room is in Bengaluru
const adminZone = "America/Toronto"; // the admin is in Toronto

// Word order differs too, so whole sentences are templates per language, never "a + ' of ' + b".
const ofTotal = { "en-US": "{done} of {total}", "fr-FR": "{done} sur {total}", "ja-JP": "{total}中 {done}" };

for (const locale of ["en-US", "fr-FR", "ja-JP"]) {
  const when = (timeZone) =>
    new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", timeZone, timeZoneName: "short" }).format(event);
  const ago = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
  const size = new Intl.NumberFormat(locale, { style: "unit", unit: "megabyte", maximumFractionDigits: 1 });
  const pct = new Intl.NumberFormat(locale, { style: "percent" });
  const names = new Intl.DisplayNames([locale], { type: "language" });
  console.log(`[${locale}]`);
  console.log(`  room time:  ${when(deviceZone)}`);
  console.log(`  your time:  ${when(adminZone)}`);
  console.log(`  last seen:  ${ago.format(-2, "minute")} | ${ago.format(-1, "day")}`);
  const progress = ofTotal[locale].replace("{done}", size.format(812.46)).replace("{total}", size.format(1024));
  console.log(`  upload:     ${progress} (${pct.format(812.46 / 1024)})`);
  console.log(`  languages:  ${["en-US", "fr-FR", "ja-JP"].map((l) => names.of(l)).join(", ")}`);
}

// Plurals: English has two forms, but many languages have more, so pick the message by category.
const messages = {
  en: { one: "{n} change needs a restart", other: "{n} changes need a restart" },
  pl: { one: "{n} zmiana wymaga restartu", few: "{n} zmiany wymagają restartu", many: "{n} zmian wymaga restartu", other: "{n} zmiany wymaga restartu" },
};
for (const lang of ["en", "pl"]) {
  const rules = new Intl.PluralRules(lang);
  console.log(lang, [1, 2, 5, 22].map((n) => `${rules.select(n)}: ${messages[lang][rules.select(n)].replace("{n}", n)}`).join(" | "));
}
// Each language's name in its own language, for the language picker itself.
console.log("picker:", ["en-US", "fr-FR", "ja-JP", "ar-EG"].map((l) => new Intl.DisplayNames([l], { type: "language" }).of(l)).join(" · "));
Result
[en-US]
  room time:  Oct 27, 2026, 7:35 PM GMT+5:30
  your time:  Oct 27, 2026, 10:05 AM EDT
  last seen:  2 minutes ago | yesterday
  upload:     812.5 MB of 1,024 MB (79%)
  languages:  American English, French (France), Japanese (Japan)
[fr-FR]
  room time:  27 oct. 2026, 19:35 UTC+5:30
  your time:  27 oct. 2026, 10:05 UTC−4
  last seen:  il y a 2 minutes | hier
  upload:     812,5 Mo sur 1 024 Mo (79 %)
  languages:  anglais américain, français (France), japonais (Japon)
[ja-JP]
  room time:  2026年10月27日 19:35 GMT+5:30
  your time:  2026年10月27日 10:05 GMT-4
  last seen:  2 分前 | 昨日
  upload:     1,024 MB中 812.5 MB (79%)
  languages:  アメリカ英語, フランス語 (フランス), 日本語 (日本)
en one: 1 change needs a restart | other: 2 changes need a restart | other: 5 changes need a restart | other: 22 changes need a restart
pl one: 1 zmiana wymaga restartu | few: 2 zmiany wymagają restartu | many: 5 zmian wymaga restartu | few: 22 zmiany wymagają restartu
picker: American English · français (France) · 日本語 (日本) · العربية (مصر)
What’s happening
  1. The log event is stored in UTC, 14:05Z, and shown twice: in the room's time zone (Asia/Kolkata, 19:35, GMT+5:30) and in the admin's (America/Toronto, 10:05, EDT). For a device UI both matter: "the camera dropped at 19:35 room time" matches what people in the room remember; the admin's own time matches their calendar. Always label which one you're showing; the zone name in the formatted string does that.
  2. The first version of this model passed dateStyle, timeStyle and timeZoneName, and Node 26 threw TypeError: Invalid option : option: dateStyle/timeStyle can't be combined with individual fields like timeZoneName. Listing the fields (year, month, day, hour, minute) fixed it, and that's why the formats look the way they do.
  3. The same date comes out as "Oct 27, 2026, 7:35 PM", "27 oct. 2026, 19:35" and "2026年10月27日 19:35": different order, 12- vs 24-hour clock, different separators. The zone names differ too (GMT+5:30 vs UTC+5:30, EDT vs UTC−4). None of that should be in your code.
  4. RelativeTimeFormat with numeric: "auto" gives "2 minutes ago", "il y a 2 minutes", "2 分前", and "yesterday"/"hier"/"昨日" instead of "1 day ago". The unit formatter gives 812.5 MB and 812,5 Mo (French uses a comma and its own unit symbol), the percent formatter 79 % with a space in French. The spaces in "1 024 Mo" are narrow no-break spaces (U+202F) and the one before "%" a no-break space (U+00A0), which keep each value on one line; tests that compare formatted strings must use those characters, not plain spaces.
  5. Sentence templates are per language because word order differs: "812.5 MB of 1,024 MB", "812,5 Mo sur 1 024 Mo", and the Japanese template here puts the total first. These templates are this model's illustrations; in a product they come from translators, keyed by message id.
  6. Plurals: English has one and other, but Polish uses one, few, many (and other for fractions). Intl.PluralRules("pl") classifies 1 as one, 2 and 22 as few, 5 as many, so the code picks the right sentence. An n === 1 ? … : … test gets Polish wrong for 5 and 22.
  7. DisplayNames gives each language's name in the current UI language ("French (France)", "français (France)", "フランス語 (フランス)"), and the last line shows each in its own language, which is what a language picker should show: someone who can't read the current language must still recognise their own.
AdvancedLocalisation checklist for device UIs
  • Strings from the device are codes, not prose. If the device API returns "connected display supports up to 1080p" in English, the French UI can't translate it. Return a problem type and parameters ({ type: "…/resolution-unsupported", max: "1080p" }) and let the UI own the sentence; see Errors with problem details.
  • The device's language and the UI's language are different settings. Poly's guide, for example, has a setting for the language of the in-room local interface; the web UI's language should follow the admin's browser or account, so a Japanese admin can manage a room set up in French.
  • Plan for longer text. The W3C Internationalization article "Text size in translation" quotes IBM guidance that English strings of up to 10 characters can grow by 200–300% in translation, and those over 70 characters by about 130%. Buttons like "Save" and table headers are where layouts break. Test with pseudo-localisation (accented, padded strings) before real translations arrive.
  • Don't translate identifiers. Setting keys in exports, API field names, log lines and parameter names (device.local.timezone) stay as they are; translate the labels shown next to them.
  • Number inputs and locales. An <input type="number">'s value is always a dot-decimal string, whatever the display locale, so keep machine values (IP octets, volumes, prefixes) in fields with explicit formats and show localised numbers only for display.
  • Right-to-left. Use logical CSS properties (margin-inline-start, inset-inline-end), dir="auto" for user-entered names, and dir="ltr" for addresses, versions and log text, as in the demo.

Written fresh for a device-platform full stack role. Runnable examples were run on Node 26; product facts come from public documentation, cited where used.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.