Explorar el Código

Updated Serverless logging, and interface (#222)

* feat(serverless): modern System Settings UI + per-endpoint execution metrics

- Gateway struct gains endpointStats map (sync.RWMutex-guarded) so every
  external AGI invocation is tracked in memory without touching the DB.
- Each ExtAPIHandler call now assigns a UUID request-ID, measures wall-clock
  duration, and calls recordExecution() which maintains:
    • total / successful / failed execution counters
    • cumulative and average execution time (ms)
    • last-called Unix timestamp
    • ring-buffer of last 10 successful and 10 failed ExecLog entries
      (request_id, timestamp, duration_ms, method, message)
- New GetEndpointStats handler returns per-endpoint stats for the current
  user; endpoints with no calls yet are returned with zeroed counters.
- New GET /api/ajgi/stats route registered via the existing externalAGIRouter.
- Serverless index.html rewritten as a macOS System Settings-style SPA:
    • Persistent sidebar with Endpoints / Statistics / Execution Logs nav
    • Global stat strip: endpoint count, total / successful / failed calls
    • Per-endpoint cards with success bar, avg exec time, last-called time
    • Statistics page: full per-endpoint metric grid + dual-colour ratio bar
    • Logs page: tabbed Success / Failed table (last 10 each, all endpoints
      merged and sorted newest-first) with request ID, timestamp, duration,
      and message columns
    • Inline 'New Endpoint' panel with file picker (replaces old form)
    • Toast notifications for copy / add / delete actions
    • Zero external CSS frameworks — pure CSS custom properties + flexbox/grid

https://claude.ai/code/session_01RGUh8MTYU73Twu1J6PqdZx

* fix(serverless): use named fileLoader callback for file selector

ao_module_openFileSelector in virtual-desktop mode reads callback.name
and injects it as an eval string into the floating selector window.
Anonymous functions have an empty .name, causing the selector to fail
with 'Selection Failed. Is parent window alive?'.

Replace the inline anonymous function with a named top-level fileLoader
function so the selector can call back correctly in both virtual-desktop
and normal (new-tab / localStorage polling) modes.

https://claude.ai/code/session_01RGUh8MTYU73Twu1J6PqdZx

* feat(serverless): persist stats to BoltDB + dual-colour endpoint bar

Backend — externalReqHandler.go
• Added ext_agi_stats BoltDB table for persistent metrics storage.
• ensureStatsTable / loadStatsFromDB / saveStatsToDB / deleteStatsFromDB
  helpers wrap the same two-level JSON encoding pattern used by the rest
  of the codebase (Write stores a string; Read decodes one JSON layer).
• recordExecution: on the first execution of a given UUID the in-memory
  cache is cold; we now release the mutex, load from BoltDB, re-acquire
  and re-check to avoid a race, then fall back to a blank entry.  After
  updating the struct we marshal under the lock (consistent snapshot) and
  write to BoltDB outside the lock to avoid holding it during I/O.
• GetEndpointStats: switched to a write lock so DB-loaded entries can be
  promoted into the memory cache; applies the same release/reload/re-check
  pattern as recordExecution to avoid TOCTOU races.
• RemoveExternalEndPoint: now also calls deleteStatsFromDB so removed
  endpoints do not leave stale rows in ext_agi_stats.
• Result: all stats (counters, average time, last-called timestamp, last-10
  success/failure logs with request-ID) survive process restarts.

Frontend — index.html
• renderEndpointCard: added failRate variable (mirrors what the Statistics
  page already computes) and replaced the single-colour green success bar
  with the same dual-colour success/failure bar used on the Statistics page
  — green left segment for success %, red right segment for failure %.
• Added a two-label legend beneath the bar (● X% success  ● Y% failed).

https://claude.ai/code/session_01RGUh8MTYU73Twu1J6PqdZx

* fix(serverless): grey bar for zero-execution state + SVG icons everywhere

Grey bar / no-executions state
- Statistics page: failRate was computed as (100 - rate) which caused the
  bar to show 100% red and the legend to read '100% failed' when total = 0.
  Now failRate = total ? round(fail/total*100) : 0 (mirrors the same fix
  already applied to the endpoint card).
- Both Statistics and Endpoints pages now render a solid grey bar with a
  'No executions recorded' label when total_executions == 0, instead of
  an invisible or misleading coloured bar.
- When total > 0 but one side is 0%, border-radius is adjusted so the
  single remaining segment stays fully rounded rather than half-flat.

SVG icons (no more emojis)
- Log tabs: '✅ Successful' / '❌ Failed' replaced with inline Material
  checkmark / close SVG paths coloured via CSS variables.
- Log-table status badge: '✓ OK' / '✗ Error' text replaced with the same
  SVG paths at 11 px, vertically aligned inside the badge.
- Legend dots in both Endpoints and Statistics pages: '●' bullet character
  replaced with an 8×8 SVG circle element (fill:currentColor inherits the
  green / red from the parent span).
- Copy-URL tooltip: '✓ Copied!' changed to plain 'Copied!' (CSS ::after
  content cannot render SVG; unicode bullet removed).

https://claude.ai/code/session_01RGUh8MTYU73Twu1J6PqdZx

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung hace 3 semanas
padre
commit
cbe99fd8c3
Se han modificado 4 ficheros con 1422 adiciones y 245 borrados
  1. 1 0
      src/agi.go
  2. 6 3
      src/mod/agi/agi.go
  3. 273 51
      src/mod/agi/externalReqHandler.go
  4. 1142 191
      src/web/Serverless/index.html

+ 1 - 0
src/agi.go

@@ -88,6 +88,7 @@ func AGIInit() {
 	externalAGIRouter.HandleFunc("/api/ajgi/listExt", gw.ListExternalEndpoint)
 	externalAGIRouter.HandleFunc("/api/ajgi/addExt", gw.AddExternalEndPoint)
 	externalAGIRouter.HandleFunc("/api/ajgi/rmExt", gw.RemoveExternalEndPoint)
+	externalAGIRouter.HandleFunc("/api/ajgi/stats", gw.GetEndpointStats)
 
 	AGIGateway = gw
 }

+ 6 - 3
src/mod/agi/agi.go

@@ -9,6 +9,7 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/robertkrimen/otto"
@@ -75,16 +76,18 @@ type Gateway struct {
 	//AllowAccessPkgs  map[string][]AgiPackage
 	LoadedAGILibrary map[string]AgiLibInjectionIntergface
 	Option           *AgiSysInfo
+	endpointStats    map[string]*EndpointStats // per-UUID execution statistics (in-memory)
+	statsMux         sync.RWMutex              // guards endpointStats
 }
 
 func NewGateway(option AgiSysInfo) (*Gateway, error) {
 	//Handle startup registration of ajgi modules
 	gatewayObject := Gateway{
-		ReservedTables: option.ReservedTables,
-		NightlyScripts: []string{},
-		//AllowAccessPkgs:  map[string][]AgiPackage{},
+		ReservedTables:   option.ReservedTables,
+		NightlyScripts:   []string{},
 		LoadedAGILibrary: map[string]AgiLibInjectionIntergface{},
 		Option:           &option,
+		endpointStats:    make(map[string]*EndpointStats),
 	}
 
 	//Start all WebApps Registration

+ 273 - 51
src/mod/agi/externalReqHandler.go

@@ -6,20 +6,175 @@ import (
 	"net/http"
 	"path/filepath"
 	"strings"
+	"time"
 
 	uuid "github.com/satori/go.uuid"
 	"imuslab.com/arozos/mod/agi/static"
 	"imuslab.com/arozos/mod/utils"
 )
 
+// statsTable is the BoltDB table used to persist endpoint execution statistics.
+const statsTable = "ext_agi_stats"
+
+// endpointFormat holds the owner and script path for a registered endpoint.
 type endpointFormat struct {
 	Username string `json:"username"`
 	Path     string `json:"path"`
 }
 
-// Handle request from EXTERNAL RESTFUL API
+// ExecLog holds the details of a single execution attempt.
+type ExecLog struct {
+	RequestID  string `json:"request_id"`
+	Timestamp  int64  `json:"timestamp"`
+	DurationMs int64  `json:"duration_ms"`
+	Method     string `json:"method"`
+	Message    string `json:"message"`
+}
+
+// EndpointStats tracks cumulative statistics for a single serverless endpoint.
+type EndpointStats struct {
+	UUID            string    `json:"uuid"`
+	Path            string    `json:"path"`
+	TotalExecutions int64     `json:"total_executions"`
+	SuccessfulExecs int64     `json:"successful_executions"`
+	FailedExecs     int64     `json:"failed_executions"`
+	TotalExecTimeMs int64     `json:"total_exec_time_ms"`
+	AvgExecTimeMs   float64   `json:"avg_exec_time_ms"`
+	LastExecutedAt  int64     `json:"last_executed_at"`
+	RecentSuccess   []ExecLog `json:"recent_success"`
+	RecentFailed    []ExecLog `json:"recent_failed"`
+}
+
+// ── DB helpers ────────────────────────────────────────────────────────────────
+
+// ensureStatsTable creates the stats DB table if it does not yet exist.
+func (g *Gateway) ensureStatsTable() {
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if !sysdb.TableExists(statsTable) {
+		sysdb.NewTable(statsTable)
+	}
+}
+
+// loadStatsFromDB reads persisted EndpointStats for one UUID from BoltDB.
+// Returns nil when no record exists or the data cannot be parsed.
+// Must NOT be called while holding g.statsMux.
+func (g *Gateway) loadStatsFromDB(endpointUUID string) *EndpointStats {
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if !sysdb.TableExists(statsTable) {
+		return nil
+	}
+	if !sysdb.KeyExists(statsTable, endpointUUID) {
+		return nil
+	}
+	// The DB stores values as JSON-encoded strings; Read() decodes one layer.
+	rawJSON := ""
+	if err := sysdb.Read(statsTable, endpointUUID, &rawJSON); err != nil {
+		return nil
+	}
+	var s EndpointStats
+	if err := json.Unmarshal([]byte(rawJSON), &s); err != nil {
+		return nil
+	}
+	// Ensure slice fields are never nil (avoids JSON "null" in responses).
+	if s.RecentSuccess == nil {
+		s.RecentSuccess = []ExecLog{}
+	}
+	if s.RecentFailed == nil {
+		s.RecentFailed = []ExecLog{}
+	}
+	return &s
+}
+
+// saveStatsToDB persists the pre-marshalled stats JSON for one endpoint.
+// Must NOT be called while holding g.statsMux (to avoid lock contention on I/O).
+func (g *Gateway) saveStatsToDB(endpointUUID string, jsonBytes []byte) {
+	g.ensureStatsTable()
+	sysdb := g.Option.UserHandler.GetDatabase()
+	sysdb.Write(statsTable, endpointUUID, string(jsonBytes))
+}
+
+// deleteStatsFromDB removes persisted stats for one endpoint.
+func (g *Gateway) deleteStatsFromDB(endpointUUID string) {
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if sysdb.TableExists(statsTable) {
+		sysdb.Delete(statsTable, endpointUUID)
+	}
+}
+
+// ── Core execution tracking ───────────────────────────────────────────────────
+
+// recordExecution updates in-memory stats for endpointUUID after one execution
+// and then persists them to BoltDB. Safe for concurrent use.
+func (g *Gateway) recordExecution(endpointUUID, path, requestID, method string, durationMs int64, execErr error) {
+	g.statsMux.Lock()
+
+	stats, exists := g.endpointStats[endpointUUID]
+	if !exists {
+		// Cold-start: try to restore from the database before creating a blank entry.
+		// loadStatsFromDB must be called without the lock (it doesn't touch the map),
+		// but here we release and re-acquire to keep the load outside the lock window.
+		g.statsMux.Unlock()
+		loaded := g.loadStatsFromDB(endpointUUID)
+		g.statsMux.Lock()
+
+		// Re-check in case another goroutine populated it while we were loading.
+		if stats, exists = g.endpointStats[endpointUUID]; !exists {
+			if loaded != nil {
+				stats = loaded
+			} else {
+				stats = &EndpointStats{
+					UUID:          endpointUUID,
+					Path:          path,
+					RecentSuccess: []ExecLog{},
+					RecentFailed:  []ExecLog{},
+				}
+			}
+			g.endpointStats[endpointUUID] = stats
+		}
+	}
+
+	stats.TotalExecutions++
+	stats.TotalExecTimeMs += durationMs
+	stats.LastExecutedAt = time.Now().Unix()
+	stats.AvgExecTimeMs = float64(stats.TotalExecTimeMs) / float64(stats.TotalExecutions)
+
+	entry := ExecLog{
+		RequestID:  requestID,
+		Timestamp:  time.Now().Unix(),
+		DurationMs: durationMs,
+		Method:     method,
+	}
+
+	if execErr != nil {
+		stats.FailedExecs++
+		entry.Message = execErr.Error()
+		stats.RecentFailed = append([]ExecLog{entry}, stats.RecentFailed...)
+		if len(stats.RecentFailed) > 10 {
+			stats.RecentFailed = stats.RecentFailed[:10]
+		}
+	} else {
+		stats.SuccessfulExecs++
+		entry.Message = "Execution successful"
+		stats.RecentSuccess = append([]ExecLog{entry}, stats.RecentSuccess...)
+		if len(stats.RecentSuccess) > 10 {
+			stats.RecentSuccess = stats.RecentSuccess[:10]
+		}
+	}
+
+	// Marshal while still holding the lock so we capture a consistent snapshot.
+	jsonBytes, _ := json.Marshal(stats)
+
+	g.statsMux.Unlock()
+
+	// DB write outside the lock to avoid holding it during I/O.
+	g.saveStatsToDB(endpointUUID, jsonBytes)
+}
+
+// ── HTTP handlers ─────────────────────────────────────────────────────────────
+
+// ExtAPIHandler handles incoming requests from external services via
+// /api/remote/{UUID}.
 func (g *Gateway) ExtAPIHandler(w http.ResponseWriter, r *http.Request) {
-	// get db
 	sysdb := g.Option.UserHandler.GetDatabase()
 
 	if !sysdb.TableExists("external_agi") {
@@ -27,19 +182,16 @@ func (g *Gateway) ExtAPIHandler(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// get the request URI from the r.URL
 	requestURI := filepath.ToSlash(filepath.Clean(r.URL.Path))
 	subpathElements := strings.Split(requestURI[1:], "/")
 
-	// check if it contains only two part, [rexec uuid]
 	if len(subpathElements) != 3 {
 		http.Error(w, "invalid API request", http.StatusBadRequest)
 		return
 	}
 
-	// check if UUID exists in the database
-	// get the info from the database
-	data, isExist := g.checkIfExternalEndpointExist(subpathElements[2])
+	endpointUUID := subpathElements[2]
+	data, isExist := g.checkIfExternalEndpointExist(endpointUUID)
 	if !isExist {
 		http.Error(w, "malformed request: invalid UUID given", http.StatusBadRequest)
 		return
@@ -48,7 +200,6 @@ func (g *Gateway) ExtAPIHandler(w http.ResponseWriter, r *http.Request) {
 	usernameFromDb := data.Username
 	pathFromDb := data.Path
 
-	// get the userinfo and the realPath
 	userInfo, err := g.Option.UserHandler.GetUserInfoFromUsername(usernameFromDb)
 	if err != nil {
 		http.Error(w, "invalid request: API author no longer exists", http.StatusBadRequest)
@@ -60,55 +211,51 @@ func (g *Gateway) ExtAPIHandler(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	//Check if the script file still exists
 	if !fsh.FileSystemAbstraction.FileExists(realPath) {
-		//Script no longer exists
 		log.Println("[Remote AGI] ", pathFromDb, " cannot be found on "+realPath)
 		http.Error(w, "invalid request: backend script not exists", http.StatusBadRequest)
 		return
 	}
 
-	// execute!
-	//start := time.Now()
-	//g.ExecuteAGIScript(scriptContent, "", "", w, r, userInfo)
-	_, result, err := g.ExecuteAGIScriptAsUser(fsh, realPath, userInfo, w, r)
-	//duration := time.Since(start)
+	// Measure wall-clock duration; the returned execID (assigned by the AGI
+	// runtime) is reused as the request ID for execution log tracing.
+	start := time.Now()
 
-	if err != nil {
-		log.Println("[Remote AGI] ", pathFromDb, " failed to execute", err.Error())
-		utils.SendErrorResponse(w, err.Error())
+	execID, result, execErr := g.ExecuteAGIScriptAsUser(fsh, realPath, userInfo, w, r)
+
+	durationMs := time.Since(start).Milliseconds()
+	g.recordExecution(endpointUUID, pathFromDb, execID, r.Method, durationMs, execErr)
+
+	if execErr != nil {
+		log.Println("[Remote AGI] ", pathFromDb, " failed to execute", execErr.Error())
+		utils.SendErrorResponse(w, execErr.Error())
 		return
 	}
 
 	w.Write([]byte(result))
-
-	//log.Println("[Remote AGI] IP:", r.RemoteAddr, "executed the script ", pathFromDb, "on behalf of", userInfo.Username, "with total duration:", duration)
-
 }
 
+// AddExternalEndPoint registers a new serverless endpoint for the current user.
 func (g *Gateway) AddExternalEndPoint(w http.ResponseWriter, r *http.Request) {
 	userInfo, err := g.Option.UserHandler.GetUserInfoFromRequest(w, r)
 	if err != nil {
 		utils.SendErrorResponse(w, "User not logged in")
 		return
 	}
-	// get db
 	sysdb := g.Option.UserHandler.GetDatabase()
 	if !sysdb.TableExists("external_agi") {
 		sysdb.NewTable("external_agi")
 	}
-	var dat endpointFormat
 
-	// uuid: [path, id]
 	path, err := utils.GetPara(r, "path")
 	if err != nil {
 		utils.SendErrorResponse(w, "Invalid path given")
 		return
 	}
 
-	// put the data in then marshal
 	id := uuid.NewV4().String()
 
+	var dat endpointFormat
 	dat.Path = path
 	dat.Username = userInfo.Username
 
@@ -119,10 +266,11 @@ func (g *Gateway) AddExternalEndPoint(w http.ResponseWriter, r *http.Request) {
 	}
 	sysdb.Write("external_agi", id, string(jsonStr))
 
-	// send the uuid to frontend
 	utils.SendJSONResponse(w, "\""+id+"\"")
 }
 
+// RemoveExternalEndPoint deletes a registered endpoint by UUID, including its
+// persisted statistics.
 func (g *Gateway) RemoveExternalEndPoint(w http.ResponseWriter, r *http.Request) {
 	userInfo, err := g.Option.UserHandler.GetUserInfoFromRequest(w, r)
 	if err != nil {
@@ -130,37 +278,41 @@ func (g *Gateway) RemoveExternalEndPoint(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	// get db
 	sysdb := g.Option.UserHandler.GetDatabase()
 	if !sysdb.TableExists("external_agi") {
 		sysdb.NewTable("external_agi")
 	}
-	// get path
-	uuid, err := utils.GetPara(r, "uuid")
+
+	endpointUUID, err := utils.GetPara(r, "uuid")
 	if err != nil {
 		utils.SendErrorResponse(w, "Invalid uuid given")
 		return
 	}
 
-	// check if endpoint is here
-	data, isExist := g.checkIfExternalEndpointExist(uuid)
+	data, isExist := g.checkIfExternalEndpointExist(endpointUUID)
 	if !isExist {
 		utils.SendErrorResponse(w, "UUID does not exists in the database!")
 		return
 	}
 
-	// make sure user cant see other's endpoint
 	if data.Username != userInfo.Username {
 		utils.SendErrorResponse(w, "Permission denied")
 		return
 	}
 
-	// delete record
-	sysdb.Delete("external_agi", uuid)
+	// Remove endpoint record and its persisted stats.
+	sysdb.Delete("external_agi", endpointUUID)
+	g.deleteStatsFromDB(endpointUUID)
+
+	// Clean up in-memory cache.
+	g.statsMux.Lock()
+	delete(g.endpointStats, endpointUUID)
+	g.statsMux.Unlock()
 
 	utils.SendOK(w)
 }
 
+// ListExternalEndpoint returns all endpoints registered by the current user.
 func (g *Gateway) ListExternalEndpoint(w http.ResponseWriter, r *http.Request) {
 	userInfo, err := g.Option.UserHandler.GetUserInfoFromRequest(w, r)
 	if err != nil {
@@ -168,35 +320,29 @@ func (g *Gateway) ListExternalEndpoint(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// get db
 	sysdb := g.Option.UserHandler.GetDatabase()
 	if !sysdb.TableExists("external_agi") {
 		sysdb.NewTable("external_agi")
 	}
 
-	// declare variable for return
 	dataFromDB := make(map[string]endpointFormat)
 
-	// O(n) method to do the lookup
 	entries, err := sysdb.ListTable("external_agi")
 	if err != nil {
 		utils.SendErrorResponse(w, "Invalid table")
 		return
 	}
 	for _, keypairs := range entries {
-		//Decode the string
 		var dataFromResult endpointFormat
-		result := ""
-		uuid := string(keypairs[0])
-		json.Unmarshal(keypairs[1], &result)
-		//fmt.Println(result)
-		json.Unmarshal([]byte(result), &dataFromResult)
+		rawJSON := ""
+		endpointUUID := string(keypairs[0])
+		json.Unmarshal(keypairs[1], &rawJSON)
+		json.Unmarshal([]byte(rawJSON), &dataFromResult)
 		if dataFromResult.Username == userInfo.Username {
-			dataFromDB[uuid] = dataFromResult
+			dataFromDB[endpointUUID] = dataFromResult
 		}
 	}
 
-	// marhsal and return
 	returnJson, err := json.Marshal(dataFromDB)
 	if err != nil {
 		utils.SendErrorResponse(w, "Invalid JSON: "+err.Error())
@@ -205,22 +351,98 @@ func (g *Gateway) ListExternalEndpoint(w http.ResponseWriter, r *http.Request) {
 	utils.SendJSONResponse(w, string(returnJson))
 }
 
-func (g *Gateway) checkIfExternalEndpointExist(uuid string) (endpointFormat, bool) {
-	// get db
+// GetEndpointStats returns execution statistics for all endpoints owned by the
+// current user.  For each endpoint the server checks the in-memory cache first;
+// on a cache miss it loads from BoltDB so that stats survive process restarts.
+// Endpoints that have never been called are included with zeroed counters.
+func (g *Gateway) GetEndpointStats(w http.ResponseWriter, r *http.Request) {
+	userInfo, err := g.Option.UserHandler.GetUserInfoFromRequest(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, "User not logged in")
+		return
+	}
+
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if !sysdb.TableExists("external_agi") {
+		utils.SendJSONResponse(w, "{}")
+		return
+	}
+
+	entries, err := sysdb.ListTable("external_agi")
+	if err != nil {
+		utils.SendErrorResponse(w, "Invalid table")
+		return
+	}
+
+	// Collect the UUIDs that belong to this user before touching the lock.
+	type epEntry struct {
+		uuid string
+		path string
+	}
+	var userEndpoints []epEntry
+	for _, keypairs := range entries {
+		var ep endpointFormat
+		rawJSON := ""
+		endpointUUID := string(keypairs[0])
+		json.Unmarshal(keypairs[1], &rawJSON)
+		json.Unmarshal([]byte(rawJSON), &ep)
+		if ep.Username == userInfo.Username {
+			userEndpoints = append(userEndpoints, epEntry{endpointUUID, ep.Path})
+		}
+	}
+
+	// For each endpoint: serve from memory, fall back to DB, or return zeros.
+	// We use a write lock because a DB-load may populate the memory cache.
+	g.statsMux.Lock()
+	result := make(map[string]*EndpointStats, len(userEndpoints))
+	for _, ep := range userEndpoints {
+		if stats, exists := g.endpointStats[ep.uuid]; exists {
+			result[ep.uuid] = stats
+		} else {
+			// Not in memory — try the database.
+			g.statsMux.Unlock()
+			dbStats := g.loadStatsFromDB(ep.uuid)
+			g.statsMux.Lock()
+
+			// Re-check after re-acquiring the lock.
+			if stats, exists = g.endpointStats[ep.uuid]; exists {
+				result[ep.uuid] = stats
+			} else if dbStats != nil {
+				g.endpointStats[ep.uuid] = dbStats
+				result[ep.uuid] = dbStats
+			} else {
+				result[ep.uuid] = &EndpointStats{
+					UUID:          ep.uuid,
+					Path:          ep.path,
+					RecentSuccess: []ExecLog{},
+					RecentFailed:  []ExecLog{},
+				}
+			}
+		}
+	}
+	g.statsMux.Unlock()
+
+	returnJson, err := json.Marshal(result)
+	if err != nil {
+		utils.SendErrorResponse(w, "Invalid JSON: "+err.Error())
+		return
+	}
+	utils.SendJSONResponse(w, string(returnJson))
+}
+
+func (g *Gateway) checkIfExternalEndpointExist(endpointUUID string) (endpointFormat, bool) {
 	sysdb := g.Option.UserHandler.GetDatabase()
 	if !sysdb.TableExists("external_agi") {
 		sysdb.NewTable("external_agi")
 	}
 	var dat endpointFormat
 
-	// check if key exist
-	if !sysdb.KeyExists("external_agi", uuid) {
+	if !sysdb.KeyExists("external_agi", endpointUUID) {
 		return dat, false
 	}
 
-	// if yes then return the value
 	jsonData := ""
-	sysdb.Read("external_agi", uuid, &jsonData)
+	sysdb.Read("external_agi", endpointUUID, &jsonData)
 	json.Unmarshal([]byte(jsonData), &dat)
 
 	return dat, true

+ 1142 - 191
src/web/Serverless/index.html

@@ -1,210 +1,1161 @@
 <!DOCTYPE html>
-<html>
-    <head>
-        <meta name="apple-mobile-web-app-capable" content="yes" />
-        <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
-        <meta charset="UTF-8">
-        <meta name="theme-color" content="#4b75ff">
-        <link rel="stylesheet" href="../script/semantic/semantic.min.css">
-        <script src="../script/jquery.min.js"></script>
-        <script src="../script/ao_module.js"></script>
-        <script src="../script/semantic/semantic.min.js"></script>
-        <script type="application/javascript" src="../script/clipboard.min.js"></script>
-        <title>Serverless</title>
-        <style>
-            body{
-                background-color:white;
-            }
-                    /* Tooltip container */
-                   .tooltip {
-                   position: relative;
-                   display: inline-block;
-                   border-bottom: 1px dotted black; /* If you want dots under the hoverable text */
-                   }
-       
-                   /* Tooltip text */
-                   .tooltip .tooltiptext {
-                   visibility: hidden;
-                   width: 120px;
-                   background-color: #555;
-                   color: #fff;
-                   text-align: center;
-                   padding: 5px 0;
-                   border-radius: 6px;
-       
-                   /* Position the tooltip text */
-                   position: absolute;
-                   z-index: 1;
-                   bottom: 125%;
-                   left: 50%;
-                   margin-left: -60px;
-       
-                   /* Fade in tooltip */
-                   opacity: 0;
-                   transition: opacity 0.3s;
-                   }
-       
-                   /* Tooltip arrow */
-                   .tooltip .tooltiptext::after {
-                   content: "";
-                   position: absolute;
-                   top: 100%;
-                   left: 50%;
-                   margin-left: -5px;
-                   border-width: 5px;
-                   border-style: solid;
-                   border-color: #555 transparent transparent transparent;
-                   }
-                   
-                   .tooltitle{
-                        height: 5em;
-                        background-color: #5d6f77;
-                        color: white;
-                   }
-               </style>
-    </head>
-    <body>
-        <div class="tooltitle">
-            <br>
-            <div class="ui container">
-                <h4 class="ui header">
-                    <div class="content" style="color: white;">
-                        Serverless Control Panel
-                      <div class="sub header" style="color: rgb(233, 233, 233);">Allow external services to run AGI scripts</div>
-                    </div>
-                </h4>
+<html lang="en">
+<head>
+    <meta name="apple-mobile-web-app-capable" content="yes" />
+    <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
+    <meta charset="UTF-8">
+    <meta name="theme-color" content="#ffffff">
+    <script src="../script/jquery.min.js"></script>
+    <script src="../script/ao_module.js"></script>
+    <script type="application/javascript" src="../script/clipboard.min.js"></script>
+    <title>Serverless</title>
+    <style>
+        /* ── Design tokens ────────────────────────────────── */
+        :root {
+            --bg:           #f2f2f7;
+            --surface:      #ffffff;
+            --surface2:     #f9f9fb;
+            --border:       rgba(0,0,0,.10);
+            --text-1:       #1c1c1e;
+            --text-2:       #6e6e73;
+            --text-3:       #aeaeb2;
+            --accent:       #0a84ff;
+            --success:      #30d158;
+            --danger:       #ff453a;
+            --warn:         #ff9f0a;
+            --radius-sm:    8px;
+            --radius-md:    14px;
+            --radius-lg:    18px;
+            --shadow-sm:    0 1px 4px rgba(0,0,0,.06), 0 0 0 .5px rgba(0,0,0,.08);
+            --shadow-md:    0 4px 20px rgba(0,0,0,.08), 0 0 0 .5px rgba(0,0,0,.06);
+            --transition:   .18s ease;
+        }
+
+        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
+            background: var(--bg);
+            color: var(--text-1);
+            font-size: 14px;
+            line-height: 1.5;
+            min-height: 100vh;
+        }
+
+        /* ── Shell layout ─────────────────────────────────── */
+        .shell {
+            display: flex;
+            height: 100vh;
+            overflow: hidden;
+        }
+
+        /* ── Sidebar ──────────────────────────────────────── */
+        .sidebar {
+            width: 220px;
+            min-width: 220px;
+            background: var(--surface);
+            border-right: 1px solid var(--border);
+            display: flex;
+            flex-direction: column;
+            overflow-y: auto;
+        }
+
+        .sidebar-header {
+            padding: 20px 16px 12px;
+            display: flex;
+            align-items: center;
+            gap: 10px;
+            border-bottom: 1px solid var(--border);
+        }
+
+        .sidebar-header .app-icon {
+            width: 36px;
+            height: 36px;
+            border-radius: 8px;
+            background: linear-gradient(135deg, #0a84ff 0%, #5856d6 100%);
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            flex-shrink: 0;
+        }
+
+        .sidebar-header .app-icon svg { width: 20px; height: 20px; fill: #fff; }
+
+        .sidebar-header .app-title {
+            font-size: 15px;
+            font-weight: 600;
+            letter-spacing: -.3px;
+        }
+
+        .sidebar-section-label {
+            font-size: 11px;
+            font-weight: 600;
+            color: var(--text-3);
+            letter-spacing: .5px;
+            text-transform: uppercase;
+            padding: 16px 16px 4px;
+        }
+
+        .nav-item {
+            display: flex;
+            align-items: center;
+            gap: 10px;
+            padding: 8px 16px;
+            border-radius: var(--radius-sm);
+            margin: 1px 8px;
+            cursor: pointer;
+            font-size: 13.5px;
+            font-weight: 500;
+            color: var(--text-1);
+            transition: background var(--transition);
+            text-decoration: none;
+            user-select: none;
+        }
+
+        .nav-item svg { width: 16px; height: 16px; flex-shrink: 0; }
+
+        .nav-item:hover { background: rgba(0,0,0,.05); }
+        .nav-item.active { background: rgba(10,132,255,.12); color: var(--accent); }
+        .nav-item.active svg { fill: var(--accent); }
+
+        .sidebar-divider {
+            height: 1px;
+            background: var(--border);
+            margin: 8px 16px;
+        }
+
+        /* ── Main content ─────────────────────────────────── */
+        .main {
+            flex: 1;
+            overflow-y: auto;
+            padding: 28px 28px 40px;
+        }
+
+        .page { display: none; }
+        .page.active { display: block; }
+
+        /* ── Page header ──────────────────────────────────── */
+        .page-header {
+            display: flex;
+            align-items: flex-start;
+            justify-content: space-between;
+            margin-bottom: 22px;
+        }
+
+        .page-header h1 {
+            font-size: 22px;
+            font-weight: 700;
+            letter-spacing: -.4px;
+        }
+
+        .page-header p {
+            font-size: 13px;
+            color: var(--text-2);
+            margin-top: 2px;
+        }
+
+        /* ── Buttons ──────────────────────────────────────── */
+        .btn {
+            display: inline-flex;
+            align-items: center;
+            gap: 6px;
+            border: none;
+            border-radius: var(--radius-sm);
+            cursor: pointer;
+            font-size: 13px;
+            font-weight: 500;
+            padding: 7px 14px;
+            transition: opacity var(--transition), background var(--transition);
+            line-height: 1;
+            white-space: nowrap;
+        }
+        .btn:hover { opacity: .85; }
+        .btn:active { opacity: .7; }
+        .btn svg { width: 13px; height: 13px; }
+
+        .btn-primary   { background: var(--accent);   color: #fff; }
+        .btn-danger    { background: var(--danger);   color: #fff; }
+        .btn-secondary { background: rgba(0,0,0,.07); color: var(--text-1); }
+
+        /* ── Global stat strip ────────────────────────────── */
+        .stat-strip {
+            display: grid;
+            grid-template-columns: repeat(4, 1fr);
+            gap: 12px;
+            margin-bottom: 24px;
+        }
+
+        .stat-card {
+            background: var(--surface);
+            border-radius: var(--radius-md);
+            padding: 18px 20px;
+            box-shadow: var(--shadow-sm);
+            display: flex;
+            flex-direction: column;
+            gap: 6px;
+        }
+
+        .stat-card .sc-label {
+            font-size: 11px;
+            font-weight: 600;
+            text-transform: uppercase;
+            letter-spacing: .5px;
+            color: var(--text-3);
+        }
+
+        .stat-card .sc-value {
+            font-size: 28px;
+            font-weight: 700;
+            letter-spacing: -1px;
+            line-height: 1;
+        }
+
+        .stat-card .sc-sub {
+            font-size: 11.5px;
+            color: var(--text-2);
+        }
+
+        .sc-value.c-success { color: var(--success); }
+        .sc-value.c-danger  { color: var(--danger);  }
+        .sc-value.c-accent  { color: var(--accent);  }
+
+        /* ── Section heading ──────────────────────────────── */
+        .section-heading {
+            font-size: 13px;
+            font-weight: 600;
+            color: var(--text-2);
+            letter-spacing: .3px;
+            text-transform: uppercase;
+            margin-bottom: 10px;
+        }
+
+        /* ── Card container ───────────────────────────────── */
+        .card {
+            background: var(--surface);
+            border-radius: var(--radius-md);
+            box-shadow: var(--shadow-sm);
+            overflow: hidden;
+        }
+
+        /* ── Endpoints list ───────────────────────────────── */
+        .endpoint-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 28px; }
+
+        .endpoint-card {
+            background: var(--surface);
+            border-radius: var(--radius-md);
+            box-shadow: var(--shadow-sm);
+            padding: 18px 20px;
+        }
+
+        .ep-top {
+            display: flex;
+            align-items: flex-start;
+            justify-content: space-between;
+            gap: 12px;
+            margin-bottom: 14px;
+        }
+
+        .ep-info { flex: 1; min-width: 0; }
+
+        .ep-name {
+            font-size: 15px;
+            font-weight: 600;
+            letter-spacing: -.2px;
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+        }
+
+        .ep-uuid {
+            font-size: 12px;
+            color: var(--text-2);
+            font-family: "SF Mono", "Menlo", "Monaco", monospace;
+            margin-top: 3px;
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+        }
+
+        .ep-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
+
+        /* progress bar */
+        .ep-metrics { display: flex; flex-direction: column; gap: 8px; }
+
+        .ep-bar-row {
+            display: flex;
+            align-items: center;
+            gap: 10px;
+        }
+
+        .ep-bar-wrap {
+            flex: 1;
+            height: 6px;
+            background: rgba(0,0,0,.06);
+            border-radius: 99px;
+            overflow: hidden;
+        }
+
+        .ep-bar-fill {
+            height: 100%;
+            border-radius: 99px;
+            transition: width .4s ease;
+        }
+
+        .ep-bar-fill.success { background: var(--success); }
+        .ep-bar-fill.danger  { background: var(--danger);  }
+
+        .ep-stat-row {
+            display: flex;
+            gap: 20px;
+            flex-wrap: wrap;
+        }
+
+        .ep-stat {
+            display: flex;
+            flex-direction: column;
+        }
+
+        .ep-stat .es-val {
+            font-size: 18px;
+            font-weight: 700;
+            letter-spacing: -.5px;
+            line-height: 1;
+        }
+
+        .ep-stat .es-lbl {
+            font-size: 11px;
+            color: var(--text-2);
+            margin-top: 2px;
+        }
+
+        .ep-stat .es-val.c-success { color: var(--success); }
+        .ep-stat .es-val.c-danger  { color: var(--danger);  }
+        .ep-stat .es-val.c-warn    { color: var(--warn);    }
+
+        .ep-last {
+            font-size: 11.5px;
+            color: var(--text-3);
+            margin-top: 6px;
+        }
+
+        /* ── Add endpoint panel ───────────────────────────── */
+        .add-panel {
+            background: var(--surface);
+            border-radius: var(--radius-md);
+            box-shadow: var(--shadow-sm);
+            padding: 20px;
+            margin-bottom: 28px;
+        }
+
+        .add-panel h3 {
+            font-size: 14px;
+            font-weight: 600;
+            margin-bottom: 12px;
+        }
+
+        .add-row {
+            display: flex;
+            gap: 10px;
+            align-items: center;
+        }
+
+        .add-row input {
+            flex: 1;
+            border: 1.5px solid var(--border);
+            border-radius: var(--radius-sm);
+            padding: 8px 12px;
+            font-size: 13px;
+            font-family: inherit;
+            background: var(--surface2);
+            color: var(--text-1);
+            outline: none;
+            transition: border-color var(--transition);
+        }
+
+        .add-row input:focus { border-color: var(--accent); }
+
+        /* ── Toast ────────────────────────────────────────── */
+        .toast {
+            position: fixed;
+            bottom: 24px;
+            left: 50%;
+            transform: translateX(-50%) translateY(80px);
+            background: rgba(30,30,30,.92);
+            backdrop-filter: blur(12px);
+            color: #fff;
+            padding: 10px 18px;
+            border-radius: 99px;
+            font-size: 13px;
+            font-weight: 500;
+            box-shadow: var(--shadow-md);
+            transition: transform .25s cubic-bezier(.34,1.56,.64,1);
+            z-index: 9999;
+            white-space: nowrap;
+            pointer-events: none;
+        }
+        .toast.show { transform: translateX(-50%) translateY(0); }
+
+        /* ── Log viewer ───────────────────────────────────── */
+        .log-tabs {
+            display: flex;
+            gap: 2px;
+            background: rgba(0,0,0,.06);
+            border-radius: var(--radius-sm);
+            padding: 3px;
+            width: fit-content;
+            margin-bottom: 14px;
+        }
+
+        .log-tab {
+            padding: 5px 14px;
+            border-radius: 6px;
+            font-size: 12.5px;
+            font-weight: 500;
+            cursor: pointer;
+            color: var(--text-2);
+            transition: background var(--transition), color var(--transition);
+            user-select: none;
+        }
+
+        .log-tab.active {
+            background: var(--surface);
+            color: var(--text-1);
+            box-shadow: var(--shadow-sm);
+        }
+
+        .log-panel { display: none; }
+        .log-panel.active { display: block; }
+
+        .log-table {
+            width: 100%;
+            border-collapse: collapse;
+        }
+
+        .log-table th {
+            text-align: left;
+            font-size: 11px;
+            font-weight: 600;
+            text-transform: uppercase;
+            letter-spacing: .4px;
+            color: var(--text-3);
+            padding: 10px 14px;
+            border-bottom: 1px solid var(--border);
+        }
+
+        .log-table td {
+            padding: 10px 14px;
+            font-size: 12.5px;
+            vertical-align: top;
+            border-bottom: 1px solid var(--border);
+        }
+
+        .log-table tr:last-child td { border-bottom: none; }
+        .log-table tr:hover td { background: var(--surface2); }
+
+        .badge {
+            display: inline-flex;
+            align-items: center;
+            gap: 4px;
+            padding: 2px 8px;
+            border-radius: 99px;
+            font-size: 11px;
+            font-weight: 600;
+        }
+
+        .badge.success { background: rgba(48,209,88,.15);  color: #218838; }
+        .badge.danger  { background: rgba(255,69,58,.15);  color: #c0392b; }
+        .badge.neutral { background: rgba(0,0,0,.07);      color: var(--text-2); }
+
+        .log-msg {
+            font-family: "SF Mono", "Menlo", "Monaco", monospace;
+            font-size: 11px;
+            color: var(--text-2);
+            white-space: pre-wrap;
+            word-break: break-all;
+            max-width: 380px;
+        }
+
+        .log-msg.err { color: var(--danger); }
+
+        .mono { font-family: "SF Mono", "Menlo", "Monaco", monospace; }
+
+        .empty-state {
+            padding: 40px 20px;
+            text-align: center;
+            color: var(--text-3);
+            font-size: 13.5px;
+        }
+
+        .empty-state svg { width: 40px; height: 40px; fill: var(--text-3); margin-bottom: 12px; }
+
+        /* ── Responsive tweaks ────────────────────────────── */
+        @media (max-width: 720px) {
+            .stat-strip { grid-template-columns: repeat(2, 1fr); }
+            .sidebar { display: none; }
+            .main { padding: 16px; }
+        }
+
+        /* ── Copy tooltip ─────────────────────────────────── */
+        .copy-tip {
+            position: relative;
+            cursor: pointer;
+        }
+
+        .copy-tip::after {
+            content: attr(data-tip);
+            position: absolute;
+            bottom: calc(100% + 6px);
+            left: 50%;
+            transform: translateX(-50%);
+            background: rgba(30,30,30,.9);
+            color: #fff;
+            font-size: 11px;
+            padding: 3px 8px;
+            border-radius: 5px;
+            white-space: nowrap;
+            opacity: 0;
+            pointer-events: none;
+            transition: opacity .15s;
+        }
+
+        .copy-tip.copied::after { opacity: 1; }
+    </style>
+</head>
+<body>
+
+<div class="shell">
+
+    <!-- ── Sidebar ──────────────────────────────────── -->
+    <aside class="sidebar">
+        <div class="sidebar-header">
+            <div class="app-icon">
+                <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/></svg>
+            </div>
+            <span class="app-title">Serverless</span>
+        </div>
+
+        <span class="sidebar-section-label">Manage</span>
+
+        <a class="nav-item active" data-page="endpoints" onclick="navigate(this)">
+            <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14l-5-5 1.41-1.41L12 14.17l7.59-7.59L21 8l-9 9z"/></svg>
+            Endpoints
+        </a>
+
+        <a class="nav-item" data-page="statistics" onclick="navigate(this)">
+            <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z"/></svg>
+            Statistics
+        </a>
+
+        <a class="nav-item" data-page="logs" onclick="navigate(this)">
+            <svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
+            Execution Logs
+        </a>
+
+        <div class="sidebar-divider"></div>
+        <span class="sidebar-section-label">About</span>
+        <a class="nav-item" style="color:var(--text-2);font-size:12.5px;pointer-events:none;">
+            <svg viewBox="0 0 24 24" style="fill:var(--text-3)"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
+            AGI Runtime 3.0
+        </a>
+    </aside>
+
+    <!-- ── Main content ──────────────────────────────── -->
+    <main class="main">
+
+        <!-- ────────── ENDPOINTS PAGE ────────── -->
+        <div id="page-endpoints" class="page active">
+            <div class="page-header">
+                <div>
+                    <h1>Endpoints</h1>
+                    <p>Serverless AGI scripts accessible via external REST calls</p>
+                </div>
+                <button class="btn btn-primary" onclick="showAddPanel()">
+                    <svg viewBox="0 0 24 24" style="fill:#fff"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
+                    New Endpoint
+                </button>
+            </div>
+
+            <!-- Global summary strip -->
+            <div class="stat-strip" id="global-stats">
+                <div class="stat-card">
+                    <span class="sc-label">Endpoints</span>
+                    <span class="sc-value c-accent" id="gs-count">—</span>
+                    <span class="sc-sub">registered</span>
+                </div>
+                <div class="stat-card">
+                    <span class="sc-label">Total Executions</span>
+                    <span class="sc-value" id="gs-total">—</span>
+                    <span class="sc-sub">all time</span>
+                </div>
+                <div class="stat-card">
+                    <span class="sc-label">Successful</span>
+                    <span class="sc-value c-success" id="gs-ok">—</span>
+                    <span class="sc-sub">executions</span>
+                </div>
+                <div class="stat-card">
+                    <span class="sc-label">Failed</span>
+                    <span class="sc-value c-danger" id="gs-fail">—</span>
+                    <span class="sc-sub">executions</span>
+                </div>
+            </div>
+
+            <!-- Add panel (hidden by default) -->
+            <div class="add-panel" id="add-panel" style="display:none;">
+                <h3>Register New Endpoint</h3>
+                <p style="font-size:12.5px;color:var(--text-2);margin-bottom:14px;">
+                    Select an AGI/JS script to expose as an external REST endpoint.
+                    The script will run with <strong>your user scope</strong>.
+                </p>
+                <div class="add-row">
+                    <input id="agiPath" type="text" placeholder="user:/path/to/script.js" readonly>
+                    <button class="btn btn-secondary" onclick="openfileselector()">
+                        <svg viewBox="0 0 24 24"><path d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2z"/></svg>
+                        Browse
+                    </button>
+                    <button class="btn btn-primary" onclick="addEndpoint()">Add</button>
+                    <button class="btn btn-secondary" onclick="hideAddPanel()">Cancel</button>
+                </div>
+                <p style="margin-top:12px;font-size:11.5px;color:var(--text-3);">
+                    ⚠️ Do not register scripts from unknown sources — they execute with your account privileges.
+                </p>
+            </div>
+
+            <!-- Endpoints list -->
+            <div class="section-heading" style="margin-bottom:10px;">Registered endpoints</div>
+            <div class="endpoint-list" id="endpoint-list">
+                <div class="empty-state" id="ep-empty" style="display:none;">
+                    <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14l-5-5 1.41-1.41L12 14.17l7.59-7.59L21 8l-9 9z"/></svg>
+                    <p>No endpoints registered yet.</p>
+                    <button class="btn btn-primary" style="margin-top:12px;" onclick="showAddPanel()">Add your first endpoint</button>
+                </div>
             </div>
         </div>
-        <div id="deleteSuceed" style="display:none;" class="ui green inverted segment"><i class="checkmark icon"></i>Service Deleted</div>
-        <div>
-            <table class="ui celled unstackable table">
-                <thead>
-                    <tr>
-                        <th>UUID (access token)</th>
-                        <th>AGI Path</th>
-                        <th>Action</th>
-                    </tr>
-                </thead>
-                <tbody id="records">
-                
-                </tbody>
-            </table>
-            <div style="width: 100%" align="center">
-                <div class="ui breadcrumb" id="pageIndexs">
+
+        <!-- ────────── STATISTICS PAGE ────────── -->
+        <div id="page-statistics" class="page">
+            <div class="page-header">
+                <div>
+                    <h1>Statistics</h1>
+                    <p>Per-endpoint execution metrics</p>
                 </div>
+                <button class="btn btn-secondary" onclick="refreshAll()">
+                    <svg viewBox="0 0 24 24"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
+                    Refresh
+                </button>
             </div>
+            <div id="stats-list"></div>
         </div>
-        <div class="ui divider"></div>
-        <div id="updateSuceed" style="display:none;" class="ui green inverted segment"><i class="checkmark icon"></i>Service Added</div>
-        <div class="ui container">
-            <h4>Select AGI Script</h4>
-            <p>Select a script to be executed by 3rd party application via RESTFUL request. <br>
-                Note that the AGI script will be executed with <b>your user scope</b></p>
-            <div class="ui action fluid input">
-                <input id="agiPath" type="text" placeholder="Select Location" readonly="true">
-                <button class="ui black button" onclick="openfileselector();"><i class="folder open icon"></i> Open</button>
-            </div>
-            <button class="ui positive right floated button" onclick="add();" style="margin-top: 0.4em;"><i class="ui checkmark icon"></i> Add</button>
-            <br><br>
-            <div class="ui divider"></div>
-            <p><small>Misuse of serverless function might affect your account safty or causes data loss. Please use this function with caution and do not copy and paste code from unknown sources.</small></p>
+
+        <!-- ────────── LOGS PAGE ────────── -->
+        <div id="page-logs" class="page">
+            <div class="page-header">
+                <div>
+                    <h1>Execution Logs</h1>
+                    <p>Last 10 successful and failed executions per endpoint</p>
+                </div>
+                <button class="btn btn-secondary" onclick="refreshAll()">
+                    <svg viewBox="0 0 24 24"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
+                    Refresh
+                </button>
+            </div>
+
+            <div class="log-tabs">
+                <div class="log-tab active" onclick="switchLogTab(this,'success')"><svg viewBox="0 0 24 24" style="width:13px;height:13px;fill:var(--success);vertical-align:middle;margin-right:4px;"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>Successful</div>
+                <div class="log-tab"        onclick="switchLogTab(this,'failed')"><svg viewBox="0 0 24 24" style="width:13px;height:13px;fill:var(--danger);vertical-align:middle;margin-right:4px;"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>Failed</div>
+            </div>
+
+            <div id="log-success" class="log-panel active card">
+                <div class="empty-state"><p>No successful executions recorded yet.</p></div>
+            </div>
+            <div id="log-failed" class="log-panel card">
+                <div class="empty-state"><p>No failed executions recorded yet.</p></div>
+            </div>
         </div>
-        <br><br>
-        <script>
-            $.getJSON( "/api/ajgi/listExt", function( data ) {
-                $.each( data, function( key, val ) {
-                    appendTable(key, val.path);
-                });
-                if(Object.keys(data).length == 0) {
-                    $("#records").append(`<tr id="zeroRec"><td>No registered endpoint</td></tr>`);
-                }
-            });
 
-            function openfileselector(){
-                ao_module_openFileSelector(fileLoader, "user:/", "file",false, {filter:["agi", "js"]});
-            }
+    </main>
+</div>
 
+<!-- Toast notification -->
+<div class="toast" id="toast"></div>
 
-            function fileLoader(filedata){
-                for (var i=0; i < filedata.length; i++){
-                    var filename = filedata[i].filename;
-                    var filepath = filedata[i].filepath;
-                    $("#agiPath").val(filepath);
-                }
-            }
+<script>
+/* ──────────────────────────────────────────────
+   State
+────────────────────────────────────────────── */
+var endpoints = {};  // uuid → {username, path}
+var statsData  = {};  // uuid → EndpointStats
+var tokenAccessPath = location.protocol + "//" + window.location.host + "/api/remote/";
 
-            function add() {
-                var path = $("#agiPath").val();
-                $.getJSON( "/api/ajgi/addExt?path=" + path, function( data ) {
-                    if(data.error == undefined) {
-                        $("#updateSuceed").slideDown("fast").delay(3000).slideUp("fast");
-                        appendTable(data, path);
-                    }else{
-                        alert(data.error);
-                    }
-                });
-            }
+/* ──────────────────────────────────────────────
+   Navigation
+────────────────────────────────────────────── */
+function navigate(el) {
+    document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
+    document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
+    el.classList.add('active');
+    var page = el.getAttribute('data-page');
+    document.getElementById('page-' + page).classList.add('active');
+    if (page === 'statistics') renderStats();
+    if (page === 'logs')       renderLogs();
+}
 
-            function delRecord(element) {
-                $.getJSON( "/api/ajgi/rmExt?uuid=" + $(element).attr("uuid"), function( data ) {
-                    if(data == "OK") {
-                        $("#deleteSuceed").slideDown("fast").delay(3000).slideUp("fast");
-                    }else{
-                        alert(data.error);
-                    }
-                });
-                $(element).parent().parent().remove().slideUp("fast");
-                if($("#records").html().trim() == '') {
-                    $("#records").append(`<tr id="zeroRec"><td>0 record returned.</td></tr>`);
-                }
+/* ──────────────────────────────────────────────
+   Toast
+────────────────────────────────────────────── */
+var toastTimer;
+function showToast(msg) {
+    clearTimeout(toastTimer);
+    var t = document.getElementById('toast');
+    t.textContent = msg;
+    t.classList.add('show');
+    toastTimer = setTimeout(function(){ t.classList.remove('show'); }, 2500);
+}
+
+/* ──────────────────────────────────────────────
+   Clipboard
+────────────────────────────────────────────── */
+function copyText(text, triggerEl) {
+    navigator.clipboard.writeText(text).then(function(){
+        showToast('Copied to clipboard');
+        if (triggerEl) {
+            triggerEl.setAttribute('data-tip', 'Copied!');
+            triggerEl.classList.add('copied');
+            setTimeout(function(){ triggerEl.classList.remove('copied'); }, 2000);
+        }
+    });
+}
+
+/* ──────────────────────────────────────────────
+   File selector
+   NOTE: ao_module_openFileSelector in virtual-desktop mode reads
+   callback.name and passes it as a string to the floating window so the
+   window can eval() it back in this scope. The callback MUST be a named
+   top-level function — anonymous functions have an empty .name and cause
+   the "Selection Failed. Is parent window alive?" error.
+────────────────────────────────────────────── */
+function fileLoader(filedata) {
+    if (filedata && filedata.length > 0) {
+        document.getElementById('agiPath').value = filedata[0].filepath;
+    }
+}
+
+function openfileselector() {
+    ao_module_openFileSelector(fileLoader, "user:/", "file", false, {filter: ["agi", "js"]});
+}
+
+/* ──────────────────────────────────────────────
+   Add / remove endpoints
+────────────────────────────────────────────── */
+function showAddPanel() {
+    document.getElementById('add-panel').style.display = '';
+    document.getElementById('agiPath').focus();
+}
+
+function hideAddPanel() {
+    document.getElementById('add-panel').style.display = 'none';
+    document.getElementById('agiPath').value = '';
+}
+
+function addEndpoint() {
+    var path = document.getElementById('agiPath').value.trim();
+    if (!path) { showToast('Please select a script first'); return; }
+    $.getJSON("/api/ajgi/addExt?path=" + encodeURIComponent(path), function(data) {
+        if (data.error) { showToast('Error: ' + data.error); return; }
+        var newUUID = data.replace(/"/g,'');
+        endpoints[newUUID] = {path: path};
+        statsData[newUUID]  = {uuid: newUUID, path: path, total_executions:0, successful_executions:0, failed_executions:0, total_exec_time_ms:0, avg_exec_time_ms:0, last_executed_at:0, recent_success:[], recent_failed:[]};
+        renderEndpointCard(newUUID, path, statsData[newUUID]);
+        updateGlobalStats();
+        hideAddPanel();
+        showToast('Endpoint registered');
+    });
+}
+
+function deleteEndpoint(uuid) {
+    if (!confirm('Remove this endpoint? External services using it will stop working.')) return;
+    $.getJSON("/api/ajgi/rmExt?uuid=" + uuid, function(data) {
+        if (data && data.error) { showToast('Error: ' + data.error); return; }
+        delete endpoints[uuid];
+        delete statsData[uuid];
+        var card = document.getElementById('ep-' + uuid);
+        if (card) card.remove();
+        updateGlobalStats();
+        // also remove from stats / logs pages
+        var sc = document.getElementById('sc-' + uuid);
+        if (sc) sc.remove();
+        showToast('Endpoint removed');
+        if (Object.keys(endpoints).length === 0) {
+            document.getElementById('ep-empty').style.display = '';
+        }
+    });
+}
+
+/* ──────────────────────────────────────────────
+   Render helpers
+────────────────────────────────────────────── */
+function scriptName(path) {
+    return path.split('/').pop() || path;
+}
+
+function fmtDuration(ms) {
+    if (ms < 1000)  return ms + 'ms';
+    if (ms < 60000) return (ms/1000).toFixed(1) + 's';
+    return (ms/60000).toFixed(1) + 'min';
+}
+
+function fmtTs(unix) {
+    if (!unix) return '—';
+    return new Date(unix * 1000).toLocaleString();
+}
+
+function timeSince(unix) {
+    if (!unix) return 'never';
+    var diff = Math.floor(Date.now()/1000) - unix;
+    if (diff < 60)   return 'just now';
+    if (diff < 3600) return Math.floor(diff/60) + ' min ago';
+    if (diff < 86400) return Math.floor(diff/3600) + ' hr ago';
+    return Math.floor(diff/86400) + ' days ago';
+}
+
+function successRate(stats) {
+    if (!stats.total_executions) return 0;
+    return Math.round(stats.successful_executions / stats.total_executions * 100);
+}
+
+/* ──────────────────────────────────────────────
+   Global stat strip
+────────────────────────────────────────────── */
+function updateGlobalStats() {
+    var count = 0, total = 0, ok = 0, fail = 0;
+    for (var u in statsData) {
+        count++;
+        var s = statsData[u];
+        total += (s.total_executions||0);
+        ok    += (s.successful_executions||0);
+        fail  += (s.failed_executions||0);
+    }
+    document.getElementById('gs-count').textContent = count;
+    document.getElementById('gs-total').textContent = total;
+    document.getElementById('gs-ok').textContent    = ok;
+    document.getElementById('gs-fail').textContent  = fail;
+}
+
+/* ──────────────────────────────────────────────
+   Endpoint card (Endpoints page)
+────────────────────────────────────────────── */
+function renderEndpointCard(uuid, path, stats) {
+    document.getElementById('ep-empty').style.display = 'none';
+
+    var total    = stats.total_executions      || 0;
+    var ok       = stats.successful_executions || 0;
+    var fail     = stats.failed_executions     || 0;
+    var avgMs    = stats.avg_exec_time_ms      || 0;
+    var rate     = total ? Math.round(ok/total*100)   : 0;
+    var failRate = total ? Math.round(fail/total*100) : 0;
+    var apiURL   = tokenAccessPath + uuid;
+
+    // remove if already exists (refresh)
+    var old = document.getElementById('ep-' + uuid);
+    if (old) old.remove();
+
+    var card = document.createElement('div');
+    card.className = 'endpoint-card';
+    card.id = 'ep-' + uuid;
+    card.innerHTML = `
+        <div class="ep-top">
+            <div class="ep-info">
+                <div class="ep-name" title="${escHtml(path)}">${escHtml(scriptName(path))}</div>
+                <div class="ep-uuid" title="${uuid}">${uuid}</div>
+            </div>
+            <div class="ep-actions">
+                <button class="btn btn-secondary copy-tip" data-tip="Copy URL" onclick='copyText("${apiURL}", this)'>
+                    <svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
+                    Copy URL
+                </button>
+                <button class="btn btn-danger" onclick='deleteEndpoint("${uuid}")'>
+                    <svg viewBox="0 0 24 24" style="fill:#fff"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
+                    Delete
+                </button>
+            </div>
+        </div>
+        <div class="ep-metrics">
+            <div class="ep-stat-row">
+                <div class="ep-stat">
+                    <span class="es-val">${total}</span>
+                    <span class="es-lbl">Total Calls</span>
+                </div>
+                <div class="ep-stat">
+                    <span class="es-val c-success">${ok}</span>
+                    <span class="es-lbl">Successful</span>
+                </div>
+                <div class="ep-stat">
+                    <span class="es-val c-danger">${fail}</span>
+                    <span class="es-lbl">Failed</span>
+                </div>
+                <div class="ep-stat">
+                    <span class="es-val c-warn">${fmtDuration(Math.round(avgMs))}</span>
+                    <span class="es-lbl">Avg Time</span>
+                </div>
+                <div class="ep-stat">
+                    <span class="es-val">${rate}%</span>
+                    <span class="es-lbl">Success Rate</span>
+                </div>
+            </div>
+            ${total === 0
+                ? `<div class="ep-bar-wrap" style="background:rgba(0,0,0,.10);"></div>
+                   <div style="margin-top:5px;font-size:11px;color:var(--text-3);">No executions recorded</div>`
+                : `<div class="ep-bar-row">
+                       <div class="ep-bar-wrap" style="display:flex;gap:1px;">
+                           <div class="ep-bar-fill success" style="width:${rate}%;border-radius:${(rate>0&&failRate>0)?'99px 0 0 99px':'99px'};"></div>
+                           <div class="ep-bar-fill danger"  style="width:${failRate}%;border-radius:${(rate>0&&failRate>0)?'0 99px 99px 0':'99px'};"></div>
+                       </div>
+                   </div>
+                   <div style="display:flex;gap:12px;margin-top:5px;font-size:11px;">
+                       <span style="display:flex;align-items:center;gap:3px;color:var(--success);"><svg width="8" height="8" viewBox="0 0 8 8" style="fill:currentColor;flex-shrink:0;"><circle cx="4" cy="4" r="4"/></svg>${rate}% success</span>
+                       <span style="display:flex;align-items:center;gap:3px;color:var(--danger);"><svg width="8" height="8" viewBox="0 0 8 8" style="fill:currentColor;flex-shrink:0;"><circle cx="4" cy="4" r="4"/></svg>${failRate}% failed</span>
+                   </div>`
             }
+            <div class="ep-last">Last called: ${timeSince(stats.last_executed_at)}</div>
+        </div>
+    `;
+    document.getElementById('endpoint-list').appendChild(card);
+}
+
+/* ──────────────────────────────────────────────
+   Statistics page
+────────────────────────────────────────────── */
+function renderStats() {
+    var container = document.getElementById('stats-list');
+    container.innerHTML = '';
+
+    var uuids = Object.keys(statsData);
+    if (uuids.length === 0) {
+        container.innerHTML = '<div class="empty-state"><p>No endpoints registered yet.</p></div>';
+        return;
+    }
+
+    uuids.forEach(function(uuid) {
+        var s = statsData[uuid];
+        var total   = s.total_executions   || 0;
+        var ok      = s.successful_executions || 0;
+        var fail    = s.failed_executions  || 0;
+        var avgMs   = s.avg_exec_time_ms   || 0;
+        var totalMs = s.total_exec_time_ms || 0;
+        var rate     = total ? Math.round(ok/total*100)   : 0;
+        var failRate = total ? Math.round(fail/total*100) : 0;
 
-            
-            var tokenAccessPath = location.protocol + "//" + window.location.host + "/api/remote/";
-            new ClipboardJS('.copyURL', {
-                text: function(trigger) {
-                        var token = $(trigger).attr("token");
-                        var url = tokenAccessPath + token;
-                        console.log( $(trigger).find(".tooltiptext"));
-                        $(trigger).find(".tooltiptext").css({
-                            "visibility": "visible",
-                            "opacity": 1,
-                        });
-                        setTimeout(function(){
-                            $(trigger).find(".tooltiptext").css({
-                                "visibility": "hidden",
-                                "opacity": 0,
-                            });
-                        }, 3000);
-                    return url;
+        var el = document.createElement('div');
+        el.id = 'sc-' + uuid;
+        el.className = 'card';
+        el.style.marginBottom = '16px';
+        el.innerHTML = `
+            <div style="padding:18px 20px;border-bottom:1px solid var(--border)">
+                <div style="font-size:15px;font-weight:600;">${escHtml(scriptName(s.path))}</div>
+                <div style="font-size:12px;color:var(--text-2);font-family:monospace;">${uuid}</div>
+                <div style="font-size:11.5px;color:var(--text-3);margin-top:2px;">${escHtml(s.path)}</div>
+            </div>
+            <div style="padding:18px 20px;display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:20px;">
+                <div>
+                    <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-3);margin-bottom:4px;">Total Executions</div>
+                    <div style="font-size:26px;font-weight:700;letter-spacing:-1px;">${total}</div>
+                </div>
+                <div>
+                    <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-3);margin-bottom:4px;">Successful</div>
+                    <div style="font-size:26px;font-weight:700;letter-spacing:-1px;color:var(--success);">${ok}</div>
+                </div>
+                <div>
+                    <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-3);margin-bottom:4px;">Failed</div>
+                    <div style="font-size:26px;font-weight:700;letter-spacing:-1px;color:var(--danger);">${fail}</div>
+                </div>
+                <div>
+                    <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-3);margin-bottom:4px;">Avg Exec Time</div>
+                    <div style="font-size:26px;font-weight:700;letter-spacing:-1px;color:var(--warn);">${fmtDuration(Math.round(avgMs))}</div>
+                </div>
+                <div>
+                    <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-3);margin-bottom:4px;">Total Exec Time</div>
+                    <div style="font-size:26px;font-weight:700;letter-spacing:-1px;">${fmtDuration(totalMs)}</div>
+                </div>
+                <div>
+                    <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-3);margin-bottom:4px;">Last Called</div>
+                    <div style="font-size:13px;font-weight:600;margin-top:4px;">${s.last_executed_at ? fmtTs(s.last_executed_at) : '—'}</div>
+                </div>
+            </div>
+            <div style="padding:0 20px 18px;">
+                <div style="font-size:11px;color:var(--text-3);margin-bottom:6px;">Success / Failure ratio</div>
+                ${total === 0
+                    ? `<div style="height:8px;border-radius:99px;background:rgba(0,0,0,.10);"></div>
+                       <div style="margin-top:6px;font-size:11.5px;color:var(--text-3);">No executions recorded</div>`
+                    : `<div style="display:flex;height:8px;border-radius:99px;overflow:hidden;gap:2px;">
+                           <div style="width:${rate}%;background:var(--success);border-radius:${(rate>0&&failRate>0)?'99px 0 0 99px':'99px'};transition:width .4s;"></div>
+                           <div style="width:${failRate}%;background:var(--danger);border-radius:${(rate>0&&failRate>0)?'0 99px 99px 0':'99px'};transition:width .4s;"></div>
+                       </div>
+                       <div style="display:flex;gap:16px;margin-top:6px;font-size:11.5px;">
+                           <span style="display:flex;align-items:center;gap:4px;color:var(--success);"><svg width="8" height="8" viewBox="0 0 8 8" style="fill:currentColor;flex-shrink:0;"><circle cx="4" cy="4" r="4"/></svg>${rate}% success</span>
+                           <span style="display:flex;align-items:center;gap:4px;color:var(--danger);"><svg width="8" height="8" viewBox="0 0 8 8" style="fill:currentColor;flex-shrink:0;"><circle cx="4" cy="4" r="4"/></svg>${failRate}% failed</span>
+                       </div>`
                 }
+            </div>
+        `;
+        container.appendChild(el);
+    });
+}
+
+/* ──────────────────────────────────────────────
+   Logs page
+────────────────────────────────────────────── */
+function switchLogTab(el, panel) {
+    document.querySelectorAll('.log-tab').forEach(t => t.classList.remove('active'));
+    document.querySelectorAll('.log-panel').forEach(p => p.classList.remove('active'));
+    el.classList.add('active');
+    document.getElementById('log-' + panel).classList.add('active');
+}
+
+function renderLogs() {
+    renderLogTable('success', 'recent_success');
+    renderLogTable('failed',  'recent_failed');
+}
+
+function renderLogTable(panelId, field) {
+    var container = document.getElementById('log-' + panelId);
+    var isSuccess = (panelId === 'success');
+    var allLogs = [];
+
+    for (var uuid in statsData) {
+        var s = statsData[uuid];
+        var entries = s[field] || [];
+        entries.forEach(function(e) {
+            allLogs.push({
+                endpointPath: s.path,
+                uuid: uuid,
+                entry: e
             });
+        });
+    }
 
-            function generateClipboardText(uuid) {
-                return `
-                    <div>
-                        <div class="content" style="font-family: monospace;">
-                           ${uuid} <a style="margin-left: 12px; font-family: Arial;" token="${uuid}" class="copyURL tooltip">
-                                <i class="copy icon"></i>  Copy
-                                <span class="tooltiptext"><i class="checkmark icon"></i> Copied</span>
-                           </a> 
-                        </div>
-                    </div>
-                `;
-            }
+    // Sort by timestamp descending
+    allLogs.sort(function(a,b){ return b.entry.timestamp - a.entry.timestamp; });
+
+    if (allLogs.length === 0) {
+        container.innerHTML = '<div class="empty-state"><p>' + (isSuccess ? 'No successful executions recorded yet.' : 'No failed executions recorded yet.') + '</p></div>';
+        return;
+    }
+
+    var rows = allLogs.map(function(item) {
+        var e = item.entry;
+        var badgeClass = isSuccess ? 'success' : 'danger';
+        var badgeIcon  = isSuccess
+            ? '<svg viewBox="0 0 24 24" style="width:11px;height:11px;fill:currentColor;vertical-align:middle;margin-right:3px;"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>'
+            : '<svg viewBox="0 0 24 24" style="width:11px;height:11px;fill:currentColor;vertical-align:middle;margin-right:3px;"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>';
+        var badgeLabel = isSuccess ? 'OK' : 'Error';
+        var msgClass   = isSuccess ? '' : 'err';
+        return `<tr>
+            <td><span class="badge ${badgeClass}">${badgeIcon}${badgeLabel}</span></td>
+            <td>
+                <div style="font-weight:500;">${escHtml(scriptName(item.endpointPath))}</div>
+                <div style="font-size:11px;color:var(--text-3);">${item.uuid.substring(0,8)}…</div>
+            </td>
+            <td class="mono" style="font-size:11.5px;">${escHtml(e.request_id ? e.request_id.substring(0,12)+'…' : '—')}</td>
+            <td style="font-size:12px;color:var(--text-2);white-space:nowrap;">${fmtTs(e.timestamp)}</td>
+            <td style="white-space:nowrap;"><span class="badge neutral">${fmtDuration(e.duration_ms||0)}</span></td>
+            <td><div class="log-msg ${msgClass}">${escHtml(e.message||'')}</div></td>
+        </tr>`;
+    }).join('');
 
-            function appendTable(uuid, path) {
-                $("#zeroRec").remove().slideUp("fast");
-                $("#records").append(`<tr>
-                            <td>` + generateClipboardText(uuid) +`</td>
-                            <td>` + path + `</td>
-                            <td>
-                                <button class="ui icon basic circular negative button" uuid="` + uuid + `" onclick="delRecord(this)">
-                                <i class="close icon"></i>
-                                </button>
-                            </td>
-                        </tr>`);
+    container.innerHTML = `
+        <table class="log-table">
+            <thead>
+                <tr>
+                    <th>Status</th>
+                    <th>Endpoint</th>
+                    <th>Request ID</th>
+                    <th>Timestamp</th>
+                    <th>Duration</th>
+                    <th>Message</th>
+                </tr>
+            </thead>
+            <tbody>${rows}</tbody>
+        </table>
+    `;
+}
+
+/* ──────────────────────────────────────────────
+   Data loading
+────────────────────────────────────────────── */
+function loadAll() {
+    // Load endpoints and stats in parallel
+    $.when(
+        $.getJSON("/api/ajgi/listExt"),
+        $.getJSON("/api/ajgi/stats")
+    ).done(function(epResp, stResp) {
+        var epData = epResp[0];
+        var stData = stResp[0];
+
+        endpoints = epData  || {};
+        statsData = stData  || {};
+
+        // Ensure every endpoint has a stats entry
+        for (var uuid in endpoints) {
+            if (!statsData[uuid]) {
+                statsData[uuid] = {
+                    uuid: uuid,
+                    path: endpoints[uuid].path,
+                    total_executions: 0,
+                    successful_executions: 0,
+                    failed_executions: 0,
+                    total_exec_time_ms: 0,
+                    avg_exec_time_ms: 0,
+                    last_executed_at: 0,
+                    recent_success: [],
+                    recent_failed: []
+                };
             }
-        </script>
-    </body>
-</html>
+        }
+
+        // Render endpoint cards
+        var list = document.getElementById('endpoint-list');
+        // Clear existing cards (but keep empty state div)
+        Array.from(list.children).forEach(function(c){
+            if (c.id !== 'ep-empty') c.remove();
+        });
+
+        var uuids = Object.keys(statsData);
+        if (uuids.length === 0) {
+            document.getElementById('ep-empty').style.display = '';
+        } else {
+            document.getElementById('ep-empty').style.display = 'none';
+            uuids.forEach(function(uuid) {
+                renderEndpointCard(uuid, statsData[uuid].path || (endpoints[uuid]||{}).path || uuid, statsData[uuid]);
+            });
+        }
+
+        updateGlobalStats();
+    }).fail(function() {
+        showToast('Failed to load data');
+    });
+}
+
+function refreshAll() {
+    loadAll();
+    showToast('Refreshed');
+}
+
+/* ──────────────────────────────────────────────
+   Utility
+────────────────────────────────────────────── */
+function escHtml(str) {
+    return String(str)
+        .replace(/&/g, '&amp;')
+        .replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;')
+        .replace(/"/g, '&quot;');
+}
+
+/* ──────────────────────────────────────────────
+   Bootstrap
+────────────────────────────────────────────── */
+$(document).ready(function() {
+    loadAll();
+});
+</script>
+</body>
+</html>