Skip to main content
The panel’s operations surfaces arrived in v0.7. They sit on the v0.6 shell: same session cookie, same CSRF token, same compiled-in permission catalogue, and the same /v1/panel prefix. What they add is the ability to run the business without SQL or shell access. Every route is gated twice. The Next.js server boundary refuses to render a page an operator cannot use, so a forbidden surface never produces markup. The Go API checks the same permission on every request, and that check — not the hidden route — is what actually protects the data.

Permissions added in v0.7

Risk is separate from customer administration because the two answer different questions: reading a customer record is routine support work, and deciding that an external list’s opinion of them is correct is a judgement with consequences. Digital goods is separate from catalog because a goods provider holds the operator’s own money — someone who may price a VPN plan should not automatically be able to rotate a credential that spends from a funded balance. Support and finance receive read access to all three. Finance additionally gets gifts.write, because revoking an unredeemed gift is a refund decision.

Reason header

Most mutations require an explanation, supplied in the X-Operator-Reason request header. The API refuses a change that needs one and did not get one, so the panel disables the control until a reason is typed rather than letting an operator discover the requirement from an error. A reason is mandatory for suspending or reactivating a customer, deciding or appealing a blocklist match, allowlisting a customer, revoking a gift, cancelling a shop delivery, issuing a refund, and previewing a bulk action.

Dashboard

Every tile publishes the definition of what it counted. This is not decoration: the failure mode of an operations dashboard is not a wrong number, it is a right number somebody read as meaning something else. “Active customers” counts customer records that are neither suspended nor deleted — it is not the number of people with a live subscription, and the tile says so. Three properties are worth knowing. Times are UTC. The payload states the timezone and the panel converts for display, so two operators in different places never compare figures that silently covered different days. Revenue is three numbers, not one. Money taken through a provider, wallet credit spent, and money returned are reported separately. Wallet credit was already counted as revenue when the balance was funded, so adding it to provider money counts it twice. They are drawn as three bars side by side, one chart per currency, and both choices carry that rule. Bars are grouped rather than stacked because a stack is a picture of a total, and these three have no total. Currencies get a chart each because a shared axis asserts that a hundred of one is comparable to a hundred of the other. The exact figures sit in a table under every chart — a chart is not a substitute for a number somebody has to act on, and one of the three series colours sits below the contrast floor on the light surface, so nothing is ever readable by colour alone. Traffic is observed, not authoritative. Remnawave owns traffic. Omniflow holds the counter it last observed, and the tile is labelled accordingly. Shape sits under the counts. Under each group is the same data as a picture: customer records, entitlements, and payment intents as rings, and the operations queues as one bar chart sorted by size. Only compositions are drawn as rings, and a measure that is a subset of another is deliberately left out — stale tickets sit inside open ones and stuck payments inside those in flight, so a slice for either would assert a whole that does not exist. The queues are a bar chart rather than a ring for the same reason in reverse: they are unrelated piles that happen to be measured at one moment, and adding them produces a number nothing is. Every chart carries its figures as a table beneath it. The “needs action” list is derived from the same figures the tiles show, so an alert can never contradict the tile beside it. Each entry deep-links into the ordinary surface that resolves it rather than offering a shortcut that bypasses the usual confirmation and audit. Every tile with a list behind it is a link to that list, so a number that looks wrong can be opened rather than searched for.

Customers

Search accepts an Omniflow customer identifier, a Telegram identifier, or a Remnawave username, and decides which one it is from its shape. There is deliberately no free-text search. Contact values are stored as ciphertext plus a fingerprint precisely so they cannot be trawled, and a substring search over customer records is the query that turns a support account into a bulk-export account. Segments filter by relationship rather than by status: has a live subscription, lapsed, never purchased, or flagged for review. The customer CSV export (GET /v1/panel/customers/export) needs customers.export as well as customers.read, and only the owner and administrator roles hold it. Looking up one customer to answer a ticket and downloading every customer with their Telegram ID are different acts, so support, marketing, and the auditor can do the first but not the second. Every export writes panel.customers.exported (category customer) to the audit trail before the first row is sent, so an export abandoned half way is still on record. A customer profile lists every concurrent subscription with the entitlement currently governing it, plus orders, wallet movements, support history, consent state, and any risk matches. The wallet tab is gated on finance.read rather than on customer access, so a support operator can see that a customer has a balance without reading every movement that produced it.
Suspending a customer does not disable a Remnawave user directly. The fulfillment pipeline observes customer state; a panel click that bypassed it would leave Omniflow and Remnawave disagreeing with no record of why.

Catalogue

Plans, their versions, and promotions. The catalogue is versioned, so nothing here rewrites history: hiding or archiving a plan stops new purchases and leaves every order that already bought it priced against the immutable version it was sold at. The plan list shows how many order lines reference a plan so archiving one in active use is a visible decision rather than a surprise. v0.7 widens promotion rewards beyond discounts: Two promotions combine only when both are marked stackable. precedence orders evaluation, highest first, so applying several is deterministic rather than dependent on the order the customer entered them. A promotion’s kind is not editable. Changing a live promotion from a percentage discount into a wallet credit would silently change what every unredeemed code is worth; a different reward is a different promotion.

Finance

Order search, the reconciliation queue, and the failed-charge review. The reconciliation queue lists payment intents that have been in flight longer than any supported provider should take. Reconciling re-polls the provider through the existing idempotent payment service — the panel records who asked and why, and never decides how a payment settles. The CSV export has a stable schema: a later version appends a column and never inserts one, so an importer built against an older export keeps working. Amounts are integers in the currency’s minor unit and every timestamp is UTC, both stated in the column names rather than only here.

Jobs and diagnostics

Failed fulfillment operations, provider callbacks, Remnawave drift, and the outbox backlog. A retry re-queues the same operation under the same idempotency key, so the worker still performs it exactly once. Cancelling is offered only for an operation that has not succeeded: a completed provisioning cannot be retracted by a panel click. A webhook may be replayed only if it failed or was ignored — reprocessing is keyed on the provider event identifier, so a second pass reaches the same terminal state instead of applying twice. None of these views shows a provider payload or a Remnawave response body. They carry a status, a classification, and a correlation identifier, which is what an operator needs to decide whether to retry, and none of which can leak a subscription link onto an operator’s screen.

Settings

Wallet top-up limits and subscription concurrency were environment variables until v0.7. They are now rows in commerce_settings. The migration path is deliberate. On the first start after upgrading, the API seeds the row from the environment the installation already had, so an operator coming from v0.5 keeps the limits they configured rather than getting schema defaults. From then on the panel is the only thing that writes them, and the environment variables stay readable so restoring into a fresh database reproduces the same starting point. The provider list shows what the operator configured beside what each compiled-in adapter actually declares. A recurring switch that is unavailable is explained rather than merely refused: either the integration has no way to bind a payment method, or the operator’s own merchant account has not passed a capability test.

Bulk actions

A bulk action is previewed, then applied. Only a ready operation may start, so “impact preview before bulk change” is a property of the database rather than a habit of the panel — there is no path from a request to an applied change that skips the preview. One action is limited to 1000 targets. That is a limit rather than a page: a change whose preview an operator cannot review is a change nobody has actually approved. Each run keeps its own mandatory reason, its own audit event, and a per-item outcome, and the counters are recomputed from the items rather than incremented, so a retried worker cannot double-count. A running operation cannot be cancelled. Some of its targets have already been changed, and a half-applied action reporting “cancelled” would misrepresent what happened; it runs to completion and the per-item results say exactly which targets were touched. Previewing requires an Idempotency-Key header, so a double-submitted form returns the preview it already created rather than a second one.