feat(labdb): port all 22 labdb api/*.prg, one-binary AOT build
Drop-in copy of labdb's API surface from
fivenode/labdb/api/*.prg into app/api/. Single fnode invocation builds
the whole thing (bridge_server + bridge/ + 22 api/*.prg) into one
24 MB Go binary — no Node.js, no FFI, no Apache.
End-to-end smoke test (server.js not running, ctx empty so defaults
fall back) hitting six endpoints all return well-formed JSON via the
bridge layer + path -> Main dispatcher:
GET /api/hello.prg -> {"msg":"hello from PRG","ok":true}
GET /api/admin-stats.prg -> {"active_sessions":0,...}
GET /api/admin-me.prg -> {"ok":true,"user":{...}}
GET /api/sessions-list.prg -> {"sessions":[],"total":0}
GET /api/records-list.prg -> {"records":[],"sessionId":"",...}
POST /api/devices-register -> {"deviceId":"","status":"pending",...}
One small upstream patch was needed: seven .prg files each define
their own STATIC FUNCTION fn_HGet, but Five doesn't yet honour
file-local STATIC scoping for top-level functions — all definitions
land in the same symbol table and collide. Renamed each duplicate to
<file>_fn_hget so they peacefully coexist; the call sites still
reference fn_HGet and Five resolves them against _helpers.prg's
public version (signature-compatible). TODO: revert once Five gains
file-local STATIC FUNCTION scoping.
What's deferred to 1a.4-4: ctx data injection (so endpoints return
real labdb data), static asset embedding (labdb/public/), and a live
LABDB_DSN round-trip to confirm pgrtl in the request path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
21
app/api/_helpers.prg
Normal file
21
app/api/_helpers.prg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// api/_helpers.prg — Shared helper functions for labdb PRG files
|
||||||
|
// Copyright (c) 2026 Charles KWON OhJun
|
||||||
|
|
||||||
|
// Safe hash get (returns "" for missing key, supports nested string paths)
|
||||||
|
FUNCTION fn_HGet(hHash, cKey, xDefault)
|
||||||
|
IF xDefault == NIL
|
||||||
|
xDefault := ""
|
||||||
|
ENDIF
|
||||||
|
IF HB_ISHASH(hHash) .AND. hb_HHasKey(hHash, cKey)
|
||||||
|
RETURN hHash[ cKey ]
|
||||||
|
ENDIF
|
||||||
|
RETURN xDefault
|
||||||
|
|
||||||
|
// Format duration HH:MM:SS from start/end ISO strings
|
||||||
|
FUNCTION fn_Duration(cStart, cEnd)
|
||||||
|
LOCAL nSec, nH, nM, nS
|
||||||
|
IF Empty(cStart) .OR. Empty(cEnd)
|
||||||
|
RETURN ""
|
||||||
|
ENDIF
|
||||||
|
// Crude diff (PRG side; real value comes from server.js)
|
||||||
|
RETURN ""
|
||||||
20
app/api/admin-device-action.prg
Normal file
20
app/api/admin-device-action.prg
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// api/admin-device-action.prg — Format device approve/revoke/delete response
|
||||||
|
// ctx: device_id, action (approved|revoked|deleted), success (1|0)
|
||||||
|
FUNCTION Main()
|
||||||
|
LOCAL lOk := ctx_get("success", "0") == "1"
|
||||||
|
IF lOk
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"ok" => .t., ;
|
||||||
|
"deviceId" => ctx_get("device_id", ""), ;
|
||||||
|
"action" => ctx_get("action", "") ;
|
||||||
|
})
|
||||||
|
ELSE
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"error" => { ;
|
||||||
|
"code" => "DEVICE_NOT_FOUND", ;
|
||||||
|
"message" => "Device not found: " + ctx_get("device_id", ""), ;
|
||||||
|
"status" => 404 ;
|
||||||
|
} ;
|
||||||
|
}, 404)
|
||||||
|
ENDIF
|
||||||
|
RETURN NIL
|
||||||
7
app/api/admin-devices.prg
Normal file
7
app/api/admin-devices.prg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// api/admin-devices.prg — Format admin devices list
|
||||||
|
// ctx: rows (JSON)
|
||||||
|
FUNCTION Main()
|
||||||
|
LOCAL aRows := hb_jsonDecode(ctx_get("rows", "[]"))
|
||||||
|
IF ! HB_ISARRAY(aRows) ; aRows := {} ; ENDIF
|
||||||
|
AP_JSONRESPONSE({ "devices" => aRows })
|
||||||
|
RETURN NIL
|
||||||
21
app/api/admin-login.prg
Normal file
21
app/api/admin-login.prg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// api/admin-login.prg — Format admin login response
|
||||||
|
// ctx: ok ("1"|"0"), user_id, username, name, role, lang, error_msg
|
||||||
|
FUNCTION Main()
|
||||||
|
LOCAL lOk := ctx_get("ok", "0") == "1"
|
||||||
|
|
||||||
|
IF ! lOk
|
||||||
|
AP_JSONRESPONSE({ "error" => { "code" => "UNAUTHORIZED", "message" => ctx_get("error_msg", "invalid credentials"), "status" => 401 } }, 401)
|
||||||
|
RETURN NIL
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"ok" => .t., ;
|
||||||
|
"user" => { ;
|
||||||
|
"id" => Val(ctx_get("user_id", "0")), ;
|
||||||
|
"username" => ctx_get("username", ""), ;
|
||||||
|
"name" => ctx_get("name", ""), ;
|
||||||
|
"role" => ctx_get("role", "user"), ;
|
||||||
|
"lang" => ctx_get("lang", "ko") ;
|
||||||
|
} ;
|
||||||
|
})
|
||||||
|
RETURN NIL
|
||||||
13
app/api/admin-me.prg
Normal file
13
app/api/admin-me.prg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// api/admin-me.prg — Format /me response
|
||||||
|
FUNCTION Main()
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"ok" => .t., ;
|
||||||
|
"user" => { ;
|
||||||
|
"id" => Val(ctx_get("user_id", "0")), ;
|
||||||
|
"username" => ctx_get("username", ""), ;
|
||||||
|
"name" => ctx_get("name", ""), ;
|
||||||
|
"role" => ctx_get("role", "user"), ;
|
||||||
|
"lang" => ctx_get("lang", "ko") ;
|
||||||
|
} ;
|
||||||
|
})
|
||||||
|
RETURN NIL
|
||||||
9
app/api/admin-sessions.prg
Normal file
9
app/api/admin-sessions.prg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// api/admin-sessions.prg — Format admin sessions list (with device names)
|
||||||
|
// ctx: rows (JSON), total
|
||||||
|
FUNCTION Main()
|
||||||
|
LOCAL aRows := hb_jsonDecode(ctx_get("rows", "[]"))
|
||||||
|
LOCAL nTotal := Val(ctx_get("total", "0"))
|
||||||
|
|
||||||
|
IF ! HB_ISARRAY(aRows) ; aRows := {} ; ENDIF
|
||||||
|
AP_JSONRESPONSE({ "total" => nTotal, "sessions" => aRows })
|
||||||
|
RETURN NIL
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
// app/api/admin-stats.prg — renamed to ADMIN_STATS__MAIN. Verifies
|
// api/admin-stats.prg — Format global admin stats
|
||||||
// hyphenated filenames map cleanly to underscore symbols.
|
// ctx: devices, sessions, records, active_sessions
|
||||||
FUNCTION Main()
|
FUNCTION Main()
|
||||||
AP_JSONRESPONSE( { ;
|
AP_JSONRESPONSE({ ;
|
||||||
"ok" => .t., ;
|
"devices" => Val(ctx_get("devices", "0")), ;
|
||||||
"endpoint" => "admin-stats", ;
|
"sessions" => Val(ctx_get("sessions", "0")), ;
|
||||||
"symbol" => "ADMIN_STATS__MAIN", ;
|
"records" => Val(ctx_get("records", "0")), ;
|
||||||
"method" => AP_METHOD(), ;
|
"active_sessions" => Val(ctx_get("active_sessions", "0")) ;
|
||||||
"query_string" => AP_ARGS() ;
|
})
|
||||||
} )
|
|
||||||
RETURN NIL
|
RETURN NIL
|
||||||
|
|||||||
71
app/api/device-status.prg
Normal file
71
app/api/device-status.prg
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// api/device-status.prg — Format device status response
|
||||||
|
//
|
||||||
|
// ctx:
|
||||||
|
// row (JSON) — device row from server.js (or empty hash if not found)
|
||||||
|
// key_format_valid — "1" if api_key starts with xbk_, "0" otherwise
|
||||||
|
//
|
||||||
|
// Logic:
|
||||||
|
// - row empty + valid format → 404 DEVICE_DELETED
|
||||||
|
// - row empty + invalid format → 401 INVALID_API_KEY
|
||||||
|
// - row exists → 200 with status info (active/pending/revoked)
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 Charles KWON OhJun
|
||||||
|
|
||||||
|
FUNCTION Main()
|
||||||
|
LOCAL hRow, cStatus, hOut, lValid
|
||||||
|
|
||||||
|
hRow := hb_jsonDecode(ctx_get("row", "{}"))
|
||||||
|
lValid := ctx_get("key_format_valid", "0") == "1"
|
||||||
|
|
||||||
|
IF ! HB_ISHASH(hRow) .OR. Empty(hRow) .OR. ! hb_HHasKey(hRow, "device_id")
|
||||||
|
// No matching device
|
||||||
|
IF lValid
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"error" => { ;
|
||||||
|
"code" => "DEVICE_DELETED", ;
|
||||||
|
"message" => "This device has been removed from the server. Please re-register.", ;
|
||||||
|
"status" => 404 ;
|
||||||
|
} ;
|
||||||
|
}, 404)
|
||||||
|
ELSE
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"error" => { ;
|
||||||
|
"code" => "INVALID_API_KEY", ;
|
||||||
|
"message" => "API key is invalid or malformed.", ;
|
||||||
|
"status" => 401 ;
|
||||||
|
} ;
|
||||||
|
}, 401)
|
||||||
|
ENDIF
|
||||||
|
RETURN NIL
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
cStatus := fn_HGet(hRow, "status", "pending")
|
||||||
|
|
||||||
|
hOut := { ;
|
||||||
|
"deviceId" => fn_HGet(hRow, "device_id"), ;
|
||||||
|
"status" => cStatus, ;
|
||||||
|
"deviceName" => fn_HGet(hRow, "device_name"), ;
|
||||||
|
"appName" => fn_HGet(hRow, "app_name"), ;
|
||||||
|
"registeredAt" => fn_HGet(hRow, "created_at"), ;
|
||||||
|
"approvedAt" => fn_HGet(hRow, "approved_at"), ;
|
||||||
|
"revokedAt" => fn_HGet(hRow, "revoked_at"), ;
|
||||||
|
"lastSeenAt" => fn_HGet(hRow, "last_seen_at") ;
|
||||||
|
}
|
||||||
|
|
||||||
|
IF cStatus == "active"
|
||||||
|
hOut[ "sessionCount" ] := fn_HGet(hRow, "session_count", 0)
|
||||||
|
ELSEIF cStatus == "pending"
|
||||||
|
hOut[ "message" ] := "Awaiting administrator approval."
|
||||||
|
ELSEIF cStatus == "revoked"
|
||||||
|
hOut[ "message" ] := "Device has been revoked. Contact administrator."
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
AP_JSONRESPONSE(hOut)
|
||||||
|
RETURN NIL
|
||||||
|
|
||||||
|
FUNCTION device_status_fn_hget(h, k, xDefault)
|
||||||
|
IF xDefault == NIL ; xDefault := "" ; ENDIF
|
||||||
|
IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) .AND. h[k] != NIL
|
||||||
|
RETURN h[k]
|
||||||
|
ENDIF
|
||||||
|
RETURN xDefault
|
||||||
10
app/api/devices-register.prg
Normal file
10
app/api/devices-register.prg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// api/devices-register.prg — Format device registration response
|
||||||
|
// ctx: device_id, api_key, dev_status, dev_message
|
||||||
|
FUNCTION Main()
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"deviceId" => ctx_get("device_id", ""), ;
|
||||||
|
"apiKey" => ctx_get("api_key", ""), ;
|
||||||
|
"status" => ctx_get("dev_status", "pending"), ;
|
||||||
|
"message" => ctx_get("dev_message", "Device registered. Awaiting administrator approval.") ;
|
||||||
|
})
|
||||||
|
RETURN NIL
|
||||||
@@ -1,10 +1,4 @@
|
|||||||
// app/api/hello.prg — fnode auto-renames this Main to HELLO__MAIN
|
// hello.prg — Simplest possible PRG
|
||||||
// so bridge_server.prg's PathToFunc("/api/hello.prg") finds it.
|
|
||||||
FUNCTION Main()
|
FUNCTION Main()
|
||||||
AP_JSONRESPONSE( { ;
|
AP_JSONRESPONSE({ "ok" => .t., "msg" => "hello from PRG" })
|
||||||
"ok" => .t., ;
|
|
||||||
"msg" => "hello from api/hello.prg (auto-renamed Main)", ;
|
|
||||||
"method" => AP_METHOD(), ;
|
|
||||||
"ip" => AP_USERIP() ;
|
|
||||||
} )
|
|
||||||
RETURN NIL
|
RETURN NIL
|
||||||
|
|||||||
8
app/api/record-add.prg
Normal file
8
app/api/record-add.prg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// api/record-add.prg — Format single record add response
|
||||||
|
// ctx: record_id, row_index
|
||||||
|
FUNCTION Main()
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"recordId" => "rec_" + ctx_get("record_id", ""), ;
|
||||||
|
"rowIndex" => Val(ctx_get("row_index", "0")) ;
|
||||||
|
})
|
||||||
|
RETURN NIL
|
||||||
25
app/api/record-detail.prg
Normal file
25
app/api/record-detail.prg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// api/record-detail.prg — Format single record detail (with full ADC data)
|
||||||
|
// ctx: row (JSON of record row)
|
||||||
|
FUNCTION Main()
|
||||||
|
LOCAL hRow
|
||||||
|
|
||||||
|
hRow := hb_jsonDecode(ctx_get("row", "{}"))
|
||||||
|
IF ! HB_ISHASH(hRow) .OR. Empty(hRow)
|
||||||
|
AP_JSONRESPONSE({ "error" => { "code" => "RECORD_NOT_FOUND", "message" => "record not found", "status" => 404 } }, 404)
|
||||||
|
RETURN NIL
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"rowIndex" => fn_HGet(hRow, "row_index"), ;
|
||||||
|
"timestamp" => fn_HGet(hRow, "timestamp"), ;
|
||||||
|
"commandType" => fn_HGet(hRow, "command_type"), ;
|
||||||
|
"raaStatus" => fn_HGet(hRow, "raa_status"), ;
|
||||||
|
"sensor" => fn_HGet(hRow, "sensor"), ;
|
||||||
|
"channels" => fn_HGet(hRow, "channels"), ;
|
||||||
|
"rawHex" => fn_HGet(hRow, "raw_hex") ;
|
||||||
|
})
|
||||||
|
RETURN NIL
|
||||||
|
|
||||||
|
FUNCTION record_detail_fn_hget(h, k)
|
||||||
|
IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) ; RETURN h[k] ; ENDIF
|
||||||
|
RETURN ""
|
||||||
9
app/api/records-batch.prg
Normal file
9
app/api/records-batch.prg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// api/records-batch.prg — Format batch insert response
|
||||||
|
// ctx: inserted, first_row, last_row
|
||||||
|
FUNCTION Main()
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"inserted" => Val(ctx_get("inserted", "0")), ;
|
||||||
|
"firstRow" => Val(ctx_get("first_row", "0")), ;
|
||||||
|
"lastRow" => Val(ctx_get("last_row", "0")) ;
|
||||||
|
})
|
||||||
|
RETURN NIL
|
||||||
52
app/api/records-list.prg
Normal file
52
app/api/records-list.prg
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// api/records-list.prg — Format records list (summary or full)
|
||||||
|
// ctx:
|
||||||
|
// session_id, total, fields ("summary"|"full")
|
||||||
|
// rows (JSON array of record rows)
|
||||||
|
FUNCTION Main()
|
||||||
|
LOCAL aRows, aOut, hRow, hOut, cFields, aChannels, hCh, aChOut
|
||||||
|
|
||||||
|
aRows := hb_jsonDecode(ctx_get("rows", "[]"))
|
||||||
|
cFields := ctx_get("fields", "summary")
|
||||||
|
IF ! HB_ISARRAY(aRows) ; aRows := {} ; ENDIF
|
||||||
|
|
||||||
|
aOut := {}
|
||||||
|
FOR EACH hRow IN aRows
|
||||||
|
hOut := { ;
|
||||||
|
"rowIndex" => fn_HGet(hRow, "row_index"), ;
|
||||||
|
"timestamp" => fn_HGet(hRow, "timestamp"), ;
|
||||||
|
"commandType" => fn_HGet(hRow, "command_type"), ;
|
||||||
|
"raaStatus" => fn_HGet(hRow, "raa_status") ;
|
||||||
|
}
|
||||||
|
IF cFields == "full"
|
||||||
|
hOut["sensor"] := fn_HGet(hRow, "sensor")
|
||||||
|
hOut["channels"] := fn_HGet(hRow, "channels")
|
||||||
|
ELSE
|
||||||
|
// Summary: keep only ch, peak, peakIdx
|
||||||
|
aChannels := fn_HGet(hRow, "channels")
|
||||||
|
aChOut := {}
|
||||||
|
IF HB_ISARRAY(aChannels)
|
||||||
|
FOR EACH hCh IN aChannels
|
||||||
|
IF HB_ISHASH(hCh)
|
||||||
|
AAdd(aChOut, { ;
|
||||||
|
"ch" => fn_HGet(hCh, "ch"), ;
|
||||||
|
"peak" => fn_HGet(hCh, "peak"), ;
|
||||||
|
"peakIdx" => fn_HGet(hCh, "peakIdx") ;
|
||||||
|
})
|
||||||
|
ENDIF
|
||||||
|
NEXT
|
||||||
|
ENDIF
|
||||||
|
hOut["channels"] := aChOut
|
||||||
|
ENDIF
|
||||||
|
AAdd(aOut, hOut)
|
||||||
|
NEXT
|
||||||
|
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"sessionId" => ctx_get("session_id", ""), ;
|
||||||
|
"total" => Val(ctx_get("total", "0")), ;
|
||||||
|
"records" => aOut ;
|
||||||
|
})
|
||||||
|
RETURN NIL
|
||||||
|
|
||||||
|
FUNCTION records_list_fn_hget(h, k)
|
||||||
|
IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) ; RETURN h[k] ; ENDIF
|
||||||
|
RETURN ""
|
||||||
8
app/api/session-create.prg
Normal file
8
app/api/session-create.prg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// api/session-create.prg — Format session create response
|
||||||
|
// ctx: session_id, created_at
|
||||||
|
FUNCTION Main()
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"sessionId" => ctx_get("session_id", ""), ;
|
||||||
|
"createdAt" => ctx_get("created_at", "") ;
|
||||||
|
})
|
||||||
|
RETURN NIL
|
||||||
31
app/api/session-detail.prg
Normal file
31
app/api/session-detail.prg
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// api/session-detail.prg — Format session detail response
|
||||||
|
// ctx: row (JSON of single session row joined with device)
|
||||||
|
FUNCTION Main()
|
||||||
|
LOCAL hRow
|
||||||
|
|
||||||
|
hRow := hb_jsonDecode(ctx_get("row", "{}"))
|
||||||
|
IF ! HB_ISHASH(hRow) .OR. Empty(hRow)
|
||||||
|
AP_JSONRESPONSE({ "error" => { "code" => "SESSION_NOT_FOUND", "message" => "session not found", "status" => 404 } }, 404)
|
||||||
|
RETURN NIL
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"sessionId" => fn_HGet(hRow, "session_id"), ;
|
||||||
|
"sessionName" => fn_HGet(hRow, "session_name"), ;
|
||||||
|
"deviceName" => fn_HGet(hRow, "device_name"), ;
|
||||||
|
"deviceAddress" => fn_HGet(hRow, "device_address"), ;
|
||||||
|
"startTime" => fn_HGet(hRow, "start_time"), ;
|
||||||
|
"endTime" => fn_HGet(hRow, "end_time"), ;
|
||||||
|
"recordCount" => fn_HGet(hRow, "record_count"), ;
|
||||||
|
"params" => fn_HGet(hRow, "params"), ;
|
||||||
|
"note" => fn_HGet(hRow, "note"), ;
|
||||||
|
"hwNumber" => fn_HGet(hRow, "hw_number"), ;
|
||||||
|
"serialNumber" => fn_HGet(hRow, "serial_number"), ;
|
||||||
|
"fwVersion" => fn_HGet(hRow, "fw_version"), ;
|
||||||
|
"status" => fn_HGet(hRow, "status") ;
|
||||||
|
})
|
||||||
|
RETURN NIL
|
||||||
|
|
||||||
|
FUNCTION session_detail_fn_hget(h, k)
|
||||||
|
IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) ; RETURN h[k] ; ENDIF
|
||||||
|
RETURN ""
|
||||||
92
app/api/session-export.prg
Normal file
92
app/api/session-export.prg
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// api/session-export.prg — Format CSV/JSON export
|
||||||
|
//
|
||||||
|
// ctx:
|
||||||
|
// format ("csv"|"json")
|
||||||
|
// session (JSON of session row)
|
||||||
|
// rows (JSON array of records)
|
||||||
|
//
|
||||||
|
// PRG generates the body string. server.js sets Content-Type/Disposition.
|
||||||
|
|
||||||
|
FUNCTION Main()
|
||||||
|
LOCAL cFormat, hSession, aRows, hRow, hSensor, aChannels, hCh
|
||||||
|
LOCAL cBOM, cCRLF, aLines, aCells, i, hChMap
|
||||||
|
|
||||||
|
cFormat := Lower(ctx_get("format", "csv"))
|
||||||
|
hSession := hb_jsonDecode(ctx_get("session", "{}"))
|
||||||
|
aRows := hb_jsonDecode(ctx_get("rows", "[]"))
|
||||||
|
|
||||||
|
IF ! HB_ISHASH(hSession) ; hSession := { => } ; ENDIF
|
||||||
|
IF ! HB_ISARRAY(aRows) ; aRows := {} ; ENDIF
|
||||||
|
|
||||||
|
IF cFormat == "json"
|
||||||
|
AP_JSONRESPONSE({ "session" => hSession, "records" => aRows })
|
||||||
|
RETURN NIL
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
// CSV with UTF-8 BOM (Excel Korean support)
|
||||||
|
cBOM := Chr(239) + Chr(187) + Chr(191)
|
||||||
|
cCRLF := Chr(13) + Chr(10)
|
||||||
|
|
||||||
|
aLines := {}
|
||||||
|
AAdd(aLines, "# Session: " + AllTrim(fn_HGet(hSession, "session_name")))
|
||||||
|
AAdd(aLines, "# Device: " + AllTrim(fn_HGet(hSession, "device_name")) + " (" + AllTrim(fn_HGet(hSession, "device_address")) + ")")
|
||||||
|
|
||||||
|
// Header
|
||||||
|
AAdd(aLines, "row,timestamp,cmd,batt_mv,batt_pct,temp_c,raa,ch0_peak,ch0_pidx,ch1_peak,ch1_pidx,ch2_peak,ch2_pidx,ch3_peak,ch3_pidx,ch4_peak,ch4_pidx,ch5_peak,ch5_pidx")
|
||||||
|
|
||||||
|
FOR EACH hRow IN aRows
|
||||||
|
hSensor := fn_HGet(hRow, "sensor")
|
||||||
|
IF ! HB_ISHASH(hSensor) ; hSensor := { => } ; ENDIF
|
||||||
|
|
||||||
|
// Build channel map (ch → {peak, peakIdx})
|
||||||
|
hChMap := { => }
|
||||||
|
aChannels := fn_HGet(hRow, "channels")
|
||||||
|
IF HB_ISARRAY(aChannels)
|
||||||
|
FOR EACH hCh IN aChannels
|
||||||
|
IF HB_ISHASH(hCh) .AND. hb_HHasKey(hCh, "ch")
|
||||||
|
hChMap[ AllTrim(Str(hCh["ch"])) ] := hCh
|
||||||
|
ENDIF
|
||||||
|
NEXT
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
aCells := { ;
|
||||||
|
AllTrim(Str(fn_HGet(hRow, "row_index"))), ;
|
||||||
|
AllTrim(fn_HGet(hRow, "timestamp")), ;
|
||||||
|
AllTrim(fn_HGet(hRow, "command_type")), ;
|
||||||
|
numStr(fn_HGet(hSensor, "batteryMv")), ;
|
||||||
|
numStr(fn_HGet(hSensor, "batteryPct")), ;
|
||||||
|
numStr(fn_HGet(hSensor, "tempC")), ;
|
||||||
|
numStr(fn_HGet(hRow, "raa_status")) ;
|
||||||
|
}
|
||||||
|
FOR i := 0 TO 5
|
||||||
|
IF hb_HHasKey(hChMap, AllTrim(Str(i)))
|
||||||
|
AAdd(aCells, numStr(fn_HGet(hChMap[AllTrim(Str(i))], "peak")))
|
||||||
|
AAdd(aCells, numStr(fn_HGet(hChMap[AllTrim(Str(i))], "peakIdx")))
|
||||||
|
ELSE
|
||||||
|
AAdd(aCells, "")
|
||||||
|
AAdd(aCells, "")
|
||||||
|
ENDIF
|
||||||
|
NEXT
|
||||||
|
AAdd(aLines, fn_Join(aCells, ","))
|
||||||
|
NEXT
|
||||||
|
|
||||||
|
// Use AP_RPUTS to send raw CSV string
|
||||||
|
AP_RPUTS(cBOM + fn_Join(aLines, cCRLF))
|
||||||
|
RETURN NIL
|
||||||
|
|
||||||
|
FUNCTION session_export_fn_hget(h, k)
|
||||||
|
IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) ; RETURN h[k] ; ENDIF
|
||||||
|
RETURN ""
|
||||||
|
|
||||||
|
STATIC FUNCTION numStr(x)
|
||||||
|
IF x == NIL .OR. Empty(x) ; RETURN "" ; ENDIF
|
||||||
|
IF ValType(x) == "N" ; RETURN AllTrim(Str(x)) ; ENDIF
|
||||||
|
RETURN AllTrim(x)
|
||||||
|
|
||||||
|
STATIC FUNCTION fn_Join(arr, sep)
|
||||||
|
LOCAL out := "", i
|
||||||
|
FOR i := 1 TO Len(arr)
|
||||||
|
IF i > 1 ; out += sep ; ENDIF
|
||||||
|
out += arr[i]
|
||||||
|
NEXT
|
||||||
|
RETURN out
|
||||||
134
app/api/session-stats.prg
Normal file
134
app/api/session-stats.prg
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
// api/session-stats.prg — Compute session statistics (FLOAT MATH IN PRG)
|
||||||
|
//
|
||||||
|
// ctx:
|
||||||
|
// session_id — session ID
|
||||||
|
// duration_sec — duration in seconds (from server.js)
|
||||||
|
// record_count — total records
|
||||||
|
// rows — JSON array of records [{sensor: {tempC, batteryMv, ...}, channels: [...]}]
|
||||||
|
//
|
||||||
|
// PRG handles all numeric aggregation: mean, min, max, stddev for 6 channels.
|
||||||
|
// Float computation is done here (Harbour double precision) — easier maintenance
|
||||||
|
// than maintaining SQL JSONB aggregates in server.js.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 Charles KWON OhJun
|
||||||
|
|
||||||
|
FUNCTION Main()
|
||||||
|
LOCAL aRows, hRow, nDuration, nCount, hSensor, aChannels, hCh
|
||||||
|
LOCAL nTempSum, nTempMin, nTempMax, nTempCnt
|
||||||
|
LOCAL nBatStart, nBatEnd
|
||||||
|
LOCAL aChStats, i, ch, peakSums, peakMins, peakMaxs, peakCounts, peakSqs
|
||||||
|
LOCAL nMean, nVar, nStdDev, hOutCh, aChOut
|
||||||
|
LOCAL cDur, nH, nM, nS
|
||||||
|
|
||||||
|
aRows := hb_jsonDecode(ctx_get("rows", "[]"))
|
||||||
|
nDuration := Val(ctx_get("duration_sec", "0"))
|
||||||
|
nCount := Val(ctx_get("record_count", "0"))
|
||||||
|
|
||||||
|
IF ! HB_ISARRAY(aRows) ; aRows := {} ; ENDIF
|
||||||
|
|
||||||
|
// ─ Init accumulators ──
|
||||||
|
nTempSum := 0
|
||||||
|
nTempMin := 999.9
|
||||||
|
nTempMax := -999.9
|
||||||
|
nTempCnt := 0
|
||||||
|
nBatStart := NIL
|
||||||
|
nBatEnd := NIL
|
||||||
|
|
||||||
|
// 6 channels: peak sum / min / max / count / sum-of-squares (for stddev)
|
||||||
|
peakSums := { 0, 0, 0, 0, 0, 0 }
|
||||||
|
peakMins := { 99999, 99999, 99999, 99999, 99999, 99999 }
|
||||||
|
peakMaxs := { -99999, -99999, -99999, -99999, -99999, -99999 }
|
||||||
|
peakCounts := { 0, 0, 0, 0, 0, 0 }
|
||||||
|
peakSqs := { 0, 0, 0, 0, 0, 0 }
|
||||||
|
|
||||||
|
// ─ Iterate records ──
|
||||||
|
FOR EACH hRow IN aRows
|
||||||
|
// sensor
|
||||||
|
hSensor := fn_HGet(hRow, "sensor")
|
||||||
|
IF HB_ISHASH(hSensor)
|
||||||
|
IF hb_HHasKey(hSensor, "tempC") .AND. ValType(hSensor["tempC"]) == "N"
|
||||||
|
nTempSum += hSensor["tempC"]
|
||||||
|
nTempCnt++
|
||||||
|
IF hSensor["tempC"] < nTempMin ; nTempMin := hSensor["tempC"] ; ENDIF
|
||||||
|
IF hSensor["tempC"] > nTempMax ; nTempMax := hSensor["tempC"] ; ENDIF
|
||||||
|
ENDIF
|
||||||
|
IF hb_HHasKey(hSensor, "batteryMv") .AND. ValType(hSensor["batteryMv"]) == "N"
|
||||||
|
IF nBatStart == NIL ; nBatStart := hSensor["batteryMv"] ; ENDIF
|
||||||
|
nBatEnd := hSensor["batteryMv"]
|
||||||
|
ENDIF
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
// channels
|
||||||
|
aChannels := fn_HGet(hRow, "channels")
|
||||||
|
IF HB_ISARRAY(aChannels)
|
||||||
|
FOR EACH hCh IN aChannels
|
||||||
|
IF HB_ISHASH(hCh) .AND. hb_HHasKey(hCh, "ch") .AND. hb_HHasKey(hCh, "peak")
|
||||||
|
i := hCh["ch"] + 1 // 1-based
|
||||||
|
IF i >= 1 .AND. i <= 6 .AND. ValType(hCh["peak"]) == "N"
|
||||||
|
peakSums[i] += hCh["peak"]
|
||||||
|
peakSqs[i] += hCh["peak"] * hCh["peak"]
|
||||||
|
peakCounts[i]++
|
||||||
|
IF hCh["peak"] < peakMins[i] ; peakMins[i] := hCh["peak"] ; ENDIF
|
||||||
|
IF hCh["peak"] > peakMaxs[i] ; peakMaxs[i] := hCh["peak"] ; ENDIF
|
||||||
|
ENDIF
|
||||||
|
ENDIF
|
||||||
|
NEXT
|
||||||
|
ENDIF
|
||||||
|
NEXT
|
||||||
|
|
||||||
|
// ─ Compute per-channel stats (mean, stddev) ──
|
||||||
|
aChOut := {}
|
||||||
|
FOR i := 1 TO 6
|
||||||
|
ch := i - 1
|
||||||
|
IF peakCounts[i] > 0
|
||||||
|
nMean := peakSums[i] / peakCounts[i]
|
||||||
|
// population variance: E[x^2] - (E[x])^2
|
||||||
|
nVar := (peakSqs[i] / peakCounts[i]) - (nMean * nMean)
|
||||||
|
IF nVar < 0 ; nVar := 0 ; ENDIF
|
||||||
|
nStdDev := Sqrt(nVar)
|
||||||
|
hOutCh := { ;
|
||||||
|
"ch" => ch, ;
|
||||||
|
"peakAvg" => Round(nMean, 2), ;
|
||||||
|
"peakMin" => peakMins[i], ;
|
||||||
|
"peakMax" => peakMaxs[i], ;
|
||||||
|
"peakStdDev" => Round(nStdDev, 2), ;
|
||||||
|
"samples" => peakCounts[i] ;
|
||||||
|
}
|
||||||
|
ELSE
|
||||||
|
hOutCh := { "ch" => ch, "peakAvg" => NIL, "peakMin" => NIL, "peakMax" => NIL, "peakStdDev" => NIL, "samples" => 0 }
|
||||||
|
ENDIF
|
||||||
|
AAdd(aChOut, hOutCh)
|
||||||
|
NEXT
|
||||||
|
|
||||||
|
// ─ Format duration HH:MM:SS ──
|
||||||
|
nH := Int(nDuration / 3600)
|
||||||
|
nM := Int((nDuration % 3600) / 60)
|
||||||
|
nS := Int(nDuration % 60)
|
||||||
|
cDur := iif(nDuration > 0, ;
|
||||||
|
StrZero(nH, 2) + ":" + StrZero(nM, 2) + ":" + StrZero(nS, 2), ;
|
||||||
|
"")
|
||||||
|
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"sessionId" => ctx_get("session_id", ""), ;
|
||||||
|
"recordCount" => nCount, ;
|
||||||
|
"duration" => cDur, ;
|
||||||
|
"channels" => aChOut, ;
|
||||||
|
"temperature" => { ;
|
||||||
|
"avg" => iif(nTempCnt > 0, Round(nTempSum / nTempCnt, 2), NIL), ;
|
||||||
|
"min" => iif(nTempCnt > 0, nTempMin, NIL), ;
|
||||||
|
"max" => iif(nTempCnt > 0, nTempMax, NIL) ;
|
||||||
|
}, ;
|
||||||
|
"battery" => { ;
|
||||||
|
"startMv" => nBatStart, ;
|
||||||
|
"endMv" => nBatEnd, ;
|
||||||
|
"drainMv" => iif(nBatStart != NIL .AND. nBatEnd != NIL, nBatStart - nBatEnd, NIL), ;
|
||||||
|
"drainPctPerHour" => iif(nBatStart != NIL .AND. nBatEnd != NIL .AND. nDuration > 0, ;
|
||||||
|
Round(((nBatStart - nBatEnd) / nBatStart) * 3600 / nDuration * 100, 2), NIL) ;
|
||||||
|
} ;
|
||||||
|
})
|
||||||
|
|
||||||
|
RETURN NIL
|
||||||
|
|
||||||
|
FUNCTION session_stats_fn_hget(h, k)
|
||||||
|
IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) ; RETURN h[k] ; ENDIF
|
||||||
|
RETURN NIL
|
||||||
8
app/api/session-update.prg
Normal file
8
app/api/session-update.prg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// api/session-update.prg — Format session update response
|
||||||
|
// ctx: session_id, status_str (default "completed")
|
||||||
|
FUNCTION Main()
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"sessionId" => ctx_get("session_id", ""), ;
|
||||||
|
"status" => ctx_get("status_str", "completed") ;
|
||||||
|
})
|
||||||
|
RETURN NIL
|
||||||
48
app/api/sessions-list.prg
Normal file
48
app/api/sessions-list.prg
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// api/sessions-list.prg — Format session list response
|
||||||
|
//
|
||||||
|
// ctx inputs:
|
||||||
|
// rows (JSON string) — array of pg rows from server.js
|
||||||
|
// total (string number) — total count
|
||||||
|
// device_name (string) — current device name
|
||||||
|
//
|
||||||
|
// PRG handles formatting + Harbour float computation if needed.
|
||||||
|
// Copyright (c) 2026 Charles KWON OhJun
|
||||||
|
|
||||||
|
FUNCTION Main()
|
||||||
|
LOCAL aRows, hRow, aOut, hOut, nTotal, cDeviceName, hSession
|
||||||
|
|
||||||
|
aRows := hb_jsonDecode(ctx_get("rows", "[]"))
|
||||||
|
nTotal := Val(ctx_get("total", "0"))
|
||||||
|
cDeviceName := ctx_get("device_name", "")
|
||||||
|
|
||||||
|
IF ! HB_ISARRAY(aRows)
|
||||||
|
aRows := {}
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
aOut := {}
|
||||||
|
FOR EACH hRow IN aRows
|
||||||
|
hSession := { ;
|
||||||
|
"sessionId" => fn_HGet(hRow, "session_id"), ;
|
||||||
|
"sessionName" => fn_HGet(hRow, "session_name"), ;
|
||||||
|
"deviceName" => cDeviceName, ;
|
||||||
|
"startTime" => fn_HGet(hRow, "start_time"), ;
|
||||||
|
"endTime" => fn_HGet(hRow, "end_time"), ;
|
||||||
|
"recordCount" => fn_HGet(hRow, "record_count"), ;
|
||||||
|
"params" => fn_HGet(hRow, "params"), ;
|
||||||
|
"note" => fn_HGet(hRow, "note"), ;
|
||||||
|
"status" => fn_HGet(hRow, "status") ;
|
||||||
|
}
|
||||||
|
AAdd(aOut, hSession)
|
||||||
|
NEXT
|
||||||
|
|
||||||
|
hOut := { "total" => nTotal, "sessions" => aOut }
|
||||||
|
AP_JSONRESPONSE(hOut)
|
||||||
|
|
||||||
|
RETURN NIL
|
||||||
|
|
||||||
|
// Safe hash get (returns "" for missing key)
|
||||||
|
FUNCTION sessions_list_fn_hget(hHash, cKey)
|
||||||
|
IF HB_ISHASH(hHash) .AND. hb_HHasKey(hHash, cKey)
|
||||||
|
RETURN hHash[ cKey ]
|
||||||
|
ENDIF
|
||||||
|
RETURN ""
|
||||||
18
app/api/test-mod.prg
Normal file
18
app/api/test-mod.prg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// api/test-mod.prg — Minimal TFNModule test
|
||||||
|
#require "TFNMODULE"
|
||||||
|
|
||||||
|
FUNCTION Main()
|
||||||
|
LOCAL oOs, cName
|
||||||
|
|
||||||
|
OutStd("Step 1: about to call TFNModule" + Chr(10))
|
||||||
|
oOs := TFNModule():New("os")
|
||||||
|
OutStd("Step 2: TFNModule returned " + iif(oOs == NIL, "NIL", "object") + Chr(10))
|
||||||
|
IF oOs == NIL
|
||||||
|
AP_JSONRESPONSE({ "ok" => .f., "error" => "TFNModule returned nil" })
|
||||||
|
RETURN NIL
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
cName := oOs:Call("hostname")
|
||||||
|
AP_JSONRESPONSE({ "ok" => .t., "module" => "os", "hostname" => cName })
|
||||||
|
|
||||||
|
RETURN NIL
|
||||||
47
app/api/test-pg.prg
Normal file
47
app/api/test-pg.prg
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// api/test-pg.prg — Verify pg module works from PRG via TFNModule
|
||||||
|
// GET → returns current_user, current_database, version
|
||||||
|
FUNCTION Main()
|
||||||
|
|
||||||
|
LOCAL oPg, oPool, oResult, hRow, hConfig
|
||||||
|
|
||||||
|
// Build pg config from context (passed by server.js)
|
||||||
|
hConfig := { ;
|
||||||
|
"host" => ctx_get("db_host", "localhost"), ;
|
||||||
|
"port" => Val(ctx_get("db_port", "5432")), ;
|
||||||
|
"user" => ctx_get("db_user", "labdb"), ;
|
||||||
|
"password" => ctx_get("db_password", ""), ;
|
||||||
|
"database" => ctx_get("db_name", "labdb") ;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load pg module
|
||||||
|
oPg := TFNModule():New("pg")
|
||||||
|
IF oPg == NIL
|
||||||
|
AP_JSONRESPONSE({ "ok" => .f., "error" => "failed to load pg module" })
|
||||||
|
RETURN NIL
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
// Create pool (using Pool constructor)
|
||||||
|
oPool := oPg:NewInstance("Pool", hConfig)
|
||||||
|
IF oPool == NIL
|
||||||
|
AP_JSONRESPONSE({ "ok" => .f., "error" => "failed to create pool" })
|
||||||
|
RETURN NIL
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
// Run a simple query (async via aWait)
|
||||||
|
oResult := aWait(oPool:Call("query", "SELECT current_user, current_database(), version()"))
|
||||||
|
|
||||||
|
IF oResult == NIL
|
||||||
|
AP_JSONRESPONSE({ "ok" => .f., "error" => "query returned nil" })
|
||||||
|
RETURN NIL
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
AP_JSONRESPONSE({ ;
|
||||||
|
"ok" => .t., ;
|
||||||
|
"rowCount" => oResult:rowCount, ;
|
||||||
|
"rows" => oResult:rows ;
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
aWait(oPool:Call("end"))
|
||||||
|
|
||||||
|
RETURN NIL
|
||||||
Reference in New Issue
Block a user