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:
2026-05-27 11:23:16 +09:00
parent b213f594aa
commit d26624c63b
22 changed files with 662 additions and 17 deletions

21
app/api/_helpers.prg Normal file
View 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 ""

View 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

View 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
View 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
View 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

View 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

View File

@@ -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
View 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

View 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

View File

@@ -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
View 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
View 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 ""

View 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
View 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 ""

View 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

View 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 ""

View 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
View 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

View 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
View 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
View 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
View 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