@takagaki/cortex-decisions-viewer 0.4.56 → 0.4.58

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/dist/build.js CHANGED
@@ -157,8 +157,16 @@ async function readFleetSources(repoRoot) {
157
157
  label: String(s.label ?? ""),
158
158
  url: s.url != null ? String(s.url) : undefined,
159
159
  lastSync: s.lastSync != null ? String(s.lastSync) : undefined,
160
- matchKeys: Array.isArray(s.matchKeys)
161
- ? s.matchKeys.map((k) => String(k)).filter(Boolean)
160
+ // 会議名に入れる合図。**古い fleet-status.json の matchKeys は引き継がない**
161
+ // (client名と廃止した meetingNamePatterns の寄せ集めで、いま照合には使われていない。
162
+ // 出すと「この語を会議名に入れれば拾われる」と読めてしまう)。
163
+ meetingKey: s.meetingKey != null ? String(s.meetingKey) : undefined,
164
+ // 既存の定例を、いまの名前のまま拾うために登録した別名。設定ファイルにしか
165
+ // 無いと「なぜこの会議だけ入るのか」が画面から分からない
166
+ // **String() を先に噛ませない。** `String(null)` は "null" という文字列になり
167
+ // `.filter(Boolean)` を通り抜ける(画面に「null」という会議名が出る)
168
+ meetingAliases: Array.isArray(s.meetingAliases)
169
+ ? s.meetingAliases.filter((a) => typeof a === "string" && a !== "")
162
170
  : undefined,
163
171
  driveSync: typeof s.driveSync === "boolean" ? s.driveSync : undefined,
164
172
  enabled: typeof s.enabled === "boolean" ? s.enabled : undefined,
package/dist/render.js CHANGED
@@ -165,7 +165,12 @@ function renderConnectionMap(site) {
165
165
  : c.cap === "会議"
166
166
  ? internal?.ingestState !== "on"
167
167
  : !(internal?.lastSync || internal?.url));
168
- const state = off ? "cm-off" : warn ? "cm-warn" : idle ? "cm-idle" : "cm-ok";
168
+ // **未接続は警告より優先する。** 繋がっていない能力のパイプラインが失敗するのは
169
+ // 「壊れた」のではなく「まだ設定が済んでいない」結果で、順番を逆にすると
170
+ // **⚠️の箱に「未接続」と書かれ、しかも繋がっていないのに線が引かれる**(実際にそうなった:
171
+ // APIキー未投入で sync-backlog が失敗し、課題管理が警告色で線付きになった)。
172
+ // 失敗そのものは下の「自動化パイプライン」の一覧に出るので、情報は失われない。
173
+ const state = off ? "cm-off" : idle ? "cm-idle" : warn ? "cm-warn" : "cm-ok";
169
174
  // **ラベルは能力名(会議・課題管理…)。ツール名は下に小さく置く。**
170
175
  // ツールは「その能力をいま何で実現しているか」という差し替え可能なアダプタで、
171
176
  // 主語ではない。ツール名を主にすると、Teams のように複数の能力に使われるものが
@@ -737,6 +742,9 @@ a:hover { text-decoration: underline; }
737
742
  .datasources-head { font-size: 18px; font-weight: 700; color: var(--text); margin: 0 0 4px; }
738
743
  .datasources-intro { color: var(--muted); font-size: 13.5px; line-height: 1.8; margin: 0 0 6px; }
739
744
  .datasources-asof { color: var(--muted); font-size: 12.5px; line-height: 1.7; margin: 0 0 14px; }
745
+ /* 最新を取得できなかったとき。古い値を現在値と読ませないよう、通常の注記より目立たせる */
746
+ .datasources-asof.is-stale { color: var(--warn, #a15c00); }
747
+ .conn-map-asof { color: var(--muted); font-size: 12px; margin: 6px 0 0; text-align: right; }
740
748
  .ds-sub { font-size: 13px; font-weight: 700; color: var(--muted); margin: 20px 0 8px; }
741
749
  .ds-list { display: flex; flex-direction: column; gap: 10px; }
742
750
  .ds-card {
@@ -790,6 +798,12 @@ a.ds-name:hover { text-decoration: underline; }
790
798
  .cm-sync { font-size: 10.5px; fill: var(--muted); font-variant-numeric: tabular-nums; }
791
799
  .cm-node.cm-off .cm-sync { fill: #9ca3af; }
792
800
  .cm-badge { font-size: 13px; }
801
+ /* 「入ってこない」ときに自分で確かめる口。一覧の直前に置く */
802
+ .ds-syncnow { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin: 0 0 .6rem; }
803
+ .ds-syncnow-label { font-size: .82rem; color: var(--muted); }
804
+ /* 自分で繋げばよいことを、手順より先に伝える。埋もれると管理者に依頼しに行ってしまう */
805
+ .sec-preferred { margin: .4rem 0 .2rem; padding: .5rem .7rem; font-size: .84rem; line-height: 1.6;
806
+ border-left: 3px solid var(--accent); background: var(--accent-weak); border-radius: 0 6px 6px 0; }
793
807
  /* 追加した直後にその場で見せる行。正式な一覧(スナップショット由来)に載るまでの繋ぎ */
794
808
  .ds-pending { margin-top: .5rem; display: flex; flex-direction: column; gap: .25rem; }
795
809
  .ds-pending-item { display: flex; align-items: center; gap: .5rem; font-size: .85rem;
@@ -1457,15 +1471,85 @@ const CLIENT_JS = `
1457
1471
  if (url) return el("a", { class: "ds-name", href: url, target: "_blank", rel: "noopener", text: text });
1458
1472
  return el("span", { class: "ds-name", text: text });
1459
1473
  }
1460
- function renderDataSources() {
1461
- var internals = SITE.internalSources || [];
1462
- var sources = SITE.externalSources || [];
1463
- var pipes = SITE.pipelines || [];
1474
+ /**
1475
+ * 「いま同期する」。中央の取り込み(Apps Script)を即時に起こす。
1476
+ *
1477
+ * **押しても即座に画面は変わらない。** 取り込み コミット → 接続状況の再生成 →
1478
+ * ビューアの再ビルド、と数分かかる。それを言わずにボタンだけ置くと、
1479
+ * 「押したのに何も起きない」と読まれる(設定の追加でまさにそうなった)。
1480
+ */
1481
+ function syncNowRow() {
1482
+ var row = el("div", { class: "ds-syncnow" });
1483
+ if (!SITE.intake) return row;
1484
+ var msg = el("span", { class: "ds-toggle-msg" });
1485
+ var mk = function (kind, label) {
1486
+ var btn = el("button", { class: "ds-toggle-btn", type: "button", text: label });
1487
+ btn.addEventListener("click", async function () {
1488
+ btn.disabled = true;
1489
+ msg.className = "ds-toggle-msg";
1490
+ msg.textContent = "実行中…";
1491
+ var intake = SITE.intake;
1492
+ var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
1493
+ try {
1494
+ var res = await fetch(pfx + "op=sync&t=" + encodeURIComponent(intake.token), {
1495
+ method: "POST",
1496
+ headers: { "content-type": "application/json" },
1497
+ body: JSON.stringify({ kind: kind }),
1498
+ });
1499
+ var j = await res.json().catch(function () { return {}; });
1500
+ // **起こせなかったときも「失敗」と言わない。** 定期実行が拾うので、
1501
+ // 押した人にできることは無い。何が起きるかだけを伝える
1502
+ msg.className = "ds-toggle-msg" + (res.ok && j.started ? " is-ok" : "");
1503
+ msg.textContent = (res.ok && j.started ? "✓ " : "") + (j.message || "開始できませんでした。");
1504
+ } catch (e) {
1505
+ msg.className = "ds-toggle-msg";
1506
+ msg.textContent = "通信に失敗しました。定期実行(30分ごと)で取り込まれます。";
1507
+ }
1508
+ btn.disabled = false;
1509
+ });
1510
+ return btn;
1511
+ };
1512
+ row.appendChild(el("span", { class: "ds-syncnow-label", text: "いま同期する:" }));
1513
+ row.appendChild(mk("materials", "資料"));
1514
+ row.appendChild(mk("transcripts", "文字起こし"));
1515
+ row.appendChild(msg);
1516
+ return row;
1517
+ }
1518
+
1519
+ /**
1520
+ * 接続状況のライブ値(op=state の応答)。取得前・取得失敗時は null。
1521
+ *
1522
+ * **静的ビルド(SITE)は捨てない。** ビルド時に焼かれた値で先に描き切り、取れたら差し替える。
1523
+ * こうすると (1) いま即座に出ている操作タブに読み込み待ちが入らない (2) 失敗しても
1524
+ * 既に描き終わっているので空表示にならない。詳細表示の本文(loadBody)と同じ考え方。
1525
+ */
1526
+ var LIVE_FLEET = null;
1527
+ var LIVE_FLEET_FAILED = false;
1528
+ /**
1529
+ * 接続状況の値を引く。ライブがあればライブ、無ければ静的ビルドの値。
1530
+ * SITE 側はキー名が fleetGeneratedAt だけ異なる(他は fleet-status.json と同名)。
1531
+ */
1532
+ function fleetVal(key) {
1533
+ // **ライブが取れたら、そちらだけを見る(キー単位で静的へ落ちない)。**
1534
+ // キーごとにフォールバックすると、たとえば externalSources はライブ・secretSources は
1535
+ // 前日のビルド、という**2つの時点が混ざった画面**になる。上に出す「◯◯時点」は1つしか
1536
+ // 無いので、その注記が嘘になる——「変えたはずなのに違う値が出ている」という、
1537
+ // この節がまさに警告している誤読を作ることになる。
1538
+ // 欠けているキーは「無い」として扱い、既存の空配列ガードに任せる。
1539
+ if (LIVE_FLEET) return LIVE_FLEET[key];
1540
+ return SITE[key === "generatedAt" ? "fleetGeneratedAt" : key];
1541
+ }
1542
+
1543
+ /** 組み立てだけを行う(設置は呼び出し側)。ライブ差し替えで同じ節を作り直すため分けている */
1544
+ function buildDataSources() {
1545
+ var internals = fleetVal("internalSources") || [];
1546
+ var sources = fleetVal("externalSources") || [];
1547
+ var pipes = fleetVal("pipelines") || [];
1464
1548
  // 能力→ツールの宣言(例: チャット: "none")。"none" の能力は「未使用」のグレー行で存在だけ見せる
1465
- var tools = SITE.tools || {};
1549
+ var tools = fleetVal("tools") || {};
1466
1550
  var chatOff = tools["チャット"] === "none";
1467
1551
  var devOff = tools["開発"] === "none";
1468
- if (!internals.length && !sources.length && !pipes.length && !chatOff && !devOff) return; // すべて無ければセクションごと出さない
1552
+ if (!internals.length && !sources.length && !pipes.length && !chatOff && !devOff) return null; // すべて無ければセクションごと出さない
1469
1553
 
1470
1554
  var sec = el("section", { class: "datasources" });
1471
1555
  sec.appendChild(el("h2", { class: "datasources-head", text: "データソースと自動化" }));
@@ -1473,14 +1557,33 @@ const CLIENT_JS = `
1473
1557
  // **いつ時点の情報かを必ず出す。** この節は夜間バッチが作るスナップショットを読んでいるので、
1474
1558
  // 設定を変えても次の生成までは古い値のまま。時刻が無いと「変えたのに反映されない」ではなく
1475
1559
  // 「変えたはずなのに違う値が出ている」と読まれてしまう(実際に設定変更直後にそうなった)。
1476
- var asOf = SITE.fleetGeneratedAt ? dsFmtTime(SITE.fleetGeneratedAt) : "";
1560
+ //
1561
+ // **取得できたかで文言を変える。** 静的ビルドの値をそのまま「現在値」として見せると、
1562
+ // まさにここが警告している誤読を自分で起こす。取りに行って失敗したことは隠さない。
1563
+ var asOf = fleetVal("generatedAt") ? dsFmtTime(fleetVal("generatedAt")) : "";
1477
1564
  if (asOf) {
1478
- sec.appendChild(el("p", { class: "datasources-asof",
1479
- text: "接続状況は " + asOf + " 時点の自動チェック結果です。設定を変えた場合、ここに反映されるのは次回の実行後になります。" }));
1565
+ var asOfText = LIVE_FLEET_FAILED
1566
+ ? "最新の接続状況を取得できませんでした。表示しているのは " + asOf + " 時点の情報です。"
1567
+ : "接続状況は " + asOf + " 時点の自動チェック結果です。設定を変えた場合、ここに反映されるのは次回の実行後になります。";
1568
+ sec.appendChild(el("p", {
1569
+ class: "datasources-asof" + (LIVE_FLEET_FAILED ? " is-stale" : ""),
1570
+ text: asOfText,
1571
+ }));
1480
1572
  }
1481
1573
 
1482
1574
  if (internals.length) {
1483
1575
  sec.appendChild(el("div", { class: "ds-sub", text: "リポジトリ内の情報源(自動同期)" }));
1576
+ // **「いま同期する」ボタンは出さない。** 受け口(Apps Script の Webアプリ)を
1577
+ // 公開できないため、押しても届かない。
1578
+ //
1579
+ // Workspace のポリシーで**匿名アクセス可能なWebアプリを公開できない**
1580
+ // (デプロイ設定の「アクセスできるユーザー」に「全員」が出ず、「Classmethod.jp 内の全員」まで)。
1581
+ // Lambda は Google アカウントを持たないのでドメイン制限を通れない。
1582
+ //
1583
+ // 代わりに**待ち時間そのものを縮めた**(トリガー30分→15分・設定キャッシュ30分→5分で
1584
+ // 最悪60分→20分)。押す口が要るなら、bot の資格情報で Lambda が認証する経路を
1585
+ // 用意してからここを戻す(syncNowRow と Lambda の op=sync は残してある)。
1586
+ // sec.appendChild(syncNowRow());
1484
1587
  var ilist = el("div", { class: "ds-list" });
1485
1588
  internals.forEach(function (s) {
1486
1589
  // ラベルの下に補足行(会議の照合キー・Drive未設定の注記)を重ねられるよう縦組みにする
@@ -1490,30 +1593,36 @@ const CLIENT_JS = `
1490
1593
  // (Router.gs:「フィルタは持たない…招待が同意のゲート」)。enabled はルーティングの
1491
1594
  // 可否であって取り込みの可否ではないので、OFFにすると**Botは録り続けるのに
1492
1595
  // 文字起こしがINBOXに溜まる**という、招待しないより悪い状態を作れてしまう。
1493
- // **案件キーを先頭に足す。** 照合は「案件キー 顧客名 meetingNamePatterns」の
1494
- // 3段で行われるが、fleet-status が持つのは後ろ2つだけ。第1段は艦隊レジストリの
1495
- // キーで、投入設定から取れる。運用規約「会議名に案件キーを含める」の本体なので、
1496
- // これが抜けていると一番使われている目印が画面に出ない。
1497
- var keys = [];
1596
+ // **合図(会議名に入れる目印)を出す。** 照合は「会議名に [合図] / 【合図】
1597
+ // 入っているか」だけ(Router.gs)。合図は案件リポの meetingKey、未宣言なら艦隊
1598
+ // レジストリのキーで、後者は案件リポも fleet-status も知らない——投入設定からしか取れない。
1599
+ //
1600
+ // **括弧を付けた形で出す。** 語だけ見せると 【KC定例】 のように混ぜて書かれ、
1601
+ // どこも一致せず未仕分けへ落ちる(実際に起きた)。そのままコピーできる形にする。
1602
+ var signal = null;
1498
1603
  // **自動取り込みが動いているときだけ出す。** Teams等で手動運用している案件に
1499
1604
  // 会議名の条件を出すと、自動で拾われると誤解させる(実際には拾われない)。
1500
1605
  if (s.kind === "会議" && s.ingestState === "on") {
1501
- // 案件キーは艦隊レジストリのもので、案件リポ自身は知らない(fleet-status にも無い)。
1502
- // ビルド時に注入される投入設定からしか取れないため、設定UIが未配線の案件では出ない。
1503
- if (SITE.intake && SITE.intake.projectKey) keys.push(SITE.intake.projectKey);
1504
- (s.matchKeys || []).forEach(function (k) {
1505
- // 照合は大小文字を区別しないので、違いだけの重複は出さない(「cortex / Cortex」は雑音)
1506
- var dup = keys.some(function (x) { return x.toLowerCase() === String(k).toLowerCase(); });
1507
- if (!dup) keys.push(k);
1508
- });
1606
+ signal = s.meetingKey || (SITE.intake && SITE.intake.projectKey) || null;
1607
+ }
1608
+ var aliases = (s.kind === "会議" && s.ingestState === "on" && s.meetingAliases) || [];
1609
+ if (signal) {
1610
+ col.appendChild(el("span", {
1611
+ class: "ds-keys",
1612
+ text: "会議名に入れる目印: 【" + signal + "】 または [" + signal + "]",
1613
+ title: "会議名のどこかにこの括弧付きの目印が入っていると、この案件に振り分けられます(大小文字は問いません)。括弧の中に他の文字を混ぜると一致しません。",
1614
+ }));
1509
1615
  }
1510
- if (keys.length) {
1511
- var shown = keys.slice(0, 6).join(" / ");
1512
- if (keys.length > 6) shown += " 他" + (keys.length - 6) + "件";
1616
+ if (aliases.length) {
1617
+ // **目印が無くても入る会議。** 既に走っていて改名を頼みにくい定例を、
1618
+ // いまの名前のまま登録したもの。出さないと「なぜこれだけ入るのか」
1619
+ // 「新しい定例をどこに足すのか」が画面から分からない
1620
+ var shown = aliases.slice(0, 5).join(" / ");
1621
+ if (aliases.length > 5) shown += " 他" + (aliases.length - 5) + "件";
1513
1622
  col.appendChild(el("span", {
1514
1623
  class: "ds-keys",
1515
- text: "取り込み対象の会議名: " + shown,
1516
- title: "会議名にこれらの語が含まれていると、この案件に振り分けられます",
1624
+ text: "目印なしで取り込む会議: " + shown,
1625
+ title: "この名前の会議は、目印が無くてもこの案件に振り分けられます(大小文字・前後の空白は問いません。ファイル名に使えない記号(コロン・スラッシュ等)は同じものとして扱います)。既に走っている定例を改名せずに取り込むための登録です。",
1517
1626
  }));
1518
1627
  }
1519
1628
  if (s.driveSync === false) {
@@ -1646,7 +1755,11 @@ const CLIENT_JS = `
1646
1755
  sec.appendChild(plist);
1647
1756
  }
1648
1757
 
1649
- app.appendChild(sec);
1758
+ return sec;
1759
+ }
1760
+ function renderDataSources() {
1761
+ var sec = buildDataSources();
1762
+ if (sec) app.appendChild(sec);
1650
1763
  }
1651
1764
  // 未使用能力のプレースホルダ行(アダプターとして存在するが、この案件では設定されていないもの)
1652
1765
  function inactiveSourceRow(icon, typeLabel, anchorId) {
@@ -1710,7 +1823,7 @@ const CLIENT_JS = `
1710
1823
  // 「足したい案件でだけ足せない」という逆転になっていた。
1711
1824
  // 押しても必ず失敗するもの(別ツールが宣言済み)は usable() で弾く。
1712
1825
  if (!SITE.intake) return null;
1713
- var tools = SITE.tools || {};
1826
+ var tools = fleetVal("tools") || {};
1714
1827
  var usable = function (cap, tool) { var v = tools[cap]; return !v || v === "none" || v === tool; };
1715
1828
  var box = el("div", {});
1716
1829
  var used = false;
@@ -1915,7 +2028,7 @@ const CLIENT_JS = `
1915
2028
  function renderExternalAdders() {
1916
2029
  var box = el("div", {});
1917
2030
  if (!SITE.intake) return box;
1918
- var tools = SITE.tools || {};
2031
+ var tools = fleetVal("tools") || {};
1919
2032
  var usable = function (cap, tool) { var v = tools[cap]; return !v || v === "none" || v === tool; };
1920
2033
 
1921
2034
  // **Slack は tools 宣言で隠さない。** tools.チャット は能力ごとに1値しか持てないので、
@@ -2236,9 +2349,166 @@ const CLIENT_JS = `
2236
2349
  }
2237
2350
 
2238
2351
  // ---------- 操作タブ(接続マップ・データソースと自動化・取り込み。今後この受け皿に機能を足す) ----------
2352
+ /**
2353
+ * 接続状況をライブで取りに行き、取れたら操作タブを描き直す。
2354
+ *
2355
+ * **静的ビルドで描き切った後に呼ぶ。** 待たせないため、そして失敗しても既に画面が
2356
+ * 出ているため。1タブ表示につき1回だけで、ポーリングはしない(GitHubトークンは
2357
+ * 組織で共有していて、枯渇すると同じ組織の全案件のビルド・同期が止まる)。
2358
+ */
2359
+ /**
2360
+ * ライブ応答の最低限の正規化。
2361
+ *
2362
+ * **静的経路は build.ts が同じことをしている。** あちらは要素を許可リストで組み直し、
2363
+ * 非オブジェクトを落として型を強制する。ライブ経路にはそれが無いので、生成側
2364
+ * (別リポの cortex-engine)が壊れた1件を混ぜると、要素を読む段で TypeError になり
2365
+ * **操作タブが丸ごと出なくなる**(データソースだけでなく、その後の取り込みフォームも
2366
+ * 鍵の登録も消える)。fleet-passthrough.test.mjs が「壊れた1件でビルド全体が落ちると
2367
+ * 案件のビューアが丸ごと出なくなる」と書いているのと同じ事故を、ライブ経路で起こさない。
2368
+ *
2369
+ * ここは build.ts の許可リストを写さない(2枚になると列挙し忘れが静かに消える)。
2370
+ * **落とすのは「読めない形のもの」だけ**——配列の非オブジェクト要素と、
2371
+ * 文字列前提の配列に混ざった非文字列。
2372
+ */
2373
+ /** 入力中・送信中の節か(フォーカスがある/入力に値がある/進行中の表示がある) */
2374
+ function isBeingEdited(node) {
2375
+ if (node.contains(document.activeElement) && document.activeElement !== document.body) return true;
2376
+ var inputs = node.querySelectorAll("input, textarea");
2377
+ for (var i = 0; i < inputs.length; i++) { if (inputs[i].value) return true; }
2378
+ var msgs = node.querySelectorAll(".ds-toggle-msg");
2379
+ for (var m = 0; m < msgs.length; m++) { if (msgs[m].textContent) return true; }
2380
+ return false;
2381
+ }
2382
+
2383
+ /** 文字列だけを並べる配列。非文字列が混ざると "null" や "[object Object]" が表示される */
2384
+ var STRING_LIST_FIELDS = { meetingAliases: 1, driveFolderIds: 1 };
2385
+
2386
+ function normalizeFleet(fleet) {
2387
+ if (!fleet || typeof fleet !== "object") return null;
2388
+ var out = {};
2389
+ for (var k in fleet) {
2390
+ if (!Object.prototype.hasOwnProperty.call(fleet, k)) continue;
2391
+ var v = fleet[k];
2392
+ if (!Array.isArray(v)) { out[k] = v; continue; }
2393
+ out[k] = v
2394
+ .filter(function (item) { return item != null && typeof item === "object" && !Array.isArray(item); })
2395
+ .map(function (item) {
2396
+ var copy = {};
2397
+ for (var f in item) {
2398
+ if (!Object.prototype.hasOwnProperty.call(item, f)) continue;
2399
+ var fv = item[f];
2400
+ // 画面が文字列として並べる配列(会議の別名・DriveフォルダID)に非文字列が
2401
+ // 混ざると "null" や "[object Object]" が表示される。build.ts:178 が
2402
+ // 名指しで防いでいる罠なので、ライブ側でも同じ扱いにする
2403
+ if (Array.isArray(fv)) {
2404
+ copy[f] = STRING_LIST_FIELDS[f]
2405
+ ? fv.filter(function (x) { return typeof x === "string" && x !== ""; })
2406
+ : fv.filter(function (x) { return x != null; });
2407
+ } else {
2408
+ copy[f] = fv;
2409
+ }
2410
+ }
2411
+ return copy;
2412
+ });
2413
+ }
2414
+ return out;
2415
+ }
2416
+
2417
+ /**
2418
+ * ライブ値が届いたときの差し替え。**画面全体を描き直さない。**
2419
+ *
2420
+ * 当初は route() を呼んでいたが、これは renderList() → app.innerHTML = "" で
2421
+ * 操作タブを丸ごと作り直す。操作タブは DOM に状態を持つ設計なので
2422
+ * (貼りかけのトークン・「保存中…」・押した直後のボタンの向き)、
2423
+ * **保存中に応答が届くと入力が消え、成功メッセージは切り離された古いノードに書かれて
2424
+ * 画面に出ない**——保存は成功しているのに「押したのに何も起きなかった」と見える。
2425
+ * 差し替えるのはデータソース節と接続マップの注記だけにする。
2426
+ */
2427
+ function refreshDataSources() {
2428
+ if (listState.tab !== "ops") return;
2429
+ var old = app.querySelector(".datasources");
2430
+ // **触っている最中は差し替えない。** この節の中には追加フォームの入力欄があり、
2431
+ // 貼りかけのURLや「保存中…」の表示ごと作り直してしまう。保存が成功していても
2432
+ // 結果メッセージは切り離された古いノードに書かれ、画面には出ない——
2433
+ // 「押したのに何も起きなかった」と見えて、もう一度貼って押す(=二重保存)に誘導する。
2434
+ // 見送っても値は LIVE_FLEET に入っているので、次に操作タブを描くときに反映される。
2435
+ if (old && isBeingEdited(old)) return;
2436
+ var next = buildDataSources();
2437
+ if (old && next) old.parentNode.replaceChild(next, old);
2438
+ else if (old) old.parentNode.removeChild(old);
2439
+ else if (next) {
2440
+ // 静的では空でライブで出るようになった場合。接続マップの直後に入れる
2441
+ var map = app.querySelector(".conn-map");
2442
+ if (map && map.nextSibling) app.insertBefore(next, map.nextSibling);
2443
+ else app.insertBefore(next, app.firstChild);
2444
+ }
2445
+ updateConnMapAsOf();
2446
+ updateSecretStates();
2447
+ }
2448
+
2449
+ /**
2450
+ * 鍵カードの「現在: ◯◯ から取得中」だけを更新する。
2451
+ * **カードごと作り直さない**(貼りかけのトークンが消える)。表示は1行なので差し替えで足りる。
2452
+ */
2453
+ function updateSecretStates() {
2454
+ var sources = fleetVal("secretSources") || {};
2455
+ var cards = app.querySelectorAll("[data-secret-kind]");
2456
+ for (var i = 0; i < cards.length; i++) {
2457
+ var kind = cards[i].getAttribute("data-secret-kind");
2458
+ var line = cards[i].querySelector(".sec-state");
2459
+ if (!line) continue;
2460
+ var src = sources[kind] || "";
2461
+ line.className = src
2462
+ ? "sec-state" + (src === "Secrets Manager" ? " is-managed" : "")
2463
+ : "sec-state is-none";
2464
+ line.textContent = src ? "現在: " + src + " から取得中" : "現在: 未設定";
2465
+ }
2466
+ }
2467
+
2468
+ /**
2469
+ * 取得中フラグ。**完了しても寝かせたままにしない。**
2470
+ * 一度 true のままだと、タブを移動して戻ってきても再取得せず、設定を変えた人が
2471
+ * その場で反映を確かめる手段がフルリロードだけになる。取得が終わったら倒して、
2472
+ * **操作タブに入り直したときだけ**もう一度取りに行く(ポーリングはしない)。
2473
+ */
2474
+ var liveFleetRequested = false;
2475
+ function loadLiveFleet() {
2476
+ if (liveFleetRequested) return;
2477
+ var intake = SITE.intake;
2478
+ if (!intake) return;
2479
+ // **fetch の有無を確かめてから呼ぶ。** テスト環境(JSDOM)は window.fetch を持たない。
2480
+ // 素で呼ぶと描画中の ReferenceError になり、「画面が例外を出さずに描き切る」検査を割る。
2481
+ if (typeof fetch !== "function") return;
2482
+ liveFleetRequested = true;
2483
+ var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
2484
+ fetch(pfx + "op=state&t=" + encodeURIComponent(intake.token), {
2485
+ method: "POST",
2486
+ headers: { "content-type": "application/json" },
2487
+ body: "{}",
2488
+ })
2489
+ // **非2xxも失敗として扱う。** ビューアとLambdaの版は独立に進むので、
2490
+ // op=state 未対応のLambdaは 400 を返す。通信エラーだけを見ていると空表示になる。
2491
+ .then(function (res) { if (!res.ok) throw new Error("http " + res.status); return res.json(); })
2492
+ .then(function (j) {
2493
+ if (!j || !j.fleet) throw new Error("no fleet");
2494
+ LIVE_FLEET = normalizeFleet(j.fleet);
2495
+ LIVE_FLEET_FAILED = false;
2496
+ liveFleetRequested = false;
2497
+ refreshDataSources();
2498
+ })
2499
+ .catch(function () {
2500
+ // 静的ビルドの値で描き続ける。ただし「古いかもしれない」ことは画面に出す
2501
+ LIVE_FLEET = null;
2502
+ LIVE_FLEET_FAILED = true;
2503
+ liveFleetRequested = false;
2504
+ refreshDataSources();
2505
+ });
2506
+ }
2507
+
2239
2508
  function renderOps() {
2240
2509
  renderConnMap();
2241
2510
  renderDataSources();
2511
+ loadLiveFleet();
2242
2512
  if (SITE.intake) {
2243
2513
  app.appendChild(el("p", { class: "ops-intro", text: "Cortex にコンテキストを取り込む操作です。ファイルを選ぶかテキストを貼って送信すると、自動で取り込まれます(数分で反映)。" }));
2244
2514
  app.appendChild(el("div", { class: "section-label", text: "取り込み" }));
@@ -2262,7 +2532,21 @@ const CLIENT_JS = `
2262
2532
  icon: "📋",
2263
2533
  title: "Backlog のAPIキー",
2264
2534
  desc: "課題・Wiki・ドキュメントを毎晩同期するために使います。",
2265
- how: "Backlog → 個人設定 → API で発行",
2535
+ how: "Backlog → 個人設定 → API で発行(この案件のプロジェクトが見えるアカウントで)",
2536
+ // **案件の担当者が自分で繋ぐ。** 以前は艦隊共通の読み取りアカウントを1つ運用していたが、
2537
+ // 案件が増えるたびに「その1人がプロジェクトへ参加追加してキーを配る」ことになり、
2538
+ // セットアップが一箇所に詰まる。**繋ぐのは、その案件を持っている人**にする。
2539
+ //
2540
+ // 代償は「その人が離れると同期が止まる」こと。ただしこれは**担当が変わったときに
2541
+ // 次の人が入れ直せば済む**話で、誰か1人に依存し続けるより回復しやすい。
2542
+ selfServe: {
2543
+ text:
2544
+ "この案件を担当しているあなたのアカウントで発行して、ここに貼ってください。" +
2545
+ "管理者への依頼は要りません。",
2546
+ note:
2547
+ "担当が変わったら、新しい担当者が同じ手順で入れ直してください" +
2548
+ "(前の担当者のアカウントが無効になると同期が止まります)。",
2549
+ },
2266
2550
  },
2267
2551
  "external-sources": {
2268
2552
  icon: "🐙",
@@ -2294,7 +2578,7 @@ const CLIENT_JS = `
2294
2578
  // **いま何で動いているか**を先に出す。入れ直したのに反映されていない状態が
2295
2579
  // 画面で分かるようにする(接続状況は夜間バッチのスナップショットなので、
2296
2580
  // 保存の直後は前日の値のまま。時刻は上の節に出している)。
2297
- var src = (SITE.secretSources || {})[kind] || "";
2581
+ var src = (fleetVal("secretSources") || {})[kind] || "";
2298
2582
  var state = src
2299
2583
  ? el("p", { class: "sec-state" + (src === "Secrets Manager" ? " is-managed" : ""),
2300
2584
  text: "現在: " + src + " から取得中" })
@@ -2344,14 +2628,19 @@ const CLIENT_JS = `
2344
2628
  }
2345
2629
  });
2346
2630
 
2347
- return el("div", { class: "ops-card" }, [
2631
+ // ライブ取得後に「現在: …」だけを差し替えるための目印(カードごと作り直すと入力が消える)
2632
+ return el("div", { class: "ops-card", "data-secret-kind": kind }, [
2348
2633
  el("div", { class: "ops-card-head" }, [
2349
2634
  el("span", { class: "ops-card-icon", text: meta.icon }),
2350
2635
  el("h3", { class: "ops-card-title", text: meta.title }),
2351
2636
  ]),
2352
2637
  el("p", { class: "ops-card-desc", text: meta.desc }),
2353
2638
  state,
2639
+ // **未設定のときだけ、先に試すべき手を出す。** 既に動いているカードに
2640
+ // 「まずこれをやれ」と出すと、直す必要が無いものを触らせることになる
2641
+ !src && meta.selfServe ? el("p", { class: "sec-preferred", text: meta.selfServe.text }) : null,
2354
2642
  el("p", { class: "sec-how", text: meta.how }),
2643
+ meta.selfServe ? el("p", { class: "sec-how", text: meta.selfServe.note }) : null,
2355
2644
  input,
2356
2645
  el("div", { class: "ds-toggle" }, [btn, msg]),
2357
2646
  ]);
@@ -2376,6 +2665,26 @@ const CLIENT_JS = `
2376
2665
  if (target && target.scrollIntoView) target.scrollIntoView({ behavior: "smooth", block: "center" });
2377
2666
  });
2378
2667
  app.appendChild(wrap);
2668
+ updateConnMapAsOf();
2669
+ }
2670
+
2671
+ /**
2672
+ * 接続マップに「いつ時点か」を付ける/外す。
2673
+ *
2674
+ * **この図はビルド時に焼かれている。** 下の一覧はライブに差し替わるので、ライブが
2675
+ * 取れているときは図だけが古い。同じ画面で図が⚠️・直下が「接続OK」になる状態を
2676
+ * 黙って出さないよう、図がいつ時点かを明示する(図の再描画は別途)。
2677
+ * ライブが取れていないときは図と一覧が同じ時点なので、注記は出さない。
2678
+ */
2679
+ function updateConnMapAsOf() {
2680
+ var wrap = app.querySelector(".conn-map");
2681
+ if (!wrap) return;
2682
+ var existing = wrap.querySelector(".conn-map-asof");
2683
+ if (existing) existing.parentNode.removeChild(existing);
2684
+ if (!LIVE_FLEET || !SITE.fleetGeneratedAt) return;
2685
+ var mapAsOf = dsFmtTime(SITE.fleetGeneratedAt);
2686
+ if (!mapAsOf) return;
2687
+ wrap.appendChild(el("p", { class: "conn-map-asof", text: "この図は " + mapAsOf + " 時点です(下の一覧は最新)。" }));
2379
2688
  }
2380
2689
  function opsCard(icon, title, desc, kind, btnLabel) {
2381
2690
  var btn = el("button", { class: "ops-card-btn", text: "+ " + btnLabel });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.4.56",
3
+ "version": "0.4.58",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,