lopata 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,6 +42,7 @@
42
42
  --color-rose-400: oklch(71.2% .194 13.428);
43
43
  --color-rose-500: oklch(64.5% .246 16.439);
44
44
  --color-gray-400: oklch(70.7% .022 261.325);
45
+ --color-gray-500: oklch(55.1% .027 264.364);
45
46
  --color-gray-900: oklch(21% .034 264.665);
46
47
  --color-neutral-400: oklch(70.8% 0 0);
47
48
  --color-neutral-500: oklch(55.6% 0 0);
@@ -1438,6 +1439,16 @@
1438
1439
  background-color: var(--color-gray-400);
1439
1440
  }
1440
1441
 
1442
+ .bg-gray-500\/15 {
1443
+ background-color: #6a728226;
1444
+ }
1445
+
1446
+ @supports (color: color-mix(in lab, red, red)) {
1447
+ .bg-gray-500\/15 {
1448
+ background-color: color-mix(in oklab, var(--color-gray-500) 15%, transparent);
1449
+ }
1450
+ }
1451
+
1441
1452
  .bg-gray-900 {
1442
1453
  background-color: var(--color-gray-900);
1443
1454
  }
@@ -1964,6 +1975,10 @@
1964
1975
  color: var(--color-emerald-600);
1965
1976
  }
1966
1977
 
1978
+ .text-gray-400 {
1979
+ color: var(--color-gray-400);
1980
+ }
1981
+
1967
1982
  .text-indigo-400 {
1968
1983
  color: var(--color-indigo-400);
1969
1984
  }
@@ -2304,6 +2319,18 @@
2304
2319
  }
2305
2320
  }
2306
2321
 
2322
+ @media (hover: hover) {
2323
+ .hover\:bg-accent-lime\/90:hover {
2324
+ background-color: #f0a030e6;
2325
+ }
2326
+
2327
+ @supports (color: color-mix(in lab, red, red)) {
2328
+ .hover\:bg-accent-lime\/90:hover {
2329
+ background-color: color-mix(in oklab, var(--color-accent-lime) 90%, transparent);
2330
+ }
2331
+ }
2332
+ }
2333
+
2307
2334
  @media (hover: hover) {
2308
2335
  .hover\:bg-amber-500\/10:hover {
2309
2336
  background-color: #f99c001a;
@@ -6264,6 +6264,325 @@ function truncateUrl(url) {
6264
6264
  }
6265
6265
  }
6266
6266
 
6267
+ // src/dashboard/views/generations.tsx
6268
+ var STATE_COLORS = {
6269
+ active: "bg-emerald-500/15 text-emerald-500",
6270
+ draining: "bg-yellow-500/15 text-yellow-500",
6271
+ stopped: "bg-gray-500/15 text-gray-400"
6272
+ };
6273
+ var STATE_DESCRIPTIONS = {
6274
+ active: "Receiving new requests",
6275
+ draining: "Finishing in-flight requests, no new requests accepted",
6276
+ stopped: "All work finished, will be removed shortly"
6277
+ };
6278
+ function formatRelativeTime(timestamp) {
6279
+ const diff = Date.now() - timestamp;
6280
+ if (diff < 1000)
6281
+ return "just now";
6282
+ if (diff < 60000)
6283
+ return `${Math.floor(diff / 1000)}s ago`;
6284
+ if (diff < 3600000)
6285
+ return `${Math.floor(diff / 60000)}m ago`;
6286
+ return `${Math.floor(diff / 3600000)}h ago`;
6287
+ }
6288
+ function GenerationCard({ gen, onReload, onStop }) {
6289
+ const [detail, setDetail] = d2(null);
6290
+ const [expanded, setExpanded] = d2(false);
6291
+ y2(() => {
6292
+ if (!expanded)
6293
+ return;
6294
+ rpc("generations.detail", { id: gen.id, workerName: gen.workerName }).then(setDetail).catch(() => {});
6295
+ }, [expanded, gen.id]);
6296
+ return /* @__PURE__ */ u3("div", {
6297
+ class: "bg-panel rounded-lg border border-border p-4",
6298
+ children: [
6299
+ /* @__PURE__ */ u3("div", {
6300
+ class: "flex items-center justify-between",
6301
+ children: [
6302
+ /* @__PURE__ */ u3("div", {
6303
+ class: "flex items-center gap-3",
6304
+ children: [
6305
+ /* @__PURE__ */ u3("span", {
6306
+ class: "text-lg font-bold font-mono text-ink",
6307
+ children: [
6308
+ "#",
6309
+ gen.id
6310
+ ]
6311
+ }, undefined, true, undefined, this),
6312
+ /* @__PURE__ */ u3("span", {
6313
+ title: STATE_DESCRIPTIONS[gen.state],
6314
+ children: /* @__PURE__ */ u3(StatusBadge, {
6315
+ status: gen.state,
6316
+ colorMap: STATE_COLORS
6317
+ }, undefined, false, undefined, this)
6318
+ }, undefined, false, undefined, this),
6319
+ gen.workerName && /* @__PURE__ */ u3("span", {
6320
+ class: "inline-flex px-2 py-0.5 rounded-md text-xs font-medium bg-panel-hover text-text-secondary",
6321
+ children: gen.workerName
6322
+ }, undefined, false, undefined, this)
6323
+ ]
6324
+ }, undefined, true, undefined, this),
6325
+ /* @__PURE__ */ u3("div", {
6326
+ class: "flex items-center gap-2",
6327
+ children: [
6328
+ gen.state === "active" && /* @__PURE__ */ u3("button", {
6329
+ onClick: onReload,
6330
+ class: "rounded-md px-3 py-1.5 text-xs font-medium bg-panel border border-border text-text-secondary hover:bg-panel-hover transition-all",
6331
+ children: "Reload"
6332
+ }, undefined, false, undefined, this),
6333
+ gen.state === "draining" && /* @__PURE__ */ u3("button", {
6334
+ onClick: () => onStop(gen.id),
6335
+ class: "rounded-md px-3 py-1.5 text-xs font-medium bg-panel border border-border text-text-secondary btn-danger transition-all",
6336
+ children: "Force Stop"
6337
+ }, undefined, false, undefined, this)
6338
+ ]
6339
+ }, undefined, true, undefined, this)
6340
+ ]
6341
+ }, undefined, true, undefined, this),
6342
+ /* @__PURE__ */ u3("div", {
6343
+ class: "mt-3 flex items-center gap-4 text-xs text-text-muted",
6344
+ children: [
6345
+ /* @__PURE__ */ u3("span", {
6346
+ title: new Date(gen.createdAt).toLocaleString(),
6347
+ children: [
6348
+ "Loaded ",
6349
+ formatRelativeTime(gen.createdAt)
6350
+ ]
6351
+ }, undefined, true, undefined, this),
6352
+ /* @__PURE__ */ u3("span", {
6353
+ title: "Number of HTTP requests currently being processed by this generation",
6354
+ children: [
6355
+ gen.activeRequests,
6356
+ " in-flight request",
6357
+ gen.activeRequests !== 1 ? "s" : ""
6358
+ ]
6359
+ }, undefined, true, undefined, this)
6360
+ ]
6361
+ }, undefined, true, undefined, this),
6362
+ gen.durableObjects && gen.durableObjects.length > 0 && /* @__PURE__ */ u3("div", {
6363
+ class: "mt-3 flex flex-wrap gap-2",
6364
+ children: gen.durableObjects.map((d3) => /* @__PURE__ */ u3("span", {
6365
+ class: "inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs bg-panel-hover text-text-secondary",
6366
+ title: `Durable Object class "${d3.namespace}": ${d3.activeInstances} active instance(s), ${d3.totalWebSockets} WebSocket connection(s)`,
6367
+ children: [
6368
+ d3.namespace,
6369
+ ":",
6370
+ /* @__PURE__ */ u3("span", {
6371
+ class: "font-medium text-ink",
6372
+ children: d3.activeInstances
6373
+ }, undefined, false, undefined, this),
6374
+ " instance",
6375
+ d3.activeInstances !== 1 ? "s" : "",
6376
+ d3.totalWebSockets > 0 && /* @__PURE__ */ u3("span", {
6377
+ class: "text-text-muted",
6378
+ children: [
6379
+ "(",
6380
+ d3.totalWebSockets,
6381
+ " ws)"
6382
+ ]
6383
+ }, undefined, true, undefined, this)
6384
+ ]
6385
+ }, d3.namespace, true, undefined, this))
6386
+ }, undefined, false, undefined, this),
6387
+ /* @__PURE__ */ u3("button", {
6388
+ onClick: () => setExpanded(!expanded),
6389
+ class: "mt-2 text-xs text-text-muted hover:text-ink transition-colors",
6390
+ children: expanded ? "Hide DO instances" : "Show DO instances"
6391
+ }, undefined, false, undefined, this),
6392
+ expanded && detail && /* @__PURE__ */ u3("div", {
6393
+ class: "mt-3 border-t border-border pt-3",
6394
+ children: detail.doNamespaces.length === 0 ? /* @__PURE__ */ u3("div", {
6395
+ class: "text-xs text-text-muted",
6396
+ children: "No active Durable Object instances in this generation"
6397
+ }, undefined, false, undefined, this) : detail.doNamespaces.map((ns) => /* @__PURE__ */ u3("div", {
6398
+ class: "mb-3",
6399
+ children: [
6400
+ /* @__PURE__ */ u3("div", {
6401
+ class: "text-xs font-medium text-text-secondary mb-1",
6402
+ children: ns.namespace
6403
+ }, undefined, false, undefined, this),
6404
+ ns.instances.length === 0 ? /* @__PURE__ */ u3("div", {
6405
+ class: "text-xs text-text-muted ml-2",
6406
+ children: "No active instances"
6407
+ }, undefined, false, undefined, this) : /* @__PURE__ */ u3("div", {
6408
+ class: "ml-2 space-y-0.5",
6409
+ children: ns.instances.map((inst) => /* @__PURE__ */ u3("div", {
6410
+ class: "text-xs font-mono text-text-data flex items-center gap-2",
6411
+ children: [
6412
+ /* @__PURE__ */ u3("span", {
6413
+ class: "truncate max-w-[200px]",
6414
+ title: inst.id,
6415
+ children: inst.id
6416
+ }, undefined, false, undefined, this),
6417
+ inst.wsCount > 0 && /* @__PURE__ */ u3("span", {
6418
+ class: "text-text-muted",
6419
+ title: "Active WebSocket connections",
6420
+ children: [
6421
+ inst.wsCount,
6422
+ " ws"
6423
+ ]
6424
+ }, undefined, true, undefined, this)
6425
+ ]
6426
+ }, inst.id, true, undefined, this))
6427
+ }, undefined, false, undefined, this)
6428
+ ]
6429
+ }, ns.namespace, true, undefined, this))
6430
+ }, undefined, false, undefined, this)
6431
+ ]
6432
+ }, undefined, true, undefined, this);
6433
+ }
6434
+ function GenerationsView() {
6435
+ const { data, refetch } = useQuery("generations.list");
6436
+ const reload = useMutation("generations.reload");
6437
+ const drain = useMutation("generations.drain");
6438
+ const configMutation = useMutation("generations.config");
6439
+ const [gracePeriod, setGracePeriod] = d2("");
6440
+ y2(() => {
6441
+ if (data?.gracePeriodMs != null && gracePeriod === "") {
6442
+ setGracePeriod(String(data.gracePeriodMs));
6443
+ }
6444
+ }, [data?.gracePeriodMs]);
6445
+ y2(() => {
6446
+ const timer = setInterval(refetch, 2000);
6447
+ return () => clearInterval(timer);
6448
+ }, [refetch]);
6449
+ const handleReload = q2(async (workerName) => {
6450
+ await reload.mutate(workerName ? { workerName } : undefined);
6451
+ refetch();
6452
+ }, [reload, refetch]);
6453
+ const handleStop = q2(async (workerName) => {
6454
+ await drain.mutate(workerName ? { workerName } : undefined);
6455
+ refetch();
6456
+ }, [drain, refetch]);
6457
+ const handleGracePeriodSave = q2(() => {
6458
+ const ms = parseInt(gracePeriod, 10);
6459
+ if (Number.isNaN(ms) || ms < 0)
6460
+ return;
6461
+ configMutation.mutate({ gracePeriodMs: ms });
6462
+ }, [gracePeriod, configMutation]);
6463
+ if (!data) {
6464
+ return /* @__PURE__ */ u3("div", {
6465
+ class: "p-4 sm:p-8",
6466
+ children: [
6467
+ /* @__PURE__ */ u3(PageHeader, {
6468
+ title: "Generations"
6469
+ }, undefined, false, undefined, this),
6470
+ /* @__PURE__ */ u3("div", {
6471
+ class: "text-text-muted text-sm text-center py-12",
6472
+ children: "Loading..."
6473
+ }, undefined, false, undefined, this)
6474
+ ]
6475
+ }, undefined, true, undefined, this);
6476
+ }
6477
+ const generations = data.generations;
6478
+ return /* @__PURE__ */ u3("div", {
6479
+ class: "p-4 sm:p-8",
6480
+ children: [
6481
+ /* @__PURE__ */ u3(PageHeader, {
6482
+ title: "Generations",
6483
+ subtitle: `${generations.length} generation(s)`,
6484
+ actions: /* @__PURE__ */ u3("div", {
6485
+ class: "flex items-center gap-2",
6486
+ children: [
6487
+ /* @__PURE__ */ u3("div", {
6488
+ class: "flex items-center gap-1",
6489
+ title: "How long to wait for in-flight requests to finish before force-stopping an old generation",
6490
+ children: [
6491
+ /* @__PURE__ */ u3("label", {
6492
+ class: "text-xs text-text-muted",
6493
+ children: "Grace period (ms):"
6494
+ }, undefined, false, undefined, this),
6495
+ /* @__PURE__ */ u3("input", {
6496
+ type: "number",
6497
+ value: gracePeriod,
6498
+ onInput: (e3) => setGracePeriod(e3.target.value),
6499
+ onBlur: handleGracePeriodSave,
6500
+ class: "bg-panel border border-border rounded-md px-2 py-1 text-xs w-24 outline-none focus:border-border focus:ring-1 focus:ring-border"
6501
+ }, undefined, false, undefined, this)
6502
+ ]
6503
+ }, undefined, true, undefined, this),
6504
+ /* @__PURE__ */ u3("button", {
6505
+ onClick: () => handleReload(),
6506
+ disabled: reload.isLoading,
6507
+ class: "rounded-md px-3 py-1.5 text-sm font-medium bg-accent-lime text-surface hover:bg-accent-lime/90 disabled:opacity-50 transition-all",
6508
+ children: reload.isLoading ? "Reloading..." : "Reload"
6509
+ }, undefined, false, undefined, this)
6510
+ ]
6511
+ }, undefined, true, undefined, this)
6512
+ }, undefined, false, undefined, this),
6513
+ /* @__PURE__ */ u3("p", {
6514
+ class: "text-xs text-text-muted mb-6 max-w-xl leading-relaxed",
6515
+ children: [
6516
+ "A generation is a snapshot of your worker code at a point in time. When code changes, a new generation is created and the old one is drained (finishes in-flight work, then stops). Each request and Durable Object is tied to the generation that was active when it started. The",
6517
+ " ",
6518
+ /* @__PURE__ */ u3("span", {
6519
+ class: "font-mono",
6520
+ children: "Gen"
6521
+ }, undefined, false, undefined, this),
6522
+ " column in Traces shows which generation handled each request."
6523
+ ]
6524
+ }, undefined, true, undefined, this),
6525
+ generations.length === 0 ? /* @__PURE__ */ u3(EmptyState, {
6526
+ message: "No generations yet. Start your dev server to see generations here."
6527
+ }, undefined, false, undefined, this) : /* @__PURE__ */ u3("div", {
6528
+ class: "space-y-4",
6529
+ children: generations.map((gen) => /* @__PURE__ */ u3(GenerationCard, {
6530
+ gen,
6531
+ onReload: () => handleReload(),
6532
+ onStop: (id) => handleStop()
6533
+ }, gen.id, false, undefined, this))
6534
+ }, undefined, false, undefined, this),
6535
+ data.workers && data.workers.length > 0 && /* @__PURE__ */ u3("div", {
6536
+ class: "mt-8",
6537
+ children: [
6538
+ /* @__PURE__ */ u3("h2", {
6539
+ class: "text-lg font-bold text-ink mb-1",
6540
+ children: "Per-Worker Generations"
6541
+ }, undefined, false, undefined, this),
6542
+ /* @__PURE__ */ u3("p", {
6543
+ class: "text-xs text-text-muted mb-4",
6544
+ children: "In multi-worker mode, each worker has its own independent generation lifecycle."
6545
+ }, undefined, false, undefined, this),
6546
+ data.workers.map((w3) => /* @__PURE__ */ u3("div", {
6547
+ class: "mb-6",
6548
+ children: [
6549
+ /* @__PURE__ */ u3("div", {
6550
+ class: "flex items-center gap-3 mb-3",
6551
+ children: [
6552
+ /* @__PURE__ */ u3("h3", {
6553
+ class: "text-sm font-bold text-ink",
6554
+ children: w3.workerName
6555
+ }, undefined, false, undefined, this),
6556
+ /* @__PURE__ */ u3("span", {
6557
+ class: "text-xs text-text-muted",
6558
+ children: [
6559
+ w3.generations.length,
6560
+ " generation(s)"
6561
+ ]
6562
+ }, undefined, true, undefined, this),
6563
+ /* @__PURE__ */ u3("button", {
6564
+ onClick: () => handleReload(w3.workerName),
6565
+ class: "rounded-md px-2 py-1 text-xs font-medium bg-panel border border-border text-text-secondary hover:bg-panel-hover transition-all",
6566
+ children: "Reload"
6567
+ }, undefined, false, undefined, this)
6568
+ ]
6569
+ }, undefined, true, undefined, this),
6570
+ /* @__PURE__ */ u3("div", {
6571
+ class: "space-y-3",
6572
+ children: w3.generations.map((gen) => /* @__PURE__ */ u3(GenerationCard, {
6573
+ gen,
6574
+ onReload: () => handleReload(w3.workerName),
6575
+ onStop: () => handleStop(w3.workerName)
6576
+ }, gen.id, false, undefined, this))
6577
+ }, undefined, false, undefined, this)
6578
+ ]
6579
+ }, w3.workerName, true, undefined, this))
6580
+ ]
6581
+ }, undefined, true, undefined, this)
6582
+ ]
6583
+ }, undefined, true, undefined, this);
6584
+ }
6585
+
6267
6586
  // src/dashboard/views/home.tsx
6268
6587
  var INVENTORY = [
6269
6588
  { key: "kv", label: "KV", path: "/kv", icon: "kv" },
@@ -7688,7 +8007,8 @@ function useTraceStream() {
7688
8007
  startTime: s3.startTime,
7689
8008
  durationMs: s3.durationMs,
7690
8009
  spanCount: 1,
7691
- errorCount: 0
8010
+ errorCount: 0,
8011
+ generationId: typeof s3.attributes["lopata.generation_id"] === "number" ? s3.attributes["lopata.generation_id"] : null
7692
8012
  });
7693
8013
  } else if (event.type === "span.end" && event.span.parentSpanId === null) {
7694
8014
  const s3 = event.span;
@@ -7970,6 +8290,10 @@ function TracesListView() {
7970
8290
  class: "text-left px-4 py-2.5 text-xs text-text-muted font-medium",
7971
8291
  children: "Worker"
7972
8292
  }, undefined, false, undefined, this),
8293
+ /* @__PURE__ */ u3("th", {
8294
+ class: "text-left px-4 py-2.5 text-xs text-text-muted font-medium",
8295
+ children: "Gen"
8296
+ }, undefined, false, undefined, this),
7973
8297
  /* @__PURE__ */ u3("th", {
7974
8298
  class: "text-left px-4 py-2.5 text-xs text-text-muted font-medium",
7975
8299
  style: { minWidth: "140px" },
@@ -8017,6 +8341,16 @@ function TracesListView() {
8017
8341
  children: trace.workerName
8018
8342
  }, undefined, false, undefined, this)
8019
8343
  }, undefined, false, undefined, this),
8344
+ /* @__PURE__ */ u3("td", {
8345
+ class: "px-4 py-2.5",
8346
+ children: trace.generationId != null && /* @__PURE__ */ u3("span", {
8347
+ class: "inline-flex px-1.5 py-0.5 rounded text-[10px] font-mono font-medium bg-panel-hover text-text-secondary",
8348
+ children: [
8349
+ "#",
8350
+ trace.generationId
8351
+ ]
8352
+ }, undefined, true, undefined, this)
8353
+ }, undefined, false, undefined, this),
8020
8354
  /* @__PURE__ */ u3("td", {
8021
8355
  class: "px-4 py-2.5",
8022
8356
  children: /* @__PURE__ */ u3(DurationBar, {
@@ -8113,6 +8447,13 @@ function TraceDetailPage({ traceId }) {
8113
8447
  class: "ml-2 inline-flex px-2 py-0.5 rounded-md text-xs font-medium bg-panel-hover text-text-secondary",
8114
8448
  children: rootSpan.workerName
8115
8449
  }, undefined, false, undefined, this),
8450
+ typeof rootSpan?.attributes["lopata.generation_id"] === "number" && /* @__PURE__ */ u3("span", {
8451
+ class: "ml-2 inline-flex px-1.5 py-0.5 rounded text-[10px] font-mono font-medium bg-panel-hover text-text-secondary",
8452
+ children: [
8453
+ "Gen #",
8454
+ rootSpan.attributes["lopata.generation_id"]
8455
+ ]
8456
+ }, undefined, true, undefined, this),
8116
8457
  rootSpan?.durationMs != null && /* @__PURE__ */ u3("span", {
8117
8458
  class: "ml-2 text-text-secondary",
8118
8459
  children: formatDuration(rootSpan.durationMs)
@@ -9539,6 +9880,8 @@ function App() {
9539
9880
  }, undefined, false, undefined, this);
9540
9881
  if (route.startsWith("/workers"))
9541
9882
  return /* @__PURE__ */ u3(WorkersView, {}, undefined, false, undefined, this);
9883
+ if (route.startsWith("/generations"))
9884
+ return /* @__PURE__ */ u3(GenerationsView, {}, undefined, false, undefined, this);
9542
9885
  if (route.startsWith("/kv"))
9543
9886
  return /* @__PURE__ */ u3(KvView, {
9544
9887
  route
@@ -9655,8 +9998,26 @@ function App() {
9655
9998
  }, undefined, true, undefined, this),
9656
9999
  /* @__PURE__ */ u3("div", {
9657
10000
  class: "border-t border-border px-2 py-2",
9658
- children: /* @__PURE__ */ u3(ThemeSwitcher, {}, undefined, false, undefined, this)
9659
- }, undefined, false, undefined, this)
10001
+ children: [
10002
+ /* @__PURE__ */ u3("a", {
10003
+ href: "#/generations",
10004
+ class: `flex items-center gap-2 w-full px-3 py-1.5 text-xs font-mono rounded-md transition-colors ${activeSection === "/generations" ? "text-ink bg-panel-hover" : "text-text-muted hover:text-ink hover:bg-panel-hover"}`,
10005
+ children: [
10006
+ /* @__PURE__ */ u3("span", {
10007
+ class: "w-4 text-center opacity-60",
10008
+ children: /* @__PURE__ */ u3(BindingIcon, {
10009
+ type: "workers",
10010
+ class: "w-4 text-center opacity-60 flex items-center justify-center"
10011
+ }, undefined, false, undefined, this)
10012
+ }, undefined, false, undefined, this),
10013
+ /* @__PURE__ */ u3("span", {
10014
+ children: "Generations"
10015
+ }, undefined, false, undefined, this)
10016
+ ]
10017
+ }, undefined, true, undefined, this),
10018
+ /* @__PURE__ */ u3(ThemeSwitcher, {}, undefined, false, undefined, this)
10019
+ ]
10020
+ }, undefined, true, undefined, this)
9660
10021
  ]
9661
10022
  }, undefined, true, undefined, this);
9662
10023
  return /* @__PURE__ */ u3("div", {
@@ -8,7 +8,7 @@
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
10
10
 
11
- <link rel="stylesheet" crossorigin href="/__dashboard/assets/chunk-pqnphvm2.css"><script type="module" crossorigin src="/__dashboard/assets/chunk-5nxa3jfc.js"></script></head>
11
+ <link rel="stylesheet" crossorigin href="/__dashboard/assets/chunk-csyd2tq2.css"><script type="module" crossorigin src="/__dashboard/assets/chunk-yxzrcvyh.js"></script></head>
12
12
  <body class="h-full bg-surface text-ink" style="font-family: system-ui, -apple-system, sans-serif;">
13
13
  <script>
14
14
  // Apply saved theme before first paint to prevent flash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lopata",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -11,7 +11,9 @@
11
11
  "lopata": "src/cli.ts"
12
12
  },
13
13
  "exports": {
14
- "./vite-plugin": "./src/vite-plugin/index.ts"
14
+ "./vite-plugin": "./src/vite-plugin/index.ts",
15
+ "./testing": "./src/testing/index.ts",
16
+ "./testing/setup": "./src/testing/setup.ts"
15
17
  },
16
18
  "files": [
17
19
  "src/",
@@ -22,7 +24,7 @@
22
24
  "scripts": {
23
25
  "dev": "cd examples/playground && bun ../../src/cli.ts dev",
24
26
  "cli": "bun src/cli.ts",
25
- "test": "bun test tests/",
27
+ "test": "bun test tests/ examples/playground/tests/",
26
28
  "lint": "biome lint .",
27
29
  "lint:fix": "biome lint . --write",
28
30
  "format": "dprint fmt",
@@ -1,12 +1,26 @@
1
- import type { GenerationInfo, GenerationsData, HandlerContext, OkResponse } from '../types'
1
+ import type { GenerationDetail, GenerationInfo, GenerationsData, HandlerContext, OkResponse } from '../types'
2
2
 
3
3
  export const handlers = {
4
- 'generations.list'(_input: {}, ctx: HandlerContext): GenerationsData {
5
- if (!ctx.manager) throw new Error('Generation manager not available')
4
+ 'generations.detail'(input: { id: number; workerName?: string }, ctx: HandlerContext): GenerationDetail {
5
+ let manager = ctx.manager
6
+ if (input.workerName && ctx.registry) {
7
+ manager = ctx.registry.getManager(input.workerName) ?? null
8
+ }
9
+ if (!manager) throw new Error('Generation manager not available')
10
+ const gen = manager.get(input.id)
11
+ if (!gen) throw new Error(`Generation ${input.id} not found`)
12
+ const info = gen.getInfo()
13
+ const doNamespaces = gen.registry.durableObjects.map(entry => ({
14
+ namespace: entry.className,
15
+ instances: entry.namespace._listActiveExecutors(),
16
+ }))
17
+ return { ...info, doNamespaces }
18
+ },
6
19
 
20
+ 'generations.list'(_input: {}, ctx: HandlerContext): GenerationsData {
7
21
  const result: GenerationsData = {
8
- generations: ctx.manager.list(),
9
- gracePeriodMs: ctx.manager.gracePeriodMs,
22
+ generations: ctx.manager?.list() ?? [],
23
+ gracePeriodMs: ctx.manager?.gracePeriodMs ?? 10_000,
10
24
  }
11
25
 
12
26
  // Include per-worker data when registry is available (multi-worker mode)
package/src/api/types.ts CHANGED
@@ -240,6 +240,20 @@ export interface GenerationsData {
240
240
  workers?: WorkerGenerations[]
241
241
  }
242
242
 
243
+ export interface GenerationDoInstance {
244
+ id: string
245
+ wsCount: number
246
+ }
247
+
248
+ export interface GenerationDoNamespace {
249
+ namespace: string
250
+ instances: GenerationDoInstance[]
251
+ }
252
+
253
+ export interface GenerationDetail extends GenerationInfo {
254
+ doNamespaces: GenerationDoNamespace[]
255
+ }
256
+
243
257
  // Workers
244
258
  export interface WorkerBinding {
245
259
  type: string
@@ -1,4 +1,6 @@
1
1
  import type { Database } from 'bun:sqlite'
2
+ import type { Clock } from '../testing/clock'
3
+ import { realClock } from '../testing/clock'
2
4
 
3
5
  export interface CacheLimits {
4
6
  maxBodySize?: number // default 512 MiB (CF limit)
@@ -42,12 +44,12 @@ function parseCacheControlMaxAge(header: string | null): number | null {
42
44
  * Compute the expiration timestamp (ms since epoch) from response headers.
43
45
  * Returns null if no expiration info is present (cache indefinitely).
44
46
  */
45
- function computeExpiresAt(headers: Headers): number | null {
47
+ function computeExpiresAt(headers: Headers, now: number): number | null {
46
48
  const cacheControl = headers.get('cache-control')
47
49
  const maxAge = parseCacheControlMaxAge(cacheControl)
48
50
 
49
51
  if (maxAge === -1) return -1 // signal: no-store
50
- if (maxAge !== null) return Date.now() + maxAge * 1000
52
+ if (maxAge !== null) return now + maxAge * 1000
51
53
 
52
54
  // Fallback to Expires header
53
55
  const expires = headers.get('expires')
@@ -63,11 +65,13 @@ export class SqliteCache {
63
65
  private db: Database
64
66
  private cacheName: string
65
67
  private limits: Required<CacheLimits>
68
+ private clock: Clock
66
69
 
67
- constructor(db: Database, cacheName: string, limits?: CacheLimits) {
70
+ constructor(db: Database, cacheName: string, limits?: CacheLimits, clock?: Clock) {
68
71
  this.db = db
69
72
  this.cacheName = cacheName
70
73
  this.limits = { ...CACHE_DEFAULTS, ...limits }
74
+ this.clock = clock ?? realClock
71
75
  }
72
76
 
73
77
  async match(request: Request | string, options?: { ignoreMethod?: boolean }): Promise<Response | undefined> {
@@ -86,7 +90,7 @@ export class SqliteCache {
86
90
  if (!row) return undefined
87
91
 
88
92
  // Check expiration — lazily delete expired entries
89
- if (row.expires_at !== null && row.expires_at <= Date.now()) {
93
+ if (row.expires_at !== null && row.expires_at <= this.clock.now()) {
90
94
  this.db.query(
91
95
  'DELETE FROM cache_entries WHERE cache_name = ? AND url = ?',
92
96
  ).run(this.cacheName, url)
@@ -123,7 +127,7 @@ export class SqliteCache {
123
127
  }
124
128
 
125
129
  // Parse expiration from Cache-Control / Expires
126
- const expiresAt = computeExpiresAt(response.headers)
130
+ const expiresAt = computeExpiresAt(response.headers, this.clock.now())
127
131
 
128
132
  // no-store — don't cache at all
129
133
  if (expiresAt === -1) {
@@ -165,15 +169,17 @@ export class SqliteCache {
165
169
  export class SqliteCacheStorage {
166
170
  private db: Database
167
171
  private limits?: CacheLimits
172
+ private clock: Clock
168
173
  public default: SqliteCache
169
174
 
170
- constructor(db: Database, limits?: CacheLimits) {
175
+ constructor(db: Database, limits?: CacheLimits, clock?: Clock) {
171
176
  this.db = db
172
177
  this.limits = limits
173
- this.default = new SqliteCache(db, 'default', limits)
178
+ this.clock = clock ?? realClock
179
+ this.default = new SqliteCache(db, 'default', limits, this.clock)
174
180
  }
175
181
 
176
182
  async open(cacheName: string): Promise<SqliteCache> {
177
- return new SqliteCache(this.db, cacheName, this.limits)
183
+ return new SqliteCache(this.db, cacheName, this.limits, this.clock)
178
184
  }
179
185
  }