doc2vec 2.12.0 → 2.13.0

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.
package/README.md CHANGED
@@ -6,7 +6,12 @@ This project provides a configurable tool (`doc2vec`) to crawl specified website
6
6
 
7
7
  The primary goal is to prepare documentation content for Retrieval-Augmented Generation (RAG) systems or semantic search applications.
8
8
 
9
- > **⚠️ Version 2.0.0 Breaking Change:** Version 2.0.0 introduced enhanced chunking with new metadata fields (`chunk_index` and `total_chunks`) that enable page reconstruction and improved chunk ordering. The database schema has changed, and databases created with versions prior to 2.0.0 use a different format. **If you're upgrading to version 2.0.0 or later, you should start with fresh databases** to take advantage of the new features. While the MCP server maintains backward compatibility for querying old databases, doc2vec itself will create databases in the new format. If you need to migrate existing data, consider re-running doc2vec on your sources to regenerate the databases with the enhanced chunking format.
9
+ Run it two ways:
10
+
11
+ * **One-shot sync** — `doc2vec run config.yaml` processes every source in a config file and exits. Ideal for a cron job or CI step.
12
+ * **[Controller mode](#controller-mode)** — `doc2vec controller ./configs/` stays running: it schedules each config on its own cron expression, keeps run history and per-source statistics in Postgres, and serves a web UI with live log streaming, searchable run logs, and chunk inspection. Deploy it once and manage all your sources from the browser.
13
+
14
+ [![doc2vec controller dashboard](docs/images/controller-dashboard.png)](#controller-mode)
10
15
 
11
16
  ## Key Features
12
17
 
@@ -52,7 +57,7 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
52
57
  * **Incremental Updates:** For GitHub and Zendesk sources, tracks the last run date to only fetch new or updated issues/tickets.
53
58
  * **Cleanup:** Removes obsolete chunks from the database corresponding to pages or files that are no longer found during processing.
54
59
  * **Configuration:** Driven by a YAML configuration file (`config.yaml`) specifying sites, repositories, local directories, Zendesk instances, database types, metadata, and other parameters.
55
- * **[Controller Mode](#controller-mode):** Optionally runs as a long-lived controller that schedules sync jobs from multiple config files (cron `schedule` field), persists run history and statistics in Postgres, and serves a web UI with live log streaming — read-only (configs from files/ConfigMap) or read-write (create/edit configs from the UI).
60
+ * **[Controller Mode](#controller-mode):** Optionally runs as a long-lived controller that schedules sync jobs from multiple config files (cron `schedule` field), persists run history and statistics in Postgres, and serves a web UI with live log streaming, whole-run log search, and chunk inspection — read-only (configs from files/ConfigMap) or read-write (create/edit configs from the UI).
56
61
  * **Structured Logging:** Uses a custom logger (`logger.ts`) with levels, timestamps, colors, progress bars, and child loggers for clear execution monitoring.
57
62
 
58
63
  ## Chunk Metadata & Page Reconstruction
@@ -446,7 +451,19 @@ Besides the one-shot sync, doc2vec can run as a **long-lived controller** that s
446
451
  doc2vec controller --database-url postgres://user:pass@host:5432/doc2vec ./configs/
447
452
  ```
448
453
 
449
- Open http://localhost:8080 to see the dashboard: configs with their schedule and next run, run history with per-source statistics, and live log streaming for running jobs.
454
+ Open http://localhost:8080 to see the dashboard ([screenshot above](#doc2vec)): every config with its schedule, next run, and the outcome of its last run, plus a **Run now** button.
455
+
456
+ Each config gets its own page with the full run history, per-source results, and the raw YAML:
457
+
458
+ ![Config run history](docs/images/controller-runs.png)
459
+
460
+ …and a **Stats** tab with runs per day, success rate, and duration trend over the last 7/30/90 days:
461
+
462
+ ![Config statistics](docs/images/controller-stats.png)
463
+
464
+ A run page shows what the sync did — duration, exit code, warning and error counts, per-source chunk counts and errors — above the run's logs.
465
+
466
+ ![Run detail with filtered logs](docs/images/controller-run-logs.png)
450
467
 
451
468
  ### Scheduling
452
469
 
@@ -462,6 +479,20 @@ sources:
462
479
 
463
480
  Configs without a `schedule` are still listed in the UI and can be triggered manually with **Run now**. Each run executes `doc2vec run <config.yaml>` as a child process; overlapping runs of the same config are skipped, and `--max-parallel` (default 1) caps how many sync jobs run at once — keep it low, since website sources launch a headless Chromium.
464
481
 
482
+ ### Logs
483
+
484
+ Every line a sync job writes is stored in Postgres and streamed to the run page as it happens (`● live`), with follow-on-scroll and a `↓ Follow` button when you scroll away.
485
+
486
+ Long runs produce a lot of output, so the browser only keeps the last 10,000 lines in memory — the header tells you when that is the case (`30,000 lines (showing last 10,000)`). Everything else stays one query away:
487
+
488
+ * **Level chips** (`ERROR 4`, `WARN 93`, …) count the whole run, not the visible window, and clicking one filters **server-side** — so the 4 errors from the first minute of a 50-minute run are one click away. Results page in with **Load 2,000 more**.
489
+ * **Keyword filter** matches the message or the module, case-insensitively, across the whole run.
490
+ * **↓ download log** streams the complete log as plain text (`run-<id>.log`) for grepping locally or attaching to an issue. With a filter active it becomes **↓ download matches** and applies the same filter.
491
+
492
+ While a run is live, new lines matching the active filter keep arriving in the filtered view.
493
+
494
+ Run logs are pruned after `--log-retention-days` (default 14); run history and statistics are kept indefinitely.
495
+
465
496
  ### Flags
466
497
 
467
498
  | Flag | Default | Description |
@@ -533,7 +564,22 @@ The controller detects ConfigMap updates without a restart. Health endpoint: `GE
533
564
 
534
565
  ### API
535
566
 
536
- The UI is backed by a small REST API you can also use directly: `GET /api/configs`, `POST /api/configs/:id/run`, `GET /api/runs?configId=`, `GET /api/runs/:id/logs`, `POST /api/runs/:id/cancel`, `GET /api/configs/:id/stats?days=30`, plus server-sent events at `GET /api/events` and `GET /api/runs/:id/logs/stream`.
567
+ The UI is backed by a small REST API you can also use directly:
568
+
569
+ | Endpoint | Description |
570
+ |----------|-------------|
571
+ | `GET /api/configs` | Configs with schedule, next run, and last run |
572
+ | `POST /api/configs/:id/run` | Trigger a run now |
573
+ | `GET /api/configs/:id/stats?days=30` | Run counts, success rate, duration history |
574
+ | `GET /api/configs/:id/chunks?product_name=&url=` | Inspect the stored chunks for a URL |
575
+ | `GET /api/runs?configId=&status=&limit=&before=` | Run history |
576
+ | `GET /api/runs/:id/logs?afterSeq=&limit=&levels=&q=` | Log lines, filtered by level (csv) and/or keyword |
577
+ | `GET /api/runs/:id/logs/counts` | Line totals per level for the whole run |
578
+ | `GET /api/runs/:id/logs/download?levels=&q=` | Whole log (or the matching lines) as plain text |
579
+ | `POST /api/runs/:id/cancel` | Cancel a running job |
580
+ | `GET /api/health` | Status, mode, version |
581
+
582
+ Server-sent events: `GET /api/events` (run and config updates) and `GET /api/runs/:id/logs/stream?tail=10000` (replays the last `tail` stored lines, then tails live output).
537
583
 
538
584
  ## Database Options
539
585
 
@@ -721,6 +767,13 @@ If you don't specify a config path, it will look for config.yaml in the current
721
767
 
722
768
  ## Recent Changes
723
769
 
770
+ ### Searchable Controller Run Logs
771
+ - **Server-side log filtering:** Level chips and the keyword filter now query Postgres instead of the browser's in-memory buffer, so errors and warnings from the start of a long run are reachable (`GET /api/runs/:id/logs?levels=error,warn&q=...`, paged with **Load 2,000 more**)
772
+ - **Whole-run level counts:** New `GET /api/runs/:id/logs/counts` endpoint backs the level chips, which previously only counted the lines still held in the browser
773
+ - **Plain-text download:** `GET /api/runs/:id/logs/download` streams the full log (or just the matching lines) as `run-<id>.log`
774
+ - **Bounded replay:** The log stream accepts `?tail=N` so opening a 30,000-line run replays only the trailing window instead of the entire history
775
+ - **Index:** New `d2v_run_logs (run_id, level, seq)` index keeps level filtering fast on long runs
776
+
724
777
  ### Postgres Markdown Store
725
778
  - **New feature:** Optionally store the generated markdown for each crawled website URL in a Postgres table (`url`, `product_name`, `markdown`, `updated_at`)
726
779
  - **Top-level configuration:** Configure Postgres connection once at the top level via `connection_string` or individual `host`/`port`/`database`/`user`/`password` fields, with environment variable substitution support
@@ -1841,7 +1841,9 @@ class ContentProcessor {
1841
1841
  if (!content) {
1842
1842
  continue;
1843
1843
  }
1844
- const searchableText = contextPrefix + content;
1844
+ // See chunkMarkdown: an unpaired surrogate makes the store's JSON
1845
+ // body invalid, so sanitize before the id is derived from the text.
1846
+ const searchableText = utils_1.Utils.stripLoneSurrogates(contextPrefix + content);
1845
1847
  const chunkId = utils_1.Utils.generateHash(`${url}::${searchableText}`);
1846
1848
  documentChunks.push({
1847
1849
  content: searchableText,
@@ -1913,7 +1915,10 @@ class ContentProcessor {
1913
1915
  // to searches for parent topics even if the body doesn't mention them.
1914
1916
  const breadcrumbs = hierarchy.filter(h => h).join(" > ");
1915
1917
  const contextPrefix = breadcrumbs ? `[Topic: ${breadcrumbs}]\n` : "";
1916
- const searchableText = contextPrefix + content.trim();
1918
+ // Sanitize before hashing so the id and the stored text always agree:
1919
+ // an unpaired surrogate (from a split pair, or straight from the
1920
+ // source) makes the JSON body invalid and the write fails.
1921
+ const searchableText = utils_1.Utils.stripLoneSurrogates(contextPrefix + content.trim());
1917
1922
  const chunkId = utils_1.Utils.generateHash(searchableText);
1918
1923
  const chunk = {
1919
1924
  content: searchableText,
@@ -1954,7 +1959,11 @@ class ContentProcessor {
1954
1959
  // If the section is a massive guide, split it but keep headers on every sub-piece.
1955
1960
  const overlapSize = Math.floor(MAX_CHARS * OVERLAP_PERCENT);
1956
1961
  for (let i = 0; i < trimmedBuffer.length; i += (MAX_CHARS - overlapSize)) {
1957
- const subContent = trimmedBuffer.slice(i, i + MAX_CHARS);
1962
+ // Slice on code points, not code units: a boundary falling
1963
+ // inside a surrogate pair (emoji, some CJK) would leave a
1964
+ // lone surrogate, which Qdrant's JSON parser rejects — the
1965
+ // chunk then fails to store and is silently lost.
1966
+ const subContent = utils_1.Utils.sliceSafe(trimmedBuffer, i, i + MAX_CHARS);
1958
1967
  chunks.push(createDocumentChunk(subContent, topicHierarchy));
1959
1968
  }
1960
1969
  }
@@ -52,4 +52,9 @@ exports.MIGRATIONS = [
52
52
  PRIMARY KEY (run_id, seq)
53
53
  );
54
54
  `,
55
+ // 2: index for server-side log filtering by level (error/warn lookups on
56
+ // long runs, where the client only keeps a trailing window in memory)
57
+ `
58
+ CREATE INDEX IF NOT EXISTS d2v_run_logs_level_idx ON d2v_run_logs (run_id, level, seq);
59
+ `,
55
60
  ];
@@ -44,6 +44,16 @@ const chunk_inspector_1 = require("./chunk-inspector");
44
44
  const config_registry_1 = require("./config-registry");
45
45
  const types_1 = require("./types");
46
46
  const SSE_HEARTBEAT_MS = 15000;
47
+ const LOG_PAGE_SIZE = 5000;
48
+ const LOG_LEVELS = new Set(['error', 'warn', 'info', 'debug']);
49
+ /** Shared parsing for the `levels` (csv) and `q` log query parameters. */
50
+ function parseLogFilter(req) {
51
+ const levels = typeof req.query.levels === 'string'
52
+ ? req.query.levels.split(',').map(l => l.trim().toLowerCase()).filter(l => LOG_LEVELS.has(l))
53
+ : [];
54
+ const keyword = typeof req.query.q === 'string' ? req.query.q.trim() : '';
55
+ return { levels, keyword: keyword || undefined };
56
+ }
47
57
  function getVersion() {
48
58
  try {
49
59
  return require('../../package.json').version;
@@ -202,7 +212,39 @@ function createServer(deps) {
202
212
  throw new types_1.NotFoundError('run not found');
203
213
  const afterSeq = Number(req.query.afterSeq) || 0;
204
214
  const limit = Number(req.query.limit) || 1000;
205
- res.json(await store.getLogs(id, afterSeq, limit));
215
+ res.json(await store.getLogs(id, afterSeq, limit, parseLogFilter(req)));
216
+ });
217
+ /** Whole-run line totals per level — the UI can only hold a trailing window. */
218
+ app.get('/api/runs/:id/logs/counts', async (req, res) => {
219
+ const id = parseId(String(req.params.id));
220
+ if (!await store.getRun(id))
221
+ throw new types_1.NotFoundError('run not found');
222
+ res.json(await store.countLogsByLevel(id));
223
+ });
224
+ // Full log as a plain-text download: the escape hatch when the viewer's
225
+ // in-memory window or filters aren't enough (grep it locally, attach it).
226
+ app.get('/api/runs/:id/logs/download', async (req, res) => {
227
+ const id = parseId(String(req.params.id));
228
+ if (!await store.getRun(id))
229
+ throw new types_1.NotFoundError('run not found');
230
+ const filter = parseLogFilter(req);
231
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
232
+ res.setHeader('Content-Disposition', `attachment; filename="run-${id}.log"`);
233
+ let afterSeq = 0;
234
+ let batch;
235
+ do {
236
+ batch = await store.getLogs(id, afterSeq, LOG_PAGE_SIZE, filter);
237
+ for (const row of batch) {
238
+ const module = row.module ? ` [${row.module}]` : '';
239
+ // pg hands back TIMESTAMPTZ as a Date; JSON responses serialise
240
+ // it for us, a text body has to do it explicitly
241
+ const ts = new Date(row.ts).toISOString();
242
+ res.write(`${ts} ${row.level.toUpperCase().padEnd(5)}${module} ${row.message}\n`);
243
+ }
244
+ if (batch.length > 0)
245
+ afterSeq = batch[batch.length - 1].seq;
246
+ } while (batch.length === LOG_PAGE_SIZE);
247
+ res.end();
206
248
  });
207
249
  app.post('/api/runs/:id/cancel', async (req, res) => {
208
250
  res.json(await runner.cancel(parseId(String(req.params.id))));
@@ -227,7 +269,12 @@ function createServer(deps) {
227
269
  if (!run)
228
270
  throw new types_1.NotFoundError('run not found');
229
271
  sseInit(res);
272
+ // `tail` keeps the replay to the trailing window the viewer can hold —
273
+ // older lines stay reachable through the filter/download endpoints.
274
+ const tail = Number(req.query.tail) || 0;
230
275
  let lastSeq = Number(req.query.afterSeq) || 0;
276
+ if (!lastSeq && tail > 0)
277
+ lastSeq = await store.getTailStartSeq(id, tail);
231
278
  let replayDone = false;
232
279
  const pending = [];
233
280
  const sendRow = (row) => {
@@ -259,9 +306,9 @@ function createServer(deps) {
259
306
  try {
260
307
  let batch;
261
308
  do {
262
- batch = await store.getLogs(id, lastSeq, 5000);
309
+ batch = await store.getLogs(id, lastSeq, LOG_PAGE_SIZE);
263
310
  batch.forEach(sendRow);
264
- } while (batch.length === 5000);
311
+ } while (batch.length === LOG_PAGE_SIZE);
265
312
  }
266
313
  finally {
267
314
  replayDone = true;
@@ -3,6 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ControllerStore = void 0;
4
4
  const pg_1 = require("pg");
5
5
  const migrations_1 = require("./migrations");
6
+ /** Neutralise LIKE wildcards so a keyword search stays a literal substring match. */
7
+ function escapeLike(value) {
8
+ return value.replace(/[\\%_]/g, ch => `\\${ch}`);
9
+ }
6
10
  /**
7
11
  * All Postgres access for controller mode: configs, runs, and run logs.
8
12
  */
@@ -143,11 +147,44 @@ class ControllerStore {
143
147
  await this.pool.query(`INSERT INTO d2v_run_logs (run_id, seq, ts, level, module, message) VALUES ${placeholders.join(', ')}
144
148
  ON CONFLICT DO NOTHING`, values);
145
149
  }
146
- async getLogs(runId, afterSeq = 0, limit = 1000) {
150
+ /**
151
+ * Page through a run's logs. Filtering happens here rather than in the
152
+ * browser so that a search covers the whole run: the UI only holds a
153
+ * trailing window of a long run's output in memory.
154
+ */
155
+ async getLogs(runId, afterSeq = 0, limit = 1000, filter = {}) {
156
+ const values = [runId, afterSeq];
157
+ let where = 'run_id = $1 AND seq > $2';
158
+ if (filter.levels?.length) {
159
+ values.push(filter.levels);
160
+ where += ` AND level = ANY($${values.length})`;
161
+ }
162
+ if (filter.keyword) {
163
+ values.push(`%${escapeLike(filter.keyword)}%`);
164
+ where += ` AND (message ILIKE $${values.length} ESCAPE '\\' OR module ILIKE $${values.length} ESCAPE '\\')`;
165
+ }
166
+ values.push(Math.min(limit, 5000));
147
167
  const { rows } = await this.pool.query(`SELECT seq, ts, level, module, message FROM d2v_run_logs
148
- WHERE run_id = $1 AND seq > $2 ORDER BY seq LIMIT $3`, [runId, afterSeq, Math.min(limit, 5000)]);
168
+ WHERE ${where} ORDER BY seq LIMIT $${values.length}`, values);
149
169
  return rows.map((r) => ({ ...r, seq: Number(r.seq) }));
150
170
  }
171
+ /**
172
+ * Seq to replay from so that only the last `tail` lines follow, i.e. the
173
+ * seq of the (tail + 1)-th newest line — 0 when the run is shorter.
174
+ */
175
+ async getTailStartSeq(runId, tail) {
176
+ const { rows } = await this.pool.query(`SELECT seq FROM d2v_run_logs WHERE run_id = $1 ORDER BY seq DESC OFFSET $2 LIMIT 1`, [runId, Math.max(0, tail)]);
177
+ return rows.length > 0 ? Number(rows[0].seq) : 0;
178
+ }
179
+ /** Per-level line totals for a whole run, for the log viewer's filter chips. */
180
+ async countLogsByLevel(runId) {
181
+ const { rows } = await this.pool.query(`SELECT level, COUNT(*)::bigint AS count FROM d2v_run_logs
182
+ WHERE run_id = $1 GROUP BY level`, [runId]);
183
+ const counts = {};
184
+ for (const row of rows)
185
+ counts[row.level] = Number(row.count);
186
+ return counts;
187
+ }
151
188
  async pruneOldLogs(retentionDays) {
152
189
  const { rowCount } = await this.pool.query(`DELETE FROM d2v_run_logs USING d2v_runs
153
190
  WHERE d2v_run_logs.run_id = d2v_runs.id
package/dist/doc2vec.js CHANGED
@@ -254,22 +254,31 @@ class Doc2Vec {
254
254
  // Get the last run date from the database
255
255
  const startDate = sourceConfig.start_date || '2025-01-01';
256
256
  const lastRunDate = await database_1.DatabaseManager.getLastRunDate(dbConnection, repo, `${startDate}T00:00:00Z`, logger);
257
- const fetchWithRetry = async (url, params = {}, retries = 5, delay = 5000) => {
257
+ // Retryable: 5xx, 429, rate-limited 403s, and network/timeout errors.
258
+ // Everything else is deterministic — the same request fails identically,
259
+ // so retrying only burns wall-clock. In particular a 422 from deep
260
+ // page-number pagination used to cost ~75s of backoff before failing.
261
+ const isRetryableStatus = (status) => status === undefined || status >= 500 || status === 429;
262
+ const requestWithRetry = async (url, params, retries = 5, delay = 5000) => {
258
263
  for (let attempt = 0; attempt < retries; attempt++) {
259
264
  try {
260
265
  // Only log on retries to reduce noise during pagination
261
266
  if (attempt > 0) {
262
267
  logger.debug(`GitHub API retry: ${url} (attempt ${attempt + 1}/${retries})`);
263
268
  }
269
+ // Callers need the response headers (Link) for cursor
270
+ // pagination, so the whole response is returned.
264
271
  const response = await axios_1.default.get(url, {
265
272
  headers: {
266
273
  Authorization: `token ${GITHUB_TOKEN}`,
267
274
  Accept: 'application/vnd.github.v3+json',
268
275
  },
269
- params,
276
+ // Subsequent pages come from a Link URL that already
277
+ // carries its query string — adding params would duplicate it
278
+ ...(params && { params }),
270
279
  timeout: 30000, // 30 second timeout
271
280
  });
272
- return response.data;
281
+ return response;
273
282
  }
274
283
  catch (error) {
275
284
  // Enhanced error logging for debugging
@@ -304,6 +313,13 @@ class Doc2Vec {
304
313
  throw error;
305
314
  }
306
315
  }
316
+ else if (!isRetryableStatus(error.response?.status)) {
317
+ // Deterministic failure (422, 404, 401, ...) — surface the
318
+ // API's own message, which explains what to do instead.
319
+ const apiMessage = error.response?.data?.message;
320
+ logger.error(`GitHub API returned ${error.response.status} (not retryable): ${apiMessage || error.message}`);
321
+ throw error;
322
+ }
307
323
  else {
308
324
  // For non-403 errors, wait before retrying (exponential backoff)
309
325
  if (attempt < retries - 1) {
@@ -320,23 +336,45 @@ class Doc2Vec {
320
336
  }
321
337
  throw new Error('Max retries reached');
322
338
  };
339
+ /**
340
+ * Walk every issue updated since `sinceDate` by following GitHub's
341
+ * `Link: rel="next"` cursor URLs.
342
+ *
343
+ * Page-number pagination (`?page=N`) cannot be used here: GitHub caps it
344
+ * on this endpoint and answers HTTP 422 ("Pagination with the page
345
+ * parameter is not supported for large datasets, please use cursor based
346
+ * pagination") once the offset grows past its limit. That limit is well
347
+ * below the size of a full-window rebuild on a busy repo, so page-based
348
+ * paging aborted the whole source and left the collection empty. The
349
+ * Link URL already contains the opaque `after=` cursor and is passed
350
+ * through verbatim.
351
+ */
323
352
  const fetchAllIssues = async (sinceDate) => {
324
353
  let issues = [];
325
- let page = 1;
326
- const perPage = 100;
327
- while (true) {
354
+ // sort=updated&direction=asc makes the walk stable: results are
355
+ // ordered by the same field `since` filters on, so a page boundary
356
+ // can't shuffle items in or out mid-walk.
357
+ let url = GITHUB_API_URL;
358
+ let params = {
359
+ per_page: 100,
360
+ state: 'all',
361
+ since: sinceDate,
362
+ sort: 'updated',
363
+ direction: 'asc',
364
+ };
365
+ let page = 0;
366
+ while (url) {
367
+ page++;
328
368
  // Log progress every 10 pages to reduce noise
329
369
  if (page === 1 || page % 10 === 0) {
330
370
  logger.debug(`Fetching issues page ${page}... (${issues.length} issues so far)`);
331
371
  }
332
- const data = await fetchWithRetry(GITHUB_API_URL, {
333
- per_page: perPage,
334
- page,
335
- state: 'all',
336
- since: sinceDate,
337
- });
338
- if (data.length === 0)
372
+ const response = await requestWithRetry(url, params);
373
+ const data = response.data;
374
+ if (!Array.isArray(data)) {
375
+ logger.warn(`Unexpected non-array response while paginating issues for ${repo}; stopping the walk`);
339
376
  break;
377
+ }
340
378
  // The `since` param already scopes results to issues whose
341
379
  // updated_at is after sinceDate — i.e. anything created, closed,
342
380
  // reopened, edited, or newly commented since the last run. Do NOT
@@ -344,19 +382,45 @@ class Doc2Vec {
344
382
  // received a new comment has an old created_at and would be
345
383
  // dropped, leaving its status and comments permanently stale.
346
384
  issues = issues.concat(data);
347
- if (data.length < perPage)
385
+ const nextUrl = utils_1.Utils.parseNextLink(response.headers?.link ?? response.headers?.Link);
386
+ // Guard against a server echoing the same cursor forever
387
+ if (!nextUrl || nextUrl === url)
348
388
  break;
349
- page++;
389
+ url = nextUrl;
390
+ params = undefined; // the cursor URL is already fully-formed
350
391
  }
392
+ logger.info(`Fetched ${issues.length} issues/PRs for ${repo} across ${page} page(s)`);
351
393
  return issues;
352
394
  };
353
395
  const fetchIssueComments = async (issueNumber) => {
354
- const url = `${GITHUB_API_URL}/${issueNumber}/comments`;
355
- return await fetchWithRetry(url);
396
+ // GitHub returns 30 comments per page by default and paginates the
397
+ // rest behind Link headers; requesting the first page only silently
398
+ // truncated long discussions, so the stored chunks were missing the
399
+ // newest comments.
400
+ const comments = [];
401
+ let url = `${GITHUB_API_URL}/${issueNumber}/comments`;
402
+ let params = { per_page: 100 };
403
+ while (url) {
404
+ const response = await requestWithRetry(url, params);
405
+ if (!Array.isArray(response.data))
406
+ break;
407
+ comments.push(...response.data);
408
+ const nextUrl = utils_1.Utils.parseNextLink(response.headers?.link ?? response.headers?.Link);
409
+ if (!nextUrl || nextUrl === url)
410
+ break;
411
+ url = nextUrl;
412
+ params = undefined;
413
+ }
414
+ return comments;
356
415
  };
357
416
  const generateMarkdownForIssue = async (issue) => {
358
417
  const comments = await fetchIssueComments(issue.number);
359
- let md = `# Issue #${issue.number}: ${issue.title}\n\n`;
418
+ // This endpoint returns both issues and pull requests; only PRs carry
419
+ // a `pull_request` object. Labelling every item "Issue #N" made
420
+ // retrieved PR chunks read as issues, so use the real kind.
421
+ const itemLabel = issue.pull_request ? 'PR' : 'Issue';
422
+ let md = `# ${itemLabel} #${issue.number}: ${issue.title}\n\n`;
423
+ md += `- **Type:** ${issue.pull_request ? 'Pull request' : 'Issue'}\n`;
360
424
  md += `- **Author:** ${issue.user.login}\n`;
361
425
  md += `- **State:** ${issue.state}\n`;
362
426
  md += `- **Created on:** ${new Date(issue.created_at).toDateString()}\n`;
@@ -373,6 +437,9 @@ class Doc2Vec {
373
437
  }
374
438
  return md;
375
439
  };
440
+ // Chunks that could not be stored (e.g. a rejected Qdrant upsert).
441
+ // Tallied across all issues and escalated once the walk completes.
442
+ let failedChunks = 0;
376
443
  // Process a single issue and store its chunks
377
444
  const processIssue = async (issue) => {
378
445
  const issueNumber = issue.number;
@@ -461,7 +528,11 @@ class Doc2Vec {
461
528
  }
462
529
  }
463
530
  catch (error) {
464
- logger.error(`Error processing chunk in Qdrant:`, error);
531
+ // Keep going so one bad chunk can't cost the whole repo,
532
+ // but remember it: the count is escalated below so the run
533
+ // fails and the last-run date is not advanced.
534
+ failedChunks++;
535
+ logger.error(`Error processing chunk ${chunkId} for ${url} in Qdrant:`, error);
465
536
  }
466
537
  }
467
538
  }
@@ -474,6 +545,12 @@ class Doc2Vec {
474
545
  logger.info(`Processing issue ${i + 1}/${issues.length}`);
475
546
  await processIssue(issues[i]);
476
547
  }
548
+ // A chunk that failed to store is silent data loss, so refuse to advance
549
+ // the last-run date: the next run reprocesses this window instead of
550
+ // treating the gap as synced. Everything that did store is kept.
551
+ if (failedChunks > 0) {
552
+ throw new Error(`${failedChunks} chunk(s) failed to store for ${repo}; not advancing the last-run date so the next run retries them`);
553
+ }
477
554
  // Update the last run date in the database after processing all issues
478
555
  await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger, this.embeddingDimension, syncStartDate);
479
556
  logger.info(`Successfully processed ${issues.length} issues`);
@@ -0,0 +1 @@
1
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-2xl:42rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:system-ui, -apple-system, "Segoe UI", sans-serif;--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.h-2{height:calc(var(--spacing) * 2)}.h-4{height:calc(var(--spacing) * 4)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[560px\]{max-height:560px}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing) * 2)}.w-4{width:calc(var(--spacing) * 4)}.w-10{width:calc(var(--spacing) * 10)}.w-44{width:calc(var(--spacing) * 44)}.w-72{width:calc(var(--spacing) * 72)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.shrink-0{flex-shrink:0}.animate-pulse{animation:var(--animate-pulse)}.cursor-pointer{cursor:pointer}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-accent,.border-accent\/30{border-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.border-accent\/30{border-color:color-mix(in oklab,var(--accent) 30%,transparent)}}.border-accent\/40{border-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.border-accent\/40{border-color:color-mix(in oklab,var(--accent) 40%,transparent)}}.border-critical\/40{border-color:var(--status-critical)}@supports (color:color-mix(in lab,red,red)){.border-critical\/40{border-color:color-mix(in oklab,var(--status-critical) 40%,transparent)}}.border-critical\/50{border-color:var(--status-critical)}@supports (color:color-mix(in lab,red,red)){.border-critical\/50{border-color:color-mix(in oklab,var(--status-critical) 50%,transparent)}}.border-current{border-color:currentColor}.border-edge,.border-edge\/60{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-edge\/60{border-color:color-mix(in oklab,var(--border) 60%,transparent)}}.border-good\/40{border-color:var(--status-good)}@supports (color:color-mix(in lab,red,red)){.border-good\/40{border-color:color-mix(in oklab,var(--status-good) 40%,transparent)}}.border-transparent{border-color:#0000}.border-warning\/40{border-color:var(--status-warning)}@supports (color:color-mix(in lab,red,red)){.border-warning\/40{border-color:color-mix(in oklab,var(--status-warning) 40%,transparent)}}.border-warning\/50{border-color:var(--status-warning)}@supports (color:color-mix(in lab,red,red)){.border-warning\/50{border-color:color-mix(in oklab,var(--status-warning) 50%,transparent)}}.bg-accent{background-color:var(--accent)}.bg-edge\/10{background-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.bg-edge\/10{background-color:color-mix(in oklab,var(--border) 10%,transparent)}}.bg-page{background-color:var(--page)}.bg-surface{background-color:var(--surface-1)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.px-1{padding-inline:var(--spacing)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent{color:var(--accent)}.text-critical{color:var(--status-critical)}.text-good-text{color:var(--good-text)}.text-ink{color:var(--text-primary)}.text-ink-muted{color:var(--text-muted)}.text-ink-secondary{color:var(--text-secondary)}.text-warning{color:var(--status-warning)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline-offset-2{text-underline-offset:2px}.accent-\(--accent\){accent-color:var(--accent)}.opacity-30{opacity:.3}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.placeholder\:text-ink-muted\/60::placeholder{color:var(--text-muted)}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-ink-muted\/60::placeholder{color:color-mix(in oklab,var(--text-muted) 60%,transparent)}}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}@media(hover:hover){.hover\:border-accent:hover,.hover\:border-accent\/50:hover{border-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.hover\:border-accent\/50:hover{border-color:color-mix(in oklab,var(--accent) 50%,transparent)}}.hover\:bg-critical\/10:hover{background-color:var(--status-critical)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-critical\/10:hover{background-color:color-mix(in oklab,var(--status-critical) 10%,transparent)}}.hover\:bg-edge\/20:hover{background-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-edge\/20:hover{background-color:color-mix(in oklab,var(--border) 20%,transparent)}}.hover\:bg-ink\/\[0\.03\]:hover{background-color:var(--text-primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-ink\/\[0\.03\]:hover{background-color:color-mix(in oklab,var(--text-primary) 3%,transparent)}}.hover\:text-accent:hover{color:var(--accent)}.hover\:text-critical:hover{color:var(--status-critical)}.hover\:text-ink-secondary:hover{color:var(--text-secondary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}}.focus\:border-accent:focus{border-color:var(--accent)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:48rem){.md\:grid{display:grid}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[minmax\(9rem\,1fr\)_12rem_15rem_8rem_auto\]{grid-template-columns:minmax(9rem,1fr) 12rem 15rem 8rem auto}.md\:items-start{align-items:flex-start}.md\:gap-x-6{column-gap:calc(var(--spacing) * 6)}.md\:self-center{align-self:center}}@media(min-width:64rem){.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}}:root{--page:#f9f9f7;--surface-1:#fcfcfb;--text-primary:#0b0b0b;--text-secondary:#52514e;--text-muted:#898781;--gridline:#e1e0d9;--baseline:#c3c2b7;--border:#0b0b0b1a;--series-1:#2a78d6;--status-good:#0ca30c;--status-warning:#fab219;--status-critical:#d03b3b;--good-text:#006300;--accent:#2a78d6}@media(prefers-color-scheme:dark){:root{--page:#0d0d0d;--surface-1:#1a1a19;--text-primary:#fff;--text-secondary:#c3c2b7;--text-muted:#898781;--gridline:#2c2c2a;--baseline:#383835;--border:#ffffff1a;--series-1:#3987e5;--status-good:#0ca30c;--status-warning:#fab219;--status-critical:#d03b3b;--good-text:#0ca30c;--accent:#3987e5}}html{background:var(--page);color:var(--text-primary);font-family:system-ui,-apple-system,Segoe UI,sans-serif}.log-scroll::-webkit-scrollbar{width:10px;height:10px}.log-scroll::-webkit-scrollbar-thumb{background:var(--baseline);border-radius:5px}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}}