Some checks failed
CI / Quality (push) Failing after 1m45s
BackendConnection's ConnectionState enum is now Connecting / Online / Reconnecting / Offline (PLAN.md §5). The probe loop tracks the first failure since the last Online and transitions to Reconnecting on any failed probe, then to Offline once the configurable threshold (30 s default) is exceeded. The Error state is gone; Reconnecting + the exposed `error` string subsume its UI role. ReactiveListModel is the headline QML type: - QAbstractListModel that GETs `baseUrl + source` for an initial JSON array and then keeps in sync via an internal MercureClient subscribed to `topic`. - Role names are derived dynamically from the first row's keys plus an internal `pending` boolean role used by optimistic mutations. - Diff application: upsert (insert-or-update), delete, replace; gap detection via the envelope `version` field with auto re-fetch. - `invoke(method, path, body, optimistic)` is the optimistic command primitive. Generates an Idempotency-Key, applies the local diff, POST/PATCH/DELETEs with that key, and resolves on the matching Mercure echo (correlation-key matched in ModelPublisher's envelope). Rolls back and emits commandFailed on 4xx/5xx, commandTimedOut after 10 s without an echo. Phase 4 packaging will surface configuration for the timeout. AppShell.qml is the optional convenience root: - Reads BackendConnection.connectionState. - Reconnecting → top banner. - Offline → modal overlay with the error string and a Retry button (calls BackendConnection.restart()). - Wraps user content via `default property alias content`. Apps that want full chrome control can skip AppShell entirely; the skeleton's Main.qml keeps its own status display for demonstration and is unaffected. ReactiveObject (single-entity twin of ReactiveListModel) is intentionally deferred — same envelope handling, smaller surface; will land in Phase 2 follow-up or Phase 3 alongside the todo example. Cursor pagination is similarly deferred (the Phase 2 done criterion uses small lists). Smoke tested: /healthz + /api/ping round-trip cleanly, zero Mercure 401s, clean shutdown. composer quality stays green (16 tests, 45 assertions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
4.4 KiB
QML
139 lines
4.4 KiB
QML
import QtQuick
|
||
import QtQuick.Controls
|
||
import QtQuick.Layouts
|
||
import QtQuick.Window
|
||
|
||
import PhpQml.Bridge
|
||
|
||
ApplicationWindow {
|
||
id: window
|
||
width: 760
|
||
height: 540
|
||
visible: true
|
||
title: "php-qml — skeleton"
|
||
|
||
RestClient {
|
||
id: rest
|
||
baseUrl: BackendConnection.url
|
||
token: BackendConnection.token
|
||
}
|
||
|
||
MercureClient {
|
||
id: mercure
|
||
hubUrl: BackendConnection.url + "/.well-known/mercure"
|
||
topic: "app://ping"
|
||
// No token in Phase 1 dev mode — Caddyfile enables `anonymous`
|
||
// for Mercure subscribers. Phase 4 swaps in a Mercure-issued JWT
|
||
// when the bundled mode tightens auth.
|
||
onUpdate: function(data, id) {
|
||
log.append("← mercure " + (id ? id.split(":").pop() : "") + " " + data)
|
||
}
|
||
onError: function(detail) {
|
||
log.append("× mercure error: " + detail)
|
||
}
|
||
}
|
||
|
||
Connections {
|
||
target: BackendConnection
|
||
function onConnectionStateChanged() {
|
||
if (BackendConnection.connectionState === BackendConnection.Online) {
|
||
if (!mercure.active) mercure.start()
|
||
} else {
|
||
if (mercure.active) mercure.stop()
|
||
}
|
||
}
|
||
}
|
||
|
||
Connections {
|
||
target: SingleInstance
|
||
function onLaunchArgsReceived(args) {
|
||
window.requestActivate()
|
||
log.append("· launch args from peer: " + args.join(" "))
|
||
}
|
||
}
|
||
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 16
|
||
spacing: 12
|
||
|
||
RowLayout {
|
||
spacing: 10
|
||
Layout.fillWidth: true
|
||
|
||
Rectangle {
|
||
Layout.preferredWidth: 12; Layout.preferredHeight: 12; radius: 6
|
||
color: {
|
||
switch (BackendConnection.connectionState) {
|
||
case BackendConnection.Online: return "#3ab36c"
|
||
case BackendConnection.Reconnecting: return "#d89614"
|
||
case BackendConnection.Offline: return "#d8503c"
|
||
default: return "#888"
|
||
}
|
||
}
|
||
}
|
||
Label { text: "Backend: " + window._stateName(BackendConnection.connectionState) }
|
||
|
||
Item { width: 12 }
|
||
|
||
Rectangle {
|
||
Layout.preferredWidth: 12; Layout.preferredHeight: 12; radius: 6
|
||
color: mercure.active ? "#3ab36c" : "#666"
|
||
}
|
||
Label { text: "Mercure: " + (mercure.active ? "subscribed" : "off") }
|
||
|
||
Item { Layout.fillWidth: true }
|
||
|
||
Button {
|
||
text: "Ping"
|
||
enabled: BackendConnection.connectionState === BackendConnection.Online
|
||
onClicked: {
|
||
const t0 = Date.now()
|
||
rest.get("/api/ping").then(function(r) {
|
||
const dt = Date.now() - t0
|
||
log.append("→ ping " + dt + "ms " + JSON.stringify(r.body))
|
||
}).catch(function(e) {
|
||
log.append("× ping " + e.status + " " + JSON.stringify(e.problem || e.raw))
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
Label {
|
||
text: "URL: " + BackendConnection.url
|
||
+ (BackendConnection.error ? " error: " + BackendConnection.error : "")
|
||
color: BackendConnection.error ? "#d8503c" : "#888"
|
||
font.pixelSize: 12
|
||
elide: Text.ElideRight
|
||
Layout.fillWidth: true
|
||
}
|
||
|
||
Frame {
|
||
Layout.fillWidth: true
|
||
Layout.fillHeight: true
|
||
padding: 0
|
||
ScrollView {
|
||
anchors.fill: parent
|
||
TextArea {
|
||
id: log
|
||
readOnly: true
|
||
wrapMode: TextArea.Wrap
|
||
font.family: "monospace"
|
||
font.pixelSize: 12
|
||
placeholderText: "events will appear here…"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function _stateName(s) {
|
||
switch (s) {
|
||
case BackendConnection.Connecting: return "connecting"
|
||
case BackendConnection.Online: return "online"
|
||
case BackendConnection.Reconnecting: return "reconnecting"
|
||
case BackendConnection.Offline: return "offline"
|
||
}
|
||
return "?"
|
||
}
|
||
}
|