@deepblend/dsh-blender-ui 0.2.0 → 0.2.2

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/lib/client.js CHANGED
@@ -127,6 +127,10 @@ window.__ModuleLoader__.load({
127
127
  state: '/deepblend/state',
128
128
  projects: '/deepblend/projects',
129
129
  capabilities: '/deepblend/capabilities',
130
+ // The one route here that is not fetched by the panel: it is the `href` of the export link in
131
+ // the header, so the BROWSER makes the request and writes the file. `contract/diagnostics.test.mjs`
132
+ // holds these four paths to the route table the Host actually matches.
133
+ diagnostics: '/deepblend/diagnostics',
130
134
  }
131
135
 
132
136
  /** Route for one project's own surfaces. */
@@ -1363,6 +1367,17 @@ window.__ModuleLoader__.load({
1363
1367
  ? Badge({ tone: 'warn', name: 'api', children: `hostApiVersion ${state.hostApiVersion} ≠ ${EXPECTED_HOST_API}` })
1364
1368
  : null,
1365
1369
  el('span', { style: { flex: 1 } }),
1370
+ // AN ANCHOR, NOT A BUTTON. The export is a download, and a download is what an anchor with
1371
+ // `download` does: the browser fetches the route and writes the file itself, in both faces
1372
+ // (React passes `href`/`download` through, the DOM binding sets them as attributes). A button
1373
+ // would need imperative blob plumbing in a renderer that has no place to put it.
1374
+ el('a', {
1375
+ className: 'db-btn',
1376
+ href: ROUTES.diagnostics,
1377
+ download: 'deepblend-diagnostics.json',
1378
+ 'data-action': 'export-diagnostics',
1379
+ title: '导出一份可以附在问题里的诊断信息(版本、配置、项目与任务的摘要)',
1380
+ }, '导出诊断'),
1366
1381
  Button({ action: 'reload', onClick: actions.reload, children: '刷新' }),
1367
1382
  ),
1368
1383
  el('div', { className: 'db-nav' }, VIEWS.map(entry => el('button', {
package/lib/index.js CHANGED
@@ -23,6 +23,8 @@
23
23
  * Plane: Host composition (the Web Client half is `./client.js`)
24
24
  */
25
25
 
26
+ import { homedir } from 'node:os'
27
+
26
28
  import { Service } from '@deepseek-ai/cordis'
27
29
  import z from '@deepseek-ai/schemastery'
28
30
 
@@ -39,6 +41,9 @@ import {
39
41
  buildRevisionDiff,
40
42
  buildSceneTree,
41
43
  buildSettingsCard,
44
+ buildDiagnosticsBundle,
45
+ DIAGNOSTICS_JOB_LIMIT,
46
+ DIAGNOSTICS_FAILURE_LIMIT,
42
47
  matchUiRoute,
43
48
  assertKnownConfigKeys,
44
49
  } from '@deepblend/dsh-blender-contracts'
@@ -476,6 +481,102 @@ export function createHandlers(ctx) {
476
481
  }
477
482
  },
478
483
 
484
+ /**
485
+ * The shareable diagnostic bundle (ledger C10).
486
+ *
487
+ * IT PROBES BLENDER, AND THAT IS THE POINT. The first question a maintainer asks about a report is
488
+ * "does Blender work on that machine", and a bundle that answered "not probed" would send the
489
+ * conversation back to the user. The probe is the same one the settings card already makes
490
+ * (coalesced, cached for `capabilitiesCacheMs`), and a FAILED probe is a structured value here
491
+ * rather than an error page — a machine without Blender is exactly the machine whose bundle
492
+ * somebody is about to send.
493
+ *
494
+ * `config` IS AN ALLOWLIST. Every key below is named on purpose. A spread would publish whatever
495
+ * configuration key is added next, and a diagnostic bundle is the last place that should happen.
496
+ */
497
+ diagnostics: async () => {
498
+ let capabilities = null
499
+ let probeError = null
500
+ try {
501
+ capabilities = await studio().describeCapabilities({})
502
+ } catch (cause) {
503
+ const error = cause instanceof BlenderError
504
+ ? cause
505
+ : new BlenderError(
506
+ BlenderErrorCode.CAPABILITY_PROBE_FAILED,
507
+ cause instanceof Error ? cause.message : String(cause),
508
+ )
509
+ probeError = { code: error.code ?? BlenderErrorCode.CAPABILITY_PROBE_FAILED, message: error.message ?? '' }
510
+ }
511
+
512
+ const listed = await studio().listProjects()
513
+ const projects = []
514
+ const failures = []
515
+ for (const record of listed.projects) {
516
+ const jobs = await studio().listJobs({ projectId: record.projectId })
517
+ const byStatus = {}
518
+ for (const job of jobs.jobs) byStatus[job.status] = (byStatus[job.status] ?? 0) + 1
519
+ const failed = jobs.jobs.filter(job => job.status === 'failed').slice(-DIAGNOSTICS_JOB_LIMIT)
520
+ for (const job of failed) {
521
+ failures.push({
522
+ projectId: record.projectId,
523
+ jobId: job.jobId,
524
+ type: job.type ?? null,
525
+ errorCode: job.errorCode ?? null,
526
+ message: job.error ?? null,
527
+ frames: job.frames ?? null,
528
+ updatedAt: job.updatedAt ?? null,
529
+ })
530
+ }
531
+ projects.push({
532
+ projectId: record.projectId,
533
+ title: record.title ?? null,
534
+ currentRevision: record.currentRevision ?? null,
535
+ revisionCount: record.revisionCount ?? 0,
536
+ updatedAt: record.updatedAt ?? null,
537
+ jobs: {
538
+ total: jobs.jobs.length,
539
+ byStatus,
540
+ unfinished: jobs.unfinished?.length ?? 0,
541
+ recoveryFindings: jobs.recovery?.length ?? 0,
542
+ recent: jobs.jobs.slice(-DIAGNOSTICS_JOB_LIMIT).map(job => ({
543
+ jobId: job.jobId,
544
+ type: job.type ?? null,
545
+ status: job.status,
546
+ revision: job.revision ?? null,
547
+ frames: job.frames ?? null,
548
+ errorCode: job.errorCode ?? null,
549
+ updatedAt: job.updatedAt ?? null,
550
+ })),
551
+ },
552
+ })
553
+ }
554
+
555
+ // The Host projects its own configuration (the keys are declared there, and this half's schema
556
+ // declares none of them), and the same projection supplies the workspace root below — one read,
557
+ // so the two fields cannot disagree about where this deployment keeps its state.
558
+ const projected = studio().describeConfiguration?.() ?? {}
559
+
560
+ return buildDiagnosticsBundle({
561
+ generatedAt: new Date().toISOString(),
562
+ product: { name: 'DeepBlend Studio', version: studio().productVersion?.() ?? null, hostApiVersion: HOST_API_VERSION },
563
+ environment: { node: process.version, platform: process.platform, arch: process.arch, home: homedir() },
564
+ blender: {
565
+ probed: true,
566
+ note: 'Blender was probed for this export (the same probe the settings card makes).',
567
+ capabilities,
568
+ error: probeError,
569
+ },
570
+ config: projected,
571
+ store: { projectsRoot: listed.projectsRoot ?? null, workspaceRoot: projected.workspaceRoot ?? null, projects },
572
+ failures,
573
+ notes: [
574
+ `jobs per project are capped at ${DIAGNOSTICS_JOB_LIMIT}`,
575
+ `failures are capped at ${DIAGNOSTICS_FAILURE_LIMIT}`,
576
+ ],
577
+ })
578
+ },
579
+
479
580
  /** Everything the panel needs to render itself from scratch, in one request. */
480
581
  state: async ({ query }) => {
481
582
  const listed = await studio().listProjects()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-ui",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "DeepBlend workbench UI: Host half (typed HTTP API) and Web Client half (sidebar, panels, tool cards).",
6
6
  "main": "lib/index.js",
@@ -19,7 +19,7 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@deepblend/dsh-blender-contracts": "0.2.0"
22
+ "@deepblend/dsh-blender-contracts": "0.2.2"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {