chore: untrack sqlite_api + registry.db, expand fn_monitoring docs
- sqlite_api se extrae a su propio repo Gitea (dataforge/sqlite_api), siguiendo la convencion de apps/*/ (cada app = su repo). - registry.db ya estaba en .gitignore (regenerable con fn index + fn sync), pero seguia tracked por historia. Destracked. - project.md de fn_monitoring ampliado con operacion completa: arranque del service (dev / start.sh / systemd user), flujo de datos dashboard, troubleshooting, como extender. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,59 +0,0 @@
|
||||
---
|
||||
name: sqlite_api
|
||||
lang: go
|
||||
domain: infra
|
||||
description: "API REST HTTP read-only sobre registry.db y operations.db de cada app. Permite consultas SQL (solo SELECT/PRAGMA), busqueda FTS5, exploracion de tablas y schema. Bind por defecto a localhost:8484."
|
||||
tags: [service, api, sqlite, http, registry, fts5]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
framework: "net/http"
|
||||
entry_point: "main.go"
|
||||
dir_path: "projects/fn_monitoring/apps/sqlite_api"
|
||||
---
|
||||
|
||||
## Uso
|
||||
|
||||
```bash
|
||||
# Arrancar (default: 127.0.0.1:8484)
|
||||
cd apps/sqlite_api && go run -tags fts5 .
|
||||
|
||||
# Bind personalizado
|
||||
go run -tags fts5 . --bind 0.0.0.0:8484
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Metodo | Path | Descripcion |
|
||||
|--------|------|-------------|
|
||||
| GET | `/health` | Health check |
|
||||
| GET | `/api/databases` | Lista DBs disponibles (registry + ops:*) |
|
||||
| GET | `/api/databases/:db/tables` | Tablas y vistas de una DB |
|
||||
| GET | `/api/databases/:db/schema` | Schema SQL completo |
|
||||
| POST | `/api/databases/:db/query` | Ejecuta query SQL read-only |
|
||||
| GET | `/api/databases/:db/fts?q=...&table=...` | Busqueda FTS5 directa |
|
||||
|
||||
## Seguridad
|
||||
|
||||
- Solo queries SELECT, PRAGMA, WITH y EXPLAIN
|
||||
- SQLite abierto con `?mode=ro` (read-only a nivel driver)
|
||||
- Timeout de 5 segundos por query
|
||||
- Bind a localhost por defecto
|
||||
- CORS habilitado para acceso desde frontends
|
||||
|
||||
## Bases de datos
|
||||
|
||||
- `registry` — registry.db de la raiz
|
||||
- `ops:{app}` — operations.db de apps/{app}/ y projects/*/apps/{app}/
|
||||
|
||||
Auto-descubre operations.db al arrancar escaneando apps/ y projects/*/apps/.
|
||||
|
||||
## Health check
|
||||
|
||||
```bash
|
||||
curl http://localhost:8484/health
|
||||
# {"status":"ok"}
|
||||
```
|
||||
|
||||
## Puerto
|
||||
|
||||
8484 (no colisiona con Metabase 3000, Jupyter 8888, deploy_server 9090).
|
||||
@@ -1,190 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// DBEntry represents a registered database.
|
||||
type DBEntry struct {
|
||||
Alias string // "registry" or "ops:app_name"
|
||||
Path string // absolute path to .db file
|
||||
Kind string // "registry" or "operations"
|
||||
}
|
||||
|
||||
// DBPool manages read-only connections to registered databases.
|
||||
type DBPool struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]DBEntry
|
||||
conns map[string]*sql.DB
|
||||
}
|
||||
|
||||
func NewDBPool() *DBPool {
|
||||
return &DBPool{
|
||||
entries: make(map[string]DBEntry),
|
||||
conns: make(map[string]*sql.DB),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a database entry. Does not open the connection until first use.
|
||||
func (p *DBPool) Register(entry DBEntry) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.entries[entry.Alias] = entry
|
||||
}
|
||||
|
||||
// Get returns a read-only connection to the named database.
|
||||
func (p *DBPool) Get(alias string) (*sql.DB, error) {
|
||||
p.mu.RLock()
|
||||
if db, ok := p.conns[alias]; ok {
|
||||
p.mu.RUnlock()
|
||||
return db, nil
|
||||
}
|
||||
p.mu.RUnlock()
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock.
|
||||
if db, ok := p.conns[alias]; ok {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
entry, ok := p.entries[alias]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("database %q not found", alias)
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?mode=ro", entry.Path)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening %s: %w", alias, err)
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("pinging %s: %w", alias, err)
|
||||
}
|
||||
db.SetMaxOpenConns(4)
|
||||
p.conns[alias] = db
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// List returns all registered database entries.
|
||||
func (p *DBPool) List() []DBEntry {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
out := make([]DBEntry, 0, len(p.entries))
|
||||
for _, e := range p.entries {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Close closes all open connections.
|
||||
func (p *DBPool) Close() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, db := range p.conns {
|
||||
db.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Config holds the server configuration.
|
||||
type Config struct {
|
||||
Bind string // address to bind (default "127.0.0.1:8484")
|
||||
RegistryDir string // root of fn_registry (where registry.db lives)
|
||||
}
|
||||
|
||||
// DiscoverDatabases finds registry.db and all operations.db files.
|
||||
func DiscoverDatabases(root string) []DBEntry {
|
||||
var entries []DBEntry
|
||||
|
||||
// registry.db
|
||||
regPath := filepath.Join(root, "registry.db")
|
||||
if _, err := os.Stat(regPath); err == nil {
|
||||
entries = append(entries, DBEntry{
|
||||
Alias: "registry",
|
||||
Path: regPath,
|
||||
Kind: "registry",
|
||||
})
|
||||
}
|
||||
|
||||
// apps/*/operations.db
|
||||
appsDir := filepath.Join(root, "apps")
|
||||
dirEntries, err := os.ReadDir(appsDir)
|
||||
if err != nil {
|
||||
return entries
|
||||
}
|
||||
for _, d := range dirEntries {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
opsPath := filepath.Join(appsDir, d.Name(), "operations.db")
|
||||
if _, err := os.Stat(opsPath); err == nil {
|
||||
entries = append(entries, DBEntry{
|
||||
Alias: "ops:" + d.Name(),
|
||||
Path: opsPath,
|
||||
Kind: "operations",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// projects/*/apps/*/operations.db
|
||||
projectsDir := filepath.Join(root, "projects")
|
||||
projEntries, err := os.ReadDir(projectsDir)
|
||||
if err != nil {
|
||||
return entries
|
||||
}
|
||||
for _, p := range projEntries {
|
||||
if !p.IsDir() {
|
||||
continue
|
||||
}
|
||||
projAppsDir := filepath.Join(projectsDir, p.Name(), "apps")
|
||||
projAppEntries, err := os.ReadDir(projAppsDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, a := range projAppEntries {
|
||||
if !a.IsDir() {
|
||||
continue
|
||||
}
|
||||
opsPath := filepath.Join(projAppsDir, a.Name(), "operations.db")
|
||||
if _, err := os.Stat(opsPath); err == nil {
|
||||
entries = append(entries, DBEntry{
|
||||
Alias: "ops:" + a.Name(),
|
||||
Path: opsPath,
|
||||
Kind: "operations",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// ValidateQuery checks that the SQL is a read-only statement (SELECT or PRAGMA).
|
||||
func ValidateQuery(sql string) error {
|
||||
trimmed := strings.TrimSpace(sql)
|
||||
upper := strings.ToUpper(trimmed)
|
||||
|
||||
// Remove leading comments
|
||||
for strings.HasPrefix(upper, "--") {
|
||||
idx := strings.Index(upper, "\n")
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("query contains only comments")
|
||||
}
|
||||
upper = strings.TrimSpace(upper[idx+1:])
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "SELECT") || strings.HasPrefix(upper, "PRAGMA") ||
|
||||
strings.HasPrefix(upper, "WITH") || strings.HasPrefix(upper, "EXPLAIN") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("only SELECT, PRAGMA, WITH, and EXPLAIN queries are allowed")
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const queryTimeout = 5 * time.Second
|
||||
|
||||
// Server holds the HTTP handlers and DB pool.
|
||||
type Server struct {
|
||||
pool *DBPool
|
||||
}
|
||||
|
||||
func NewServer(pool *DBPool) *Server {
|
||||
return &Server{pool: pool}
|
||||
}
|
||||
|
||||
// Routes registers all API routes on the given mux.
|
||||
func (s *Server) Routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("GET /api/databases", s.handleDatabases)
|
||||
mux.HandleFunc("GET /api/databases/{db}/tables", s.handleTables)
|
||||
mux.HandleFunc("GET /api/databases/{db}/schema", s.handleSchema)
|
||||
mux.HandleFunc("POST /api/databases/{db}/query", s.handleQuery)
|
||||
mux.HandleFunc("GET /api/databases/{db}/fts", s.handleFTS)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleDatabases(w http.ResponseWriter, r *http.Request) {
|
||||
entries := s.pool.List()
|
||||
type dbInfo struct {
|
||||
Alias string `json:"alias"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
out := make([]dbInfo, len(entries))
|
||||
for i, e := range entries {
|
||||
out[i] = dbInfo{Alias: e.Alias, Kind: e.Kind}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleTables(w http.ResponseWriter, r *http.Request) {
|
||||
dbAlias := resolveDBAlias(r.PathValue("db"))
|
||||
db, err := s.pool.Get(dbAlias)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
rows, err := db.QueryContext(ctx, "SELECT type, name FROM sqlite_master WHERE type IN ('table','view') ORDER BY type, name")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type tableInfo struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var tables []tableInfo
|
||||
for rows.Next() {
|
||||
var t tableInfo
|
||||
if err := rows.Scan(&t.Type, &t.Name); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
tables = append(tables, t)
|
||||
}
|
||||
if tables == nil {
|
||||
tables = []tableInfo{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tables)
|
||||
}
|
||||
|
||||
func (s *Server) handleSchema(w http.ResponseWriter, r *http.Request) {
|
||||
dbAlias := resolveDBAlias(r.PathValue("db"))
|
||||
db, err := s.pool.Get(dbAlias)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
rows, err := db.QueryContext(ctx, "SELECT sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type, name")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var statements []string
|
||||
for rows.Next() {
|
||||
var stmt string
|
||||
if err := rows.Scan(&stmt); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"statements": statements,
|
||||
"count": len(statements),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
|
||||
dbAlias := resolveDBAlias(r.PathValue("db"))
|
||||
db, err := s.pool.Get(dbAlias)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
SQL string `json:"sql"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.SQL == "" {
|
||||
writeError(w, http.StatusBadRequest, "sql field is required")
|
||||
return
|
||||
}
|
||||
if err := ValidateQuery(req.SQL); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
rows, err := db.QueryContext(ctx, req.SQL)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var resultRows [][]any
|
||||
for rows.Next() {
|
||||
vals := make([]any, len(columns))
|
||||
ptrs := make([]any, len(columns))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Convert []byte to string for JSON serialization.
|
||||
for i, v := range vals {
|
||||
if b, ok := v.([]byte); ok {
|
||||
vals[i] = string(b)
|
||||
}
|
||||
}
|
||||
resultRows = append(resultRows, vals)
|
||||
}
|
||||
if resultRows == nil {
|
||||
resultRows = [][]any{}
|
||||
}
|
||||
|
||||
durationMs := time.Since(start).Milliseconds()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"columns": columns,
|
||||
"rows": resultRows,
|
||||
"count": len(resultRows),
|
||||
"duration_ms": durationMs,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleFTS(w http.ResponseWriter, r *http.Request) {
|
||||
dbAlias := resolveDBAlias(r.PathValue("db"))
|
||||
db, err := s.pool.Get(dbAlias)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query().Get("q")
|
||||
if q == "" {
|
||||
writeError(w, http.StatusBadRequest, "q parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
table := r.URL.Query().Get("table")
|
||||
if table == "" {
|
||||
table = "functions"
|
||||
}
|
||||
|
||||
// Map table to its FTS table and columns to return.
|
||||
ftsTable := table + "_fts"
|
||||
var selectCols string
|
||||
switch table {
|
||||
case "functions":
|
||||
selectCols = "id, name, kind, purity, domain, description"
|
||||
case "types":
|
||||
selectCols = "id, name, algebraic, domain, description"
|
||||
case "unit_tests":
|
||||
selectCols = "id, name, function_id, lang"
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported FTS table: %s", table))
|
||||
return
|
||||
}
|
||||
|
||||
limit := r.URL.Query().Get("limit")
|
||||
if limit == "" {
|
||||
limit = "20"
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"SELECT %s FROM %s WHERE id IN (SELECT id FROM %s WHERE %s MATCH ?) LIMIT %s",
|
||||
selectCols, table, ftsTable, ftsTable, limit,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
rows, err := db.QueryContext(ctx, query, q)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var resultRows [][]any
|
||||
for rows.Next() {
|
||||
vals := make([]any, len(columns))
|
||||
ptrs := make([]any, len(columns))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for i, v := range vals {
|
||||
if b, ok := v.([]byte); ok {
|
||||
vals[i] = string(b)
|
||||
}
|
||||
}
|
||||
resultRows = append(resultRows, vals)
|
||||
}
|
||||
if resultRows == nil {
|
||||
resultRows = [][]any{}
|
||||
}
|
||||
|
||||
durationMs := time.Since(start).Milliseconds()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"columns": columns,
|
||||
"rows": resultRows,
|
||||
"count": len(resultRows),
|
||||
"duration_ms": durationMs,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveDBAlias converts URL path segment to pool alias.
|
||||
// "registry" → "registry", "ops:app_name" → "ops:app_name"
|
||||
// URL-encoded colons: "ops%3Aapp_name" → already decoded by net/http.
|
||||
func resolveDBAlias(raw string) string {
|
||||
return raw
|
||||
}
|
||||
|
||||
// CORS middleware for browser access.
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// sanitizeFTS escapes special FTS5 characters in user input.
|
||||
func sanitizeFTS(input string) string {
|
||||
// Wrap each term in double quotes to treat as literal.
|
||||
terms := strings.Fields(input)
|
||||
for i, t := range terms {
|
||||
terms[i] = `"` + strings.ReplaceAll(t, `"`, `""`) + `"`
|
||||
}
|
||||
return strings.Join(terms, " ")
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) (*DBPool, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create a small test database with FTS5.
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stmts := []string{
|
||||
`CREATE TABLE items (id TEXT PRIMARY KEY, name TEXT, kind TEXT)`,
|
||||
`INSERT INTO items VALUES ('a', 'alpha', 'first')`,
|
||||
`INSERT INTO items VALUES ('b', 'beta', 'second')`,
|
||||
`CREATE VIRTUAL TABLE items_fts USING fts5(id, name, kind, content=items, content_rowid=rowid)`,
|
||||
`INSERT INTO items_fts(items_fts) VALUES('rebuild')`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("setup sql %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
db.Close()
|
||||
|
||||
pool := NewDBPool()
|
||||
pool.Register(DBEntry{Alias: "testdb", Path: dbPath, Kind: "test"})
|
||||
return pool, dir
|
||||
}
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
pool := NewDBPool()
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %s", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabasesEndpoint(t *testing.T) {
|
||||
pool := NewDBPool()
|
||||
pool.Register(DBEntry{Alias: "registry", Path: "/fake/path", Kind: "registry"})
|
||||
pool.Register(DBEntry{Alias: "ops:myapp", Path: "/fake/path2", Kind: "operations"})
|
||||
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/databases", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp []map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if len(resp) != 2 {
|
||||
t.Fatalf("expected 2 databases, got %d", len(resp))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryEndpoint(t *testing.T) {
|
||||
pool, _ := setupTestDB(t)
|
||||
defer pool.Close()
|
||||
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
body := `{"sql": "SELECT id, name FROM items ORDER BY id"}`
|
||||
req := httptest.NewRequest("POST", "/api/databases/testdb/query", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if int(resp["count"].(float64)) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %v", resp["count"])
|
||||
}
|
||||
cols := resp["columns"].([]any)
|
||||
if len(cols) != 2 || cols[0] != "id" || cols[1] != "name" {
|
||||
t.Fatalf("unexpected columns: %v", cols)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRejectsWrite(t *testing.T) {
|
||||
pool, _ := setupTestDB(t)
|
||||
defer pool.Close()
|
||||
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
cases := []string{
|
||||
`{"sql": "INSERT INTO items VALUES ('c', 'gamma', 'third')"}`,
|
||||
`{"sql": "UPDATE items SET name = 'x' WHERE id = 'a'"}`,
|
||||
`{"sql": "DELETE FROM items WHERE id = 'a'"}`,
|
||||
`{"sql": "DROP TABLE items"}`,
|
||||
}
|
||||
|
||||
for _, body := range cases {
|
||||
req := httptest.NewRequest("POST", "/api/databases/testdb/query", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for %s, got %d", body, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablesEndpoint(t *testing.T) {
|
||||
pool, _ := setupTestDB(t)
|
||||
defer pool.Close()
|
||||
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/databases/testdb/tables", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp []map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if len(resp) == 0 {
|
||||
t.Fatal("expected at least one table")
|
||||
}
|
||||
found := false
|
||||
for _, tbl := range resp {
|
||||
if tbl["name"] == "items" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected 'items' table in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaEndpoint(t *testing.T) {
|
||||
pool, _ := setupTestDB(t)
|
||||
defer pool.Close()
|
||||
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/databases/testdb/schema", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
count := int(resp["count"].(float64))
|
||||
if count == 0 {
|
||||
t.Fatal("expected at least one schema statement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFoundDB(t *testing.T) {
|
||||
pool := NewDBPool()
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
body := `{"sql": "SELECT 1"}`
|
||||
req := httptest.NewRequest("POST", "/api/databases/nonexistent/query", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateQuery(t *testing.T) {
|
||||
valid := []string{
|
||||
"SELECT * FROM t",
|
||||
" select id from t",
|
||||
"PRAGMA table_info(t)",
|
||||
"WITH cte AS (SELECT 1) SELECT * FROM cte",
|
||||
"EXPLAIN SELECT * FROM t",
|
||||
"-- comment\nSELECT 1",
|
||||
}
|
||||
for _, q := range valid {
|
||||
if err := ValidateQuery(q); err != nil {
|
||||
t.Errorf("expected valid: %q, got error: %v", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"INSERT INTO t VALUES (1)",
|
||||
"UPDATE t SET x = 1",
|
||||
"DELETE FROM t",
|
||||
"DROP TABLE t",
|
||||
"CREATE TABLE t (id INT)",
|
||||
"ALTER TABLE t ADD COLUMN x INT",
|
||||
}
|
||||
for _, q := range invalid {
|
||||
if err := ValidateQuery(q); err == nil {
|
||||
t.Errorf("expected invalid: %q, got nil error", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverDatabases(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create registry.db
|
||||
os.WriteFile(filepath.Join(dir, "registry.db"), []byte{}, 0644)
|
||||
|
||||
// Create apps/myapp/operations.db
|
||||
os.MkdirAll(filepath.Join(dir, "apps", "myapp"), 0755)
|
||||
os.WriteFile(filepath.Join(dir, "apps", "myapp", "operations.db"), []byte{}, 0644)
|
||||
|
||||
// Create projects/proj1/apps/papp/operations.db
|
||||
os.MkdirAll(filepath.Join(dir, "projects", "proj1", "apps", "papp"), 0755)
|
||||
os.WriteFile(filepath.Join(dir, "projects", "proj1", "apps", "papp", "operations.db"), []byte{}, 0644)
|
||||
|
||||
entries := DiscoverDatabases(dir)
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d: %+v", len(entries), entries)
|
||||
}
|
||||
|
||||
aliases := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
aliases[e.Alias] = true
|
||||
}
|
||||
for _, want := range []string{"registry", "ops:myapp", "ops:papp"} {
|
||||
if !aliases[want] {
|
||||
t.Errorf("missing alias %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func main() {
|
||||
bind := flag.String("bind", "127.0.0.1:8484", "address to bind")
|
||||
flag.Parse()
|
||||
|
||||
root := findRegistryRoot()
|
||||
if root == "" {
|
||||
log.Fatal("cannot find fn_registry root (no registry.db found). Set FN_REGISTRY_ROOT or run from the registry directory.")
|
||||
}
|
||||
|
||||
pool := NewDBPool()
|
||||
for _, entry := range DiscoverDatabases(root) {
|
||||
pool.Register(entry)
|
||||
log.Printf("registered database: %s (%s)", entry.Alias, entry.Path)
|
||||
}
|
||||
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
handler := corsMiddleware(mux)
|
||||
|
||||
log.Printf("sqlite_api listening on %s (registry root: %s)", *bind, root)
|
||||
if err := http.ListenAndServe(*bind, handler); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// findRegistryRoot walks up from cwd (or uses FN_REGISTRY_ROOT) to find registry.db.
|
||||
func findRegistryRoot() string {
|
||||
if env := os.Getenv("FN_REGISTRY_ROOT"); env != "" {
|
||||
if _, err := os.Stat(filepath.Join(env, "registry.db")); err == nil {
|
||||
return env
|
||||
}
|
||||
}
|
||||
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "registry.db")); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
log.SetFlags(log.Ltime)
|
||||
log.SetPrefix("[sqlite_api] ")
|
||||
fmt.Fprintln(os.Stderr, "sqlite_api — HTTP API for fn_registry databases")
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start sqlite_api in the background. Logs to sqlite_api.log.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
LOG="$SCRIPT_DIR/sqlite_api.log"
|
||||
PID_FILE="$SCRIPT_DIR/sqlite_api.pid"
|
||||
|
||||
# Kill previous instance if running
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
old_pid=$(cat "$PID_FILE")
|
||||
if kill -0 "$old_pid" 2>/dev/null; then
|
||||
echo "Stopping previous instance (PID $old_pid)"
|
||||
kill "$old_pid"
|
||||
sleep 0.5
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
export FN_REGISTRY_ROOT="$REGISTRY_ROOT"
|
||||
|
||||
cd "$REGISTRY_ROOT"
|
||||
CGO_ENABLED=1 go run -tags fts5 ./projects/fn_monitoring/apps/sqlite_api/ "$@" \
|
||||
>"$LOG" 2>&1 &
|
||||
|
||||
echo $! > "$PID_FILE"
|
||||
echo "sqlite_api started (PID $!, log: $LOG)"
|
||||
sleep 0.5
|
||||
if curl -sf http://127.0.0.1:8484/health >/dev/null 2>&1; then
|
||||
echo "Health check OK"
|
||||
else
|
||||
echo "Waiting for startup..."
|
||||
sleep 1.5
|
||||
if curl -sf http://127.0.0.1:8484/health >/dev/null 2>&1; then
|
||||
echo "Health check OK"
|
||||
else
|
||||
echo "Warning: health check failed — check $LOG"
|
||||
fi
|
||||
fi
|
||||
@@ -7,18 +7,220 @@ repo_url: ""
|
||||
|
||||
## Apps
|
||||
|
||||
| App | Descripcion |
|
||||
|-----|-------------|
|
||||
| sqlite_api | API REST HTTP read-only para registry.db y operations.db (Go, net/http, puerto 8484) |
|
||||
| registry_dashboard | Dashboard ImGui con KPIs, charts y tablas del registry (C++, consume sqlite_api) |
|
||||
| App | Lang | Descripcion |
|
||||
|-----|------|-------------|
|
||||
| [sqlite_api](apps/sqlite_api/app.md) | Go | API REST HTTP read-only sobre `registry.db` y todas las `operations.db`. Puerto `8484`. |
|
||||
| [registry_dashboard](apps/registry_dashboard/app.md) | C++ / ImGui | Dashboard con KPIs, charts y tablas del registry. Consume `sqlite_api` (HTTP) con fallback a SQLite directo. |
|
||||
|
||||
Cada `app.md` es la referencia canonica del binario — endpoints completos, flags, dependencias. Este documento cubre **como operar el proyecto como un todo**: arranque, service, flujo de datos, troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
sqlite_api (Go, :8484)
|
||||
└── registry.db, apps/*/operations.db (read-only)
|
||||
↑
|
||||
registry_dashboard (C++/ImGui)
|
||||
└── HTTP GET/POST → sqlite_api
|
||||
└── Fallback: SQLite directo si API no disponible
|
||||
registry.db (raiz)
|
||||
apps/*/operations.db
|
||||
projects/*/apps/*/operations.db
|
||||
│ (read-only, mode=ro)
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ sqlite_api (Go net/http, :8484) │
|
||||
│ /health │
|
||||
│ /api/databases │
|
||||
│ /api/databases/:db/tables │
|
||||
│ /api/databases/:db/schema │
|
||||
│ /api/databases/:db/query (POST, SELECT) │
|
||||
│ /api/databases/:db/fts │
|
||||
└──────────────────────────────────────────────┘
|
||||
▲
|
||||
│ HTTP GET/POST
|
||||
│ (cpp-httplib + nlohmann/json)
|
||||
│
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ registry_dashboard (C++ / ImGui + ImPlot) │
|
||||
│ main.cpp → reload_data() │
|
||||
│ data_http.cpp (primario, HTTP) │
|
||||
│ data.cpp (fallback, SQLite C API) │
|
||||
│ views.cpp → KPI row, charts, tables │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Separacion de responsabilidades:**
|
||||
|
||||
- `sqlite_api` **no conoce el dashboard**. Es una API generica: expone cualquier DB SQLite de `fn_registry/` read-only con FTS5.
|
||||
- `registry_dashboard` **no conoce la estructura de registry.db directamente**, solo a traves del JSON que devuelve la API. El modo SQLite directo es fallback para entornos sin red.
|
||||
|
||||
**Puerto `8484`** — elegido para no colisionar con Metabase (3000), Jupyter (8888) ni deploy_server (9090).
|
||||
|
||||
---
|
||||
|
||||
## Servicio sqlite_api
|
||||
|
||||
### Modos de arranque
|
||||
|
||||
| Modo | Comando | Cuando usarlo |
|
||||
|------|---------|---------------|
|
||||
| Dev (foreground, `go run`) | `cd projects/fn_monitoring/apps/sqlite_api && go run -tags fts5 .` | Iteracion rapida, ver logs en la terminal |
|
||||
| Dev (background) | `./start.sh` (dentro de `apps/sqlite_api/`) | Probar el dashboard rapido sin systemd. Escribe PID en `sqlite_api.pid` y log en `sqlite_api.log` |
|
||||
| Production (systemd) | `sudo systemctl start sqlite_api` | Arranque en boot, restart on failure, logs en journal |
|
||||
|
||||
### Variables de entorno
|
||||
|
||||
| Var | Valor | Proposito |
|
||||
|-----|-------|-----------|
|
||||
| `FN_REGISTRY_ROOT` | ruta absoluta a la raiz del registry | Evita que el binario busque `registry.db` subiendo por el cwd. Obligatoria bajo systemd. |
|
||||
|
||||
### Instalar como servicio systemd (local)
|
||||
|
||||
Usar el pipeline del registry `install_systemd_service_bash_pipelines`:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
|
||||
# 1. Build del binario
|
||||
CGO_ENABLED=1 go build -tags fts5 \
|
||||
-o projects/fn_monitoring/apps/sqlite_api/sqlite_api \
|
||||
./projects/fn_monitoring/apps/sqlite_api/
|
||||
|
||||
# 2. Instalar unit + enable + start (requiere sudo sin password para systemctl)
|
||||
source bash/functions/pipelines/install_systemd_service.sh
|
||||
install_systemd_service \
|
||||
--name sqlite_api \
|
||||
--exec "$(pwd)/projects/fn_monitoring/apps/sqlite_api/sqlite_api" \
|
||||
--workdir "$(pwd)" \
|
||||
--env "FN_REGISTRY_ROOT=$(pwd)" \
|
||||
--description "fn_registry SQLite HTTP API" \
|
||||
--after network.target \
|
||||
--restart on-failure
|
||||
```
|
||||
|
||||
### Operacion
|
||||
|
||||
```bash
|
||||
sudo systemctl status sqlite_api # estado + ultimas lineas del journal
|
||||
sudo systemctl restart sqlite_api # tras rebuild del binario
|
||||
sudo systemctl stop sqlite_api # parar
|
||||
journalctl -u sqlite_api -f # logs en vivo
|
||||
curl http://127.0.0.1:8484/health # health check
|
||||
```
|
||||
|
||||
### Redeploy tras cambios en el codigo Go
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
CGO_ENABLED=1 go build -tags fts5 \
|
||||
-o projects/fn_monitoring/apps/sqlite_api/sqlite_api \
|
||||
./projects/fn_monitoring/apps/sqlite_api/
|
||||
sudo systemctl restart sqlite_api
|
||||
```
|
||||
|
||||
No hace falta reinstalar el unit — solo recompilar y reiniciar.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard registry_dashboard
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd cpp
|
||||
cmake -B build/linux -S .
|
||||
cmake --build build/linux --target registry_dashboard -j$(nproc)
|
||||
```
|
||||
|
||||
El binario queda en `cpp/build/linux/registry_dashboard` (o `projects/fn_monitoring/apps/registry_dashboard/registry_dashboard.exe` en Windows).
|
||||
|
||||
### Ejecucion
|
||||
|
||||
```bash
|
||||
# Modo API (por defecto, intenta localhost:8484)
|
||||
./registry_dashboard
|
||||
|
||||
# API remoto
|
||||
./registry_dashboard --api http://192.168.1.10:8484
|
||||
|
||||
# API + fallback SQLite
|
||||
./registry_dashboard --api http://127.0.0.1:8484 /home/lucas/fn_registry/registry.db
|
||||
|
||||
# Solo SQLite (sin API)
|
||||
./registry_dashboard /home/lucas/fn_registry/registry.db
|
||||
```
|
||||
|
||||
La UI muestra en la cabecera de donde vienen los datos (HTTP vs SQLite). `F5` recarga.
|
||||
|
||||
### Flujo de datos
|
||||
|
||||
1. `main.cpp::reload_data()` intenta HTTP primero via `load_registry_data_http()`.
|
||||
2. Si la API responde `200` y el JSON parsea, los datos pueblan `RegistryData`.
|
||||
3. Si falla la API (timeout, 5xx, JSON invalido) y hay `--db`, cae a `load_registry_data()` (SQLite directo).
|
||||
4. Si ninguno funciona, la UI muestra un mensaje de error y no hay reintento automatico — hay que pulsar reload.
|
||||
|
||||
### Vistas
|
||||
|
||||
| Seccion | Datos | Query subyacente |
|
||||
|---------|-------|------------------|
|
||||
| KPI row (8 cards) | totales y porcentajes | `SELECT COUNT(*)` sobre functions, types, apps, analysis, unit_tests, proposals + agregados tested/pure |
|
||||
| Charts (bar + pie) | funciones por lang/domain, reparto pure/impure, kind | `GROUP BY lang`, `GROUP BY domain`, `GROUP BY purity`, `GROUP BY kind` |
|
||||
| Tablas | ultimas 20 functions, apps, analysis, types | `ORDER BY updated_at DESC LIMIT 20` |
|
||||
|
||||
Detalle de composicion de componentes viz en `apps/registry_dashboard/app.md`.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Sintoma | Causa probable | Verificacion / Fix |
|
||||
|---------|----------------|--------------------|
|
||||
| Dashboard dice "HTTP API failed, falling back to SQLite" | `sqlite_api` no esta corriendo | `curl http://127.0.0.1:8484/health` — si falla, `systemctl status sqlite_api` o `./start.sh` |
|
||||
| `sqlite_api` arranca y muere inmediatamente | No encuentra `registry.db` | Exportar `FN_REGISTRY_ROOT=/home/lucas/fn_registry` o correr desde la raiz del registry |
|
||||
| `systemctl start sqlite_api` pide password | Falta sudoers para systemctl | Ver `.claude/rules/deploy.md` — el usuario necesita `NOPASSWD` para `systemctl`, `mv` a `/etc/systemd/system/` |
|
||||
| Dashboard abre pero todas las cifras son 0 | API conecta pero devuelve DB vacia | `curl -X POST http://127.0.0.1:8484/api/databases/registry/query -d '{"sql":"SELECT COUNT(*) FROM functions"}'` |
|
||||
| API responde lento / timeout | Query pesada sobre FTS5 | Timeout hardcoded a 5s en `handlers.go`. Revisar la query en journal. |
|
||||
| Bind rechazado (`address already in use`) | Otro proceso en `8484` | `ss -tlnp | grep 8484` — matar el huerfano o cambiar `--bind` |
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# systemd
|
||||
journalctl -u sqlite_api -n 100 --no-pager
|
||||
journalctl -u sqlite_api -f
|
||||
|
||||
# start.sh
|
||||
tail -f projects/fn_monitoring/apps/sqlite_api/sqlite_api.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Como extender
|
||||
|
||||
### Anadir un endpoint a sqlite_api
|
||||
|
||||
1. Registrar la ruta en `Server.Routes()` (`handlers.go`).
|
||||
2. Handler lee `r.URL.Path` / `r.Body`, delega en `DBPool` para resolver la DB, ejecuta SQL read-only.
|
||||
3. Test en `handlers_test.go` (patron: tabla de casos HTTP).
|
||||
4. Rebuild + `systemctl restart sqlite_api`.
|
||||
5. Documentar en `apps/sqlite_api/app.md` (tabla de endpoints).
|
||||
|
||||
### Anadir una vista al dashboard
|
||||
|
||||
1. Nuevo campo en `RegistryData` (`data.h`) + su equivalente en la respuesta JSON.
|
||||
2. Parseo en `data_http.cpp` y carga SQL en `data.cpp` (ambos paths, para mantener el fallback).
|
||||
3. Renderizado en `views.cpp` usando componentes del dominio `viz` (`kpi_card`, `bar_chart`, etc.) — ver regla `frontend_theming` analoga para C++: usar primitivos del registry antes que ImGui crudo.
|
||||
4. Rebuild con CMake.
|
||||
|
||||
### Anadir una DB nueva
|
||||
|
||||
La descubre automaticamente `DiscoverDatabases()` escaneando `apps/*/operations.db` y `projects/*/apps/*/operations.db`. No hay que registrar nada — al reiniciar `sqlite_api` aparecen con alias `ops:{app_name}`.
|
||||
|
||||
---
|
||||
|
||||
## Deploy en otros PCs
|
||||
|
||||
Este proyecto se instala identico en cualquier maquina con el registry clonado:
|
||||
|
||||
1. `fn sync` para traer los metadatos del proyecto.
|
||||
2. Build + systemd install (seccion "Instalar como servicio systemd" arriba).
|
||||
3. Build del dashboard.
|
||||
|
||||
Los datos son los `.db` locales — cada PC ve su propio estado del registry y sus propias `operations.db`. No hay sincronizacion remota de datos en este servicio: para eso existe `fn sync` contra `registry_api` (proyecto diferente, ver memoria `project_registry_api`).
|
||||
|
||||
Reference in New Issue
Block a user