internal/server,shared: support request metadata (#850)
- add support for http handlers in the request chain to append metadata to the request - metrics middleware will include metadata in the activity log - update Activity UI to support metadata, drag sort columns - update Activity UI capture dialog to use more screen space Updates #834
This commit is contained in:
@@ -3,6 +3,7 @@ package scheduler
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mostlygeek/llama-swap/internal/config"
|
"github.com/mostlygeek/llama-swap/internal/config"
|
||||||
@@ -278,6 +279,11 @@ func (s *FIFO) grantHandler(req HandlerReq, modelID string) {
|
|||||||
s.effects.GrantError(req, shared.ConcurrencyLimitError{})
|
s.effects.GrantError(req, shared.ConcurrencyLimitError{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := shared.SetReqData(req.Ctx, "fifo_priority", strconv.Itoa(s.cfg.Priority[req.Model])); err != nil {
|
||||||
|
s.logger.Debugf("failed to set fifo_priority metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if s.effects.GrantServe(req, modelID) {
|
if s.effects.GrantServe(req, modelID) {
|
||||||
s.inFlight[modelID]++
|
s.inFlight[modelID]++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package scheduler
|
package scheduler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -54,8 +55,9 @@ type stopRec struct {
|
|||||||
// fakeEffects is an in-memory scheduler.Effects. Tests program process states
|
// fakeEffects is an in-memory scheduler.Effects. Tests program process states
|
||||||
// and GrantServe outcomes, then assert on the recorded calls.
|
// and GrantServe outcomes, then assert on the recorded calls.
|
||||||
type fakeEffects struct {
|
type fakeEffects struct {
|
||||||
states map[string]process.ProcessState // model -> state; missing => not handled
|
states map[string]process.ProcessState // model -> state; missing => not handled
|
||||||
serveResult map[string]bool // GrantServe return per model (default true)
|
serveResult map[string]bool // GrantServe return per model (default true)
|
||||||
|
lastServeReq HandlerReq
|
||||||
|
|
||||||
starts []startRec
|
starts []startRec
|
||||||
grants []grantRec
|
grants []grantRec
|
||||||
@@ -98,6 +100,7 @@ func (f *fakeEffects) GrantServe(req HandlerReq, modelID string) bool {
|
|||||||
if v, set := f.serveResult[modelID]; set {
|
if v, set := f.serveResult[modelID]; set {
|
||||||
ok = v
|
ok = v
|
||||||
}
|
}
|
||||||
|
f.lastServeReq = req
|
||||||
f.grants = append(f.grants, grantRec{model: modelID, serve: ok})
|
f.grants = append(f.grants, grantRec{model: modelID, serve: ok})
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
@@ -169,6 +172,27 @@ func TestFIFO_FastPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFIFO_GrantSetsPriorityMetadata(t *testing.T) {
|
||||||
|
eff := newFakeEffects()
|
||||||
|
eff.states["a"] = process.StateReady
|
||||||
|
cfg := config.FifoConfig{Priority: map[string]int{"a": 7}}
|
||||||
|
s := NewFIFO("test", logmon.NewWriter(io.Discard), &stubPlanner{}, cfg, nil, eff)
|
||||||
|
|
||||||
|
ctx := shared.SetContext(context.Background(), shared.ReqContextData{ModelID: "a", Metadata: make(map[string]string)})
|
||||||
|
s.OnRequest(HandlerReq{Model: "a", Ctx: ctx})
|
||||||
|
|
||||||
|
if got := eff.served("a"); got != 1 {
|
||||||
|
t.Fatalf("served(a)=%d want 1", got)
|
||||||
|
}
|
||||||
|
data, ok := shared.ReadContext(eff.lastServeReq.Ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("context data missing from granted request")
|
||||||
|
}
|
||||||
|
if data.Metadata["fifo_priority"] != "7" {
|
||||||
|
t.Errorf("fifo_priority = %q, want 7", data.Metadata["fifo_priority"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFIFO_ModelNotFound(t *testing.T) {
|
func TestFIFO_ModelNotFound(t *testing.T) {
|
||||||
eff := newFakeEffects() // no states => model unknown
|
eff := newFakeEffects() // no states => model unknown
|
||||||
s := newFIFO(&stubPlanner{}, eff)
|
s := newFIFO(&stubPlanner{}, eff)
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ func (s *Server) startPreload() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
req = req.WithContext(shared.SetContext(req.Context(), shared.ReqContextData{Model: modelID, ModelID: modelID}))
|
req = req.WithContext(shared.SetContext(req.Context(), shared.ReqContextData{Model: modelID, ModelID: modelID, Metadata: make(map[string]string)}))
|
||||||
|
|
||||||
dw := &discardResponseWriter{status: http.StatusOK}
|
dw := &discardResponseWriter{status: http.StatusOK}
|
||||||
s.local.ServeHTTP(dw, req)
|
s.local.ServeHTTP(dw, req)
|
||||||
@@ -338,7 +338,7 @@ func (s *Server) handleUpstream(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Strip the /upstream/<model> prefix before forwarding.
|
// Strip the /upstream/<model> prefix before forwarding.
|
||||||
r.URL.Path = remainingPath
|
r.URL.Path = remainingPath
|
||||||
// Pin the resolved model so the router skips body/query extraction.
|
// Pin the resolved model so the router skips body/query extraction.
|
||||||
*r = *r.WithContext(shared.SetContext(r.Context(), shared.ReqContextData{Model: searchName, ModelID: modelID}))
|
*r = *r.WithContext(shared.SetContext(r.Context(), shared.ReqContextData{Model: searchName, ModelID: modelID, Metadata: make(map[string]string)}))
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case s.local.Handles(modelID):
|
case s.local.Handles(modelID):
|
||||||
|
|||||||
@@ -33,15 +33,16 @@ type TokenMetrics struct {
|
|||||||
|
|
||||||
// ActivityLogEntry represents parsed token statistics from llama-server logs.
|
// ActivityLogEntry represents parsed token statistics from llama-server logs.
|
||||||
type ActivityLogEntry struct {
|
type ActivityLogEntry struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
ReqPath string `json:"req_path"`
|
ReqPath string `json:"req_path"`
|
||||||
RespContentType string `json:"resp_content_type"`
|
RespContentType string `json:"resp_content_type"`
|
||||||
RespStatusCode int `json:"resp_status_code"`
|
RespStatusCode int `json:"resp_status_code"`
|
||||||
Tokens TokenMetrics `json:"tokens"`
|
Tokens TokenMetrics `json:"tokens"`
|
||||||
DurationMs int `json:"duration_ms"`
|
DurationMs int `json:"duration_ms"`
|
||||||
HasCapture bool `json:"has_capture"`
|
HasCapture bool `json:"has_capture"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActivityLogEvent carries a single activity log entry to event subscribers.
|
// ActivityLogEvent carries a single activity log entry to event subscribers.
|
||||||
@@ -135,6 +136,13 @@ func (mp *metricsMonitor) record(modelID string, r *http.Request, recorder *resp
|
|||||||
DurationMs: int(time.Since(recorder.StartTime()).Milliseconds()),
|
DurationMs: int(time.Since(recorder.StartTime()).Milliseconds()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ctxData, ok := shared.ReadContext(r.Context()); ok && len(ctxData.Metadata) > 0 {
|
||||||
|
tm.Metadata = make(map[string]string, len(ctxData.Metadata))
|
||||||
|
for k, v := range ctxData.Metadata {
|
||||||
|
tm.Metadata[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
queueAndEmit := func() {
|
queueAndEmit := func() {
|
||||||
tm.ID = mp.queueMetrics(tm)
|
tm.ID = mp.queueMetrics(tm)
|
||||||
mp.emitMetric(tm)
|
mp.emitMetric(tm)
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mostlygeek/llama-swap/internal/shared"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,6 +60,33 @@ func TestServer_ProcessStreamingResponse_NoData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMetricsMonitor_RecordMetadata(t *testing.T) {
|
||||||
|
mm := newMetricsMonitor(nil, 10, 0)
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"usage":{}}`))
|
||||||
|
r = r.WithContext(shared.SetContext(r.Context(), shared.ReqContextData{
|
||||||
|
ModelID: "m",
|
||||||
|
Metadata: map[string]string{"client": "web", "trace": "abc"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
copier := newBodyCopier(w)
|
||||||
|
copier.WriteHeader(http.StatusOK)
|
||||||
|
copier.Write([]byte(`{"usage":{"prompt_tokens":1,"completion_tokens":2}}`))
|
||||||
|
|
||||||
|
mm.record("m", r, copier, 0, nil, nil)
|
||||||
|
|
||||||
|
entries := mm.getMetrics()
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("want 1 entry, got %d", len(entries))
|
||||||
|
}
|
||||||
|
if entries[0].Metadata["client"] != "web" {
|
||||||
|
t.Errorf("client = %q, want web", entries[0].Metadata["client"])
|
||||||
|
}
|
||||||
|
if entries[0].Metadata["trace"] != "abc" {
|
||||||
|
t.Errorf("trace = %q, want abc", entries[0].Metadata["trace"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_ParseMetrics_Infill(t *testing.T) {
|
func TestServer_ParseMetrics_Infill(t *testing.T) {
|
||||||
// /infill responses are arrays; timings live in the last element.
|
// /infill responses are arrays; timings live in the last element.
|
||||||
body := `[{"content":"a"},{"content":"b","timings":{"prompt_n":5,"predicted_n":9,"prompt_ms":10,"predicted_ms":20}}]`
|
body := `[{"content":"a"},{"content":"b","timings":{"prompt_n":5,"predicted_n":9,"prompt_ms":10,"predicted_ms":20}}]`
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ type ReqContextData struct {
|
|||||||
ModelID string
|
ModelID string
|
||||||
Streaming bool
|
Streaming bool
|
||||||
SendLoadingState bool
|
SendLoadingState bool
|
||||||
|
// Metadata is a request-scoped key/value bag that handlers may mutate
|
||||||
|
// while processing. The metrics middleware copies it into ActivityLogEntry.
|
||||||
|
Metadata map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -123,6 +126,25 @@ func ReadContext(ctx context.Context) (ReqContextData, bool) {
|
|||||||
return data, ok
|
return data, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetReqData attaches a key/value pair to the request context's metadata map.
|
||||||
|
// The metadata map must already exist in the context's ReqContextData; callers
|
||||||
|
// should ensure FetchContext has run or initialize the map themselves.
|
||||||
|
// It returns an error for nil contexts or contexts without request data.
|
||||||
|
func SetReqData(ctx context.Context, key, value string) error {
|
||||||
|
if ctx == nil {
|
||||||
|
return fmt.Errorf("cannot set request metadata on nil context")
|
||||||
|
}
|
||||||
|
data, ok := ReadContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no request context data found")
|
||||||
|
}
|
||||||
|
if data.Metadata == nil {
|
||||||
|
return fmt.Errorf("no metadata map in request context")
|
||||||
|
}
|
||||||
|
data.Metadata[key] = value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// extractContext pulls fields from an HTTP request into a ReqContextData,
|
// extractContext pulls fields from an HTTP request into a ReqContextData,
|
||||||
// returning whatever is available. For GET requests it reads query parameters.
|
// returning whatever is available. For GET requests it reads query parameters.
|
||||||
// For POST requests it inspects Content-Type and parses JSON,
|
// For POST requests it inspects Content-Type and parses JSON,
|
||||||
@@ -139,6 +161,7 @@ func extractContext(r *http.Request) (ReqContextData, error) {
|
|||||||
Model: q.Get("model"),
|
Model: q.Get("model"),
|
||||||
Streaming: q.Get("stream") == "true",
|
Streaming: q.Get("stream") == "true",
|
||||||
ApiKey: apiKey,
|
ApiKey: apiKey,
|
||||||
|
Metadata: make(map[string]string),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +180,7 @@ func extractContext(r *http.Request) (ReqContextData, error) {
|
|||||||
Model: gjson.GetBytes(bodyBytes, "model").String(),
|
Model: gjson.GetBytes(bodyBytes, "model").String(),
|
||||||
Streaming: gjson.GetBytes(bodyBytes, "stream").Bool(),
|
Streaming: gjson.GetBytes(bodyBytes, "stream").Bool(),
|
||||||
ApiKey: apiKey,
|
ApiKey: apiKey,
|
||||||
|
Metadata: make(map[string]string),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +202,7 @@ func extractContext(r *http.Request) (ReqContextData, error) {
|
|||||||
Model: r.FormValue("model"),
|
Model: r.FormValue("model"),
|
||||||
Streaming: r.FormValue("stream") == "true",
|
Streaming: r.FormValue("stream") == "true",
|
||||||
ApiKey: apiKey,
|
ApiKey: apiKey,
|
||||||
|
Metadata: make(map[string]string),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -387,6 +387,38 @@ func TestExtractContext_ApiKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetReqData(t *testing.T) {
|
||||||
|
ctx := SetContext(context.Background(), ReqContextData{Model: "llama3", ModelID: "llama3", Metadata: make(map[string]string)})
|
||||||
|
|
||||||
|
if err := SetReqData(ctx, "client", "web"); err != nil {
|
||||||
|
t.Fatalf("SetReqData: %v", err)
|
||||||
|
}
|
||||||
|
if err := SetReqData(ctx, "trace", "abc123"); err != nil {
|
||||||
|
t.Fatalf("SetReqData: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := ReadContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("context data missing")
|
||||||
|
}
|
||||||
|
if data.Metadata["client"] != "web" {
|
||||||
|
t.Errorf("client = %q, want %q", data.Metadata["client"], "web")
|
||||||
|
}
|
||||||
|
if data.Metadata["trace"] != "abc123" {
|
||||||
|
t.Errorf("trace = %q, want %q", data.Metadata["trace"], "abc123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetReqData_Errors(t *testing.T) {
|
||||||
|
if err := SetReqData(context.Background(), "k", "v"); err == nil {
|
||||||
|
t.Error("expected error when no request context data exists")
|
||||||
|
}
|
||||||
|
ctx := SetContext(context.Background(), ReqContextData{Model: "llama3", ModelID: "llama3"})
|
||||||
|
if err := SetReqData(ctx, "k", "v"); err == nil {
|
||||||
|
t.Error("expected error when metadata map is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_ExtractAPIKey(t *testing.T) {
|
func TestServer_ExtractAPIKey(t *testing.T) {
|
||||||
basicHeader := func(user, pass string) string {
|
basicHeader := func(user, pass string) string {
|
||||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
|
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
|
||||||
|
|||||||
@@ -193,7 +193,7 @@
|
|||||||
<dialog
|
<dialog
|
||||||
bind:this={dialogEl}
|
bind:this={dialogEl}
|
||||||
onclose={handleDialogClose}
|
onclose={handleDialogClose}
|
||||||
class="bg-surface text-txtmain rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] p-0 backdrop:bg-black/50 m-auto"
|
class="bg-surface text-txtmain rounded-lg shadow-xl max-w-[80%] w-full max-h-[90vh] p-0 backdrop:bg-black/50 m-auto"
|
||||||
>
|
>
|
||||||
{#if capture}
|
{#if capture}
|
||||||
<div class="flex flex-col max-h-[90vh]">
|
<div class="flex flex-col max-h-[90vh]">
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
metadata: Record<string, string> | undefined;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { metadata, children }: Props = $props();
|
||||||
|
|
||||||
|
let entries = $derived(Object.entries(metadata || {}));
|
||||||
|
let triggerEl: HTMLElement | undefined = $state();
|
||||||
|
let tooltipEl: HTMLDivElement | undefined = $state();
|
||||||
|
let show = $state(false);
|
||||||
|
let tooltipStyle = $state("");
|
||||||
|
|
||||||
|
function positionTooltip() {
|
||||||
|
if (!triggerEl || !tooltipEl) return;
|
||||||
|
const triggerRect = triggerEl.getBoundingClientRect();
|
||||||
|
const tooltipRect = tooltipEl.getBoundingClientRect();
|
||||||
|
const margin = 8;
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let left = triggerRect.left;
|
||||||
|
let top = triggerRect.bottom + margin;
|
||||||
|
|
||||||
|
// Keep tooltip within horizontal viewport bounds
|
||||||
|
if (left + tooltipRect.width > viewportWidth - margin) {
|
||||||
|
left = triggerRect.right - tooltipRect.width;
|
||||||
|
}
|
||||||
|
if (left < margin) {
|
||||||
|
left = margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip above trigger if it would overflow the bottom
|
||||||
|
if (top + tooltipRect.height > viewportHeight - margin) {
|
||||||
|
top = triggerRect.top - tooltipRect.height - margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltipStyle = `left: ${left}px; top: ${top}px; max-width: calc(100vw - ${margin * 2}px);`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnter() {
|
||||||
|
show = true;
|
||||||
|
requestAnimationFrame(positionTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeave() {
|
||||||
|
show = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
bind:this={triggerEl}
|
||||||
|
onmouseenter={onEnter}
|
||||||
|
onmouseleave={onLeave}
|
||||||
|
onfocus={onEnter}
|
||||||
|
onblur={onLeave}
|
||||||
|
class="inline-flex"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Show metadata"
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if show && entries.length > 0}
|
||||||
|
<div
|
||||||
|
bind:this={tooltipEl}
|
||||||
|
style={tooltipStyle}
|
||||||
|
class="fixed px-3 py-2 bg-gray-900 text-white text-sm rounded-md z-50 normal-case min-w-[12rem] max-w-[24rem] shadow-lg whitespace-normal"
|
||||||
|
>
|
||||||
|
<table class="w-full text-left">
|
||||||
|
<tbody>
|
||||||
|
{#each entries as [key, value]}
|
||||||
|
<tr class="border-b border-white/10 last:border-0">
|
||||||
|
<td class="py-1 pr-3 font-medium whitespace-nowrap text-primary">{key}</td>
|
||||||
|
<td class="py-1 break-all">{value}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -41,6 +41,7 @@ export interface ActivityLogEntry {
|
|||||||
tokens: TokenMetrics;
|
tokens: TokenMetrics;
|
||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
has_capture: boolean;
|
has_capture: boolean;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReqRespCapture {
|
export interface ReqRespCapture {
|
||||||
|
|||||||
@@ -2,25 +2,13 @@
|
|||||||
import { metrics, getCapture } from "../stores/api";
|
import { metrics, getCapture } from "../stores/api";
|
||||||
import ActivityStats from "../components/ActivityStats.svelte";
|
import ActivityStats from "../components/ActivityStats.svelte";
|
||||||
import Tooltip from "../components/Tooltip.svelte";
|
import Tooltip from "../components/Tooltip.svelte";
|
||||||
|
import MetadataTooltip from "../components/MetadataTooltip.svelte";
|
||||||
import CaptureDialog from "../components/CaptureDialog.svelte";
|
import CaptureDialog from "../components/CaptureDialog.svelte";
|
||||||
import { persistentStore } from "../stores/persistent";
|
import { persistentStore } from "../stores/persistent";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import type { ReqRespCapture } from "../lib/types";
|
import type { ReqRespCapture } from "../lib/types";
|
||||||
|
|
||||||
type ColumnKey =
|
type ColumnKey = string;
|
||||||
| "id"
|
|
||||||
| "time"
|
|
||||||
| "model"
|
|
||||||
| "req_path"
|
|
||||||
| "resp_status_code"
|
|
||||||
| "resp_content_type"
|
|
||||||
| "cached"
|
|
||||||
| "prompt"
|
|
||||||
| "generated"
|
|
||||||
| "prompt_speed"
|
|
||||||
| "gen_speed"
|
|
||||||
| "duration"
|
|
||||||
| "capture";
|
|
||||||
|
|
||||||
interface ColumnDef {
|
interface ColumnDef {
|
||||||
key: ColumnKey;
|
key: ColumnKey;
|
||||||
@@ -42,17 +30,21 @@
|
|||||||
{ key: "gen_speed", label: "Gen Speed", defaultVisible: true },
|
{ key: "gen_speed", label: "Gen Speed", defaultVisible: true },
|
||||||
{ key: "duration", label: "Duration", defaultVisible: true },
|
{ key: "duration", label: "Duration", defaultVisible: true },
|
||||||
{ key: "capture", label: "Capture", defaultVisible: true },
|
{ key: "capture", label: "Capture", defaultVisible: true },
|
||||||
|
{ key: "meta", label: "Meta", defaultVisible: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const defaultVisibleKeys = columns.filter((c) => c.defaultVisible).map((c) => c.key);
|
const defaultVisibleKeys = columns.filter((c) => c.defaultVisible).map((c) => c.key);
|
||||||
|
|
||||||
const visibleColumns = persistentStore<ColumnKey[]>(
|
const visibleColumns = persistentStore<ColumnKey[]>("activity-columns", defaultVisibleKeys);
|
||||||
"activity-columns",
|
const columnOrder = persistentStore<ColumnKey[]>(
|
||||||
defaultVisibleKeys
|
"activity-column-order",
|
||||||
|
columns.map((c) => c.key)
|
||||||
);
|
);
|
||||||
|
|
||||||
let columnsMenuOpen = $state(false);
|
let columnsMenuOpen = $state(false);
|
||||||
let dropdownContainer: HTMLDivElement | null = null;
|
let dropdownContainer: HTMLDivElement | null = null;
|
||||||
|
let dragKey: ColumnKey | null = $state(null);
|
||||||
|
let dragOverKey: ColumnKey | null = $state(null);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
@@ -84,6 +76,84 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isColumnVisible(key: ColumnKey): boolean {
|
||||||
|
return $visibleColumns.includes(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragStart(e: DragEvent, key: ColumnKey) {
|
||||||
|
dragKey = key;
|
||||||
|
e.dataTransfer?.setData("text/plain", key);
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent, key: ColumnKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
}
|
||||||
|
dragOverKey = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent, targetKey: ColumnKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!dragKey || dragKey === targetKey) return;
|
||||||
|
const order = [...$columnOrder];
|
||||||
|
const fromIndex = order.indexOf(dragKey);
|
||||||
|
let toIndex = order.indexOf(targetKey);
|
||||||
|
if (fromIndex === -1 || toIndex === -1) return;
|
||||||
|
order.splice(fromIndex, 1);
|
||||||
|
if (fromIndex < toIndex) {
|
||||||
|
toIndex -= 1;
|
||||||
|
}
|
||||||
|
order.splice(toIndex, 0, dragKey);
|
||||||
|
columnOrder.set(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
dragKey = null;
|
||||||
|
dragOverKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let orderedColumns = $derived(
|
||||||
|
columns.slice().sort((a, b) => {
|
||||||
|
const aIndex = $columnOrder.indexOf(a.key);
|
||||||
|
const bIndex = $columnOrder.indexOf(b.key);
|
||||||
|
if (aIndex === -1 && bIndex === -1) return 0;
|
||||||
|
if (aIndex === -1) return 1;
|
||||||
|
if (bIndex === -1) return -1;
|
||||||
|
return aIndex - bIndex;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let activeVisibleColumns = $derived(
|
||||||
|
columns
|
||||||
|
.filter((c) => isColumnVisible(c.key))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIndex = $columnOrder.indexOf(a.key);
|
||||||
|
const bIndex = $columnOrder.indexOf(b.key);
|
||||||
|
if (aIndex === -1 && bIndex === -1) return 0;
|
||||||
|
if (aIndex === -1) return 1;
|
||||||
|
if (bIndex === -1) return -1;
|
||||||
|
return aIndex - bIndex;
|
||||||
|
})
|
||||||
|
.map((c) => c.key)
|
||||||
|
);
|
||||||
|
|
||||||
|
let columnLabelMap = $derived(Object.fromEntries(columns.map((c) => [c.key, c.label])));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const staticKeys = new Set(columns.map((c) => c.key));
|
||||||
|
const order = $columnOrder;
|
||||||
|
const hasStale = order.some((k) => !staticKeys.has(k));
|
||||||
|
const missing = columns.filter((c) => !order.includes(c.key)).map((c) => c.key);
|
||||||
|
if (hasStale || missing.length > 0) {
|
||||||
|
const cleaned = order.filter((k) => staticKeys.has(k));
|
||||||
|
columnOrder.set([...cleaned, ...missing]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function formatSpeed(speed: number): string {
|
function formatSpeed(speed: number): string {
|
||||||
return speed < 0 ? "unknown" : speed.toFixed(2) + " t/s";
|
return speed < 0 ? "unknown" : speed.toFixed(2) + " t/s";
|
||||||
}
|
}
|
||||||
@@ -157,22 +227,37 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{#if columnsMenuOpen}
|
{#if columnsMenuOpen}
|
||||||
<div class="absolute right-0 top-full mt-1 bg-surface border border-gray-200 dark:border-white/10 rounded shadow-lg z-10 py-1 min-w-[16rem]">
|
<div class="absolute right-0 top-full mt-1 bg-surface border border-gray-200 dark:border-white/10 rounded shadow-lg z-10 py-1 min-w-[16rem]" role="list">
|
||||||
<div class="px-3 py-2 text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-white/10">
|
<div class="px-3 py-2 text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-white/10" role="presentation">
|
||||||
Columns
|
Columns
|
||||||
</div>
|
</div>
|
||||||
{#each columns as col (col.key)}
|
{#each orderedColumns as col (col.key)}
|
||||||
<label
|
{@const key = col.key}
|
||||||
class="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer hover:bg-secondary-hover transition-colors"
|
<div
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-secondary-hover transition-colors {dragOverKey === key && dragKey !== key ? 'bg-primary/10 ring-1 ring-primary/40' : ''} {dragKey === key ? 'opacity-40' : ''}"
|
||||||
|
role="listitem"
|
||||||
|
ondragover={(e) => handleDragOver(e, key)}
|
||||||
|
ondrop={(e) => handleDrop(e, key)}
|
||||||
>
|
>
|
||||||
<input
|
<span
|
||||||
type="checkbox"
|
class="text-txtsecondary select-none cursor-grab"
|
||||||
checked={$visibleColumns.includes(col.key)}
|
draggable={true}
|
||||||
onchange={() => toggleColumn(col.key)}
|
role="button"
|
||||||
class="rounded"
|
tabindex="-1"
|
||||||
/>
|
aria-label="Drag to reorder {col.label}"
|
||||||
{col.label}
|
ondragstart={(e) => handleDragStart(e, key)}
|
||||||
</label>
|
ondragend={handleDragEnd}
|
||||||
|
>⋮⋮</span>
|
||||||
|
<label class="flex items-center gap-2 flex-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isColumnVisible(key)}
|
||||||
|
onchange={() => toggleColumn(key)}
|
||||||
|
class="rounded"
|
||||||
|
/>
|
||||||
|
{col.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -182,112 +267,80 @@
|
|||||||
<table class="min-w-full divide-y">
|
<table class="min-w-full divide-y">
|
||||||
<thead class="border-gray-200 dark:border-white/10">
|
<thead class="border-gray-200 dark:border-white/10">
|
||||||
<tr class="text-left text-xs uppercase tracking-wider">
|
<tr class="text-left text-xs uppercase tracking-wider">
|
||||||
{#if $visibleColumns.includes("id")}
|
{#each activeVisibleColumns as key (key)}
|
||||||
<th class="px-6 py-3">ID</th>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("time")}
|
|
||||||
<th class="px-6 py-3">Time</th>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("model")}
|
|
||||||
<th class="px-6 py-3">Model</th>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("req_path")}
|
|
||||||
<th class="px-6 py-3">Path</th>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("resp_status_code")}
|
|
||||||
<th class="px-6 py-3">Status</th>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("resp_content_type")}
|
|
||||||
<th class="px-6 py-3">Content-Type</th>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("cached")}
|
|
||||||
<th class="px-6 py-3">
|
<th class="px-6 py-3">
|
||||||
Cached <Tooltip content="prompt tokens from cache" />
|
{#if key === "cached"}
|
||||||
|
Cached <Tooltip content="prompt tokens from cache" />
|
||||||
|
{:else if key === "prompt"}
|
||||||
|
Prompt <Tooltip content="new prompt tokens processed" />
|
||||||
|
{:else}
|
||||||
|
{columnLabelMap[key] ?? key}
|
||||||
|
{/if}
|
||||||
</th>
|
</th>
|
||||||
{/if}
|
{/each}
|
||||||
{#if $visibleColumns.includes("prompt")}
|
|
||||||
<th class="px-6 py-3">
|
|
||||||
Prompt <Tooltip content="new prompt tokens processed" />
|
|
||||||
</th>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("generated")}
|
|
||||||
<th class="px-6 py-3">Generated</th>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("prompt_speed")}
|
|
||||||
<th class="px-6 py-3">Prompt Speed</th>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("gen_speed")}
|
|
||||||
<th class="px-6 py-3">Gen Speed</th>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("duration")}
|
|
||||||
<th class="px-6 py-3">Duration</th>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("capture")}
|
|
||||||
<th class="px-6 py-3">Capture</th>
|
|
||||||
{/if}
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y">
|
<tbody class="divide-y">
|
||||||
{#if sortedMetrics.length === 0}
|
{#if sortedMetrics.length === 0}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan={$visibleColumns.length} class="px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
<td colspan={activeVisibleColumns.length} class="px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
No activity recorded
|
No activity recorded
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
{#each sortedMetrics as metric (metric.id)}
|
{#each sortedMetrics as metric (metric.id)}
|
||||||
<tr class="whitespace-nowrap text-sm border-gray-200 dark:border-white/10">
|
<tr class="whitespace-nowrap text-sm border-gray-200 dark:border-white/10">
|
||||||
{#if $visibleColumns.includes("id")}
|
{#each activeVisibleColumns as key (key)}
|
||||||
<td class="px-4 py-4">{metric.id + 1}</td>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("time")}
|
|
||||||
<td class="px-6 py-4">{formatRelativeTime(metric.timestamp)}</td>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("model")}
|
|
||||||
<td class="px-6 py-4">{metric.model}</td>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("req_path")}
|
|
||||||
<td class="px-6 py-4">{metric.req_path || "-"}</td>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("resp_status_code")}
|
|
||||||
<td class="px-6 py-4">{metric.resp_status_code || "-"}</td>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("resp_content_type")}
|
|
||||||
<td class="px-6 py-4">{metric.resp_content_type || "-"}</td>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("cached")}
|
|
||||||
<td class="px-6 py-4">{metric.tokens.cache_tokens > 0 ? metric.tokens.cache_tokens.toLocaleString() : "-"}</td>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("prompt")}
|
|
||||||
<td class="px-6 py-4">{metric.tokens.input_tokens.toLocaleString()}</td>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("generated")}
|
|
||||||
<td class="px-6 py-4">{metric.tokens.output_tokens.toLocaleString()}</td>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("prompt_speed")}
|
|
||||||
<td class="px-6 py-4">{formatSpeed(metric.tokens.prompt_per_second)}</td>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("gen_speed")}
|
|
||||||
<td class="px-6 py-4">{formatSpeed(metric.tokens.tokens_per_second)}</td>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("duration")}
|
|
||||||
<td class="px-6 py-4">{formatDuration(metric.duration_ms)}</td>
|
|
||||||
{/if}
|
|
||||||
{#if $visibleColumns.includes("capture")}
|
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
{#if metric.has_capture}
|
{#if key === "id"}
|
||||||
<button
|
{metric.id + 1}
|
||||||
onclick={() => viewCapture(metric.id)}
|
{:else if key === "time"}
|
||||||
disabled={loadingCaptureId === metric.id}
|
{formatRelativeTime(metric.timestamp)}
|
||||||
class="btn btn--sm"
|
{:else if key === "model"}
|
||||||
>
|
{metric.model}
|
||||||
{loadingCaptureId === metric.id ? "..." : "View"}
|
{:else if key === "req_path"}
|
||||||
</button>
|
{metric.req_path || "-"}
|
||||||
|
{:else if key === "resp_status_code"}
|
||||||
|
{metric.resp_status_code || "-"}
|
||||||
|
{:else if key === "resp_content_type"}
|
||||||
|
{metric.resp_content_type || "-"}
|
||||||
|
{:else if key === "cached"}
|
||||||
|
{metric.tokens.cache_tokens > 0 ? metric.tokens.cache_tokens.toLocaleString() : "-"}
|
||||||
|
{:else if key === "prompt"}
|
||||||
|
{metric.tokens.input_tokens.toLocaleString()}
|
||||||
|
{:else if key === "generated"}
|
||||||
|
{metric.tokens.output_tokens.toLocaleString()}
|
||||||
|
{:else if key === "prompt_speed"}
|
||||||
|
{formatSpeed(metric.tokens.prompt_per_second)}
|
||||||
|
{:else if key === "gen_speed"}
|
||||||
|
{formatSpeed(metric.tokens.tokens_per_second)}
|
||||||
|
{:else if key === "duration"}
|
||||||
|
{formatDuration(metric.duration_ms)}
|
||||||
|
{:else if key === "capture"}
|
||||||
|
{#if metric.has_capture}
|
||||||
|
<button
|
||||||
|
onclick={() => viewCapture(metric.id)}
|
||||||
|
disabled={loadingCaptureId === metric.id}
|
||||||
|
class="btn btn--sm"
|
||||||
|
>
|
||||||
|
{loadingCaptureId === metric.id ? "..." : "View"}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="text-txtsecondary">-</span>
|
||||||
|
{/if}
|
||||||
|
{:else if key === "meta"}
|
||||||
|
{#if Object.keys(metric.metadata || {}).length > 0}
|
||||||
|
<MetadataTooltip metadata={metric.metadata}>
|
||||||
|
<span class="cursor-help text-txtsecondary hover:text-txtmain">...</span>
|
||||||
|
</MetadataTooltip>
|
||||||
|
{:else}
|
||||||
|
<span class="text-txtsecondary">-</span>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-txtsecondary">-</span>
|
-
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{/if}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user