Traction

Operations

Every operator action is one curl. The dashboard only watches.

The admin API lives on the control-plane service under /traction/api. Examples use the demo's http://localhost:8080; ids in a path go in as typed (crm:acme, no encoding).

Look

curl -s http://localhost:8080/traction/api/airports | jq             # the fleet: status, gates, depths, slots
curl -s http://localhost:8080/traction/api/airports/crm:acme | jq    # one airport
curl -s http://localhost:8080/traction/api/airports/crm:acme/incidents | jq          # newest 100 incident rows
curl -s 'http://localhost:8080/traction/api/charters?airportId=mail:acme' | jq       # charters, newest first

A stopped airport or gate carries reason and detail, for example "detail":"3 distinct GATE_FAULT failures in 5m flight=7f6d67". Queue depths are cached for up to 10 s, and null means the broker couldn't say (never a made-up zero).

Act

Fix the cause first, then reopen, then replay. Replaying into a stopped gate just parks the flights again.

# Reopen an airport (after AIRPORT_FAULT)
curl -X POST http://localhost:8080/traction/api/airports/crm:acme/reopen

# Reopen one gate (after a streak); also clears that gate's streaks
curl -X POST http://localhost:8080/traction/api/airports/crm:acme/gates/company-sync/reopen

# Replay every parked flight of an airport back to its runway
curl -X POST http://localhost:8080/traction/api/airports/crm:acme/replayParked

# Abort a charter: its remaining flights are counted SKIPPED and dropped, not kept (no replay;
# re-dispatch from your source to redo them). X-Actor is optional, recorded as abortedBy
curl -X POST -H 'X-Actor: dana' \
  http://localhost:8080/traction/api/charters/mail:acme/welcome/run-1792774953/abort

The charter path is the charterRef (airportId/gateId/charterId) spelled as three segments. Without X-Actor, the abort is recorded as anonymous.

Discard parked flights

This is the only action that drops work Traction still holds, so it requires a confirm token:

curl -X POST http://localhost:8080/traction/api/airports/crm:acme/discardParked \
  -H 'Content-Type: application/json' -d '{"confirm":"discard-all"}'

Responses

Action Success body Errors
reopen {"airportId":"crm:acme","reopened":true} (false = already open) 404 unknown airport or gate
replayParked {"airportId":"crm:acme","replayed":50,"skipped":0} 404; 409 a replay or discard is already running; 500 the broker didn't confirm a republish
discardParked {"airportId":"crm:acme","dropped":7} 400 token missing or wrong (nothing dropped); 404; 409
abort {"charterRef":"…","aborted":true,"actor":"dana"} 400 malformed id; 404 no such charter, or it already completed

Errors come back as {"error":"…"}.

Notifications

One log line per event out of the box; add your own hook for anything else.

The default LogNotifyHook writes one line per event, with a stable prefix you can count or alert on:

traction.notify type=GROUND_STOP airport=crm:acme gate=- reason=AIRPORT_FAULT evidence="AuthException | 401 {\"error\":\"token expired\"}" category=AIRPORT_FAULT flight=b4d5fd

The shape is traction.notify type=<TYPE> airport=<id> gate=<id> reason=<r> evidence=<e> followed by detail key=value pairs in key order; an absent value prints -. Types: GROUND_STOP, REOPEN, FLIGHT_INCIDENT (at most 25 an hour per airport and disposition), CHARTER_OPENED, CHARTER_COMPLETED, CHARTER_ABORTED, PARKED_REPLAYED, PARKED_DISCARDED. To add your own, declare a NotifyHook bean; every hook gets every event, beside the log hook:

@Bean
NotifyHook groundStopsToChat(RestClient.Builder http) {
    RestClient chat = http.baseUrl("https://chat.example.com").build();
    return event -> {
        if (event.type() != NotifyEvent.Type.GROUND_STOP) {
            return;
        }
        try {
            chat.post().uri("/hooks/ops")
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(Map.of("text", event.airportId() + " stopped: " + event.reason()))
                    .retrieve()
                    .toBodilessEntity();
        } catch (RuntimeException e) {
            // a hook must never throw; Traction logs and skips one that does
        }
    };
}

Hooks run inline on the control plane's thread, so hand slow work off. Every flight row (including INTERIM ones) also goes to any FlightEventHook bean, on the data plane.

Configuration

Property Default What it does
traction.control-plane.enabled false Makes this service the control plane (rules, admin API, dashboard). Exactly one service.
traction.enabled true false declares the queues but starts no consumers, so flights wait on the runway.
traction.notify.log.enabled true false removes the default log hook.
traction.control-plane.status-sweep-interval 60s How often stored statuses are re-broadcast.
traction.control-plane.charter-sweep-interval 1h How often open charters are swept.
traction.control-plane.charter-stale-after 24h A charter idle this long on a drained gate is closed (source=SWEEP).

Log lines of a flight carry its ids in MDC as correlationId and flightId; the demo's pattern prints them as [cid=… flight=…].

Dashboard tour

Open http://localhost:8080/traction: it polls the admin API every 3 seconds.

It has no buttons: actions stay in the API. The RabbitMQ management UI (http://localhost:15673 in the demo) shows the raw queues: tr.<airportId>.runway, .holding, .parked, tr.tower.queue and tr.dlq.

Securing it in production

v1 ships no authentication on the admin API or the dashboard.

Anyone who can reach /traction/** on the control-plane service can reopen, replay, abort and discard. Put those paths behind your own security chain (for example a Spring Security rule that requires an operator role for /traction/**), keep them off the public ingress, or both. Traction deliberately doesn't ship its own auth: it's your service, so it's your login.