+342
-497
internal/api/handlers.go
+342
-497
internal/api/handlers.go
···
1
1
package api
2
2
3
3
import (
4
-
"bufio"
5
-
"bytes"
4
+
"context"
6
5
"crypto/sha256"
7
6
"encoding/hex"
8
7
"encoding/json"
···
17
16
"github.com/atscan/atscanner/internal/plc"
18
17
"github.com/atscan/atscanner/internal/storage"
19
18
"github.com/gorilla/mux"
20
-
"github.com/klauspost/compress/zstd"
21
19
)
22
20
23
-
// ====================
24
-
// Endpoint Handlers (new)
25
-
// ====================
21
+
// ===== RESPONSE HELPERS =====
22
+
23
+
type response struct {
24
+
w http.ResponseWriter
25
+
}
26
+
27
+
func newResponse(w http.ResponseWriter) *response {
28
+
return &response{w: w}
29
+
}
30
+
31
+
func (r *response) json(data interface{}) {
32
+
r.w.Header().Set("Content-Type", "application/json")
33
+
json.NewEncoder(r.w).Encode(data)
34
+
}
35
+
36
+
func (r *response) error(msg string, code int) {
37
+
http.Error(r.w, msg, code)
38
+
}
26
39
27
-
func (s *Server) handleGetEndpoints(w http.ResponseWriter, r *http.Request) {
28
-
ctx := r.Context()
40
+
func (r *response) bundleHeaders(bundle *storage.PLCBundle) {
41
+
r.w.Header().Set("X-Bundle-Number", fmt.Sprintf("%d", bundle.BundleNumber))
42
+
r.w.Header().Set("X-Bundle-Hash", bundle.Hash)
43
+
r.w.Header().Set("X-Bundle-Compressed-Hash", bundle.CompressedHash)
44
+
r.w.Header().Set("X-Bundle-Start-Time", bundle.StartTime.Format(time.RFC3339Nano))
45
+
r.w.Header().Set("X-Bundle-End-Time", bundle.EndTime.Format(time.RFC3339Nano))
46
+
r.w.Header().Set("X-Bundle-Operation-Count", fmt.Sprintf("%d", plc.BUNDLE_SIZE))
47
+
r.w.Header().Set("X-Bundle-DID-Count", fmt.Sprintf("%d", len(bundle.DIDs)))
48
+
}
29
49
30
-
filter := &storage.EndpointFilter{}
50
+
// ===== REQUEST HELPERS =====
31
51
32
-
if typ := r.URL.Query().Get("type"); typ != "" {
33
-
filter.Type = typ
34
-
}
52
+
func getBundleNumber(r *http.Request) (int, error) {
53
+
vars := mux.Vars(r)
54
+
return strconv.Atoi(vars["number"])
55
+
}
35
56
36
-
if status := r.URL.Query().Get("status"); status != "" {
37
-
filter.Status = status
57
+
func getQueryInt(r *http.Request, key string, defaultVal int) int {
58
+
if val := r.URL.Query().Get(key); val != "" {
59
+
if parsed, err := strconv.Atoi(val); err == nil {
60
+
return parsed
61
+
}
38
62
}
63
+
return defaultVal
64
+
}
39
65
40
-
if minUserCount := r.URL.Query().Get("min_user_count"); minUserCount != "" {
41
-
if count, err := strconv.ParseInt(minUserCount, 10, 64); err == nil {
42
-
filter.MinUserCount = count
66
+
func getQueryInt64(r *http.Request, key string, defaultVal int64) int64 {
67
+
if val := r.URL.Query().Get(key); val != "" {
68
+
if parsed, err := strconv.ParseInt(val, 10, 64); err == nil {
69
+
return parsed
43
70
}
44
71
}
72
+
return defaultVal
73
+
}
45
74
46
-
if limit := r.URL.Query().Get("limit"); limit != "" {
47
-
if l, err := strconv.Atoi(limit); err == nil {
48
-
filter.Limit = l
49
-
}
75
+
// ===== FORMATTING HELPERS =====
76
+
77
+
func formatBundleResponse(bundle *storage.PLCBundle) map[string]interface{} {
78
+
return map[string]interface{}{
79
+
"plc_bundle_number": bundle.BundleNumber,
80
+
"start_time": bundle.StartTime,
81
+
"end_time": bundle.EndTime,
82
+
"operation_count": plc.BUNDLE_SIZE,
83
+
"did_count": len(bundle.DIDs),
84
+
"hash": bundle.Hash,
85
+
"compressed_hash": bundle.CompressedHash,
86
+
"compressed_size": bundle.CompressedSize,
87
+
"prev_bundle_hash": bundle.PrevBundleHash,
88
+
"created_at": bundle.CreatedAt,
50
89
}
90
+
}
51
91
52
-
if offset := r.URL.Query().Get("offset"); offset != "" {
53
-
if o, err := strconv.Atoi(offset); err == nil {
54
-
filter.Offset = o
55
-
}
92
+
func formatEndpointResponse(ep *storage.Endpoint) map[string]interface{} {
93
+
return map[string]interface{}{
94
+
"id": ep.ID,
95
+
"endpoint_type": ep.EndpointType,
96
+
"endpoint": ep.Endpoint,
97
+
"discovered_at": ep.DiscoveredAt,
98
+
"last_checked": ep.LastChecked,
99
+
"status": statusToString(ep.Status),
100
+
"user_count": ep.UserCount,
56
101
}
102
+
}
57
103
58
-
endpoints, err := s.db.GetEndpoints(ctx, filter)
104
+
func statusToString(status int) string {
105
+
switch status {
106
+
case storage.EndpointStatusOnline:
107
+
return "online"
108
+
case storage.EndpointStatusOffline:
109
+
return "offline"
110
+
default:
111
+
return "unknown"
112
+
}
113
+
}
114
+
115
+
// ===== ENDPOINT HANDLERS =====
116
+
117
+
func (s *Server) handleGetEndpoints(w http.ResponseWriter, r *http.Request) {
118
+
resp := newResponse(w)
119
+
120
+
filter := &storage.EndpointFilter{
121
+
Type: r.URL.Query().Get("type"),
122
+
Status: r.URL.Query().Get("status"),
123
+
MinUserCount: getQueryInt64(r, "min_user_count", 0),
124
+
Limit: getQueryInt(r, "limit", 0),
125
+
Offset: getQueryInt(r, "offset", 0),
126
+
}
127
+
128
+
endpoints, err := s.db.GetEndpoints(r.Context(), filter)
59
129
if err != nil {
60
-
http.Error(w, err.Error(), http.StatusInternalServerError)
130
+
resp.error(err.Error(), http.StatusInternalServerError)
61
131
return
62
132
}
63
133
64
-
// Convert status codes to strings for API
65
134
response := make([]map[string]interface{}, len(endpoints))
66
135
for i, ep := range endpoints {
67
-
response[i] = map[string]interface{}{
68
-
"id": ep.ID,
69
-
"endpoint_type": ep.EndpointType,
70
-
"endpoint": ep.Endpoint,
71
-
"discovered_at": ep.DiscoveredAt,
72
-
"last_checked": ep.LastChecked,
73
-
"status": statusToString(ep.Status),
74
-
"user_count": ep.UserCount,
75
-
}
136
+
response[i] = formatEndpointResponse(ep)
76
137
}
77
138
78
-
respondJSON(w, response)
139
+
resp.json(response)
79
140
}
80
141
81
142
func (s *Server) handleGetEndpoint(w http.ResponseWriter, r *http.Request) {
82
-
ctx := r.Context()
143
+
resp := newResponse(w)
83
144
vars := mux.Vars(r)
84
145
endpoint := vars["endpoint"]
85
-
86
-
// Get type from query param, default to "pds" for backward compatibility
87
146
endpointType := r.URL.Query().Get("type")
88
147
if endpointType == "" {
89
148
endpointType = "pds"
90
149
}
91
150
92
-
ep, err := s.db.GetEndpoint(ctx, endpoint, endpointType)
151
+
ep, err := s.db.GetEndpoint(r.Context(), endpoint, endpointType)
93
152
if err != nil {
94
-
http.Error(w, "Endpoint not found", http.StatusNotFound)
153
+
resp.error("Endpoint not found", http.StatusNotFound)
95
154
return
96
155
}
97
156
98
-
// Get recent scans
99
-
scans, _ := s.db.GetEndpointScans(ctx, ep.ID, 10)
157
+
scans, _ := s.db.GetEndpointScans(r.Context(), ep.ID, 10)
100
158
101
-
response := map[string]interface{}{
102
-
"id": ep.ID,
103
-
"endpoint_type": ep.EndpointType,
104
-
"endpoint": ep.Endpoint,
105
-
"discovered_at": ep.DiscoveredAt,
106
-
"last_checked": ep.LastChecked,
107
-
"status": statusToString(ep.Status),
108
-
"user_count": ep.UserCount,
109
-
"recent_scans": scans,
110
-
}
159
+
result := formatEndpointResponse(ep)
160
+
result["recent_scans"] = scans
111
161
112
-
respondJSON(w, response)
162
+
resp.json(result)
113
163
}
114
164
115
165
func (s *Server) handleGetEndpointStats(w http.ResponseWriter, r *http.Request) {
116
-
ctx := r.Context()
117
-
118
-
stats, err := s.db.GetEndpointStats(ctx)
166
+
resp := newResponse(w)
167
+
stats, err := s.db.GetEndpointStats(r.Context())
119
168
if err != nil {
120
-
http.Error(w, err.Error(), http.StatusInternalServerError)
169
+
resp.error(err.Error(), http.StatusInternalServerError)
121
170
return
122
171
}
123
-
124
-
respondJSON(w, stats)
172
+
resp.json(stats)
125
173
}
126
174
127
-
// ====================
128
-
// DID Handlers
129
-
// ====================
175
+
// ===== DID HANDLERS =====
130
176
131
177
func (s *Server) handleGetDID(w http.ResponseWriter, r *http.Request) {
132
-
ctx := r.Context()
178
+
resp := newResponse(w)
133
179
vars := mux.Vars(r)
134
180
did := vars["did"]
135
181
136
-
bundles, err := s.db.GetBundlesForDID(ctx, did)
182
+
bundles, err := s.db.GetBundlesForDID(r.Context(), did)
137
183
if err != nil {
138
-
http.Error(w, err.Error(), http.StatusInternalServerError)
184
+
resp.error(err.Error(), http.StatusInternalServerError)
139
185
return
140
186
}
141
187
142
188
if len(bundles) == 0 {
143
-
http.Error(w, "DID not found in bundles", http.StatusNotFound)
189
+
resp.error("DID not found in bundles", http.StatusNotFound)
144
190
return
145
191
}
146
192
147
193
lastBundle := bundles[len(bundles)-1]
148
-
149
-
// Compute file path
150
-
filePath := filepath.Join(s.plcBundleDir, fmt.Sprintf("%06d.jsonl.zst", lastBundle.BundleNumber))
151
-
152
-
operations, err := s.loadBundleOperations(filePath)
194
+
ops, err := s.bundleManager.LoadBundleOperations(r.Context(), lastBundle.BundleNumber)
153
195
if err != nil {
154
-
http.Error(w, fmt.Sprintf("failed to load bundle: %v", err), http.StatusInternalServerError)
196
+
resp.error(fmt.Sprintf("failed to load bundle: %v", err), http.StatusInternalServerError)
155
197
return
156
198
}
157
199
158
200
// Find latest operation for this DID
159
-
var latestOp *plc.PLCOperation
160
-
for i := len(operations) - 1; i >= 0; i-- {
161
-
if operations[i].DID == did {
162
-
latestOp = &operations[i]
163
-
break
201
+
for i := len(ops) - 1; i >= 0; i-- {
202
+
if ops[i].DID == did {
203
+
resp.json(ops[i])
204
+
return
164
205
}
165
206
}
166
207
167
-
if latestOp == nil {
168
-
http.Error(w, "DID operation not found", http.StatusNotFound)
169
-
return
170
-
}
171
-
172
-
respondJSON(w, latestOp)
208
+
resp.error("DID operation not found", http.StatusNotFound)
173
209
}
174
210
175
211
func (s *Server) handleGetDIDHistory(w http.ResponseWriter, r *http.Request) {
176
-
ctx := r.Context()
212
+
resp := newResponse(w)
177
213
vars := mux.Vars(r)
178
214
did := vars["did"]
179
215
180
-
bundles, err := s.db.GetBundlesForDID(ctx, did)
216
+
bundles, err := s.db.GetBundlesForDID(r.Context(), did)
181
217
if err != nil {
182
-
http.Error(w, err.Error(), http.StatusInternalServerError)
218
+
resp.error(err.Error(), http.StatusInternalServerError)
183
219
return
184
220
}
185
221
186
222
if len(bundles) == 0 {
187
-
http.Error(w, "DID not found in bundles", http.StatusNotFound)
223
+
resp.error("DID not found in bundles", http.StatusNotFound)
188
224
return
189
225
}
190
226
···
192
228
var currentOp *plc.PLCOperation
193
229
194
230
for _, bundle := range bundles {
195
-
// Compute file path
196
-
filePath := filepath.Join(s.plcBundleDir, fmt.Sprintf("%06d.jsonl.zst", bundle.BundleNumber))
197
-
198
-
operations, err := s.loadBundleOperations(filePath)
231
+
ops, err := s.bundleManager.LoadBundleOperations(r.Context(), bundle.BundleNumber)
199
232
if err != nil {
200
233
log.Error("Warning: failed to load bundle: %v", err)
201
234
continue
202
235
}
203
236
204
-
for _, op := range operations {
237
+
for _, op := range ops {
205
238
if op.DID == did {
206
239
entry := plc.DIDHistoryEntry{
207
240
Operation: op,
···
213
246
}
214
247
}
215
248
216
-
history := plc.DIDHistory{
249
+
resp.json(plc.DIDHistory{
217
250
DID: did,
218
251
Current: currentOp,
219
252
Operations: allOperations,
220
-
}
221
-
222
-
respondJSON(w, history)
253
+
})
223
254
}
224
255
225
-
// ====================
226
-
// PLC Bundle Handlers
227
-
// ====================
256
+
// ===== PLC BUNDLE HANDLERS =====
228
257
229
258
func (s *Server) handleGetPLCBundle(w http.ResponseWriter, r *http.Request) {
230
-
ctx := r.Context()
231
-
vars := mux.Vars(r)
259
+
resp := newResponse(w)
232
260
233
-
bundleNumber, err := strconv.Atoi(vars["number"])
261
+
bundleNum, err := getBundleNumber(r)
234
262
if err != nil {
235
-
http.Error(w, "invalid bundle number", http.StatusBadRequest)
263
+
resp.error("invalid bundle number", http.StatusBadRequest)
236
264
return
237
265
}
238
266
239
-
bundle, err := s.db.GetBundleByNumber(ctx, bundleNumber)
267
+
bundle, err := s.db.GetBundleByNumber(r.Context(), bundleNum)
240
268
if err != nil {
241
-
http.Error(w, "bundle not found", http.StatusNotFound)
269
+
resp.error("bundle not found", http.StatusNotFound)
242
270
return
243
271
}
244
272
245
-
response := map[string]interface{}{
246
-
"plc_bundle_number": bundle.BundleNumber,
247
-
"start_time": bundle.StartTime,
248
-
"end_time": bundle.EndTime,
249
-
"operation_count": plc.BUNDLE_SIZE,
250
-
"did_count": len(bundle.DIDs),
251
-
"hash": bundle.Hash,
252
-
"compressed_hash": bundle.CompressedHash,
253
-
"compressed_size": bundle.CompressedSize,
254
-
"prev_bundle_hash": bundle.PrevBundleHash,
255
-
"created_at": bundle.CreatedAt,
256
-
}
257
-
258
-
respondJSON(w, response)
273
+
resp.json(formatBundleResponse(bundle))
259
274
}
260
275
261
276
func (s *Server) handleGetPLCBundleDIDs(w http.ResponseWriter, r *http.Request) {
262
-
ctx := r.Context()
263
-
vars := mux.Vars(r)
277
+
resp := newResponse(w)
264
278
265
-
bundleNumber, err := strconv.Atoi(vars["number"])
279
+
bundleNum, err := getBundleNumber(r)
266
280
if err != nil {
267
-
http.Error(w, "invalid bundle number", http.StatusBadRequest)
281
+
resp.error("invalid bundle number", http.StatusBadRequest)
268
282
return
269
283
}
270
284
271
-
bundle, err := s.db.GetBundleByNumber(ctx, bundleNumber)
285
+
bundle, err := s.db.GetBundleByNumber(r.Context(), bundleNum)
272
286
if err != nil {
273
-
http.Error(w, "bundle not found", http.StatusNotFound)
287
+
resp.error("bundle not found", http.StatusNotFound)
274
288
return
275
289
}
276
290
277
-
respondJSON(w, map[string]interface{}{
291
+
resp.json(map[string]interface{}{
278
292
"plc_bundle_number": bundle.BundleNumber,
279
293
"did_count": len(bundle.DIDs),
280
294
"dids": bundle.DIDs,
···
282
296
}
283
297
284
298
func (s *Server) handleDownloadPLCBundle(w http.ResponseWriter, r *http.Request) {
285
-
ctx := r.Context()
286
-
vars := mux.Vars(r)
299
+
resp := newResponse(w)
287
300
288
-
bundleNumber, err := strconv.Atoi(vars["number"])
301
+
bundleNum, err := getBundleNumber(r)
289
302
if err != nil {
290
-
http.Error(w, "invalid bundle number", http.StatusBadRequest)
303
+
resp.error("invalid bundle number", http.StatusBadRequest)
291
304
return
292
305
}
293
306
294
-
// Check if client wants uncompressed data
295
-
compressed := true
296
-
if r.URL.Query().Get("compressed") == "false" {
297
-
compressed = false
298
-
}
307
+
compressed := r.URL.Query().Get("compressed") != "false"
299
308
300
-
// Verify bundle exists in database
301
-
bundle, err := s.db.GetBundleByNumber(ctx, bundleNumber)
309
+
bundle, err := s.db.GetBundleByNumber(r.Context(), bundleNum)
302
310
if err != nil {
303
-
http.Error(w, "bundle not found", http.StatusNotFound)
311
+
resp.error("bundle not found", http.StatusNotFound)
304
312
return
305
313
}
306
314
307
-
// Build file path
308
-
filePath := filepath.Join(s.plcBundleDir, fmt.Sprintf("%06d.jsonl.zst", bundleNumber))
315
+
resp.bundleHeaders(bundle)
316
+
317
+
if compressed {
318
+
s.serveCompressedBundle(w, r, bundle)
319
+
} else {
320
+
s.serveUncompressedBundle(w, r, bundle)
321
+
}
322
+
}
309
323
310
-
// Check if file exists
311
-
fileInfo, err := os.Stat(filePath)
324
+
func (s *Server) serveCompressedBundle(w http.ResponseWriter, r *http.Request, bundle *storage.PLCBundle) {
325
+
resp := newResponse(w)
326
+
path := bundle.GetFilePath(s.plcBundleDir)
327
+
328
+
file, err := os.Open(path)
312
329
if err != nil {
313
-
if os.IsNotExist(err) {
314
-
http.Error(w, "bundle file not found on disk", http.StatusNotFound)
315
-
return
316
-
}
317
-
http.Error(w, fmt.Sprintf("error accessing bundle file: %v", err), http.StatusInternalServerError)
330
+
resp.error("bundle file not found on disk", http.StatusNotFound)
318
331
return
319
332
}
333
+
defer file.Close()
320
334
321
-
// Set common headers
322
-
w.Header().Set("X-Bundle-Number", fmt.Sprintf("%d", bundleNumber))
323
-
w.Header().Set("X-Bundle-Hash", bundle.Hash)
324
-
w.Header().Set("X-Bundle-Compressed-Hash", bundle.CompressedHash)
325
-
w.Header().Set("X-Bundle-Start-Time", bundle.StartTime.Format(time.RFC3339Nano))
326
-
w.Header().Set("X-Bundle-End-Time", bundle.EndTime.Format(time.RFC3339Nano))
327
-
w.Header().Set("X-Bundle-Operation-Count", fmt.Sprintf("%d", plc.BUNDLE_SIZE))
328
-
w.Header().Set("X-Bundle-DID-Count", fmt.Sprintf("%d", len(bundle.DIDs)))
335
+
fileInfo, _ := file.Stat()
329
336
330
-
if compressed {
331
-
// Serve compressed file
332
-
file, err := os.Open(filePath)
333
-
if err != nil {
334
-
http.Error(w, fmt.Sprintf("error opening bundle file: %v", err), http.StatusInternalServerError)
335
-
return
336
-
}
337
-
defer file.Close()
337
+
w.Header().Set("Content-Type", "application/zstd")
338
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl.zst", bundle.BundleNumber))
339
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
340
+
w.Header().Set("X-Compressed-Size", fmt.Sprintf("%d", fileInfo.Size()))
338
341
339
-
w.Header().Set("Content-Type", "application/zstd")
340
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl.zst", bundleNumber))
341
-
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
342
-
w.Header().Set("X-Compressed-Size", fmt.Sprintf("%d", fileInfo.Size()))
342
+
http.ServeContent(w, r, filepath.Base(path), bundle.CreatedAt, file)
343
+
}
343
344
344
-
http.ServeContent(w, r, filepath.Base(filePath), bundle.CreatedAt, file)
345
-
} else {
346
-
// Serve uncompressed data
347
-
compressedData, err := os.ReadFile(filePath)
348
-
if err != nil {
349
-
http.Error(w, fmt.Sprintf("error reading bundle file: %v", err), http.StatusInternalServerError)
350
-
return
351
-
}
345
+
func (s *Server) serveUncompressedBundle(w http.ResponseWriter, r *http.Request, bundle *storage.PLCBundle) {
346
+
resp := newResponse(w)
352
347
353
-
// Decompress
354
-
decoder, err := zstd.NewReader(nil)
355
-
if err != nil {
356
-
http.Error(w, fmt.Sprintf("error creating decompressor: %v", err), http.StatusInternalServerError)
357
-
return
358
-
}
359
-
defer decoder.Close()
348
+
ops, err := s.bundleManager.LoadBundleOperations(r.Context(), bundle.BundleNumber)
349
+
if err != nil {
350
+
resp.error(fmt.Sprintf("error loading bundle: %v", err), http.StatusInternalServerError)
351
+
return
352
+
}
360
353
361
-
decompressed, err := decoder.DecodeAll(compressedData, nil)
362
-
if err != nil {
363
-
http.Error(w, fmt.Sprintf("error decompressing bundle: %v", err), http.StatusInternalServerError)
364
-
return
365
-
}
354
+
// Serialize to JSONL
355
+
var buf []byte
356
+
for _, op := range ops {
357
+
buf = append(buf, op.RawJSON...)
358
+
buf = append(buf, '\n')
359
+
}
366
360
367
-
// Set headers for uncompressed data
368
-
w.Header().Set("Content-Type", "application/jsonl")
369
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl", bundleNumber))
370
-
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(decompressed)))
371
-
w.Header().Set("X-Compressed-Size", fmt.Sprintf("%d", fileInfo.Size()))
372
-
w.Header().Set("X-Uncompressed-Size", fmt.Sprintf("%d", len(decompressed)))
373
-
w.Header().Set("X-Compression-Ratio", fmt.Sprintf("%.2f", float64(len(decompressed))/float64(fileInfo.Size())))
361
+
fileInfo, _ := os.Stat(bundle.GetFilePath(s.plcBundleDir))
362
+
compressedSize := int64(0)
363
+
if fileInfo != nil {
364
+
compressedSize = fileInfo.Size()
365
+
}
374
366
375
-
// Write decompressed data
376
-
w.WriteHeader(http.StatusOK)
377
-
w.Write(decompressed)
367
+
w.Header().Set("Content-Type", "application/jsonl")
368
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl", bundle.BundleNumber))
369
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(buf)))
370
+
w.Header().Set("X-Compressed-Size", fmt.Sprintf("%d", compressedSize))
371
+
w.Header().Set("X-Uncompressed-Size", fmt.Sprintf("%d", len(buf)))
372
+
if compressedSize > 0 {
373
+
w.Header().Set("X-Compression-Ratio", fmt.Sprintf("%.2f", float64(len(buf))/float64(compressedSize)))
378
374
}
375
+
376
+
w.WriteHeader(http.StatusOK)
377
+
w.Write(buf)
379
378
}
380
379
381
380
func (s *Server) handleGetPLCBundles(w http.ResponseWriter, r *http.Request) {
382
-
ctx := r.Context()
383
-
384
-
limit := 50
385
-
if l := r.URL.Query().Get("limit"); l != "" {
386
-
if parsed, err := strconv.Atoi(l); err == nil {
387
-
limit = parsed
388
-
}
389
-
}
381
+
resp := newResponse(w)
382
+
limit := getQueryInt(r, "limit", 50)
390
383
391
-
bundles, err := s.db.GetBundles(ctx, limit)
384
+
bundles, err := s.db.GetBundles(r.Context(), limit)
392
385
if err != nil {
393
-
http.Error(w, err.Error(), http.StatusInternalServerError)
386
+
resp.error(err.Error(), http.StatusInternalServerError)
394
387
return
395
388
}
396
389
397
390
response := make([]map[string]interface{}, len(bundles))
398
391
for i, bundle := range bundles {
399
-
response[i] = map[string]interface{}{
400
-
"plc_bundle_number": bundle.BundleNumber,
401
-
"start_time": bundle.StartTime,
402
-
"end_time": bundle.EndTime,
403
-
"operation_count": plc.BUNDLE_SIZE,
404
-
"did_count": len(bundle.DIDs),
405
-
"hash": bundle.Hash,
406
-
"compressed_hash": bundle.CompressedHash,
407
-
"compressed_size": bundle.CompressedSize,
408
-
"prev_bundle_hash": bundle.PrevBundleHash,
409
-
}
392
+
response[i] = formatBundleResponse(bundle)
410
393
}
411
394
412
-
respondJSON(w, response)
395
+
resp.json(response)
413
396
}
414
397
415
398
func (s *Server) handleGetPLCBundleStats(w http.ResponseWriter, r *http.Request) {
416
-
ctx := r.Context()
399
+
resp := newResponse(w)
417
400
418
-
count, size, err := s.db.GetBundleStats(ctx)
401
+
count, size, err := s.db.GetBundleStats(r.Context())
419
402
if err != nil {
420
-
http.Error(w, err.Error(), http.StatusInternalServerError)
403
+
resp.error(err.Error(), http.StatusInternalServerError)
421
404
return
422
405
}
423
406
424
-
respondJSON(w, map[string]interface{}{
407
+
resp.json(map[string]interface{}{
425
408
"plc_bundle_count": count,
426
409
"total_size": size,
427
410
"total_size_mb": float64(size) / 1024 / 1024,
428
411
})
429
412
}
430
413
431
-
// ====================
432
-
// Mempool Handlers
433
-
// ====================
414
+
// ===== MEMPOOL HANDLERS =====
434
415
435
416
func (s *Server) handleGetMempoolStats(w http.ResponseWriter, r *http.Request) {
417
+
resp := newResponse(w)
436
418
ctx := r.Context()
437
419
438
420
count, err := s.db.GetMempoolCount(ctx)
439
421
if err != nil {
440
-
http.Error(w, err.Error(), http.StatusInternalServerError)
422
+
resp.error(err.Error(), http.StatusInternalServerError)
441
423
return
442
424
}
443
425
444
-
response := map[string]interface{}{
426
+
result := map[string]interface{}{
445
427
"operation_count": count,
446
428
"can_create_bundle": count >= plc.BUNDLE_SIZE,
447
429
}
448
430
449
-
// Get mempool start time (first item)
450
431
if count > 0 {
451
-
firstOp, err := s.db.GetFirstMempoolOperation(ctx)
452
-
if err == nil && firstOp != nil {
453
-
response["mempool_start_time"] = firstOp.CreatedAt
432
+
if firstOp, err := s.db.GetFirstMempoolOperation(ctx); err == nil && firstOp != nil {
433
+
result["mempool_start_time"] = firstOp.CreatedAt
454
434
455
-
// Calculate estimated next bundle time
456
435
if count < plc.BUNDLE_SIZE {
457
-
lastOp, err := s.db.GetLastMempoolOperation(ctx)
458
-
if err == nil && lastOp != nil {
459
-
// Calculate rate of operations per second
436
+
if lastOp, err := s.db.GetLastMempoolOperation(ctx); err == nil && lastOp != nil {
460
437
timeSpan := lastOp.CreatedAt.Sub(firstOp.CreatedAt).Seconds()
461
-
462
438
if timeSpan > 0 {
463
439
opsPerSecond := float64(count) / timeSpan
464
-
465
440
if opsPerSecond > 0 {
466
441
remainingOps := plc.BUNDLE_SIZE - count
467
442
secondsNeeded := float64(remainingOps) / opsPerSecond
468
-
estimatedTime := time.Now().Add(time.Duration(secondsNeeded) * time.Second)
469
-
470
-
response["estimated_next_bundle_time"] = estimatedTime
471
-
response["operations_needed"] = remainingOps
472
-
response["current_rate_per_second"] = opsPerSecond
443
+
result["estimated_next_bundle_time"] = time.Now().Add(time.Duration(secondsNeeded) * time.Second)
444
+
result["operations_needed"] = remainingOps
445
+
result["current_rate_per_second"] = opsPerSecond
473
446
}
474
447
}
475
448
}
476
449
} else {
477
-
// Bundle can be created now
478
-
response["estimated_next_bundle_time"] = time.Now()
479
-
response["operations_needed"] = 0
450
+
result["estimated_next_bundle_time"] = time.Now()
451
+
result["operations_needed"] = 0
480
452
}
481
453
}
482
454
} else {
483
-
response["mempool_start_time"] = nil
484
-
response["estimated_next_bundle_time"] = nil
455
+
result["mempool_start_time"] = nil
456
+
result["estimated_next_bundle_time"] = nil
485
457
}
486
458
487
-
respondJSON(w, response)
459
+
resp.json(result)
488
460
}
489
461
490
-
// ====================
491
-
// PLC Metrics Handlers
492
-
// ====================
462
+
// ===== PLC METRICS HANDLERS =====
493
463
494
464
func (s *Server) handleGetPLCMetrics(w http.ResponseWriter, r *http.Request) {
495
-
ctx := r.Context()
496
-
497
-
limit := 10
498
-
if l := r.URL.Query().Get("limit"); l != "" {
499
-
if parsed, err := strconv.Atoi(l); err == nil {
500
-
limit = parsed
501
-
}
502
-
}
465
+
resp := newResponse(w)
466
+
limit := getQueryInt(r, "limit", 10)
503
467
504
-
metrics, err := s.db.GetPLCMetrics(ctx, limit)
468
+
metrics, err := s.db.GetPLCMetrics(r.Context(), limit)
505
469
if err != nil {
506
-
http.Error(w, err.Error(), http.StatusInternalServerError)
470
+
resp.error(err.Error(), http.StatusInternalServerError)
507
471
return
508
472
}
509
473
510
-
respondJSON(w, metrics)
474
+
resp.json(metrics)
511
475
}
512
476
513
-
// ====================
514
-
// Verification Handlers
515
-
// ====================
477
+
// ===== VERIFICATION HANDLERS =====
516
478
517
479
func (s *Server) handleVerifyPLCBundle(w http.ResponseWriter, r *http.Request) {
518
-
ctx := r.Context()
480
+
resp := newResponse(w)
519
481
vars := mux.Vars(r)
520
-
bundleNumberStr := vars["bundleNumber"]
521
482
522
-
bundleNumber, err := strconv.Atoi(bundleNumberStr)
483
+
bundleNumber, err := strconv.Atoi(vars["bundleNumber"])
523
484
if err != nil {
524
-
http.Error(w, "Invalid bundle number", http.StatusBadRequest)
485
+
resp.error("Invalid bundle number", http.StatusBadRequest)
525
486
return
526
487
}
527
488
528
-
// Get bundle from DB
529
-
bundle, err := s.db.GetBundleByNumber(ctx, bundleNumber)
489
+
bundle, err := s.db.GetBundleByNumber(r.Context(), bundleNumber)
530
490
if err != nil {
531
-
http.Error(w, "Bundle not found", http.StatusNotFound)
491
+
resp.error("Bundle not found", http.StatusNotFound)
532
492
return
533
493
}
534
494
535
-
// Get previous bundle for boundary state
495
+
// Fetch from PLC and verify
496
+
remoteOps, prevCIDs, err := s.fetchRemoteBundleOps(r.Context(), bundleNumber)
497
+
if err != nil {
498
+
resp.error(fmt.Sprintf("Failed to fetch from PLC directory: %v", err), http.StatusInternalServerError)
499
+
return
500
+
}
501
+
502
+
remoteHash := computeOperationsHash(remoteOps)
503
+
verified := bundle.Hash == remoteHash
504
+
505
+
resp.json(map[string]interface{}{
506
+
"bundle_number": bundleNumber,
507
+
"verified": verified,
508
+
"local_hash": bundle.Hash,
509
+
"remote_hash": remoteHash,
510
+
"local_op_count": plc.BUNDLE_SIZE,
511
+
"remote_op_count": len(remoteOps),
512
+
"boundary_cids_used": len(prevCIDs),
513
+
})
514
+
}
515
+
516
+
func (s *Server) fetchRemoteBundleOps(ctx context.Context, bundleNum int) ([]plc.PLCOperation, map[string]bool, error) {
536
517
var after string
537
518
var prevBoundaryCIDs map[string]bool
538
519
539
-
if bundleNumber > 1 {
540
-
prevBundle, err := s.db.GetBundleByNumber(ctx, bundleNumber-1)
520
+
if bundleNum > 1 {
521
+
prevBundle, err := s.db.GetBundleByNumber(ctx, bundleNum-1)
541
522
if err != nil {
542
-
http.Error(w, "Failed to get previous bundle", http.StatusInternalServerError)
543
-
return
523
+
return nil, nil, fmt.Errorf("failed to get previous bundle: %w", err)
544
524
}
545
525
546
526
after = prevBundle.EndTime.Format("2006-01-02T15:04:05.000Z")
547
527
548
-
// Convert stored boundary CIDs to map
549
528
if len(prevBundle.BoundaryCIDs) > 0 {
550
529
prevBoundaryCIDs = make(map[string]bool)
551
530
for _, cid := range prevBundle.BoundaryCIDs {
···
554
533
}
555
534
}
556
535
557
-
// Collect remote operations (may need multiple fetches for large bundles)
558
536
var allRemoteOps []plc.PLCOperation
559
537
seenCIDs := make(map[string]bool)
560
538
561
-
// Track boundary CIDs
562
539
for cid := range prevBoundaryCIDs {
563
540
seenCIDs[cid] = true
564
541
}
565
542
566
543
currentAfter := after
567
-
maxFetches := 20 // Enough for up to 20k operations
544
+
maxFetches := 20
568
545
569
546
for fetchNum := 0; fetchNum < maxFetches && len(allRemoteOps) < plc.BUNDLE_SIZE; fetchNum++ {
570
-
// Fetch from PLC directory
571
547
batch, err := s.plcClient.Export(ctx, plc.ExportOptions{
572
548
Count: 1000,
573
549
After: currentAfter,
574
550
})
575
-
if err != nil {
576
-
http.Error(w, fmt.Sprintf("Failed to fetch from PLC directory: %v", err), http.StatusInternalServerError)
577
-
return
578
-
}
579
-
580
-
if len(batch) == 0 {
551
+
if err != nil || len(batch) == 0 {
581
552
break
582
553
}
583
554
584
-
// Deduplicate and add unique operations
585
555
for _, op := range batch {
586
556
if !seenCIDs[op.CID] {
587
557
seenCIDs[op.CID] = true
···
592
562
}
593
563
}
594
564
595
-
// Update cursor for next fetch
596
565
if len(batch) > 0 {
597
566
lastOp := batch[len(batch)-1]
598
567
currentAfter = lastOp.CreatedAt.Format("2006-01-02T15:04:05.000Z")
599
568
}
600
569
601
-
// If we got less than 1000, we've reached the end
602
570
if len(batch) < 1000 {
603
571
break
604
572
}
605
573
}
606
574
607
-
// Trim to exact bundle size
608
575
if len(allRemoteOps) > plc.BUNDLE_SIZE {
609
576
allRemoteOps = allRemoteOps[:plc.BUNDLE_SIZE]
610
577
}
611
578
612
-
// Compute remote hash (uncompressed JSONL)
613
-
remoteHash, err := computeRemoteOperationsHash(allRemoteOps)
614
-
if err != nil {
615
-
http.Error(w, fmt.Sprintf("Failed to compute remote hash: %v", err), http.StatusInternalServerError)
616
-
return
617
-
}
618
-
619
-
// Compare hashes (use uncompressed hash)
620
-
verified := bundle.Hash == remoteHash
621
-
622
-
respondJSON(w, map[string]interface{}{
623
-
"bundle_number": bundleNumber,
624
-
"verified": verified,
625
-
"local_hash": bundle.Hash,
626
-
"remote_hash": remoteHash,
627
-
"local_op_count": plc.BUNDLE_SIZE,
628
-
"remote_op_count": len(allRemoteOps),
629
-
"boundary_cids_used": len(prevBoundaryCIDs),
630
-
})
579
+
return allRemoteOps, prevBoundaryCIDs, nil
631
580
}
632
581
633
582
func (s *Server) handleVerifyChain(w http.ResponseWriter, r *http.Request) {
583
+
resp := newResponse(w)
634
584
ctx := r.Context()
635
585
636
-
// Get last bundle number
637
586
lastBundle, err := s.db.GetLastBundleNumber(ctx)
638
587
if err != nil {
639
-
http.Error(w, err.Error(), http.StatusInternalServerError)
588
+
resp.error(err.Error(), http.StatusInternalServerError)
640
589
return
641
590
}
642
591
643
592
if lastBundle == 0 {
644
-
respondJSON(w, map[string]interface{}{
593
+
resp.json(map[string]interface{}{
645
594
"status": "empty",
646
595
"message": "No bundles to verify",
647
596
})
648
597
return
649
598
}
650
599
651
-
// Verify chain
652
600
valid := true
653
601
var brokenAt int
654
602
var errorMsg string
···
662
610
break
663
611
}
664
612
665
-
// Verify chain link
666
613
if i > 1 {
667
614
prevBundle, err := s.db.GetBundleByNumber(ctx, i-1)
668
615
if err != nil {
···
681
628
}
682
629
}
683
630
684
-
response := map[string]interface{}{
631
+
result := map[string]interface{}{
685
632
"chain_length": lastBundle,
686
633
"valid": valid,
687
634
}
688
635
689
636
if !valid {
690
-
response["broken_at"] = brokenAt
691
-
response["error"] = errorMsg
637
+
result["broken_at"] = brokenAt
638
+
result["error"] = errorMsg
692
639
}
693
640
694
-
respondJSON(w, response)
641
+
resp.json(result)
695
642
}
696
643
697
644
func (s *Server) handleGetChainInfo(w http.ResponseWriter, r *http.Request) {
645
+
resp := newResponse(w)
698
646
ctx := r.Context()
699
647
700
648
lastBundle, err := s.db.GetLastBundleNumber(ctx)
701
649
if err != nil {
702
-
http.Error(w, err.Error(), http.StatusInternalServerError)
650
+
resp.error(err.Error(), http.StatusInternalServerError)
703
651
return
704
652
}
705
653
706
654
if lastBundle == 0 {
707
-
respondJSON(w, map[string]interface{}{
655
+
resp.json(map[string]interface{}{
708
656
"chain_length": 0,
709
657
"status": "empty",
710
658
})
···
713
661
714
662
firstBundle, _ := s.db.GetBundleByNumber(ctx, 1)
715
663
lastBundleData, _ := s.db.GetBundleByNumber(ctx, lastBundle)
716
-
717
664
count, size, _ := s.db.GetBundleStats(ctx)
718
665
719
-
respondJSON(w, map[string]interface{}{
666
+
resp.json(map[string]interface{}{
720
667
"chain_length": lastBundle,
721
668
"total_bundles": count,
722
669
"total_size_mb": float64(size) / 1024 / 1024,
···
728
675
})
729
676
}
730
677
731
-
// ====================
732
-
// PLC Export Handler
733
-
// ====================
678
+
// ===== PLC EXPORT HANDLER =====
734
679
735
680
func (s *Server) handlePLCExport(w http.ResponseWriter, r *http.Request) {
681
+
resp := newResponse(w)
736
682
ctx := r.Context()
737
683
738
-
// Parse query parameters
739
-
countStr := r.URL.Query().Get("count")
740
-
afterStr := r.URL.Query().Get("after")
684
+
count := getQueryInt(r, "count", 1000)
685
+
if count > 10000 {
686
+
count = 10000
687
+
}
741
688
742
-
count := 1000 // Default
743
-
if countStr != "" {
744
-
if c, err := strconv.Atoi(countStr); err == nil && c > 0 {
745
-
count = c
746
-
if count > 10000 {
747
-
count = 10000 // Max limit
748
-
}
689
+
afterTime, err := parseAfterParam(r.URL.Query().Get("after"))
690
+
if err != nil {
691
+
resp.error(fmt.Sprintf("Invalid after parameter: %v", err), http.StatusBadRequest)
692
+
return
693
+
}
694
+
695
+
startBundle := s.findStartBundle(ctx, afterTime)
696
+
ops := s.collectOperations(ctx, startBundle, afterTime, count)
697
+
698
+
w.Header().Set("Content-Type", "application/jsonl")
699
+
w.Header().Set("X-Operation-Count", strconv.Itoa(len(ops)))
700
+
701
+
for _, op := range ops {
702
+
if len(op.RawJSON) > 0 {
703
+
w.Write(op.RawJSON)
704
+
} else {
705
+
jsonData, _ := json.Marshal(op)
706
+
w.Write(jsonData)
749
707
}
708
+
w.Write([]byte("\n"))
750
709
}
710
+
}
751
711
752
-
var afterTime time.Time
753
-
if afterStr != "" {
754
-
// Try multiple timestamp formats (from most specific to least)
755
-
formats := []string{
756
-
time.RFC3339Nano,
757
-
time.RFC3339,
758
-
"2006-01-02T15:04:05.000Z",
759
-
"2006-01-02T15:04:05",
760
-
"2006-01-02T15:04",
761
-
"2006-01-02",
712
+
func parseAfterParam(afterStr string) (time.Time, error) {
713
+
if afterStr == "" {
714
+
return time.Time{}, nil
715
+
}
716
+
717
+
formats := []string{
718
+
time.RFC3339Nano,
719
+
time.RFC3339,
720
+
"2006-01-02T15:04:05.000Z",
721
+
"2006-01-02T15:04:05",
722
+
"2006-01-02T15:04",
723
+
"2006-01-02",
724
+
}
725
+
726
+
for _, format := range formats {
727
+
if parsed, err := time.Parse(format, afterStr); err == nil {
728
+
return parsed, nil
762
729
}
730
+
}
763
731
764
-
var parsed time.Time
765
-
var parseErr error
766
-
parsed = time.Time{}
732
+
return time.Time{}, fmt.Errorf("invalid timestamp format")
733
+
}
767
734
768
-
for _, format := range formats {
769
-
parsed, parseErr = time.Parse(format, afterStr)
770
-
if parseErr == nil {
771
-
afterTime = parsed
772
-
break
773
-
}
774
-
}
735
+
func (s *Server) findStartBundle(ctx context.Context, afterTime time.Time) int {
736
+
if afterTime.IsZero() {
737
+
return 1
738
+
}
775
739
776
-
if parseErr != nil {
777
-
http.Error(w, fmt.Sprintf("Invalid after parameter: %v", parseErr), http.StatusBadRequest)
778
-
return
779
-
}
740
+
foundBundle, err := s.db.GetBundleForTimestamp(ctx, afterTime)
741
+
if err != nil {
742
+
return 1
780
743
}
781
744
782
-
// Find starting bundle (FAST - single query)
783
-
startBundle := 1
784
-
if !afterTime.IsZero() {
785
-
foundBundle, err := s.db.GetBundleForTimestamp(ctx, afterTime)
786
-
if err != nil {
787
-
log.Error("Failed to find bundle for timestamp: %v", err)
788
-
// Fallback to bundle 1
789
-
} else {
790
-
startBundle = foundBundle
791
-
// Go back one bundle to catch boundary timestamps
792
-
if startBundle > 1 {
793
-
startBundle--
794
-
}
795
-
}
745
+
if foundBundle > 1 {
746
+
return foundBundle - 1
796
747
}
748
+
return foundBundle
749
+
}
797
750
798
-
// Collect operations from bundles
751
+
func (s *Server) collectOperations(ctx context.Context, startBundle int, afterTime time.Time, count int) []plc.PLCOperation {
799
752
var allOps []plc.PLCOperation
800
753
seenCIDs := make(map[string]bool)
801
754
802
-
// Load bundles sequentially until we have enough operations
803
755
lastBundle, _ := s.db.GetLastBundleNumber(ctx)
804
756
805
757
for bundleNum := startBundle; bundleNum <= lastBundle && len(allOps) < count; bundleNum++ {
806
-
bundlePath := filepath.Join(s.plcBundleDir, fmt.Sprintf("%06d.jsonl.zst", bundleNum))
807
-
808
-
ops, err := s.loadBundleOperations(bundlePath)
758
+
ops, err := s.bundleManager.LoadBundleOperations(ctx, bundleNum)
809
759
if err != nil {
810
760
log.Error("Warning: failed to load bundle %d: %v", bundleNum, err)
811
761
continue
812
762
}
813
763
814
-
// Filter operations
815
764
for _, op := range ops {
816
-
// Skip if STRICTLY BEFORE "after" timestamp
817
-
// Include operations AT or AFTER the timestamp
818
765
if !afterTime.IsZero() && op.CreatedAt.Before(afterTime) {
819
766
continue
820
767
}
821
768
822
-
// Skip duplicates (by CID)
823
769
if seenCIDs[op.CID] {
824
770
continue
825
771
}
···
833
779
}
834
780
}
835
781
836
-
// Set headers for JSONL response
837
-
w.Header().Set("Content-Type", "application/jsonl")
838
-
w.Header().Set("X-Operation-Count", strconv.Itoa(len(allOps)))
839
-
840
-
// Write JSONL response (newline-delimited JSON with trailing newline)
841
-
for _, op := range allOps {
842
-
// Use raw JSON if available
843
-
if len(op.RawJSON) > 0 {
844
-
w.Write(op.RawJSON)
845
-
} else {
846
-
// Fallback: marshal the operation
847
-
jsonData, err := json.Marshal(op)
848
-
if err != nil {
849
-
log.Error("Failed to marshal operation: %v", err)
850
-
continue
851
-
}
852
-
w.Write(jsonData)
853
-
}
854
-
855
-
// Always add newline after each operation (including the last)
856
-
w.Write([]byte("\n"))
857
-
}
782
+
return allOps
858
783
}
859
784
860
-
// ====================
861
-
// Health Handler
862
-
// ====================
785
+
// ===== HEALTH HANDLER =====
863
786
864
787
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
865
-
respondJSON(w, map[string]string{"status": "ok"})
788
+
newResponse(w).json(map[string]string{"status": "ok"})
866
789
}
867
790
868
-
// ====================
869
-
// Helper Functions
870
-
// ====================
871
-
872
-
// loadBundleOperations loads operations from a bundle file
873
-
func (s *Server) loadBundleOperations(path string) ([]plc.PLCOperation, error) {
874
-
decoder, err := zstd.NewReader(nil)
875
-
if err != nil {
876
-
return nil, err
877
-
}
878
-
defer decoder.Close()
879
-
880
-
compressedData, err := os.ReadFile(path)
881
-
if err != nil {
882
-
return nil, err
883
-
}
884
-
885
-
decompressed, err := decoder.DecodeAll(compressedData, nil)
886
-
if err != nil {
887
-
return nil, err
888
-
}
889
-
890
-
// Parse JSONL (newline-delimited JSON)
891
-
var operations []plc.PLCOperation
892
-
scanner := bufio.NewScanner(bytes.NewReader(decompressed))
893
-
894
-
lineNum := 0
895
-
for scanner.Scan() {
896
-
lineNum++
897
-
line := scanner.Bytes()
898
-
899
-
// Skip empty lines
900
-
if len(line) == 0 {
901
-
continue
902
-
}
903
-
904
-
var op plc.PLCOperation
905
-
if err := json.Unmarshal(line, &op); err != nil {
906
-
return nil, fmt.Errorf("failed to parse operation on line %d: %w", lineNum, err)
907
-
}
908
-
909
-
// CRITICAL: Store the original raw JSON bytes
910
-
op.RawJSON = make([]byte, len(line))
911
-
copy(op.RawJSON, line)
912
-
913
-
operations = append(operations, op)
914
-
}
915
-
916
-
if err := scanner.Err(); err != nil {
917
-
return nil, fmt.Errorf("error reading JSONL: %w", err)
918
-
}
791
+
// ===== UTILITY FUNCTIONS =====
919
792
920
-
return operations, nil
921
-
}
922
-
923
-
// computeRemoteOperationsHash computes hash for remote operations
924
-
func computeRemoteOperationsHash(ops []plc.PLCOperation) (string, error) {
793
+
func computeOperationsHash(ops []plc.PLCOperation) string {
925
794
var jsonlData []byte
926
-
for i, op := range ops {
927
-
if len(op.RawJSON) > 0 {
928
-
jsonlData = append(jsonlData, op.RawJSON...)
929
-
} else {
930
-
return "", fmt.Errorf("operation %d missing raw JSON data", i)
931
-
}
932
-
// Add newline ONLY between operations
795
+
for _, op := range ops {
796
+
jsonlData = append(jsonlData, op.RawJSON...)
933
797
jsonlData = append(jsonlData, '\n')
934
798
}
935
-
936
799
hash := sha256.Sum256(jsonlData)
937
-
return hex.EncodeToString(hash[:]), nil
938
-
}
939
-
940
-
// statusToString converts status int to string
941
-
func statusToString(status int) string {
942
-
switch status {
943
-
case storage.PDSStatusOnline: // Use PDSStatusOnline (alias)
944
-
return "online"
945
-
case storage.PDSStatusOffline: // Use PDSStatusOffline (alias)
946
-
return "offline"
947
-
default:
948
-
return "unknown"
949
-
}
950
-
}
951
-
952
-
// respondJSON writes JSON response
953
-
func respondJSON(w http.ResponseWriter, data interface{}) {
954
-
w.Header().Set("Content-Type", "application/json")
955
-
json.NewEncoder(w).Encode(data)
800
+
return hex.EncodeToString(hash[:])
956
801
}
+13
-9
internal/api/server.go
+13
-9
internal/api/server.go
···
15
15
)
16
16
17
17
type Server struct {
18
-
router *mux.Router
19
-
server *http.Server
20
-
db storage.Database
21
-
plcClient *plc.Client
22
-
plcBundleDir string // NEW: Store cache dir
18
+
router *mux.Router
19
+
server *http.Server
20
+
db storage.Database
21
+
plcClient *plc.Client
22
+
plcBundleDir string
23
+
bundleManager *plc.BundleManager
23
24
}
24
25
25
26
func NewServer(db storage.Database, apiCfg config.APIConfig, plcCfg config.PLCConfig) *Server {
27
+
bundleManager, _ := plc.NewBundleManager(plcCfg.BundleDir, plcCfg.UseCache, db)
28
+
26
29
s := &Server{
27
-
router: mux.NewRouter(),
28
-
db: db,
29
-
plcClient: plc.NewClient(plcCfg.DirectoryURL),
30
-
plcBundleDir: plcCfg.BundleDir, // NEW
30
+
router: mux.NewRouter(),
31
+
db: db,
32
+
plcClient: plc.NewClient(plcCfg.DirectoryURL),
33
+
plcBundleDir: plcCfg.BundleDir,
34
+
bundleManager: bundleManager,
31
35
}
32
36
33
37
s.setupRoutes()
+19
internal/plc/bundle.go
+19
internal/plc/bundle.go
···
576
576
577
577
return operations[startIdx:]
578
578
}
579
+
580
+
// LoadBundleOperations is a public method for external access (e.g., API handlers)
581
+
func (bm *BundleManager) LoadBundleOperations(ctx context.Context, bundleNum int) ([]PLCOperation, error) {
582
+
if !bm.enabled {
583
+
return nil, fmt.Errorf("bundle manager disabled")
584
+
}
585
+
586
+
bf := bm.newBundleFile(bundleNum)
587
+
588
+
if !bf.exists() {
589
+
return nil, fmt.Errorf("bundle %06d not found", bundleNum)
590
+
}
591
+
592
+
if err := bm.load(bf); err != nil {
593
+
return nil, err
594
+
}
595
+
596
+
return bf.operations, nil
597
+
}