Appearance
Instance Maintenance Guide
How an instance handles the maintenance poster (pre-announcement banner) and maintenance mode (traffic blocking).
Two Distinct Concepts
| Concept | When set | Instance status | Tenant traffic | Purpose |
|---|---|---|---|---|
| Maintenance poster | Any time — before or during maintenance | Any | Unaffected | Inform users of upcoming/ongoing maintenance |
| Maintenance mode | When maintenance begins | maintenance | Blocked (503) | Prevent access during active maintenance |
A poster can be set while the instance is active to give users advance notice. Setting a poster does not block traffic. Enabling maintenance mode does not automatically create a poster — the two are independent but designed to work together.
How the Instance Receives This Data
Both the startup and heartbeat responses include:
json
{
"status": "active",
"maintenancePoster": {
"message": "Scheduled maintenance for database migration.",
"scheduledStart": "2026-03-15T02:00:00Z",
"scheduledEnd": "2026-03-15T04:00:00Z",
"setBy": "admin-user-id",
"setAt": "2026-03-10T12:00:00Z"
}
}maintenancePoster is null (omitted) when:
- No poster has been set, or
- The current time is before
scheduledStart, or - The current time is after
scheduledEnd.
This means the Console handles the time-windowing — the instance only receives the poster when it is relevant to display.
Scheduling rules
scheduledStart | scheduledEnd | Poster included in response when |
|---|---|---|
| not set | not set | Always (general announcement with no schedule) |
| set | not set | From scheduledStart onward |
| not set | set | Until scheduledEnd |
| set | set | Only within [scheduledStart, scheduledEnd] window |
What the Instance Should Do
1. Cache the state from every heartbeat / startup response
On receiving any heartbeat or startup response, the instance should atomically update two cached values:
go
type ConsoleState struct {
Status string
MaintenancePoster *MaintenancePoster // nil when not in window
}
var consoleState atomic.Value // stores ConsoleStateUpdate on every heartbeat response:
go
consoleState.Store(ConsoleState{
Status: resp.Status,
MaintenancePoster: resp.MaintenancePoster,
})2. Availability middleware
All tenant-facing requests should pass through an availability middleware that reads the cached state:
go
func AvailabilityMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
state := consoleState.Load().(ConsoleState)
switch state.Status {
case "maintenance", "decommissioned":
renderUnavailable(w, state)
return
}
next.ServeHTTP(w, r)
})
}Internal Console endpoints (/internal/*) must bypass this middleware — heartbeats must keep flowing so the instance can learn when maintenance is lifted.
3. Maintenance page (status = "maintenance")
When status is maintenance, return an HTML maintenance page (not a JSON error):
With poster:
html
<!DOCTYPE html>
<html>
<head>
<title>Scheduled Maintenance</title>
<meta http-equiv="refresh" content="60" />
</head>
<body>
<h1>We're under maintenance</h1>
<p>{{ poster.Message }}</p>
{% if poster.ScheduledEnd %}
<p>
Expected to be back by
<strong>{{ poster.ScheduledEnd | humanTime }}</strong>
</p>
{% endif %}
<p>
Check <a href="https://status.exto360.com">status.exto360.com</a> for live
updates.
</p>
<p>
Taking longer than expected?
<a href="https://support.exto360.com">Open a support ticket</a>.
</p>
</body>
</html>Without poster (fallback):
html
<h1>We're temporarily unavailable</h1>
<p>We're performing maintenance. We'll be back shortly.</p>
<p>
Check <a href="https://status.exto360.com">status.exto360.com</a> for live
updates.
</p>The <meta http-equiv="refresh" content="60"> tag causes the browser to reload every 60 seconds. When maintenance is lifted, the next heartbeat returns status: "active" and subsequent page loads go through normally.
4. Decommissioned page (status = "decommissioned")
Decommissioned instances show a different message — users should not expect the service to return:
html
<h1>This service has been retired</h1>
<p>Please contact support to arrange access to a new environment.</p>
<p><a href="https://support.exto360.com">Open a support ticket</a></p>Do not auto-refresh on decommissioned — the instance will not come back.
5. In-app banner (status = "active" with poster present)
When status is active but maintenancePoster is set, show a dismissible banner in the app shell. The poster appears in the heartbeat response only when now is within the scheduled window, so no client-side time comparison is needed.
Example banner (React):
tsx
function MaintenanceBanner({ poster }: { poster: MaintenancePoster }) {
return (
<div className="maintenance-banner">
<span>{poster.message}</span>
{poster.scheduledStart && (
<span> Starts {formatTime(poster.scheduledStart)}.</span>
)}
{poster.scheduledEnd && (
<span> Expected end: {formatTime(poster.scheduledEnd)}.</span>
)}
</div>
);
}Dismiss state should be stored in sessionStorage keyed on poster.setAt so re-dismissal is not required after every heartbeat.
Full Status → Behaviour Reference
status | maintenancePoster | Tenant traffic | UI behaviour |
|---|---|---|---|
active | null | Allowed | Normal |
active | set | Allowed | Show dismissible banner in app shell |
degraded | null | Allowed | Normal (Console auto-set; instance continues serving) |
degraded | set | Allowed | Show dismissible banner |
maintenance | null | Blocked (503) | Generic maintenance page with status/support links |
maintenance | set | Blocked (503) | Maintenance page with poster message + schedule |
decommissioned | any | Blocked permanently | Decommission page — no auto-refresh |
Recovering from Maintenance
The instance learns maintenance is over on the next heartbeat — no restart required.
Admin calls DELETE /api/v1/instances/:id/maintenance
→ Console sets status: maintenance → active
→ Next heartbeat response: { "status": "active" }
→ Instance middleware updates cached state
→ Browser auto-refresh (60s) reloads into the live appMedian user impact: up to 60 seconds (one heartbeat interval + one browser refresh cycle).

