@songsid/agend 2.3.1 → 2.3.2-beta.10
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/THIRD_PARTY_NOTICES.md +40 -0
- package/dist/agent-cli.js +21 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.js +5 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/antigravity-usage-scan.d.ts +58 -0
- package/dist/antigravity-usage-scan.js +257 -0
- package/dist/antigravity-usage-scan.js.map +1 -0
- package/dist/antigravity-usage-worker.d.ts +1 -0
- package/dist/antigravity-usage-worker.js +10 -0
- package/dist/antigravity-usage-worker.js.map +1 -0
- package/dist/auth-api.js +4 -0
- package/dist/auth-api.js.map +1 -1
- package/dist/authority/authority-state.d.ts +4 -0
- package/dist/authority/authority-state.js +16 -10
- package/dist/authority/authority-state.js.map +1 -1
- package/dist/authority/session-authorizations.d.ts +7 -1
- package/dist/authority/session-authorizations.js +6 -4
- package/dist/authority/session-authorizations.js.map +1 -1
- package/dist/authority/web-authority.d.ts +19 -6
- package/dist/authority/web-authority.js +22 -8
- package/dist/authority/web-authority.js.map +1 -1
- package/dist/backend/kiro-identity.d.ts +64 -0
- package/dist/backend/kiro-identity.js +101 -0
- package/dist/backend/kiro-identity.js.map +1 -1
- package/dist/backend/kiro-retired-model.d.ts +62 -0
- package/dist/backend/kiro-retired-model.js +160 -0
- package/dist/backend/kiro-retired-model.js.map +1 -0
- package/dist/backend/kiro.d.ts +71 -2
- package/dist/backend/kiro.js +352 -11
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +59 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-signin-guide.d.ts +1 -0
- package/dist/backend-signin-guide.js +9 -0
- package/dist/backend-signin-guide.js.map +1 -0
- package/dist/backend-signin-pending.d.ts +10 -0
- package/dist/backend-signin-pending.js +44 -0
- package/dist/backend-signin-pending.js.map +1 -0
- package/dist/browser-backend-signins.d.ts +56 -0
- package/dist/browser-backend-signins.js +189 -0
- package/dist/browser-backend-signins.js.map +1 -0
- package/dist/cache-ledger.d.ts +38 -2
- package/dist/cache-ledger.js +133 -8
- package/dist/cache-ledger.js.map +1 -1
- package/dist/cache-prices.d.ts +2 -1
- package/dist/cache-prices.js +5 -2
- package/dist/cache-prices.js.map +1 -1
- package/dist/cache-service.d.ts +68 -0
- package/dist/cache-service.js +355 -13
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +13 -0
- package/dist/channel/adapters/discord.js +103 -16
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +34 -7
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.js +2 -1
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +11 -0
- package/dist/classic-channel-manager.d.ts +11 -0
- package/dist/classic-channel-manager.js +39 -1
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +21 -2
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.d.ts +7 -1
- package/dist/config-validator.js +92 -12
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +19 -0
- package/dist/config.js +31 -1
- package/dist/config.js.map +1 -1
- package/dist/cpu-quota.d.ts +23 -0
- package/dist/cpu-quota.js +86 -0
- package/dist/cpu-quota.js.map +1 -0
- package/dist/daemon.d.ts +57 -3
- package/dist/daemon.js +330 -50
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard-permissions.d.ts +20 -6
- package/dist/dashboard-permissions.js +58 -9
- package/dist/dashboard-permissions.js.map +1 -1
- package/dist/delivery-outbox.d.ts +12 -1
- package/dist/delivery-outbox.js +67 -15
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +11 -1
- package/dist/doctor.js.map +1 -1
- package/dist/event-log.d.ts +5 -0
- package/dist/event-log.js +13 -0
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-manager.d.ts +212 -0
- package/dist/fleet-manager.js +914 -111
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-process-handlers.d.ts +1 -0
- package/dist/fleet-process-handlers.js +9 -1
- package/dist/fleet-process-handlers.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +9 -0
- package/dist/forged-envelope-scan.js +30 -5
- package/dist/forged-envelope-scan.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +5 -2
- package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +14 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +28 -5
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +31 -8
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +138 -59
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +145 -61
- package/dist/guest-seen-people.d.ts +38 -0
- package/dist/guest-seen-people.js +78 -0
- package/dist/guest-seen-people.js.map +1 -0
- package/dist/instance-config-impact.js +14 -6
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +20 -0
- package/dist/instance-lifecycle.js +71 -13
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/kiro-db-reader.d.ts +5 -0
- package/dist/kiro-db-reader.js +18 -0
- package/dist/kiro-db-reader.js.map +1 -1
- package/dist/kiro-engine-status.d.ts +2 -0
- package/dist/kiro-engine-status.js +9 -0
- package/dist/kiro-engine-status.js.map +1 -1
- package/dist/kiro-migrate.d.ts +49 -0
- package/dist/kiro-migrate.js +105 -0
- package/dist/kiro-migrate.js.map +1 -0
- package/dist/kiro-transcript-lane.d.ts +2 -0
- package/dist/kiro-transcript-lane.js.map +1 -1
- package/dist/kiro-transcript-worker.js +1 -0
- package/dist/kiro-transcript-worker.js.map +1 -1
- package/dist/kiro-tui-forensic.d.ts +7 -0
- package/dist/kiro-tui-forensic.js +110 -0
- package/dist/kiro-tui-forensic.js.map +1 -0
- package/dist/kiro-usage-run.d.ts +7 -0
- package/dist/kiro-usage-run.js +39 -0
- package/dist/kiro-usage-run.js.map +1 -0
- package/dist/kiro-usage-scan.d.ts +49 -0
- package/dist/kiro-usage-scan.js +223 -0
- package/dist/kiro-usage-scan.js.map +1 -0
- package/dist/kiro-usage-worker.d.ts +1 -0
- package/dist/kiro-usage-worker.js +10 -0
- package/dist/kiro-usage-worker.js.map +1 -0
- package/dist/kiro-usage.d.ts +58 -0
- package/dist/kiro-usage.js +310 -0
- package/dist/kiro-usage.js.map +1 -0
- package/dist/locale.js +120 -12
- package/dist/locale.js.map +1 -1
- package/dist/log-service.js +1 -1
- package/dist/log-service.js.map +1 -1
- package/dist/login-url.d.ts +4 -0
- package/dist/login-url.js +22 -0
- package/dist/login-url.js.map +1 -0
- package/dist/mac-auto-restore.d.ts +117 -0
- package/dist/mac-auto-restore.js +294 -0
- package/dist/mac-auto-restore.js.map +1 -0
- package/dist/needs-you-hub.d.ts +2 -1
- package/dist/needs-you-hub.js +1 -1
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you.d.ts +8 -2
- package/dist/needs-you.js +6 -0
- package/dist/needs-you.js.map +1 -1
- package/dist/notice-delivery.d.ts +16 -0
- package/dist/notice-delivery.js +35 -0
- package/dist/notice-delivery.js.map +1 -0
- package/dist/office-share-page.d.ts +16 -0
- package/dist/office-share-page.js +85 -0
- package/dist/office-share-page.js.map +1 -0
- package/dist/office-share-service.d.ts +91 -0
- package/dist/office-share-service.js +176 -0
- package/dist/office-share-service.js.map +1 -0
- package/dist/office-share.d.ts +258 -0
- package/dist/office-share.js +549 -0
- package/dist/office-share.js.map +1 -0
- package/dist/opencode-usage-scan.d.ts +28 -0
- package/dist/opencode-usage-scan.js +92 -0
- package/dist/opencode-usage-scan.js.map +1 -0
- package/dist/opencode-usage-worker.d.ts +1 -0
- package/dist/opencode-usage-worker.js +10 -0
- package/dist/opencode-usage-worker.js.map +1 -0
- package/dist/outbound-handlers.js +22 -2
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +28 -2
- package/dist/outbound-schemas.js +29 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +16 -2
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/quickstart-api.js +13 -2
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.js +2 -1
- package/dist/quickstart.js.map +1 -1
- package/dist/remote-card-answer.d.ts +83 -0
- package/dist/remote-card-answer.js +208 -0
- package/dist/remote-card-answer.js.map +1 -0
- package/dist/reply-triggers.d.ts +2 -0
- package/dist/reply-triggers.js +3 -0
- package/dist/reply-triggers.js.map +1 -0
- package/dist/settings-api.d.ts +28 -0
- package/dist/settings-api.js +148 -17
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +54 -17
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +12 -3
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-instance-patch.js +4 -1
- package/dist/settings-instance-patch.js.map +1 -1
- package/dist/setup-form.d.ts +1 -1
- package/dist/setup-form.js +55 -6
- package/dist/setup-form.js.map +1 -1
- package/dist/setup-host.d.ts +9 -0
- package/dist/setup-host.js +127 -6
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-signin-assets.d.ts +2 -0
- package/dist/setup-signin-assets.js +6 -0
- package/dist/setup-signin-assets.js.map +1 -0
- package/dist/setup-wizard.js +2 -0
- package/dist/setup-wizard.js.map +1 -1
- package/dist/start-attempt.d.ts +9 -5
- package/dist/start-attempt.js +27 -8
- package/dist/start-attempt.js.map +1 -1
- package/dist/start-failure-chat.d.ts +4 -0
- package/dist/start-failure-chat.js +23 -6
- package/dist/start-failure-chat.js.map +1 -1
- package/dist/start-failure-notice.d.ts +4 -0
- package/dist/start-failure-notice.js +8 -0
- package/dist/start-failure-notice.js.map +1 -1
- package/dist/start-failure.d.ts +25 -0
- package/dist/start-failure.js +75 -2
- package/dist/start-failure.js.map +1 -1
- package/dist/structured-signin-transport.d.ts +8 -0
- package/dist/structured-signin-transport.js +79 -0
- package/dist/structured-signin-transport.js.map +1 -0
- package/dist/structured-signin.d.ts +131 -0
- package/dist/structured-signin.js +388 -0
- package/dist/structured-signin.js.map +1 -0
- package/dist/tool-permissions.js +12 -0
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.js +6 -2
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-sources.d.ts +26 -2
- package/dist/transcript-sources.js +103 -40
- package/dist/transcript-sources.js.map +1 -1
- package/dist/turn-reply-guard.d.ts +25 -0
- package/dist/turn-reply-guard.js +111 -12
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/types.d.ts +46 -9
- package/dist/ui/backend-signin.js +68 -12
- package/dist/ui/chat-ambient.js +1 -1
- package/dist/ui/chat-effects.js +4 -4
- package/dist/ui/chat-render.js +5 -2
- package/dist/ui/chat-strings.js +4 -4
- package/dist/ui/chat-thread.js +2 -2
- package/dist/ui/first-run.js +2 -2
- package/dist/ui/fleet-canvas-view.js +114 -0
- package/dist/ui/fleet-canvas.js +15 -77
- package/dist/ui/fleet-office-editor.js +94 -14
- package/dist/ui/fleet-office-layout.js +235 -0
- package/dist/ui/fleet-office-model.js +12 -6
- package/dist/ui/fleet-office-overview.js +167 -0
- package/dist/ui/fleet-office-pack.d.ts +44 -0
- package/dist/ui/fleet-office-pack.js +352 -37
- package/dist/ui/fleet-office-skin.js +220 -36
- package/dist/ui/fleet-office-store.js +112 -7
- package/dist/ui/fleet-office-view.js +155 -0
- package/dist/ui/fleet-office-zones.js +264 -42
- package/dist/ui/fleet-office.js +957 -160
- package/dist/ui/fleet-usage.js +26 -21
- package/dist/ui/office-share-dialog.js +145 -0
- package/dist/ui/office-share-page.js +164 -0
- package/dist/ui/panel-chat.js +9 -2
- package/dist/ui/panel-details.js +2 -2
- package/dist/ui/panel-fleet.js +11 -77
- package/dist/ui/panel-log.js +4 -4
- package/dist/ui/panel-needs.js +4 -4
- package/dist/ui/panel-settings.js +608 -167
- package/dist/ui/search-dialog.js +26 -7
- package/dist/ui/settings-appearance.js +82 -29
- package/dist/ui/settings-apply.js +42 -7
- package/dist/ui/settings-dialogs.js +76 -23
- package/dist/ui/settings-draft.js +51 -0
- package/dist/ui/settings-guests.js +255 -79
- package/dist/ui/settings-learn.js +2 -2
- package/dist/ui/settings-model.js +16 -6
- package/dist/ui/settings-registry.js +146 -0
- package/dist/ui/settings-strings.js +116 -44
- package/dist/ui/settings-web-authority.js +34 -23
- package/dist/ui/shared/app-i18n.js +18 -2
- package/dist/ui/shared/app-nav.js +10 -5
- package/dist/ui/shared/app-prefs.js +80 -2
- package/dist/ui/shared/app-route.js +39 -5
- package/dist/ui/shared/app-shell.js +73 -10
- package/dist/ui/shared/app-stream.js +3 -1
- package/dist/ui/shared/app.css +307 -17
- package/dist/ui/shared/app.js +3 -0
- package/dist/ui/shared/effect-stock-trust.js +1 -1
- package/dist/ui/shared/identity-color.js +4 -4
- package/dist/ui/shared/office/cozy.png +0 -0
- package/dist/ui/shared/side-panel.js +95 -0
- package/dist/ui/shared/signin.js +2 -2
- package/dist/ui/shared/ui-dialog.js +6 -1
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/web-authorize.js +13 -12
- package/dist/ui/structured-signin.js +115 -0
- package/dist/usage-report.d.ts +20 -0
- package/dist/usage-report.js +54 -17
- package/dist/usage-report.js.map +1 -1
- package/dist/usage-rollup.d.ts +9 -1
- package/dist/usage-rollup.js +20 -4
- package/dist/usage-rollup.js.map +1 -1
- package/dist/web-api.d.ts +26 -2
- package/dist/web-api.js +361 -5
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +8 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-backend-login.d.ts +1 -7
- package/dist/web-backend-login.js +2 -24
- package/dist/web-backend-login.js.map +1 -1
- package/dist/web-experimental.d.ts +1 -1
- package/dist/web-experimental.js +1 -1
- package/dist/web-experimental.js.map +1 -1
- package/dist/web-mod-agent.js +2 -2
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-office-acts.d.ts +173 -0
- package/dist/web-office-acts.js +285 -0
- package/dist/web-office-acts.js.map +1 -0
- package/dist/web-office-skins.d.ts +109 -0
- package/dist/web-office-skins.js +299 -0
- package/dist/web-office-skins.js.map +1 -0
- package/dist/web-office-store.d.ts +8 -2
- package/dist/web-office-store.js +77 -14
- package/dist/web-office-store.js.map +1 -1
- package/dist/web-settings-patch.d.ts +8 -1
- package/dist/web-settings-patch.js +65 -5
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-shell-routes.d.ts +4 -2
- package/dist/web-shell-routes.js +17 -7
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/working-recovery.d.ts +104 -0
- package/dist/working-recovery.js +298 -0
- package/dist/working-recovery.js.map +1 -0
- package/dist/working-state-journal.d.ts +5 -1
- package/dist/working-state-journal.js +31 -3
- package/dist/working-state-journal.js.map +1 -1
- package/package.json +3 -2
- package/templates/windows-shortcuts.ps1 +155 -140
|
@@ -14,7 +14,9 @@ import { appStore, useStore } from "/assets/app-store.js";
|
|
|
14
14
|
import { useLease } from "/assets/app-ctx.js";
|
|
15
15
|
import { DevicePrefs, PanelHeader, PendingChanges, setTitle } from "/assets/app-shell.js";
|
|
16
16
|
import { navigate, navStore, setLeaveGuard } from "/assets/app-nav.js";
|
|
17
|
-
import {
|
|
17
|
+
import { discardHeldPrefs, heldPrefsCount, holdPrefs, onHeldPrefs, savePrefsHeld } from "/assets/app-prefs.js";
|
|
18
|
+
import { chatPath, LEGACY_SETTINGS, sectionPath, settingsPath, SETTINGS_GROUPS, SETTINGS_SECTIONS } from "/assets/app-route.js";
|
|
19
|
+
import { goToSetting, goToTarget, searchSettings, settingLabel, takePendingSetting } from "./settings-registry.js";
|
|
18
20
|
import { Dialog } from "/assets/ui-dialog.js";
|
|
19
21
|
import { Empty, ErrorState, Skeleton } from "/assets/ui-states.js";
|
|
20
22
|
import { Icon } from "/assets/ui-icons.js";
|
|
@@ -25,14 +27,14 @@ import "./settings-strings.js";
|
|
|
25
27
|
import { api, attach, confirmedWrite, newKey } from "./settings-confirm.js";
|
|
26
28
|
import { dismissOperation, operationActive, restartFleet, startOperation, takeLeftover } from "./settings-apply.js";
|
|
27
29
|
import {
|
|
28
|
-
ACCESS_MODES, AGENT_MODES, DASHBOARD_FEATURES, DEFAULT_SCHEMA, HANG_TIMEOUT_DEFAULT_MINUTES, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS, VISIBILITY_MODES, batchImpact,
|
|
30
|
+
ACCESS_MODES, AGENT_MODES, DASHBOARD_FEATURES, DEFAULT_SCHEMA, ownChoiceImpact, HANG_TIMEOUT_DEFAULT_MINUTES, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS, VISIBILITY_MODES, batchImpact,
|
|
29
31
|
changedFields, channelId, channelsOf, chLabel, connectionState, dashboardDefault, effectiveSummary, fleetModel, fromYaml, fullModelRequests, groupAgents,
|
|
30
|
-
hasOwn, impactOf, localeOptions, defaultsImpact, requestImpact, nonNegative, positive, replyGuardSupported, shortName, STATUS_ORDER, toYaml,
|
|
32
|
+
hasOwn, impactOf, localeOptions, defaultsImpact, requestImpact, nonNegative, positive, replyGuardSupported, sameValue, shortName, STATUS_ORDER, toYaml,
|
|
31
33
|
toolSetDefault, visibilityDefault, worstImpact,
|
|
32
34
|
} from "./settings-model.js";
|
|
33
35
|
import {
|
|
34
|
-
AgentDialog, BackendSelect, BotDialog, ChipList, ClassicDialog, Drawer, Feedback, Impact,
|
|
35
|
-
Select, ask, impactText,
|
|
36
|
+
AgentDialog, BackendSelect, BotDialog, ChipList, ClassicDialog, DefaultMark, Drawer, Feedback, Impact, InEffect, Inherited, Label, ModelSelect,
|
|
37
|
+
NewBotDialog, Override, Select, ask, impactText, inAgentDefaults, valueText,
|
|
36
38
|
} from "./settings-dialogs.js";
|
|
37
39
|
import { SetupWizard } from "./settings-wizard.js";
|
|
38
40
|
import { CreateInstanceDialog } from "./panel-fleet.js";
|
|
@@ -44,6 +46,8 @@ import { startTour } from "./chat-tour.js";
|
|
|
44
46
|
import "./chat-strings.js";
|
|
45
47
|
import { AppearanceSection } from "./settings-appearance.js";
|
|
46
48
|
import { GuestsCard } from "./settings-guests.js";
|
|
49
|
+
import { OfficeSharesCard } from "./office-share-dialog.js";
|
|
50
|
+
import { syncStage, useDraft } from "./settings-draft.js";
|
|
47
51
|
import { WebAuthorityCard } from "./settings-web-authority.js";
|
|
48
52
|
import { SignInList } from "./backend-signin.js";
|
|
49
53
|
|
|
@@ -52,7 +56,7 @@ const tn = (k, ...v) => t(`settings.${k}`, ...v);
|
|
|
52
56
|
const APPLY_ERRORS = new Set(["unsupported_sensitive_effect", "confirmation_diff_too_large", "session_required", "admin_required", "settings_changed",
|
|
53
57
|
"request_no_longer_current", "too_many_pending_changes", "fleet_unavailable", "invalid_settings_effect", "settings_confirmation_unavailable", "failed"]);
|
|
54
58
|
export const applyErrorText = (code) => (APPLY_ERRORS.has(code) ? tn(`applyError_${code}`) : tn("applyErrorOther", String(code)));
|
|
55
|
-
const ICONS = {
|
|
59
|
+
const ICONS = { preferences: "user", look: "palette", agents: "bot", "agent-defaults": "sliders", chat: "chat", "web-dashboard": "globe", access: "key", advanced: "code" };
|
|
56
60
|
const RELEASES = "https://github.com/songsid/AgEnD/releases";
|
|
57
61
|
|
|
58
62
|
/** GET under the panel's lease → { ok, status, body }. */
|
|
@@ -107,7 +111,9 @@ async function loadLive(lease) {
|
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
export function SettingsPanel({ route, navKey }) {
|
|
110
|
-
|
|
114
|
+
// #2347: an old section name (a route made before the regrouping) is its new section.
|
|
115
|
+
const moved = Object.prototype.hasOwnProperty.call(LEGACY_SETTINGS, route.section) ? LEGACY_SETTINGS[route.section].split("#")[0] : null;
|
|
116
|
+
const section = SETTINGS_SECTIONS.includes(route.section) ? route.section : moved || "agents";
|
|
111
117
|
const mount = useLease("settings"); // the panel's life on screen: its data, its guard
|
|
112
118
|
const [data, setData] = useState(null);
|
|
113
119
|
const [staged, setStaged] = useState(() => new Map());
|
|
@@ -115,6 +121,33 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
115
121
|
stagedRef.current = staged;
|
|
116
122
|
const [dialog, setDialog] = useState(null); // { kind, ...props, key: navKey }
|
|
117
123
|
const [search, setSearch] = useState("");
|
|
124
|
+
// #2347: each section's draft while it is edited (settings-draft.js), and the epoch that remounts the sections when
|
|
125
|
+
// the drafts go: a Save that went through whole, or Discard. A Save that failed keeps them (what did not land is staged
|
|
126
|
+
// again, and the form still shows it).
|
|
127
|
+
const drafts = useRef(new Map());
|
|
128
|
+
const [draftEpoch, setDraftEpoch] = useState(0);
|
|
129
|
+
// A staged change's onResult hears how its Save settled: "applied", "waiting" (for a fleet admin's OK) or "failed".
|
|
130
|
+
const results = useRef(new Map());
|
|
131
|
+
// The drafts as they were when a Save began. #2367 review: the form stays editable while it runs, so a Save that went
|
|
132
|
+
// through drops only the drafts it saved — one edited since (in any section) stays, and stays staged.
|
|
133
|
+
const submitted = useRef(null);
|
|
134
|
+
// The staged changes as they were when that Save began (what comes back after a failure is them, whole: a section's
|
|
135
|
+
// draft and onResult included), and how a Save went for a change whose section was shown again since (#2367 review:
|
|
136
|
+
// a failed Save reloads, so the section that staged it is a new one by the time it could be told).
|
|
137
|
+
const submittedStaged = useRef(new Map());
|
|
138
|
+
const outcomes = useRef(new Map());
|
|
139
|
+
// #2367 r3: how the last Save went for each change it carried ("applied" / "failed"), until its configuration is read
|
|
140
|
+
// back: a change staged against both ends of a Save (`afterSave: { applied, failed }`) becomes the one that came true.
|
|
141
|
+
const settledAs = useRef(new Map());
|
|
142
|
+
const resolveAfterSave = (key, change) => {
|
|
143
|
+
if (!change || !change.afterSave) return change;
|
|
144
|
+
const v = change.afterSave[settledAs.current.get(key) === "applied" ? "applied" : "failed"];
|
|
145
|
+
return v ? { ...v, stageKey: key } : null;
|
|
146
|
+
};
|
|
147
|
+
const dropDrafts = (saved = null) => {
|
|
148
|
+
drafts.current = saved ? new Map([...drafts.current].filter(([k, v]) => !saved.has(k) || !sameValue(v, saved.get(k)))) : new Map();
|
|
149
|
+
setDraftEpoch((n) => n + 1);
|
|
150
|
+
};
|
|
118
151
|
const { settingsOp: op, pendingChanges } = useStore(appStore);
|
|
119
152
|
const gen = useRef(0);
|
|
120
153
|
|
|
@@ -133,6 +166,12 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
133
166
|
setData((d) => (d && !d.error ? { ...d, ...live } : d));
|
|
134
167
|
};
|
|
135
168
|
|
|
169
|
+
// #2347: the "You" group — a preference chosen here (Preferences, Look) is shown at once and saved by the bar's
|
|
170
|
+
// Save with everything else: held in app-prefs while Settings is open, and staged here as one change.
|
|
171
|
+
const syncHeld = (n) => (n ? stage(YOU_PREFS, { label: tn("youPrefs"), impact: ownChoiceImpact(data && data.schema), count: n,
|
|
172
|
+
local: { commit: savePrefsHeld, revert: discardHeldPrefs } }, true) : unstage(YOU_PREFS));
|
|
173
|
+
useEffect(() => { const release = holdPrefs(); syncHeld(heldPrefsCount()); const off = onHeldPrefs(syncHeld); return () => { off(); release(); }; }, []);
|
|
174
|
+
|
|
136
175
|
// Mount: the data; requests still waiting for an admin from before a reload; the leave guard; the browser's prompt.
|
|
137
176
|
useEffect(() => {
|
|
138
177
|
reload();
|
|
@@ -148,6 +187,7 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
148
187
|
asking = false;
|
|
149
188
|
if (!yes || !mount.current()) return;
|
|
150
189
|
dropSecrets(stagedRef.current);
|
|
190
|
+
revertLocal(stagedRef.current);
|
|
151
191
|
stagedRef.current = new Map();
|
|
152
192
|
navigate(path, { force: true });
|
|
153
193
|
});
|
|
@@ -155,6 +195,14 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
155
195
|
return false;
|
|
156
196
|
}));
|
|
157
197
|
mount.on(window, "beforeunload", (e) => { if (stagedRef.current.size) { e.preventDefault(); e.returnValue = ""; } });
|
|
198
|
+
// #2380 review: a reload or a closed tab confirmed past that question keeps nothing unsaved — this person's and this
|
|
199
|
+
// device's own choices are put back before the page goes (a device switch is in this browser's storage at once).
|
|
200
|
+
mount.on(window, "pagehide", () => {
|
|
201
|
+
if (![...stagedRef.current.values()].some((c) => c.local)) return;
|
|
202
|
+
revertLocal(stagedRef.current);
|
|
203
|
+
stagedRef.current = new Map([...stagedRef.current].filter(([, c]) => !c.local));
|
|
204
|
+
setStaged((s) => new Map([...s].filter(([, c]) => !c.local)));
|
|
205
|
+
});
|
|
158
206
|
}, [mount]);
|
|
159
207
|
|
|
160
208
|
// Changes a failed Apply did not land come back to be staged again (secrets excepted: they are entered again).
|
|
@@ -162,20 +210,64 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
162
210
|
if (!op || !op.leftover || !op.leftover.length) return;
|
|
163
211
|
const back = takeLeftover();
|
|
164
212
|
if (!back.length) return;
|
|
165
|
-
setStaged((s) => {
|
|
213
|
+
setStaged((s) => {
|
|
214
|
+
const m = new Map(s);
|
|
215
|
+
// #2367 review: a change staged for the same thing since that Save began is the newer word (its draft and
|
|
216
|
+
// request carry everything still unsaved): it stays, and the failed one is not put back over it.
|
|
217
|
+
for (const c of back) {
|
|
218
|
+
const k = c.stageKey || newKey("left");
|
|
219
|
+
if (!m.has(k)) m.set(k, { ...(submittedStaged.current.get(k) || {}), ...c, stageKey: k });
|
|
220
|
+
}
|
|
221
|
+
return m;
|
|
222
|
+
});
|
|
166
223
|
}, [op && op.leftover]);
|
|
167
224
|
|
|
168
225
|
// An Apply that finished, or a confirmed request that applied, changed the configuration: read it again.
|
|
169
226
|
const lastPhase = useRef(op ? op.phase : null);
|
|
170
227
|
useEffect(() => {
|
|
171
228
|
const phase = op ? op.phase : null;
|
|
172
|
-
if (lastPhase.current !== phase && (phase === "done" || phase === "failed"))
|
|
229
|
+
if (lastPhase.current !== phase && (phase === "done" || phase === "failed")) {
|
|
230
|
+
settle(op);
|
|
231
|
+
settledAs.current = new Map(op.steps.filter((s) => s.stageKey).map((s) => [s.stageKey, s.status === "done" ? "applied" : "failed"]));
|
|
232
|
+
setStaged((s) => {
|
|
233
|
+
if (![...s.values()].some((c) => c.afterSave)) return s;
|
|
234
|
+
const m = new Map();
|
|
235
|
+
for (const [k, c] of s) { const r = resolveAfterSave(k, c); if (r) m.set(k, r); }
|
|
236
|
+
return m;
|
|
237
|
+
});
|
|
238
|
+
if (phase !== "done") settledAs.current = new Map();
|
|
239
|
+
// After a Save that went through, its drafts go once the configuration it wrote is read back — not before: a
|
|
240
|
+
// section shown on the old configuration would take an edit back to the old value for no edit at all.
|
|
241
|
+
const saved = phase === "done" ? submitted.current : null;
|
|
242
|
+
if (!saved) submitted.current = null;
|
|
243
|
+
reload().finally(() => { if (saved && submitted.current === saved) { dropDrafts(saved); submitted.current = null; settledAs.current = new Map(); } });
|
|
244
|
+
}
|
|
173
245
|
lastPhase.current = phase;
|
|
174
246
|
}, [op && op.phase]);
|
|
247
|
+
// A saved change that waits for a fleet admin's OK is told so while it waits (its section says it is not saved yet).
|
|
248
|
+
const waitingNow = op ? op.steps.filter((s) => s.status === "waiting" && s.stageKey).map((s) => s.stageKey).join(",") : "";
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
for (const k of waitingNow ? waitingNow.split(",") : []) {
|
|
251
|
+
const fn = results.current.get(k);
|
|
252
|
+
try { if (fn) fn({ status: "waiting", error: null }); } catch { /* a section's own handler: never the Save's */ }
|
|
253
|
+
}
|
|
254
|
+
}, [waitingNow]);
|
|
175
255
|
const applied = (pendingChanges || []).filter((p) => p.state === "applied").map((p) => p.id).join(",");
|
|
176
256
|
useEffect(() => { if (applied) reload(); }, [applied]);
|
|
177
257
|
|
|
178
258
|
const stage = (key, change, quiet = false) => {
|
|
259
|
+
// A change staged against both ends of a Save that has settled since is the end that came true (none: nothing left).
|
|
260
|
+
if (change && change.afterSave && settledAs.current.size) {
|
|
261
|
+
change = resolveAfterSave(key, change);
|
|
262
|
+
if (!change) { unstage(key); return false; }
|
|
263
|
+
}
|
|
264
|
+
// #2347: the editor's edit and a form's change to the same settings never wait together (the controls of each are
|
|
265
|
+
// off while the other is unsaved; this is the last word for a way in that is not).
|
|
266
|
+
const now = stagedRef.current;
|
|
267
|
+
if (isYamlKey(key) ? formsStaged(now) : writesModel(change) && yamlStaged(now)) {
|
|
268
|
+
toast(tn(isYamlKey(key) ? "devFormsUnsaved" : "yamlUnsaved"), false);
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
179
271
|
setStaged((s) => {
|
|
180
272
|
const m = new Map(s);
|
|
181
273
|
const prev = m.get(key);
|
|
@@ -187,12 +279,32 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
187
279
|
return m;
|
|
188
280
|
});
|
|
189
281
|
if (!quiet) toast(tn("queued", change.label));
|
|
282
|
+
// A section shown again after its Save failed (or still waits) hears how it went as it stages its change again.
|
|
283
|
+
if (typeof change.onResult === "function" && outcomes.current.has(key)) {
|
|
284
|
+
const outcome = outcomes.current.get(key);
|
|
285
|
+
outcomes.current.delete(key);
|
|
286
|
+
try { change.onResult(outcome); } catch { /* a section's own handler: never the Save's */ }
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
190
289
|
};
|
|
191
290
|
const unstage = (key) => setStaged((s) => { if (!s.has(key)) return s; const m = new Map(s); m.delete(key); return m; });
|
|
192
|
-
const discard = () => { dropSecrets(staged); setStaged(new Map()); reload(); };
|
|
291
|
+
const discard = () => { dropSecrets(staged); revertLocal(staged); setStaged(new Map()); outcomes.current = new Map(); dropDrafts(); reload(); };
|
|
292
|
+
/** Tell each saved change's section how its Save settled (#2347: the onResult in the change it staged). */
|
|
293
|
+
const settle = (o) => {
|
|
294
|
+
for (const step of (o && o.steps) || []) {
|
|
295
|
+
const fn = step.stageKey && results.current.get(step.stageKey);
|
|
296
|
+
if (!fn) continue;
|
|
297
|
+
const status = step.status === "done" ? "applied" : step.status === "waiting" ? "waiting" : "failed";
|
|
298
|
+
if (status !== "applied") outcomes.current.set(step.stageKey, { status, error: step.error || null });
|
|
299
|
+
try { fn({ status, error: step.error || null }); } catch { /* a section's own handler: never the Save's */ }
|
|
300
|
+
}
|
|
301
|
+
results.current = new Map();
|
|
302
|
+
};
|
|
303
|
+
|
|
193
304
|
const applying = useRef(false);
|
|
194
305
|
const apply = async () => {
|
|
195
306
|
if (applying.current || operationActive() || !staged.size) return;
|
|
307
|
+
if ([...stagedRef.current.values()].some((c) => c.invalid)) return; // #2347: Save waits for every field to be valid
|
|
196
308
|
// Every confirmation before the first write: a cancelled one must not leave earlier changes applied and later
|
|
197
309
|
// ones pending (a surprising partial apply).
|
|
198
310
|
applying.current = true;
|
|
@@ -207,19 +319,42 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
207
319
|
}
|
|
208
320
|
} finally { applying.current = false; }
|
|
209
321
|
if (!mount.current() || operationActive()) return;
|
|
322
|
+
// #2347: this person's and this device's own choices are saved here, first (no apply job: nothing in fleet.yaml).
|
|
323
|
+
const locals = [...stagedRef.current.values()].filter((c) => c.local);
|
|
324
|
+
if (locals.length) {
|
|
325
|
+
let failed = false;
|
|
326
|
+
for (const c of locals) { try { await c.local.commit(); } catch { failed = true; } }
|
|
327
|
+
if (!mount.current()) return;
|
|
328
|
+
setStaged((s) => { const m = new Map(s); for (const c of locals) m.delete(c.stageKey); return m; });
|
|
329
|
+
stagedRef.current = new Map([...stagedRef.current].filter(([, c]) => !c.local));
|
|
330
|
+
// #2380 review: a refusal is said whatever else this Save carries (the host's values are back on the page).
|
|
331
|
+
if (failed) toast(tn("youSaveFailed"), false);
|
|
332
|
+
if (!stagedRef.current.size) { if (!failed) toast(tn("youSaved")); setDraftEpoch((n) => n + 1); return; }
|
|
333
|
+
}
|
|
210
334
|
const list = [...stagedRef.current.values()].map(({ label, impact, request, connectionSecret, stageKey, key }) => ({ label, impact, request, connectionSecret, stageKey, key }));
|
|
211
|
-
|
|
335
|
+
const handlers = new Map([...stagedRef.current.values()].filter((c) => typeof c.onResult === "function").map((c) => [c.stageKey, c.onResult]));
|
|
336
|
+
if (startOperation(list)) {
|
|
337
|
+
results.current = handlers; submitted.current = new Map(drafts.current); submittedStaged.current = new Map(stagedRef.current);
|
|
338
|
+
outcomes.current = new Map(); setStaged(new Map());
|
|
339
|
+
}
|
|
212
340
|
};
|
|
213
341
|
|
|
214
|
-
|
|
342
|
+
// #2347: sections read `staged` (each change as staged, its extra fields kept: Guests carries its whole draft) and
|
|
343
|
+
// `drafts` to come back to their edits; `draftEpoch` moves on Discard and after a Save to start them from what is saved.
|
|
344
|
+
const ctx = data && !data.error ? makeCtx(data, setData, stage, unstage, reload, reloadLive, () => mount.current(),
|
|
345
|
+
{ drafts: drafts.current, draftEpoch, staged, savingDrafts: () => submitted.current,
|
|
346
|
+
savingStaged: () => (submitted.current || operationActive() ? submittedStaged.current : null) }) : null;
|
|
215
347
|
// #1519 P7: "Connect a chat app" on the first-run card elsewhere came here for the wizard: it opens once the data is in.
|
|
216
|
-
|
|
348
|
+
// #2347: it writes a connection and an agent at once — not while a fleet.yaml edit waits to be saved.
|
|
349
|
+
const openWizard = () => { if (yamlStaged(stagedRef.current)) toast(tn("yamlUnsaved"), false); else setDialog({ kind: "wizard", key: navKey }); };
|
|
350
|
+
useEffect(() => { if (ctx && takeWizardRequest()) openWizard(); }, [!!ctx, navKey]);
|
|
217
351
|
// #1523 N2: "Edit in Settings" on an instance's Details: that agent's dialog, once the data is in — only an agent this
|
|
218
352
|
// configuration has (own key), with Settings' own staged Apply and confirmations.
|
|
219
353
|
useEffect(() => {
|
|
220
354
|
if (!ctx) return;
|
|
221
355
|
const want = takeAgentRequest();
|
|
222
356
|
if (!want) return;
|
|
357
|
+
if (yamlStaged(stagedRef.current)) { toast(tn("yamlUnsaved"), false); return; } // #2347: the editor's edit waits
|
|
223
358
|
const own = (o, k) => !!o && Object.prototype.hasOwnProperty.call(o, k);
|
|
224
359
|
if (want.kind === "classic") {
|
|
225
360
|
// A ClassicBot room: its own dialog (ClassicDialog), found by its instance among this config's rooms.
|
|
@@ -232,37 +367,112 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
232
367
|
if (own(insts, want.name)) setDialog({ kind: "agent", name: want.name, inst: insts[want.name], key: navKey });
|
|
233
368
|
}, [!!ctx, navKey]);
|
|
234
369
|
// #1604: This device needs no configuration: shown at once, whatever the configuration read does.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
370
|
+
// #2347: drawn again from what is in force after a Discard or a Save (draftEpoch), like every section.
|
|
371
|
+
const toDialog = (d) => setDialog({ ...d, key: navKey });
|
|
372
|
+
const you = { key: `you:${draftEpoch}`, stage, unstage, staged, schema: data && data.schema };
|
|
373
|
+
const formKey = data && !data.error ? `${data.loadedAt}:${draftEpoch}` : "";
|
|
374
|
+
// #2347 regrouping: each card where the person looks for it (the IA in #2347 §3); a form split across sections (the
|
|
375
|
+
// defaults, Web) is one form with one draft, shown a part at a time.
|
|
376
|
+
const sectionBody = section === "preferences" ? html`<${DeviceSection} key=${you.key} /><${AppearanceSection} ...${you} part="device" />`
|
|
377
|
+
: section === "look" ? html`<${AppearanceSection} ...${you} part="look" />`
|
|
238
378
|
: !data ? html`<${Skeleton} lines=${6} />`
|
|
239
379
|
: data.error ? html`<${ErrorState} message=${tn("configLoadFailed")} onRetry=${reload} />`
|
|
240
|
-
: section === "agents" ? html`<${Agents} ctx=${ctx} search=${search} openDialog=${
|
|
241
|
-
: section === "
|
|
242
|
-
: section === "
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
380
|
+
: section === "agents" ? html`<${YamlLock} staged=${staged}><${Agents} ctx=${ctx} search=${search} openDialog=${toDialog} /></${YamlLock}>${ctx.providerOk ? html`<${ProviderKeys} ctx=${ctx} />` : null}`
|
|
381
|
+
: section === "agent-defaults" ? html`<${YamlLock} staged=${staged}><${General} key=${formKey} ctx=${ctx} part="fleet" /></${YamlLock}>`
|
|
382
|
+
: section === "chat" ? html`<${YamlLock} staged=${staged}><${Bots} ctx=${ctx} search=${search} openDialog=${toDialog} /></${YamlLock}>
|
|
383
|
+
<${WebSection} key=${formKey} ctx=${ctx} staged=${staged} part="chat" />
|
|
384
|
+
<div id="classicbot" class="s-anchor"><h2 class="s-ap-group">ClassicBot</h2><${Classic} ctx=${ctx} search=${search} openDialog=${toDialog} />
|
|
385
|
+
<${General} key=${formKey} ctx=${ctx} part="classic" /></div>`
|
|
386
|
+
: section === "web-dashboard" ? html`<${WebSection} key=${formKey} ctx=${ctx} staged=${staged} part="dashboard" /><${AppearanceSection} ...${you} part="dashboard" />`
|
|
387
|
+
: section === "access" ? html`<${WebSection} key=${formKey} ctx=${ctx} staged=${staged} part="access" />`
|
|
388
|
+
: html`<${Developer} key=${`dev:${draftEpoch}`} ctx=${ctx} /><${WebSection} key=${formKey} ctx=${ctx} staged=${staged} part="readonly" />`;
|
|
389
|
+
// #2347 §6: the setting a search result opened — brought into view once its section is drawn (its data may still be
|
|
390
|
+
// on its way), focused, and flashed for 2 s.
|
|
391
|
+
const wanted = useRef(null);
|
|
392
|
+
// Each navigation's own: one made since (without a setting to find) cancels it (#2383 review).
|
|
393
|
+
useEffect(() => { wanted.current = takePendingSetting(); }, [navKey]);
|
|
394
|
+
useEffect(() => {
|
|
395
|
+
if (!wanted.current) return undefined;
|
|
396
|
+
let tries = 0, timer = null, live = true;
|
|
397
|
+
const find = () => {
|
|
398
|
+
if (!live || !wanted.current) return;
|
|
399
|
+
const el = document.querySelector(wanted.current);
|
|
400
|
+
if (!el) {
|
|
401
|
+
// A section that draws from the configuration waits for it however long its read takes (#2383 review: a slow
|
|
402
|
+
// link); once it is drawn, the setting gets ~2 s to appear (a card loading its own data), then is given up.
|
|
403
|
+
if (section !== "preferences" && section !== "look" && !data) return; // run again when the data is in
|
|
404
|
+
if (++tries < 40) timer = setTimeout(find, 50); else wanted.current = null;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
wanted.current = null;
|
|
408
|
+
// #2347 §10: a setting in a closed drawer (Advanced) is shown — every drawer around it opens first.
|
|
409
|
+
for (let d = el.closest && el.closest("details"); d; d = d.parentElement && d.parentElement.closest("details")) d.open = true;
|
|
410
|
+
if (typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center", inline: "nearest" });
|
|
411
|
+
const field = el.matches && el.matches("input, select, textarea, button") ? el : el.querySelector && el.querySelector("input, select, textarea, button");
|
|
412
|
+
if (field && typeof field.focus === "function") field.focus({ preventScroll: true });
|
|
413
|
+
const row = (el.closest && el.closest(".field, label.check, .card")) || el;
|
|
414
|
+
row.classList.add("s-flash");
|
|
415
|
+
setTimeout(() => row.classList.remove("s-flash"), 2000);
|
|
416
|
+
};
|
|
417
|
+
find();
|
|
418
|
+
return () => { live = false; clearTimeout(timer); };
|
|
419
|
+
}, [section, navKey, !!data, draftEpoch]);
|
|
420
|
+
useEffect(() => {
|
|
421
|
+
const onKey = (e) => {
|
|
422
|
+
if (e.key !== "/" || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return;
|
|
423
|
+
const tgt = e.target;
|
|
424
|
+
if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return;
|
|
425
|
+
const box = document.getElementById("settingsSearch");
|
|
426
|
+
if (box) { e.preventDefault(); box.focus(); }
|
|
427
|
+
};
|
|
428
|
+
document.addEventListener("keydown", onKey);
|
|
429
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
430
|
+
}, []);
|
|
431
|
+
// #2347: on a phone the sections are one row that scrolls sideways: the one shown is brought into it.
|
|
432
|
+
useEffect(() => {
|
|
433
|
+
const item = typeof document !== "undefined" ? document.querySelector(".s-nav .seg-item.active") : null;
|
|
434
|
+
const row = item && item.closest ? item.closest(".s-nav-groups") : null;
|
|
435
|
+
// That row only (scrollIntoView would also scroll the page sideways, and the nav column on a wide screen).
|
|
436
|
+
if (row && row.scrollWidth > row.clientWidth) {
|
|
437
|
+
const ir = item.getBoundingClientRect(), rr = row.getBoundingClientRect();
|
|
438
|
+
if (ir.left < rr.left || ir.right > rr.right) row.scrollLeft += ir.left - rr.left - (rr.width - ir.width) / 2;
|
|
439
|
+
}
|
|
440
|
+
}, [section]);
|
|
441
|
+
// #2347: a place in a section (`/settings/chat#classicbot`, an old /settings/classic link): there, once it is drawn.
|
|
442
|
+
useEffect(() => {
|
|
443
|
+
const id = (typeof location !== "undefined" && location.hash ? location.hash.slice(1) : "");
|
|
444
|
+
if (!id) return;
|
|
445
|
+
let el = null;
|
|
446
|
+
try { el = document.getElementById(decodeURIComponent(id)); } catch { el = null; }
|
|
447
|
+
if (el && typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "start" });
|
|
448
|
+
}, [section, navKey, !!data]);
|
|
246
449
|
// A dialog belongs to the navigation that opened it (its draft names one object, never another).
|
|
247
450
|
const open = dialog && dialog.key === navKey ? dialog : null;
|
|
248
451
|
const close = () => setDialog(null);
|
|
249
|
-
const searchable = section === "agents" || section === "
|
|
250
|
-
|
|
452
|
+
const searchable = section === "agents" || section === "chat";
|
|
453
|
+
// #2347 §7: on a phone, Settings itself (/settings) is the list of sections with search on top; a section opens on its
|
|
454
|
+
// own with "‹ Settings" back to it. A wider screen always shows the list beside the section (the CSS decides).
|
|
455
|
+
// Which one is shown is the root's data-view: its class stays exactly the effects producer's ("panel p-settings", #1629).
|
|
456
|
+
const atList = typeof location !== "undefined" && location.pathname === "/settings";
|
|
457
|
+
return html`<div data-effect-panel="settings" class="panel p-settings" data-view=${atList ? "index" : "section"}>
|
|
251
458
|
<${PanelHeader} title=${tn("title")} sub=${data && data.version ? html`<a class="pill ver" href=${data.version.includes("-") ? `${RELEASES}/latest` : `${RELEASES}/tag/v${data.version}`} target="_blank" rel="noopener">v${data.version}</a>` : null}>
|
|
252
|
-
<button type="button" class="btn btn-ghost btn-sm" disabled=${!ctx}
|
|
459
|
+
<button type="button" class="btn btn-ghost btn-sm" disabled=${!ctx || yamlStaged(staged)} title=${ctx && yamlStaged(staged) ? tn("yamlUnsaved") : undefined} onClick=${openWizard}><${Icon} name="wand" size=${16} /><span class="hide-narrow">${tn("wizardButton")}</span></button>
|
|
253
460
|
<button type="button" class="icon-btn" aria-label=${tn("helpButton")} title=${tn("helpButton")} onClick=${() => setDialog({ kind: "help", key: navKey })}><${Icon} name="info" /></button>
|
|
254
461
|
</${PanelHeader}>
|
|
255
|
-
<nav data-trust="panel-navigation" class="seg" aria-label=${tn("title")}>${
|
|
256
|
-
|
|
462
|
+
<nav data-trust="panel-navigation" class="seg s-nav" aria-label=${tn("title")}><${SettingsSearch} /><div class="s-nav-groups">${SETTINGS_GROUPS.map((g) => html`<div key=${g.id} class="s-nav-group" role="group" aria-labelledby=${`s-grp-${g.id}`}>
|
|
463
|
+
<span class="s-nav-head" id=${`s-grp-${g.id}`}>${tn(`grp_${g.id}`)}</span>
|
|
464
|
+
${g.sections.map((k) => html`<a key=${k} href=${sectionPath(k)} class=${`seg-item${k === section ? " active" : ""}`}
|
|
465
|
+
aria-current=${k === section ? "page" : undefined}><${Icon} name=${ICONS[k]} size=${16} /><span>${tn(`sec_${k}`)}</span></a>`)}</div>`)}</div></nav>
|
|
257
466
|
<div data-trust="panel-content" class="panel-body"><div class="col">
|
|
467
|
+
<a class="s-back" href=${settingsPath()}><${Icon} name="back" size=${16} /><span>${tn("title")}</span><span class="s-back-here">${tn(`sec_${section}`)}</span></a>
|
|
258
468
|
${searchable ? html`<div class="s-toolbar"><label class="s-search"><${Icon} name="search" size=${14} /><span class="sr-only">${tn("searchAll")}</span>
|
|
259
469
|
<input type="search" value=${search} placeholder=${tn("searchAll")} onInput=${(e) => setSearch(e.target.value)} /></label></div>` : null}
|
|
260
470
|
${op ? html`<${OperationCard} op=${op} schema=${data && data.schema} />` : null}
|
|
261
471
|
<${PendingChanges} inline=${true} />
|
|
262
|
-
${ctx && (section === "agents" || section === "
|
|
472
|
+
${ctx && (section === "agents" || section === "chat") ? html`<${FirstRunCard} onConnect=${openWizard} />` : null}
|
|
263
473
|
${sectionBody}
|
|
264
474
|
</div></div>
|
|
265
|
-
${staged.size ? html`<${PendingBar} staged=${staged} busy=${opBusy(op)} onApply=${apply} onDiscard=${discard} />` : null}
|
|
475
|
+
${staged.size ? html`<${PendingBar} staged=${staged} busy=${opBusy(op)} onApply=${apply} onDiscard=${discard} section=${section} />` : null}
|
|
266
476
|
${open && ctx ? renderDialog(open, ctx, close) : null}
|
|
267
477
|
${open && open.kind === "help" ? html`<${HelpDialog} onClose=${close} />` : null}
|
|
268
478
|
</div>`;
|
|
@@ -274,6 +484,87 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
274
484
|
* read-only: a change there stays in that browser, and it says so. This device only: the chat tour (replayed on the
|
|
275
485
|
* chat it points at), and the layout choices named there (their controls are where they always were).
|
|
276
486
|
*/
|
|
487
|
+
/** The words of `text` that `query` names, marked (for a result's name). */
|
|
488
|
+
function marked(text, query) {
|
|
489
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
490
|
+
if (!terms.length) return text;
|
|
491
|
+
const lower = text.toLowerCase(), out = [];
|
|
492
|
+
let i = 0;
|
|
493
|
+
while (i < text.length) {
|
|
494
|
+
let best = -1, len = 0;
|
|
495
|
+
for (const term of terms) { const at = lower.indexOf(term, i); if (at !== -1 && (best === -1 || at < best)) { best = at; len = term.length; } }
|
|
496
|
+
if (best === -1) { out.push(text.slice(i)); break; }
|
|
497
|
+
if (best > i) out.push(text.slice(i, best));
|
|
498
|
+
out.push(html`<mark>${text.slice(best, best + len)}</mark>`);
|
|
499
|
+
i = best + len;
|
|
500
|
+
}
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* #2347 §6: search every setting — by its name, its config key, or the words people use (both languages: "token",
|
|
506
|
+
* "語言", "port") — results grouped by section with where they are; Enter or a click goes there. "/" focuses it.
|
|
507
|
+
*/
|
|
508
|
+
function SettingsSearch() {
|
|
509
|
+
const [q, setQ] = useState("");
|
|
510
|
+
const [active, setActive] = useState(0);
|
|
511
|
+
const hits = useMemo(() => searchSettings(q).slice(0, 40), [q]);
|
|
512
|
+
// Grouped by section, the group of the best match first (its order is the ranking's): Enter takes the best.
|
|
513
|
+
const groups = useMemo(() => [...new Set(hits.map((e) => e.section))].map((s) => ({ section: s, items: hits.filter((e) => e.section === s) })), [hits]);
|
|
514
|
+
const flat = groups.flatMap((g) => g.items);
|
|
515
|
+
const groupOf = (s) => SETTINGS_GROUPS.find((g) => g.sections.includes(s));
|
|
516
|
+
const go = (entry) => { if (!entry) return; setQ(""); setActive(0); goToSetting(entry); };
|
|
517
|
+
const onKey = (e) => {
|
|
518
|
+
if (e.isComposing) return;
|
|
519
|
+
if (e.key === "ArrowDown") { e.preventDefault(); setActive((i) => (flat.length ? (i + 1) % flat.length : 0)); }
|
|
520
|
+
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => (flat.length ? (i - 1 + flat.length) % flat.length : 0)); }
|
|
521
|
+
else if (e.key === "Enter") { e.preventDefault(); go(flat[Math.min(active, flat.length - 1)]); }
|
|
522
|
+
else if (e.key === "Escape") { if (q) { e.preventDefault(); e.stopPropagation(); setQ(""); } else e.target.blur(); }
|
|
523
|
+
};
|
|
524
|
+
const open = q.trim() !== "";
|
|
525
|
+
const at = Math.min(active, Math.max(flat.length - 1, 0));
|
|
526
|
+
let n = -1;
|
|
527
|
+
return html`<div class="s-find" role="search">
|
|
528
|
+
<label class="s-search"><${Icon} name="search" size=${14} /><span class="sr-only">${tn("searchSettings")}</span>
|
|
529
|
+
<input id="settingsSearch" type="search" value=${q} placeholder=${tn("searchSettings")} autocomplete="off" spellcheck=${false}
|
|
530
|
+
role="combobox" aria-expanded=${open ? "true" : "false"} aria-controls="s-find-results" aria-autocomplete="list"
|
|
531
|
+
aria-activedescendant=${open && flat.length ? `s-find-${at}` : undefined}
|
|
532
|
+
onInput=${(e) => { setQ(e.target.value); setActive(0); }} onKeyDown=${onKey} /></label>
|
|
533
|
+
${open ? html`<div id="s-find-results" class="s-find-results" role="listbox" aria-label=${tn("searchSettings")}>
|
|
534
|
+
${flat.length ? groups.map((g) => html`<div key=${g.section} role="group" class="s-find-group" aria-label=${tn(`sec_${g.section}`)}>
|
|
535
|
+
<div class="s-find-head" aria-hidden="true">${tn(`grp_${groupOf(g.section).id}`)} › ${tn(`sec_${g.section}`)}</div>
|
|
536
|
+
${g.items.map((entry) => { n++; const i = n; return html`<div key=${entry.id} id=${`s-find-${i}`} role="option" aria-selected=${i === at ? "true" : "false"}
|
|
537
|
+
class=${`s-find-item${i === at ? " on" : ""}`} onMouseDown=${(e) => e.preventDefault()} onMouseMove=${i === at ? undefined : () => setActive(i)} onClick=${() => go(entry)}>
|
|
538
|
+
<span class="s-find-label">${marked(settingLabel(entry), q)}</span>${entry.key ? html`<code class="s-find-key">${entry.key}</code>` : null}</div>`; })}
|
|
539
|
+
</div>`) : html`<p class="s-find-none" role="status">${tn("searchNone")}</p>`}
|
|
540
|
+
<p class="s-find-foot" aria-hidden="true">${tn("searchFoot")}</p>
|
|
541
|
+
</div>` : null}
|
|
542
|
+
</div>`;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** #2347: the staged change that stands for every held "You" preference (app-prefs.js holdPrefs). */
|
|
546
|
+
const YOU_PREFS = "you:prefs";
|
|
547
|
+
/** #2347: the fleet.yaml editor's staged writes (Advanced → Developer), one per write in order: `yaml:0`, `yaml:1`, … */
|
|
548
|
+
const YAML_KEY = "yaml:";
|
|
549
|
+
const isYamlKey = (k) => typeof k === "string" && k.startsWith(YAML_KEY);
|
|
550
|
+
// What the editor writes (fleet.yaml's defaults, channels and agents), and the setup wizard's commit of both.
|
|
551
|
+
const MODEL_WRITE = /^\/api\/settings\/(fleet\/(defaults|channels|instances)(\/|$)|quickstart\/)/;
|
|
552
|
+
const writesModel = (c) => !!(c && c.request && MODEL_WRITE.test(String(c.request.url || "")));
|
|
553
|
+
/** #2347: the editor and the forms that write the same settings refuse to mix: one waits for the other's Save or Discard. */
|
|
554
|
+
const yamlStaged = (staged) => [...staged.keys()].some(isYamlKey);
|
|
555
|
+
const formsStaged = (staged) => [...staged].some(([k, c]) => !isYamlKey(k) && writesModel(c));
|
|
556
|
+
/** A form that writes what a staged fleet.yaml edit writes: shown, not edited, until that edit is saved or discarded. */
|
|
557
|
+
function YamlLock({ staged, children }) {
|
|
558
|
+
const locked = yamlStaged(staged);
|
|
559
|
+
return html`<fieldset class="s-yaml-lock" disabled=${locked}>
|
|
560
|
+
${locked ? html`<p class="note s-yaml-lock-note" role="status">${tn("yamlUnsaved")} <a href=${settingsPath("advanced")}>${tn("yamlOpen")}</a></p>` : null}
|
|
561
|
+
${children}</fieldset>`;
|
|
562
|
+
}
|
|
563
|
+
/** Put back this person's and this device's own choices among `changes` (a Discard, or leaving Settings with them). */
|
|
564
|
+
function revertLocal(changes) {
|
|
565
|
+
for (const c of [...changes.values()].reverse()) if (c.local) { try { c.local.revert(); } catch { /* the rest still go back */ } }
|
|
566
|
+
}
|
|
567
|
+
|
|
277
568
|
export function DeviceSection() {
|
|
278
569
|
const P = () => globalThis.AgendPreview;
|
|
279
570
|
const [on, setOn] = useState(P() ? P().optedIn() : false);
|
|
@@ -312,7 +603,7 @@ export function DeviceSection() {
|
|
|
312
603
|
<section class="card s-device s-synced"><h3>${tn("syncedTitle")}</h3>
|
|
313
604
|
<p class="sub">${publicLink ? tn("syncedPublicLink") : tn("syncedLead")}</p>
|
|
314
605
|
<${DevicePrefs} labels=${true} />
|
|
315
|
-
${P() ? html`<label class="check"><input type="checkbox" checked=${on} onChange=${(e) => { const want = e.target.checked; setPreviewOptIn(want).then(setOn); }} />
|
|
606
|
+
${P() ? html`<label class="check"><input id="pref-previews" type="checkbox" checked=${on} onChange=${(e) => { const want = e.target.checked; setPreviewOptIn(want).then(setOn); }} />
|
|
316
607
|
<span>${t("chat.previews")}</span></label>` : null}
|
|
317
608
|
</section>
|
|
318
609
|
<section class="card s-device s-local"><h3>${tn("deviceTitle")}</h3>
|
|
@@ -382,12 +673,12 @@ function renderDialog(d, ctx, close) {
|
|
|
382
673
|
}
|
|
383
674
|
|
|
384
675
|
/** What the sections and dialogs work with: the data, and the ways to change it (staging, or a direct write). */
|
|
385
|
-
function makeCtx(data, setData, stage, unstage, reload, reloadLive, alive) {
|
|
676
|
+
function makeCtx(data, setData, stage, unstage, reload, reloadLive, alive, extra = {}) {
|
|
386
677
|
const { fleet, classic, schema } = data;
|
|
387
678
|
const chs = channelsOf(fleet);
|
|
388
679
|
const edit = (fn) => setData((d) => { const next = structuredClone(d); fn(next); return next; });
|
|
389
680
|
return {
|
|
390
|
-
...data, reload, reloadLive, stage, unstage, shortName,
|
|
681
|
+
...data, ...extra, reload, reloadLive, stage, unstage, shortName,
|
|
391
682
|
channelType: (id) => { const ch = chs.find((c, i) => channelId(c, i) === id); return ch ? ch.type : undefined; },
|
|
392
683
|
classicRuntime: (instanceName) => {
|
|
393
684
|
const live = data.classicLive[instanceName] || {};
|
|
@@ -475,7 +766,7 @@ function Agents({ ctx, search, openDialog }) {
|
|
|
475
766
|
try { await work(); } finally { setBusy((b) => { const n = new Map(b); n.delete(name); return n; }); }
|
|
476
767
|
};
|
|
477
768
|
const head = html`<div class="list-head"><span>${Object.keys(insts).length === 1 ? tn("countAgent") : tn("countAgents", Object.keys(insts).length)}</span>
|
|
478
|
-
<button type="button" class="btn btn-primary" onClick=${() => openDialog({ kind: "create" })}><${Icon} name="plus" size=${16} />${tn("newAgent")}</button></div>`;
|
|
769
|
+
<button type="button" id="newAgentBtn" class="btn btn-primary" onClick=${() => openDialog({ kind: "create" })}><${Icon} name="plus" size=${16} />${tn("newAgent")}</button></div>`;
|
|
479
770
|
// #2233: after the agents, each login on this machine with Sign in (backend-signin.js).
|
|
480
771
|
const signIns = html`<${SignInList} />`;
|
|
481
772
|
if (!Object.keys(insts).length) return html`${head}<${Empty} icon="bot" title=${tn("noAgents")} />${signIns}`;
|
|
@@ -542,7 +833,7 @@ function Bots({ ctx, search, openDialog }) {
|
|
|
542
833
|
const needle = search.trim().toLowerCase();
|
|
543
834
|
const rows = all.map((ch, i) => ({ ch, i })).filter(({ ch }) => !needle || [ch.id, ch.type, ch.group_id, ch.bot_token_env].some((v) => String(v ?? "").toLowerCase().includes(needle)));
|
|
544
835
|
const head = html`<div class="list-head"><span>${all.length === 1 ? tn("countBot") : tn("countBots", all.length)}</span>
|
|
545
|
-
<button type="button" class="btn btn-primary" onClick=${() => openDialog({ kind: "newBot" })}><${Icon} name="plus" size=${16} />${tn("newBot")}</button></div>`;
|
|
836
|
+
<button type="button" id="newBotBtn" class="btn btn-primary" onClick=${() => openDialog({ kind: "newBot" })}><${Icon} name="plus" size=${16} />${tn("newBot")}</button></div>`;
|
|
546
837
|
return html`${head}
|
|
547
838
|
${!rows.length ? html`<${Empty} icon="plug" title=${needle ? tn("noAgentMatch", search.trim()) : tn("noBots")} />` : html`<div class="s-list">${rows.map(({ ch, i }) => {
|
|
548
839
|
const users = (ch.access && ch.access.allowed_users) || [];
|
|
@@ -569,8 +860,7 @@ function Bots({ ctx, search, openDialog }) {
|
|
|
569
860
|
${!overPublicLink && ctx.fleetUp && meta.status !== "connected" ? html`<${ConnectionRetry} id=${channelId(ch, i)} ctx=${ctx} />` : null}
|
|
570
861
|
<button type="button" class="btn btn-sm" onClick=${() => openDialog({ kind: "bot", id: channelId(ch, i) })}>${tn("settingsButton")}</button></span>
|
|
571
862
|
</div>`;
|
|
572
|
-
})}</div>`}
|
|
573
|
-
${ctx.providerOk ? html`<${ProviderKeys} ctx=${ctx} />` : null}`;
|
|
863
|
+
})}</div>`}`;
|
|
574
864
|
}
|
|
575
865
|
|
|
576
866
|
/**
|
|
@@ -620,7 +910,7 @@ function ProviderKeys({ ctx }) {
|
|
|
620
910
|
} catch { say(spec.id, { error: tn("failed") }); }
|
|
621
911
|
finally { inFlight.current.delete(spec.id); if (lease.current()) setBusy(new Set(inFlight.current)); }
|
|
622
912
|
};
|
|
623
|
-
return html`<section class="card"><h3>${tn("providerKeys")}</h3>
|
|
913
|
+
return html`<section class="card s-provider-keys"><h3>${tn("providerKeys")}</h3>
|
|
624
914
|
${!ctx.providerSecrets.length ? html`<p class="note">${tn("noProviderVerifiers")}</p>` : ctx.providerSecrets.map((spec) => html`<div key=${spec.id} class="s-key">
|
|
625
915
|
<div class="s-key-row"><strong>${spec.display_name}</strong><span class="tag">${spec.token_present ? tn("keyConfigured") : tn("keyNotConfigured")}</span>
|
|
626
916
|
${spec.verifier === "available" ? html`<input type="password" autocomplete="new-password" aria-label=${tn("providerKeyInput", spec.display_name)}
|
|
@@ -663,141 +953,200 @@ function Classic({ ctx, search, openDialog }) {
|
|
|
663
953
|
})}</div>`;
|
|
664
954
|
}
|
|
665
955
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
956
|
+
// Sign-in notices and daily tips post in the topic or not at all: the DM choice was withdrawn (hmsg-d1809f2a,
|
|
957
|
+
// hmsg-3b8ac2af), and an old "dm" reads as on.
|
|
958
|
+
const noticeMode = (v) => v !== "false" && v !== false;
|
|
959
|
+
const noticeChoices = () => [{ value: "true", label: tn("noticeTopic") }, { value: "false", label: tn("noticeOff") }];
|
|
960
|
+
const noticeLabel = (v) => tn(v ? "noticeTopic" : "noticeOff");
|
|
961
|
+
|
|
962
|
+
/** General's form, from the saved defaults (`d`) and ClassicBot's (`c`). */
|
|
963
|
+
function generalForm(d, c, fleet = {}, schema = {}) {
|
|
674
964
|
const hang = d.hang_detector || {}, startup = d.startup || {};
|
|
675
|
-
|
|
676
|
-
const original = useMemo(() => ({ c: structuredClone(c) }), []);
|
|
677
|
-
const [f, setF] = useState(() => ({
|
|
965
|
+
return {
|
|
678
966
|
backend: d.backend || "claude-code", model: d.model || "", locale: d.locale || "", auto: String(d.auto_pause_after ?? 0),
|
|
679
967
|
agent_mode: d.agent_mode || "mcp", tool_set: TOOL_SETS.includes(d.tool_set) ? d.tool_set : "", tool_progress: d.tool_progress ?? "off",
|
|
680
968
|
visibility: visibilityDefault(d), reply: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
|
|
681
|
-
|
|
969
|
+
// #2495 review: one field per dashboard feature, so a draft keeps only the features edited (a whole-object field
|
|
970
|
+
// would bring back a feature another admin changed meanwhile).
|
|
971
|
+
...Object.fromEntries(DASHBOARD_FEATURES.map((k) => [`dash_${k}`, dashboardDefault(d, k)])),
|
|
682
972
|
hangOn: hang.enabled !== false, hang: String(hang.timeout_minutes ?? HANG_TIMEOUT_DEFAULT_MINUTES),
|
|
683
|
-
|
|
973
|
+
// #2347 §10 review: unset startup concurrency is automatic (deriveSpawnConcurrency: 2–10 by free memory and cores),
|
|
974
|
+
// not 10 — an empty field, and a save that leaves it unset (the defaults merge replaces `startup` whole).
|
|
975
|
+
concurrency: startup.concurrency == null ? "" : String(startup.concurrency), stagger: String(startup.stagger_delay_ms ?? 500),
|
|
976
|
+
tips: String(noticeMode(d.tips)),
|
|
977
|
+
replyTriggers: Boolean(d.reply_triggers ?? schema.reply_triggers_default),
|
|
978
|
+
classicReplyTriggers: Boolean(c.reply_triggers ?? d.reply_triggers ?? schema.reply_triggers_default), classicReplyTriggersInherit: !hasOwn(c, "reply_triggers"),
|
|
684
979
|
classicBackend: c.backend || d.backend || "claude-code",
|
|
685
980
|
classicToolProgress: c.tool_progress ?? d.tool_progress ?? "off", classicToolProgressInherit: !hasOwn(c, "tool_progress"),
|
|
686
981
|
classicReply: c.reply_completion_guard ?? d.reply_completion_guard ?? true, classicReplyInherit: !hasOwn(c, "reply_completion_guard"),
|
|
687
982
|
admins: [...(c.admin_users || [])], guilds: [...(c.allowed_guilds || [])],
|
|
688
|
-
|
|
689
|
-
|
|
983
|
+
roots: [...(fleet.project_roots || [])],
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* General: the fleet's defaults and ClassicBot's. #2347: an edit is unsaved at once — staged (up to two changes:
|
|
989
|
+
* defaults, ClassicBot defaults), each only with what differs from what is saved, so changing one hot default never also
|
|
990
|
+
* stages untouched settings as a fleet restart; the sticky bar saves them. A changed field carries the straight bar.
|
|
991
|
+
*/
|
|
992
|
+
function General({ ctx, part = "all" }) {
|
|
993
|
+
const { schema } = ctx;
|
|
994
|
+
const d = ctx.fleet.defaults || {}, c = (ctx.classic && ctx.classic.defaults) || {};
|
|
995
|
+
const hang = d.hang_detector || {}, startup = d.startup || {};
|
|
996
|
+
const primary = channelsOf(ctx.fleet)[0];
|
|
997
|
+
const original = useMemo(() => ({ c: structuredClone(c) }), []);
|
|
998
|
+
const base = useMemo(() => generalForm(d, c, ctx.fleet, schema), []);
|
|
999
|
+
const [f, setF] = useDraft(ctx, "general", () => base);
|
|
690
1000
|
const set = (k) => (v) => setF((x) => ({ ...x, [k]: v }));
|
|
1001
|
+
/** The class of a field's row: `field`, and `s-dirty` while one of its keys differs from what is saved. */
|
|
1002
|
+
const fc = (cls, ...keys) => `${cls}${keys.some((k) => !sameValue(f[k], base[k])) ? " s-dirty" : ""}`;
|
|
691
1003
|
const num = (v) => Number(v);
|
|
692
1004
|
const errors = {
|
|
693
1005
|
auto: nonNegative(f.auto), hang: positive(f.hang),
|
|
694
|
-
concurrency: !Number.isInteger(num(f.concurrency)) || num(f.concurrency) < 1 || num(f.concurrency) > 20 ? "concurrencyRange" : "",
|
|
1006
|
+
concurrency: f.concurrency.trim() !== "" && (!Number.isInteger(num(f.concurrency)) || num(f.concurrency) < 1 || num(f.concurrency) > 20) ? "concurrencyRange" : "",
|
|
695
1007
|
stagger: !Number.isFinite(num(f.stagger)) || f.stagger.trim() === "" || num(f.stagger) < 0 || num(f.stagger) > 30000 ? "staggerRange" : "",
|
|
696
1008
|
};
|
|
697
|
-
const warnings = { concurrency: num(f.concurrency) > 4 ? "concurrencyWarning" : "", stagger: num(f.stagger) < 100 ? "staggerWarning" : "" };
|
|
1009
|
+
const warnings = { concurrency: f.concurrency.trim() !== "" && num(f.concurrency) > 4 ? "concurrencyWarning" : "", stagger: num(f.stagger) < 100 ? "staggerWarning" : "" };
|
|
698
1010
|
const ready = !Object.values(errors).some(Boolean);
|
|
699
|
-
const
|
|
700
|
-
|
|
1011
|
+
const firstError = Object.values(errors).find(Boolean);
|
|
1012
|
+
// #2347: every edit, staged as it is made (the first render included: a draft kept from before is staged again).
|
|
1013
|
+
useEffect(() => {
|
|
1014
|
+
if (!ready) {
|
|
1015
|
+
// Where it is (#2382 review): the bar in another section names it and goes there.
|
|
1016
|
+
const at = { auto: "#g-auto", hang: "#g-hang", concurrency: "#g-conc", stagger: "#g-stag" }[Object.keys(errors).find((k) => errors[k])];
|
|
1017
|
+
syncStage(ctx, "defaults:invalid", { label: tn("updateDefaults"), invalid: tn(firstError), section: "agent-defaults", target: at });
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
ctx.unstage("defaults:invalid");
|
|
701
1021
|
const defaultsPatch = changedFields({
|
|
702
1022
|
backend: f.backend, model: f.model || undefined, locale: f.locale || null, auto_pause_after: num(f.auto),
|
|
703
1023
|
agent_mode: f.agent_mode, tool_set: f.tool_set || null, tool_progress: f.tool_progress, cross_instance_visibility: f.visibility,
|
|
704
|
-
reply_completion_guard: f.reply, log_level: f.log_level,
|
|
705
|
-
// The dashboard features every agent may use unless its own setting says otherwise
|
|
706
|
-
dashboard:
|
|
1024
|
+
tips: noticeMode(f.tips), reply_triggers: f.replyTriggers, reply_completion_guard: f.reply, log_level: f.log_level,
|
|
1025
|
+
// The dashboard features every agent may use unless its own setting says otherwise. All of them go, because the write
|
|
1026
|
+
// to the fleet's defaults merges top-level keys only (`dashboard` is replaced whole): the untouched ones as saved.
|
|
1027
|
+
dashboard: Object.fromEntries(DASHBOARD_FEATURES.filter((k) => k !== "office").map((k) => [k, f[`dash_${k}`]])),
|
|
1028
|
+
office_access: f.dash_office,
|
|
707
1029
|
hang_detector: { enabled: f.hangOn, timeout_minutes: num(f.hang) },
|
|
708
|
-
startup: { concurrency: num(f.concurrency), stagger_delay_ms: num(f.stagger) },
|
|
1030
|
+
startup: { ...(f.concurrency.trim() === "" ? {} : { concurrency: num(f.concurrency) }), stagger_delay_ms: num(f.stagger) },
|
|
709
1031
|
}, {
|
|
710
1032
|
backend: d.backend || "claude-code", model: d.model || undefined, locale: d.locale || null, auto_pause_after: d.auto_pause_after ?? 0,
|
|
711
1033
|
agent_mode: d.agent_mode || "mcp", tool_set: TOOL_SETS.includes(d.tool_set) ? d.tool_set : null, tool_progress: d.tool_progress ?? "off",
|
|
712
|
-
cross_instance_visibility: visibilityDefault(d), reply_completion_guard: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
|
|
713
|
-
dashboard: Object.fromEntries(DASHBOARD_FEATURES.map((k) => [k, dashboardDefault(d, k)])),
|
|
1034
|
+
tips: d.tips ?? true, cross_instance_visibility: visibilityDefault(d), reply_triggers: Boolean(d.reply_triggers ?? schema.reply_triggers_default), reply_completion_guard: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
|
|
1035
|
+
dashboard: Object.fromEntries(DASHBOARD_FEATURES.filter((k) => k !== "office").map((k) => [k, dashboardDefault(d, k)])),
|
|
1036
|
+
office_access: dashboardDefault(d, "office"),
|
|
714
1037
|
hang_detector: { enabled: hang.enabled !== false, timeout_minutes: hang.timeout_minutes ?? HANG_TIMEOUT_DEFAULT_MINUTES },
|
|
715
|
-
startup: { concurrency: startup.concurrency
|
|
1038
|
+
startup: { ...(startup.concurrency == null ? {} : { concurrency: startup.concurrency }), stagger_delay_ms: startup.stagger_delay_ms ?? 500 },
|
|
716
1039
|
});
|
|
717
1040
|
const oc = original.c;
|
|
718
1041
|
const classicPatch = changedFields({
|
|
1042
|
+
reply_triggers: f.classicReplyTriggersInherit ? null : f.classicReplyTriggers,
|
|
719
1043
|
backend: f.classicBackend,
|
|
720
1044
|
tool_progress: f.classicToolProgressInherit ? null : f.classicToolProgress,
|
|
721
1045
|
reply_completion_guard: f.classicReplyInherit ? null : f.classicReply,
|
|
722
1046
|
admin_users: f.admins, allowed_guilds: f.guilds,
|
|
723
1047
|
}, {
|
|
1048
|
+
reply_triggers: hasOwn(oc, "reply_triggers") ? oc.reply_triggers : null,
|
|
724
1049
|
backend: oc.backend || d.backend || "claude-code",
|
|
725
1050
|
tool_progress: hasOwn(oc, "tool_progress") ? oc.tool_progress : null,
|
|
726
1051
|
reply_completion_guard: hasOwn(oc, "reply_completion_guard") ? oc.reply_completion_guard : null,
|
|
727
1052
|
admin_users: oc.admin_users || [], allowed_guilds: oc.allowed_guilds || [],
|
|
728
1053
|
});
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
1054
|
+
// #2347 §10: where new agents may work (fleet.project_roots, from Fleet → Config): its own write, the one route
|
|
1055
|
+
// that sets it (POST /ui/config; a change to it waits for a fleet admin's OK, as it did there).
|
|
1056
|
+
const roots = f.roots.map((r) => String(r).trim()).filter(Boolean); // ChipList keeps "2024" as a number; paths are text
|
|
1057
|
+
syncStage(ctx, "fleet:project_roots", sameValue(roots, base.roots) ? null : { label: tn("projectRootsTitle"), count: 1,
|
|
1058
|
+
impact: impactOf(schema, "fleet.project_roots"), request: { method: "POST", url: "/ui/config", body: { project_roots: roots } } });
|
|
1059
|
+
const dKeys = Object.keys(defaultsPatch), cKeys = Object.keys(classicPatch);
|
|
1060
|
+
syncStage(ctx, "defaults:fleet", dKeys.length ? { label: tn("updateDefaults"), count: dKeys.length,
|
|
1061
|
+
impact: batchImpact(schema, dKeys, (k) => defaultsImpact(schema, k)),
|
|
1062
|
+
request: { method: "PUT", url: "/api/settings/fleet/defaults", body: defaultsPatch } } : null);
|
|
1063
|
+
syncStage(ctx, "defaults:classic", cKeys.length ? { label: tn("updateClassicDefaults"), count: cKeys.length,
|
|
1064
|
+
impact: cKeys.every((k) => impactOf(schema, `classic_defaults.${k}`) === "now") ? "now" : "fleet",
|
|
1065
|
+
request: { method: "PUT", url: "/api/settings/classic/defaults", body: classicPatch } } : null);
|
|
1066
|
+
}, [f, ready]);
|
|
1067
|
+
// #2347 §10: a default nobody set in fleet.yaml shows the built-in value it holds, marked "default" until it is edited;
|
|
1068
|
+
// ClassicBot's inherited ones say what they inherit from Agent defaults.
|
|
1069
|
+
const unset = {
|
|
1070
|
+
locale: !d.locale, backend: !d.backend, model: !d.model, auto: !hasOwn(d, "auto_pause_after"), tool_progress: !hasOwn(d, "tool_progress"),
|
|
1071
|
+
tips: !hasOwn(d, "tips"), replyTriggers: !hasOwn(d, "reply_triggers"), visibility: !VISIBILITY_MODES.includes(d.cross_instance_visibility), reply: !hasOwn(d, "reply_completion_guard"), tool_set: !d.tool_set,
|
|
1072
|
+
log_level: !d.log_level, agent_mode: !d.agent_mode, hangOn: !hasOwn(hang, "enabled"), hang: !hasOwn(hang, "timeout_minutes"),
|
|
1073
|
+
concurrency: !hasOwn(startup, "concurrency"), stagger: !hasOwn(startup, "stagger_delay_ms"),
|
|
739
1074
|
};
|
|
740
|
-
const
|
|
1075
|
+
const dm = (k) => (unset[k] && sameValue(f[k], base[k]) ? html`<${DefaultMark} />` : null);
|
|
1076
|
+
const fromDefaults = (k, value, target) => ({ text: valueText(value), builtin: !hasOwn(d, k), from: inAgentDefaults(target) });
|
|
1077
|
+
const L = (text, field, id, k) => html`<${Label} text=${text} schema=${schema} field=${field} htmlFor=${id} mark=${k ? dm(k) : null} />`;
|
|
741
1078
|
const fleetReply = replyGuardSupported(f.backend, f.agent_mode, d.kiro_ui) ? tn("replyGuardSupported") : tn("replyGuardUnsupported", f.backend, f.agent_mode);
|
|
742
1079
|
const classicReply = replyGuardSupported(f.classicBackend, f.agent_mode, d.kiro_ui) ? tn("replyGuardSupported") : tn("replyGuardUnsupported", f.classicBackend, f.agent_mode);
|
|
743
|
-
|
|
744
|
-
|
|
1080
|
+
// #2347: Agent defaults show the fleet's part; Chat & connections ClassicBot's. One form, one draft either way.
|
|
1081
|
+
const fleetPart = part !== "classic", classicPart = part !== "fleet";
|
|
1082
|
+
return html`<div class=${`form s-general s-part-${part}`}>
|
|
1083
|
+
${fleetPart ? html`<section class="card"><h3>${tn("defaultsTitle")}</h3>
|
|
745
1084
|
<div class="grid2">
|
|
746
|
-
<div class
|
|
1085
|
+
<div class=${fc("field", "locale")}>${L(tn("language"), "defaults.locale", "g-locale", "locale")}<${Select} id="g-locale" value=${f.locale} onChange=${set("locale")}
|
|
747
1086
|
options=${localeOptions(f.locale).map((o) => (o.auto ? { value: "", label: tn("localeAuto") } : o))} /></div>
|
|
748
|
-
<div class
|
|
749
|
-
<div class
|
|
1087
|
+
<div class=${fc("field", "backend")}>${L(tn("defaultBackend"), "defaults.backend", "g-be", "backend")}<${BackendSelect} id="g-be" value=${f.backend} onChange=${set("backend")} /></div>
|
|
1088
|
+
<div class=${fc("field", "model")}>${L(tn("defaultModel"), "defaults.model", "g-model", "model")}<${ModelSelect} id="g-model" value=${f.model} onChange=${set("model")}
|
|
750
1089
|
backend=${f.backend || "claude-code"} inherit=${tn("modelCliDefault")} /></div>
|
|
751
|
-
<div class
|
|
752
|
-
<div class
|
|
753
|
-
<div class
|
|
1090
|
+
<div class=${fc("field", "auto")}>${L(`${tn("autoPause")} (${tn("minutes")})`, "defaults.auto_pause_after", "g-auto", "auto")}<input id="g-auto" type="number" min="0" step="1" class=${errors.auto ? "invalid" : ""} value=${f.auto} onInput=${(e) => set("auto")(e.target.value)} /><${Feedback} error=${errors.auto} /></div>
|
|
1091
|
+
<div class=${fc("field", "tool_progress")}>${L(tn("toolProgress"), "defaults.tool_progress", "g-tp", "tool_progress")}<${Select} id="g-tp" value=${f.tool_progress} onChange=${set("tool_progress")} options=${TOOL_PROGRESS} /></div>
|
|
1092
|
+
<div class=${fc("field", "visibility")}>${L(tn("crossInstanceVisibility"), "defaults.cross_instance_visibility", "g-vis", "visibility")}<${Select} id="g-vis" value=${f.visibility} onChange=${set("visibility")} options=${VISIBILITY_MODES} />
|
|
754
1093
|
<p class="note">${tn("crossInstanceVisibilityHint")}</p></div>
|
|
755
|
-
<div class
|
|
1094
|
+
<div class=${fc("field", "tips")}>${L(tn("dailyTips"), "defaults.tips", "g-tips", "tips")}<${Select} id="g-tips" value=${f.tips} onChange=${set("tips")} options=${noticeChoices()} /><${InEffect} text=${noticeLabel(noticeMode(f.tips))} /><p class="note">${tn("dailyTipsHint")}</p></div>
|
|
1095
|
+
<div class=${fc("field", "replyTriggers")}><label class="check"><input id="g-reply-triggers" type="checkbox" checked=${f.replyTriggers} onChange=${(e) => set("replyTriggers")(e.target.checked)} /><span>${tn("replyTriggers")}</span>${dm("replyTriggers")}<${Impact} schema=${schema} field="defaults.reply_triggers" /></label><p class="note">${tn("replyTriggersHint")}</p></div>
|
|
1096
|
+
<div class=${fc("field", "reply")}><label class="check"><input id="g-reply" type="checkbox" checked=${f.reply} onChange=${(e) => set("reply")(e.target.checked)} /><span>${tn("replyCompletionGuard")}</span>${dm("reply")}<${Impact} schema=${schema} field="defaults.reply_completion_guard" /></label>
|
|
756
1097
|
<p class="note">${fleetReply}</p></div>
|
|
757
1098
|
</div>
|
|
758
|
-
<fieldset
|
|
1099
|
+
<fieldset id="g-dash" class=${fc("field dash-perms", ...DASHBOARD_FEATURES.map((k) => `dash_${k}`))}><legend>${L(tn("dashboardAccess"), "defaults.dashboard")}</legend>
|
|
759
1100
|
<p class="note">${tn("dashboardDefaultsHint")}</p>
|
|
760
|
-
${DASHBOARD_FEATURES.map((k) => html`<label key=${k} class="check"><input type="checkbox" checked=${f
|
|
761
|
-
onChange=${(e) =>
|
|
1101
|
+
${DASHBOARD_FEATURES.map((k) => html`<label key=${k} class="check"><input type="checkbox" checked=${f[`dash_${k}`]}
|
|
1102
|
+
onChange=${(e) => set(`dash_${k}`)(e.target.checked)} /><span>${tn(`dashboard_${k}`)}</span></label>`)}
|
|
762
1103
|
</fieldset>
|
|
763
|
-
</section
|
|
764
|
-
|
|
1104
|
+
</section>` : null}
|
|
1105
|
+
${classicPart ? html`<section class="card"><h3>${tn("classicDefaultsTitle")}</h3>
|
|
765
1106
|
<div class="grid2">
|
|
766
|
-
<div class
|
|
767
|
-
|
|
768
|
-
|
|
1107
|
+
<div class=${fc("field", "classicBackend")}>${L(tn("classicBackend"), "classic_defaults.backend", "g-cbe")}<${BackendSelect} id="g-cbe" value=${f.classicBackend} onChange=${set("classicBackend")} />
|
|
1108
|
+
${!c.backend && f.classicBackend === base.classicBackend ? html`<${Inherited} text=${valueText(f.classicBackend, "backend")} builtin=${!d.backend} from=${inAgentDefaults("#g-be")} />` : null}</div>
|
|
1109
|
+
<div class=${fc("field", "classicToolProgress", "classicToolProgressInherit")}>${L(tn("toolProgress"), "classic_defaults.tool_progress", "g-ctp")}
|
|
1110
|
+
<${Override} inherit=${f.classicToolProgressInherit} onInherit=${set("classicToolProgressInherit")} inherited=${fromDefaults("tool_progress", d.tool_progress ?? "off", "#g-tp")}>
|
|
769
1111
|
<${Select} id="g-ctp" disabled=${f.classicToolProgressInherit} value=${f.classicToolProgressInherit ? (d.tool_progress ?? "off") : f.classicToolProgress} onChange=${set("classicToolProgress")} options=${TOOL_PROGRESS} /></${Override}></div>
|
|
770
|
-
<div class
|
|
771
|
-
<${Override} inherit=${f.
|
|
772
|
-
<label class="check"><input type="checkbox" disabled=${f.
|
|
1112
|
+
<div class=${fc("field", "classicReplyTriggers", "classicReplyTriggersInherit")}>${L(tn("replyTriggers"), "classic_defaults.reply_triggers")}
|
|
1113
|
+
<${Override} inherit=${f.classicReplyTriggersInherit} onInherit=${set("classicReplyTriggersInherit")} inherited=${fromDefaults("reply_triggers", Boolean(d.reply_triggers ?? schema.reply_triggers_default), "#g-reply-triggers")}>
|
|
1114
|
+
<label class="check"><input id="g-creply-triggers" type="checkbox" disabled=${f.classicReplyTriggersInherit} checked=${f.classicReplyTriggersInherit ? Boolean(d.reply_triggers ?? schema.reply_triggers_default) : f.classicReplyTriggers} onChange=${(e) => set("classicReplyTriggers")(e.target.checked)} /><span>${tn("replyTriggers")}</span></label></${Override}><p class="note">${tn("replyTriggersHint")}</p></div>
|
|
1115
|
+
<div class=${fc("field", "classicReply", "classicReplyInherit")}>${L(tn("replyCompletionGuard"), "classic_defaults.reply_completion_guard")}
|
|
1116
|
+
<${Override} inherit=${f.classicReplyInherit} onInherit=${set("classicReplyInherit")} inherited=${fromDefaults("reply_completion_guard", d.reply_completion_guard ?? true, "#g-reply")}>
|
|
1117
|
+
<label class="check"><input id="g-creply" type="checkbox" disabled=${f.classicReplyInherit} checked=${f.classicReplyInherit ? (d.reply_completion_guard ?? true) : f.classicReply} onChange=${(e) => set("classicReply")(e.target.checked)} /><span>${tn("replyCompletionGuard")}</span></label></${Override}>
|
|
773
1118
|
<p class="note">${classicReply}</p></div>
|
|
774
1119
|
</div>
|
|
775
1120
|
<h4>${tn("whoUsesClassic")}</h4>
|
|
776
1121
|
<div class="grid2">
|
|
777
|
-
<div
|
|
778
|
-
<div
|
|
1122
|
+
<div id="g-admins" class=${fc("field", "admins")}>${L(tn("classicAdmins"), "classic.admin_users")}<${ChipList} items=${f.admins} onChange=${set("admins")} placeholder=${tn("userIdPlaceholder")} label=${tn("classicAdmins")} /></div>
|
|
1123
|
+
<div id="g-guilds" class=${fc("field", "guilds")}>${L(tn("allowedGuilds"), "classic.allowed_guilds")}<${ChipList} items=${f.guilds} onChange=${set("guilds")} placeholder=${tn("guildIdPlaceholder")} label=${tn("allowedGuilds")} /></div>
|
|
779
1124
|
</div>
|
|
780
|
-
</section
|
|
781
|
-
|
|
1125
|
+
</section>` : null}
|
|
1126
|
+
${fleetPart ? html`<section class="card" id="project-roots"><h3>${tn("projectRootsTitle")}</h3>
|
|
1127
|
+
<p class="note">${tn("projectRootsHint")}</p>
|
|
1128
|
+
<div id="g-roots" class=${fc("field", "roots")}>${L(tn("projectRootsTitle"), "fleet.project_roots")}<${ChipList} items=${f.roots} onChange=${set("roots")} placeholder="/path/to/projects" label=${tn("projectRootsTitle")} />
|
|
1129
|
+
${f.roots.length ? null : html`<${InEffect} text=${tn("projectRootsAnywhere")} />`}</div>
|
|
1130
|
+
</section>` : null}
|
|
1131
|
+
${fleetPart ? html`<${Drawer} title=${tn("advancedSection")}>
|
|
782
1132
|
<section class="card"><h3>${tn("runtimeResources")}</h3><div class="grid2">
|
|
783
|
-
<div class
|
|
1133
|
+
<div class=${fc("field", "tool_set")}>${L(tn("toolSet"), "defaults.tool_set", "g-ts", "tool_set")}<${Select} id="g-ts" value=${f.tool_set} onChange=${set("tool_set")}
|
|
784
1134
|
options=${[{ value: "", label: tn("toolSetUnset", toolSetDefault({}, {}), toolSetDefault({}, { general_topic: true })) }, ...TOOL_SETS]} /></div>
|
|
785
|
-
<div class
|
|
786
|
-
<div class
|
|
1135
|
+
<div class=${fc("field", "log_level")}>${L(tn("logLevel"), "defaults.log_level", "g-ll", "log_level")}<${Select} id="g-ll" value=${f.log_level} onChange=${set("log_level")} options=${LOG_LEVELS} /></div>
|
|
1136
|
+
<div class=${fc("field", "agent_mode")}>${L(tn("agentMode"), "defaults.agent_mode", "g-am", "agent_mode")}<${Select} id="g-am" value=${f.agent_mode} onChange=${set("agent_mode")} options=${AGENT_MODES} /></div>
|
|
787
1137
|
</div></section>
|
|
788
1138
|
<section class="card"><h3>${tn("detection")}</h3><div class="grid2">
|
|
789
|
-
<label class
|
|
790
|
-
<div class
|
|
1139
|
+
<label class=${fc("check", "hangOn")}><input id="g-hang-on" type="checkbox" checked=${f.hangOn} onChange=${(e) => set("hangOn")(e.target.checked)} /><span>${tn("hangEnabled")}</span>${dm("hangOn")}<${Impact} schema=${schema} field="defaults.hang_detector" /></label>
|
|
1140
|
+
<div class=${fc("field", "hang")}>${L(`${tn("hangTimeout")} (${tn("minutes")})`, "defaults.hang_detector", "g-hang", "hang")}<input id="g-hang" type="number" min="0.1" step="0.5" class=${errors.hang ? "invalid" : ""} value=${f.hang} onInput=${(e) => set("hang")(e.target.value)} /><${Feedback} error=${errors.hang} /></div>
|
|
791
1141
|
</div></section>
|
|
792
1142
|
<section class="card"><h3>${tn("startup")}</h3><div class="grid2">
|
|
793
|
-
<div class
|
|
794
|
-
|
|
1143
|
+
<div class=${fc("field", "concurrency")}>${L(tn("concurrency"), "fleet.spawn_concurrency", "g-conc")}<input id="g-conc" type="number" min="1" max="20" step="1" class=${errors.concurrency ? "invalid" : ""} value=${f.concurrency} onInput=${(e) => set("concurrency")(e.target.value)} /><${Feedback} error=${errors.concurrency} warning=${warnings.concurrency} />
|
|
1144
|
+
${f.concurrency.trim() === "" ? html`<${InEffect} text=${tn("concurrencyAuto")} />` : null}</div>
|
|
1145
|
+
<div class=${fc("field", "stagger")}>${L(`${tn("staggerDelay")} (ms)`, "fleet.spawn_stagger_ms", "g-stag", "stagger")}<input id="g-stag" type="number" min="0" max="30000" step="100" class=${errors.stagger ? "invalid" : ""} value=${f.stagger} onInput=${(e) => set("stagger")(e.target.value)} /><${Feedback} error=${errors.stagger} warning=${warnings.stagger} /></div>
|
|
795
1146
|
</div></section>
|
|
796
1147
|
<section class="card"><h3>${tn("accessTitle")}</h3>
|
|
797
1148
|
<p class="note">${primary ? tn("accessMovedHint", primary.id || chLabel(0)) : tn("noBots")}</p></section>
|
|
798
|
-
</${Drawer}
|
|
799
|
-
<div class="save-row"><button type="button" class="btn btn-primary" disabled=${!ready} onClick=${review}>${tn("reviewChanges")}</button>
|
|
800
|
-
${msg ? html`<span class="note" role="status">${msg}</span>` : null}</div>
|
|
1149
|
+
</${Drawer}>` : null}
|
|
801
1150
|
</div>`;
|
|
802
1151
|
}
|
|
803
1152
|
|
|
@@ -810,16 +1159,40 @@ function General({ ctx }) {
|
|
|
810
1159
|
* the temporary public link none of it can be changed — the form says so and stays disabled (the server refuses too).
|
|
811
1160
|
*/
|
|
812
1161
|
export const WEB_READ_ONLY = [
|
|
813
|
-
"web.allowed_hosts", "web.view_access", "web.
|
|
1162
|
+
"web.allowed_hosts", "web.view_access", "web.preview", "web.preview_port", "web.preview_origin", "web.preview_on_public_link",
|
|
814
1163
|
"web.usage_panel", "web.search", "web.provider_secrets", "web.upload_max_file_mb", "web.upload_max_total_mb", "web_terminal.bind", "web_terminal.tunnel",
|
|
815
1164
|
];
|
|
816
|
-
|
|
1165
|
+
/**
|
|
1166
|
+
* #2347 §10: what each of them is when fleet.yaml leaves it out, as the host applies it (null: nothing — none is used).
|
|
1167
|
+
* web.preview_port: the web port + 1 (previewSettings); the uploads: UPLOAD_LIMITS (#1937); the terminal: 127.0.0.1.
|
|
1168
|
+
*/
|
|
1169
|
+
export function webReadOnlyDefault(path, fleet) {
|
|
1170
|
+
// previewSettings: a web port of 0 (tests, harnesses) gets an ephemeral one; past 65535 there is no preview listener.
|
|
1171
|
+
const hp = fleet.health_port ?? 19280;
|
|
1172
|
+
return {
|
|
1173
|
+
"web.allowed_hosts": [], "web.view_access": "open", "web.notify_login": true, "web.preview": true, "web.preview_port": hp === 0 ? 0 : hp + 1 <= 65535 ? hp + 1 : null,
|
|
1174
|
+
// #2518: web.search unset is no age limit (the 300,000-row cap still applies); its line says so itself.
|
|
1175
|
+
"web.preview_origin": null, "web.preview_on_public_link": true, "web.usage_panel": true, "web.search": null, "web.provider_secrets": false,
|
|
1176
|
+
"web.upload_max_file_mb": uploadMbInEffect(fleet.web).file, "web.upload_max_total_mb": 100, "web_terminal.bind": "127.0.0.1", "web_terminal.tunnel": null,
|
|
1177
|
+
}[path];
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* #2347 §10 review: the upload limits in force, in MB, as uploadLimits (#1937) resolves them: a value that is not a whole
|
|
1181
|
+
* 1–500 is the default (50 / 100), and one file is never allowed more than a whole message.
|
|
1182
|
+
*/
|
|
1183
|
+
export function uploadMbInEffect(web) {
|
|
1184
|
+
const mb = (v, dflt) => (Number.isInteger(v) && v >= 1 && v <= 500 ? v : dflt);
|
|
1185
|
+
const total = mb(web && web.upload_max_total_mb, 100);
|
|
1186
|
+
return { file: Math.min(mb(web && web.upload_max_file_mb, 50), total), total };
|
|
1187
|
+
}
|
|
1188
|
+
function WebSection({ ctx, staged, part = "all" }) {
|
|
817
1189
|
const { schema } = ctx;
|
|
818
1190
|
const web = ctx.fleet.web || {}, local = (web.session && web.session.local) || {}, term = ctx.fleet.web_terminal || {};
|
|
819
1191
|
// Over the public link the fields read only; the Guests and Web authority cards are not offered (the server never
|
|
820
1192
|
// serves their reads there).
|
|
821
1193
|
const overPublicLink = globalThis.document?.body?.dataset?.publicLink === "1";
|
|
822
1194
|
const original = useMemo(() => ({
|
|
1195
|
+
notify: noticeMode(web.notify_login),
|
|
823
1196
|
echo: web.echo_to_channel ?? true,
|
|
824
1197
|
log: web.log_tab === true, // #1218: off by default
|
|
825
1198
|
usagePub: web.usage_report_on_public_link === true, // #2122: off by default
|
|
@@ -828,7 +1201,12 @@ function WebSection({ ctx, staged }) {
|
|
|
828
1201
|
terminal: { enabled: term.enabled ?? null, ttl_minutes: term.ttl_minutes ?? null },
|
|
829
1202
|
}), []);
|
|
830
1203
|
const text = (v) => (v === null || v === undefined ? "" : String(v));
|
|
831
|
-
|
|
1204
|
+
/** #2347: a field's row class: `s-dirty` while it differs from what is saved (the draft may come from before). */
|
|
1205
|
+
const savedForm = useMemo(() => ({ notify: String(original.notify), echo: original.echo, log: original.log, usagePub: original.usagePub, pub: original.pub.allow_public,
|
|
1206
|
+
ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol, hostname: original.pub.hostname, originPort: String(original.pub.origin_port),
|
|
1207
|
+
abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours), termOn: original.terminal.enabled !== false,
|
|
1208
|
+
termTtl: text(original.terminal.ttl_minutes), autoOpen: original.pub.auto_open }), []);
|
|
1209
|
+
const [f, setF] = useDraft(ctx, "web", () => savedForm, () => {
|
|
832
1210
|
// Settings sections share one draft. Reopening Web must show it as well as preserve it in the Apply bar, so a
|
|
833
1211
|
// subsequent field edit keeps earlier edits. The original disk values remain the diff/confirmation baseline.
|
|
834
1212
|
const request = staged.get("web")?.request;
|
|
@@ -836,6 +1214,7 @@ function WebSection({ ctx, staged }) {
|
|
|
836
1214
|
const pub = pending.public_link || {}, session = pending.session?.local || {}, terminal = pending.web_terminal || {};
|
|
837
1215
|
const pick = (obj, key, fallback) => Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
|
|
838
1216
|
return {
|
|
1217
|
+
notify: String(pick(pending, "notify_login", original.notify)),
|
|
839
1218
|
echo: pick(pending, "echo_to_channel", original.echo), log: pick(pending, "log_tab", original.log), usagePub: pick(pending, "usage_report_on_public_link", original.usagePub),
|
|
840
1219
|
pub: pick(pub, "allow_public", original.pub.allow_public), ttl: String(pick(pub, "ttl_minutes", original.pub.ttl_minutes)), protocol: pick(pub, "protocol", original.pub.protocol),
|
|
841
1220
|
hostname: pick(pub, "hostname", original.pub.hostname), originPort: String(pick(pub, "origin_port", original.pub.origin_port)), autoOpen: pick(pub, "auto_open", original.pub.auto_open),
|
|
@@ -843,12 +1222,12 @@ function WebSection({ ctx, staged }) {
|
|
|
843
1222
|
termOn: pick(terminal, "enabled", original.terminal.enabled) !== false, termTtl: text(pick(terminal, "ttl_minutes", original.terminal.ttl_minutes)),
|
|
844
1223
|
};
|
|
845
1224
|
});
|
|
846
|
-
const [msg, setMsg] = useState(null);
|
|
847
1225
|
const set = (k) => (v) => setF((x) => ({ ...x, [k]: v }));
|
|
1226
|
+
const fc = (cls, ...keys) => `${cls}${keys.some((k) => !sameValue(f[k], savedForm[k])) ? " s-dirty" : ""}`;
|
|
848
1227
|
// Empty = the default (null in the write: the key is removed from fleet.yaml).
|
|
849
1228
|
const value = (v) => (v.trim() === "" ? null : Number(v));
|
|
850
1229
|
const hours = (v) => { const n = value(v); return n !== null && (!Number.isFinite(n) || n < 1 || n > 720) ? "sessionRange" : ""; };
|
|
851
|
-
// The defaults (#1670: 48 h / 48 h, DEFAULT_SESSION_POLICY); an idle time left blank follows a shorter session, as
|
|
1230
|
+
// The defaults (#1670: 48 h / 48 h, DEFAULT_SESSION_POLICY; the terminal's 10 min); an idle time left blank follows a shorter session, as
|
|
852
1231
|
// the store and the validator apply it — so only an idle time that is typed can exceed the session length.
|
|
853
1232
|
const abs = value(f.abs) ?? 48, idle = value(f.idle) ?? Math.min(48, abs);
|
|
854
1233
|
// Named mode: a hostname is typed (the server judges the merged result the same way).
|
|
@@ -866,8 +1245,14 @@ function WebSection({ ctx, staged }) {
|
|
|
866
1245
|
};
|
|
867
1246
|
const ready = !overPublicLink && !Object.values(errors).some(Boolean);
|
|
868
1247
|
const review = (quiet = false) => {
|
|
869
|
-
//
|
|
870
|
-
if (
|
|
1248
|
+
// #2347: invalid text keeps the draft unsaved and Save waiting ("Fix 1 field to save"); no older valid draft is sent.
|
|
1249
|
+
if (overPublicLink) return;
|
|
1250
|
+
if (!ready) {
|
|
1251
|
+
const at = { hostname: "#publicWebHostname", originPort: "#publicWebOriginPort", ttl: "#publicWebTtl", abs: "#webSessionAbsolute", idle: "#webSessionIdle",
|
|
1252
|
+
termTtl: "#webTerminalTtl" }[Object.keys(errors).find((k) => errors[k])];
|
|
1253
|
+
syncStage(ctx, "web", { label: tn("webSettingsLabel"), invalid: tn(Object.values(errors).find(Boolean)), section: "access", target: at });
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
871
1256
|
// Keep an existing secret reference, including a custom one. Only a newly supplied hostname without any
|
|
872
1257
|
// reference adopts the standard env key; mounting or editing an unrelated field never rewrites credentials.
|
|
873
1258
|
const tokenEnv = f.hostname === original.pub.hostname ? original.pub.tunnel_token_env
|
|
@@ -878,6 +1263,7 @@ function WebSection({ ctx, staged }) {
|
|
|
878
1263
|
const terminalPatch = changedFields({ enabled: f.termOn === (original.terminal.enabled !== false) ? original.terminal.enabled : f.termOn ? null : false,
|
|
879
1264
|
ttl_minutes: value(f.termTtl) }, original.terminal);
|
|
880
1265
|
const body = {
|
|
1266
|
+
...(noticeMode(f.notify) !== original.notify ? { notify_login: noticeMode(f.notify) } : {}),
|
|
881
1267
|
...(f.echo !== original.echo ? { echo_to_channel: f.echo } : {}),
|
|
882
1268
|
...(f.log !== original.log ? { log_tab: f.log } : {}),
|
|
883
1269
|
...(f.usagePub !== original.usagePub ? { usage_report_on_public_link: f.usagePub } : {}),
|
|
@@ -886,6 +1272,7 @@ function WebSection({ ctx, staged }) {
|
|
|
886
1272
|
...(Object.keys(terminalPatch).length ? { web_terminal: terminalPatch } : {}),
|
|
887
1273
|
};
|
|
888
1274
|
const fields = [
|
|
1275
|
+
...(body.notify_login !== undefined ? ["web.notify_login"] : []),
|
|
889
1276
|
...(body.echo_to_channel !== undefined ? ["web.echo_to_channel"] : []),
|
|
890
1277
|
...(body.log_tab !== undefined ? ["web.log_tab"] : []),
|
|
891
1278
|
...(body.usage_report_on_public_link !== undefined ? ["web.usage_report_on_public_link"] : []),
|
|
@@ -894,10 +1281,9 @@ function WebSection({ ctx, staged }) {
|
|
|
894
1281
|
...Object.keys(terminalPatch).map((k) => `web_terminal.${k}`),
|
|
895
1282
|
];
|
|
896
1283
|
if (fields.length) {
|
|
897
|
-
ctx.stage("web", { label: tn("
|
|
1284
|
+
ctx.stage("web", { label: tn("webSettingsLabel"), count: fields.length, impact: batchImpact(schema, fields, (k) => impactOf(schema, k)),
|
|
898
1285
|
request: { method: "PUT", url: "/api/settings/fleet/web", body } }, quiet);
|
|
899
1286
|
} else if (staged.has("web")) ctx.unstage("web");
|
|
900
|
-
setMsg(fields.length ? tn("readyApply") : null);
|
|
901
1287
|
};
|
|
902
1288
|
// This form already has validation and a single shared Web draft. Valid edits join that draft immediately;
|
|
903
1289
|
// Apply, its confirmations and the leave guard still own all HTTP writes. Quiet staging avoids a toast per key.
|
|
@@ -905,6 +1291,9 @@ function WebSection({ ctx, staged }) {
|
|
|
905
1291
|
// parent's already-staged Web draft. Only an actual field edit replaces or removes that draft.
|
|
906
1292
|
const formMounted = useRef(false);
|
|
907
1293
|
useEffect(() => { if (!formMounted.current) { formMounted.current = true; return; } review(true); }, [f]);
|
|
1294
|
+
const roText = (v) => (v === null ? tn("valueNone") : typeof v === "object" ? JSON.stringify(v) : String(v));
|
|
1295
|
+
// #2347 §10: a key fleet.yaml leaves out shows the value the host uses, marked "default".
|
|
1296
|
+
const uploads = uploadMbInEffect(web);
|
|
908
1297
|
const shown = (path) => {
|
|
909
1298
|
const [top, key] = path.split(".");
|
|
910
1299
|
if (top === "web" && key === "search") {
|
|
@@ -914,72 +1303,106 @@ function WebSection({ ctx, staged }) {
|
|
|
914
1303
|
return String(days);
|
|
915
1304
|
}
|
|
916
1305
|
const v = (top === "web" ? web : term)[key];
|
|
917
|
-
|
|
1306
|
+
// An upload limit that is not what applies (out of range, or a file above the whole message's) says what does.
|
|
1307
|
+
const upload = top === "web" ? { upload_max_file_mb: uploads.file, upload_max_total_mb: uploads.total }[key] : undefined;
|
|
1308
|
+
const applies = upload === undefined ? v : upload;
|
|
1309
|
+
const capped = top === "web" && key === "upload_max_file_mb" && uploads.file < 50; // left out, only the whole message's limit lowers it
|
|
1310
|
+
if (v === undefined) return html`${roText(webReadOnlyDefault(path, ctx.fleet))} <${DefaultMark} />${capped ? html` <span class="tag">${tn("cappedByTotal")}</span>` : null}`;
|
|
1311
|
+
return applies === v ? roText(v) : html`${roText(v)} <span class="tag">${tn("inEffectShort", roText(applies))}</span>`;
|
|
918
1312
|
};
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1313
|
+
// #2347: one form, one draft, shown where each part belongs — Access & security (sessions, the browser terminal, the
|
|
1314
|
+
// public link and its fixed address, guests, web authority), Chat & connections (web echo), Web dashboard (the Log tab,
|
|
1315
|
+
// usage over the public link), Advanced (what only fleet.yaml sets).
|
|
1316
|
+
const has = (p) => part === "all" || part === p;
|
|
1317
|
+
const lock = (body) => html`<fieldset class="s-web-fields" disabled=${overPublicLink}>${body}</fieldset>`;
|
|
1318
|
+
return html`<div class=${`form s-web s-part-${part}`}>
|
|
1319
|
+
${has("access") ? html`<p class="sub">${tn("webLead")}</p>` : null}
|
|
1320
|
+
${overPublicLink && part !== "readonly" ? html`<p class="note" role="status" id="webPublicRefused">${tn("webPublicRefused")}</p>` : null}
|
|
1321
|
+
${has("access") ? lock(html`<section class="card"><h3>${tn("webSessionTitle")}</h3>
|
|
924
1322
|
<p class="note">${tn("webSessionHint")}</p>
|
|
1323
|
+
<div class=${fc("field", "notify")}><label for="webNotifyLogin">${tn("webNotifyLogin")}<${Impact} schema=${schema} field="web.notify_login" /></label><${Select} id="webNotifyLogin" value=${f.notify} onChange=${set("notify")} options=${noticeChoices()} /><${InEffect} text=${noticeLabel(noticeMode(f.notify))} />${web.notify_login === undefined ? html`<${DefaultMark} />` : null}<p class="note">${tn("webNotifyLoginHint")}</p></div>
|
|
925
1324
|
<div class="grid2">
|
|
926
|
-
<div class
|
|
927
|
-
class=${errors.abs ? "invalid" : ""} value=${f.abs} onInput=${(e) => set("abs")(e.target.value)} /><${Feedback} error=${errors.abs}
|
|
928
|
-
|
|
929
|
-
|
|
1325
|
+
<div class=${fc("field", "abs")}><label for="webSessionAbsolute">${tn("sessionAbsolute")}</label><input id="webSessionAbsolute" type="number" min="1" max="720" step="any"
|
|
1326
|
+
class=${errors.abs ? "invalid" : ""} value=${f.abs} onInput=${(e) => set("abs")(e.target.value)} /><${Feedback} error=${errors.abs} />
|
|
1327
|
+
${value(f.abs) === null ? html`<${InEffect} text=${tn("hoursValue", 48)} />` : null}</div>
|
|
1328
|
+
<div class=${fc("field", "idle")}><label for="webSessionIdle">${tn("sessionIdle")}</label><input id="webSessionIdle" type="number" min="1" max="720" step="any"
|
|
1329
|
+
class=${errors.idle ? "invalid" : ""} value=${f.idle} onInput=${(e) => set("idle")(e.target.value)} /><${Feedback} error=${errors.idle} />
|
|
1330
|
+
${value(f.idle) === null && !errors.abs ? html`<${InEffect} text=${tn("hoursValue", idle)} why=${idle < 48 ? tn("cappedBySession") : null} />` : null}</div>
|
|
930
1331
|
</div>
|
|
931
1332
|
</section>
|
|
932
1333
|
<section class="card"><h3>${tn("webTerminalTitle")}</h3>
|
|
933
1334
|
<p class="note">${tn("webTerminalHint")}</p>
|
|
934
|
-
<label class
|
|
1335
|
+
<label class=${fc("check", "termOn")}><input id="webTerminalEnabled" type="checkbox" checked=${f.termOn} onChange=${(e) => set("termOn")(e.target.checked)} /><span>${tn("webTerminalEnabled")}</span><${Impact} schema=${schema} field="web_terminal.enabled" /></label>
|
|
935
1336
|
<div class="grid2">
|
|
936
|
-
<div class
|
|
937
|
-
class=${errors.termTtl ? "invalid" : ""} value=${f.termTtl} onInput=${(e) => set("termTtl")(e.target.value)} /><${Feedback} error=${errors.termTtl}
|
|
1337
|
+
<div class=${fc("field", "termTtl")}><label for="webTerminalTtl">${tn("webTerminalTtl")}</label><input id="webTerminalTtl" type="number" min="1" max="20" step="1"
|
|
1338
|
+
class=${errors.termTtl ? "invalid" : ""} value=${f.termTtl} onInput=${(e) => set("termTtl")(e.target.value)} /><${Feedback} error=${errors.termTtl} />
|
|
1339
|
+
${value(f.termTtl) === null ? html`<${InEffect} text=${tn("minutesValue", 10)} />` : null}</div>
|
|
938
1340
|
</div>
|
|
939
1341
|
</section>
|
|
940
|
-
<section class="card"><h3>${tn("
|
|
941
|
-
<label class
|
|
942
|
-
<p class="note">${tn("webEchoHint")}</p>
|
|
943
|
-
<label class="check"><input id="webLogTab" type="checkbox" checked=${f.log} onChange=${(e) => set("log")(e.target.checked)} /><span>${tn("webLogTab")}</span><${Impact} schema=${schema} field="web.log_tab" /></label>
|
|
944
|
-
<p class="note">${tn("webLogTabHint")}</p>
|
|
945
|
-
<label class="check"><input id="webUsagePublic" type="checkbox" checked=${f.usagePub} onChange=${(e) => set("usagePub")(e.target.checked)} /><span>${tn("webUsagePublic")}</span><${Impact} schema=${schema} field="web.usage_report_on_public_link" /></label>
|
|
946
|
-
<p class="note">${tn("webUsagePublicHint")}</p>
|
|
947
|
-
<label class="check"><input id="publicWebLink" type="checkbox" checked=${f.pub} onChange=${(e) => set("pub")(e.target.checked)} /><span>${tn("publicLink")}</span><${Impact} schema=${schema} field="web.public_link.allow_public" /></label>
|
|
1342
|
+
<section class="card"><h3>${tn("publicLinkTitle")}</h3>
|
|
1343
|
+
<label class=${fc("check", "pub")}><input id="publicWebLink" type="checkbox" checked=${f.pub} onChange=${(e) => set("pub")(e.target.checked)} /><span>${tn("publicLink")}</span><${Impact} schema=${schema} field="web.public_link.allow_public" /></label>
|
|
948
1344
|
<p class="note">${tn("publicLinkHint")}</p>
|
|
949
1345
|
<div class="grid2">
|
|
950
|
-
<div class
|
|
951
|
-
<div class
|
|
1346
|
+
<div class=${fc("field", "hostname")}><label for="publicWebHostname">${tn("namedHostname")}</label><input id="publicWebHostname" type="text" value=${f.hostname} placeholder="agend.example.com" class=${errors.hostname ? "invalid" : ""} onInput=${(e) => set("hostname")(e.target.value)} /><${Feedback} error=${errors.hostname} /></div>
|
|
1347
|
+
<div class=${fc("field", "originPort")}><label for="publicWebOriginPort">${tn("namedOriginPort")}</label><input id="publicWebOriginPort" type="number" min="1024" max="65535" value=${f.originPort} class=${errors.originPort ? "invalid" : ""} onInput=${(e) => set("originPort")(e.target.value)} /><${Feedback} error=${errors.originPort} /></div>
|
|
952
1348
|
</div>
|
|
953
1349
|
<p class="note">${tn("namedTunnelHint")} <a href="https://github.com/songsid/AgEnD/blob/main/docs/named-tunnel.md" target="_blank" rel="noopener noreferrer">${tn("namedTunnelSetup")}</a></p>
|
|
954
1350
|
<div class="grid2">
|
|
955
|
-
<div class
|
|
956
|
-
<div class
|
|
1351
|
+
<div class=${fc("field", "ttl")}><label for="publicWebTtl">${tn(named ? "publicTtlNamed" : "publicTtl")}</label><input id="publicWebTtl" type="number" min=${named ? "0" : "1"} max=${named ? "10080" : "480"} step="1" class=${errors.ttl ? "invalid" : ""} value=${f.ttl} onInput=${(e) => set("ttl")(e.target.value)} /><${Feedback} error=${errors.ttl} /></div>
|
|
1352
|
+
<div class=${fc("field", "protocol")}><label for="publicWebProtocol">${tn("publicProtocol")}</label><${Select} id="publicWebProtocol" value=${f.protocol} onChange=${set("protocol")} options=${["http2", "quic", "auto"]} /></div>
|
|
957
1353
|
</div>
|
|
958
1354
|
${named ? html`<p class="note" id="publicTtlNamedHint">${tn("publicTtlNamedHint")}</p>` : null}
|
|
959
|
-
${named || f.autoOpen ? html`<label class
|
|
1355
|
+
${named || f.autoOpen ? html`<label class=${fc("check", "autoOpen")}><input id="publicAutoOpen" type="checkbox" checked=${f.autoOpen} onChange=${(e) => set("autoOpen")(e.target.checked)} /><span>${tn("publicAutoOpen")}</span><${Impact} schema=${schema} field="web.public_link.auto_open" /></label>
|
|
960
1356
|
<p class="note">${tn("publicAutoOpenHint")}</p><${Feedback} error=${errors.autoOpen} />` : null}
|
|
961
|
-
</section
|
|
962
|
-
|
|
963
|
-
${
|
|
964
|
-
|
|
1357
|
+
</section>`) : null}
|
|
1358
|
+
${has("access") && !overPublicLink ? html`<${PublicTunnelToken} />` : null}
|
|
1359
|
+
${has("access") ? html`<${OfficeSharesCard} />` : null}
|
|
1360
|
+
${has("access") && !overPublicLink ? html`<${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />` : null}
|
|
1361
|
+
${has("chat") ? lock(html`<section class="card"><h3>${tn("webChat")}</h3>
|
|
1362
|
+
<label class=${fc("check", "echo")}><input id="webEchoToChannel" type="checkbox" checked=${f.echo} onChange=${(e) => set("echo")(e.target.checked)} /><span>${tn("webEcho")}</span><${Impact} schema=${schema} field="web.echo_to_channel" /></label>
|
|
1363
|
+
<p class="note">${tn("webEchoHint")}</p>
|
|
1364
|
+
</section>`) : null}
|
|
1365
|
+
${has("dashboard") ? lock(html`<section class="card"><h3>${tn("webDashboardTitle")}</h3>
|
|
1366
|
+
<label class=${fc("check", "log")}><input id="webLogTab" type="checkbox" checked=${f.log} onChange=${(e) => set("log")(e.target.checked)} /><span>${tn("webLogTab")}</span><${Impact} schema=${schema} field="web.log_tab" /></label>
|
|
1367
|
+
<p class="note">${tn("webLogTabHint")}</p>
|
|
1368
|
+
<label class=${fc("check", "usagePub")}><input id="webUsagePublic" type="checkbox" checked=${f.usagePub} onChange=${(e) => set("usagePub")(e.target.checked)} /><span>${tn("webUsagePublic")}</span><${Impact} schema=${schema} field="web.usage_report_on_public_link" /></label>
|
|
1369
|
+
<p class="note">${tn("webUsagePublicHint")}</p>
|
|
1370
|
+
</section>`) : null}
|
|
1371
|
+
${has("readonly") ? html`<section class="card s-web-readonly"><h3>${tn("webReadOnlyTitle")}</h3>
|
|
965
1372
|
<p class="note">${tn("webReadOnlyLead")}</p>
|
|
966
1373
|
<dl class="s-web-ro">${WEB_READ_ONLY.map((k) => html`<div key=${k}><dt><code>${k}</code></dt><dd>${shown(k)}</dd></div>`)}</dl>
|
|
967
1374
|
<p class="note">${tn("webSearchHint")}</p>
|
|
968
|
-
</section
|
|
969
|
-
<div class="save-row"><button type="button" class="btn btn-primary" disabled=${!ready} onClick=${() => review()}>${tn("reviewChanges")}</button>
|
|
970
|
-
${msg ? html`<span class="note" role="status">${msg}</span>` : null}</div>
|
|
1375
|
+
</section>` : null}
|
|
971
1376
|
</div>`;
|
|
972
1377
|
}
|
|
973
1378
|
|
|
974
|
-
/**
|
|
1379
|
+
/**
|
|
1380
|
+
* Developer: the fleet model as YAML or JSON, to read, copy, download — or edit. #2347: an edit's Apply stages its
|
|
1381
|
+
* writes in the Save bar like every form's change (Save writes them, in order, then one apply; Discard drops them),
|
|
1382
|
+
* and the editor keeps the edit while they wait — or comes back to it after a Save that did not land.
|
|
1383
|
+
*/
|
|
975
1384
|
function Developer({ ctx }) {
|
|
976
|
-
const [
|
|
977
|
-
const [
|
|
1385
|
+
const mine = [...ctx.staged].find(([k]) => isYamlKey(k));
|
|
1386
|
+
const held = mine && mine[1].yaml ? mine[1].yaml : null;
|
|
1387
|
+
const heldCount = [...ctx.staged.keys()].filter(isYamlKey).length;
|
|
1388
|
+
const [fmt, setFmt] = useState(held ? held.fmt : "yaml");
|
|
1389
|
+
const [editing, setEditing] = useState(!!held);
|
|
978
1390
|
const model = fleetModel(ctx.fleet);
|
|
979
1391
|
const text = fmt === "yaml" ? toYaml(model) : JSON.stringify(model, null, 2);
|
|
980
|
-
const [draft, setDraft] = useState(text);
|
|
1392
|
+
const [draft, setDraft] = useState(held ? held.text : text);
|
|
981
1393
|
const [msg, setMsg] = useState(null);
|
|
982
1394
|
useEffect(() => { if (!editing) setDraft(text); }, [text, editing]);
|
|
1395
|
+
// The edit as staged: one that goes (its Save began, or Discard) leaves the editor on what is in force; one that comes
|
|
1396
|
+
// back (a Save that did not land all of it) is the editor's text again.
|
|
1397
|
+
const heldText = held ? held.text : null;
|
|
1398
|
+
const was = useRef(heldText);
|
|
1399
|
+
useEffect(() => {
|
|
1400
|
+
if (heldText !== null && heldText !== was.current) { setFmt(held.fmt); setDraft(heldText); setEditing(true); }
|
|
1401
|
+
else if (heldText === null && was.current !== null) { setEditing(false); setMsg(null); }
|
|
1402
|
+
was.current = heldText;
|
|
1403
|
+
}, [heldText]);
|
|
1404
|
+
// The forms' own unsaved changes to the same settings come first: the editor is for reading until they are saved.
|
|
1405
|
+
const blocked = formsStaged(ctx.staged);
|
|
983
1406
|
const copy = () => { try { navigator.clipboard.writeText(text).then(() => setMsg({ text: tn("copied") }), () => setMsg({ error: tn("failed") })); } catch { setMsg({ error: tn("failed") }); } };
|
|
984
1407
|
const download = () => {
|
|
985
1408
|
const a = document.createElement("a");
|
|
@@ -990,38 +1413,56 @@ function Developer({ ctx }) {
|
|
|
990
1413
|
let parsed;
|
|
991
1414
|
try { parsed = fmt === "yaml" ? fromYaml(draft) : JSON.parse(draft || "{}"); } catch (e) { setMsg({ error: e.message }); return; }
|
|
992
1415
|
if (!parsed || typeof parsed !== "object") { setMsg({ error: tn("expectedMapping") }); return; }
|
|
1416
|
+
if (formsStaged(ctx.staged)) { setMsg({ error: tn("devFormsUnsaved") }); return; }
|
|
993
1417
|
const requests = fullModelRequests(parsed, ctx.fleet);
|
|
1418
|
+
// An Apply again replaces the edit staged before: what it writes is this text's difference from what is in force.
|
|
1419
|
+
for (const k of ctx.staged.keys()) if (isYamlKey(k)) ctx.unstage(k);
|
|
1420
|
+
was.current = null; // its own Apply: not an edit that went (the editor stays on it)
|
|
994
1421
|
if (!requests.length) { setMsg({ text: tn("noChanges") }); return; }
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1422
|
+
const yaml = { fmt, text: draft };
|
|
1423
|
+
let staged = 0;
|
|
1424
|
+
for (const [i, r] of requests.entries()) {
|
|
1425
|
+
if (ctx.stage(`${YAML_KEY}${i}`, { label: `${r.method} ${decodeURIComponent(r.url.replace("/api/settings/", ""))}`, impact: requestImpact(ctx.schema, r), request: r, yaml }, true)) staged++;
|
|
999
1426
|
}
|
|
1427
|
+
setMsg(staged ? { text: tn("devStaged", staged) } : null);
|
|
1000
1428
|
};
|
|
1001
1429
|
return html`<section class="card s-dev">
|
|
1002
1430
|
<div class="s-dev-bar">
|
|
1003
1431
|
<div class="seg-inline" role="group" aria-label=${tn("format")}>
|
|
1004
1432
|
${["yaml", "json"].map((k) => html`<button key=${k} type="button" class=${`btn btn-sm${fmt === k ? " on" : ""}`} aria-pressed=${fmt === k ? "true" : "false"} onClick=${() => setFmt(k)}>${k.toUpperCase()}</button>`)}</div>
|
|
1005
|
-
<button type="button" class="btn btn-sm" onClick=${() => setEditing(!editing)}>${editing ? tn("viewMode") : tn("editMode")}</button>
|
|
1433
|
+
<button type="button" class="btn btn-sm" disabled=${blocked && !editing} title=${blocked && !editing ? tn("devFormsUnsaved") : undefined} onClick=${() => setEditing(!editing)}>${editing ? tn("viewMode") : tn("editMode")}</button>
|
|
1006
1434
|
<button type="button" class="btn btn-sm" title=${tn("copy")} onClick=${copy}><${Icon} name="copy" size=${14} /><span class="lbl">${tn("copy")}</span></button>
|
|
1007
1435
|
<button type="button" class="btn btn-sm" title=${tn("devDownload")} onClick=${download}><${Icon} name="download" size=${14} /><span class="lbl">${tn("devDownload")}</span></button>
|
|
1008
1436
|
</div>
|
|
1009
1437
|
${editing ? html`<textarea class="s-yaml" aria-label=${tn("devEditor")} spellcheck=${false} wrap="off" value=${draft} onInput=${(e) => setDraft(e.target.value)}></textarea>
|
|
1010
|
-
<div class="save-row"><button type="button" class="btn btn-primary" onClick=${save}>${tn("devApply")}</button><span class="note">${tn("devApplyHint")}</span></div>`
|
|
1438
|
+
<div class="save-row"><button type="button" class="btn btn-primary" disabled=${blocked} onClick=${save}>${tn("devApply")}</button><span class="note">${tn("devApplyHint")}</span></div>`
|
|
1011
1439
|
: html`<pre class="s-yaml">${text}</pre>`}
|
|
1440
|
+
${blocked ? html`<p class="note s-dev-blocked" role="status">${tn("devFormsUnsaved")}</p>`
|
|
1441
|
+
: held && !msg ? html`<p class="note s-dev-held" role="status">${tn("devStaged", heldCount)}</p>` : null}
|
|
1012
1442
|
${msg ? html`<p class=${`feedback${msg.error ? " error" : ""}`} role="status">${msg.error || msg.text}</p>` : null}
|
|
1013
1443
|
</section>`;
|
|
1014
1444
|
}
|
|
1015
1445
|
|
|
1016
|
-
/**
|
|
1017
|
-
|
|
1018
|
-
|
|
1446
|
+
/**
|
|
1447
|
+
* #2347: the section's Save. Shown while anything is unsaved: how many changes, what they will cost, Discard and Save —
|
|
1448
|
+
* Save waits while a field is invalid ("Fix 1 field to save").
|
|
1449
|
+
*/
|
|
1450
|
+
function PendingBar({ staged, busy, onApply, onDiscard, section }) {
|
|
1451
|
+
const changes = [...staged.values()];
|
|
1452
|
+
const kinds = [...new Set(changes.filter((c) => !c.invalid).map((c) => c.impact))];
|
|
1453
|
+
const invalid = changes.filter((c) => c.invalid).length;
|
|
1454
|
+
// #2382 review: a field to fix in another section is named, and the message goes there (its field focused).
|
|
1455
|
+
const elsewhere = changes.find((c) => c.invalid && c.section && c.section !== section);
|
|
1456
|
+
const count = changes.reduce((n, c) => n + (Number.isInteger(c.count) && c.count > 0 ? c.count : 1), 0);
|
|
1019
1457
|
return html`<div data-trust="panel-controls" class="s-pending" role="region" aria-label=${tn("pendingRegion")}>
|
|
1020
|
-
<strong>${tn("changesPending",
|
|
1458
|
+
<strong>${tn("changesPending", count)}</strong>
|
|
1021
1459
|
<span class="note">${kinds.map(impactText).join(" · ")}</span>
|
|
1022
|
-
<span class="
|
|
1023
|
-
|
|
1024
|
-
|
|
1460
|
+
<span class="s-pending-actions">
|
|
1461
|
+
${!invalid ? null : elsewhere ? html`<button type="button" class="link-btn feedback error s-fix" role="status" onClick=${() => goToTarget(elsewhere.section, elsewhere.target)}>${tn("fixToSaveIn", invalid, tn(`sec_${elsewhere.section}`))}</button>`
|
|
1462
|
+
: html`<span class="feedback error s-fix" role="status">${tn("fixToSave", invalid)}</span>`}
|
|
1463
|
+
<button type="button" class="btn" onClick=${onDiscard} disabled=${busy}>${tn("discard")}</button>
|
|
1464
|
+
<button type="button" class="btn btn-primary" onClick=${onApply} disabled=${busy || invalid > 0} title=${busy ? tn("applyBusyLocal") : undefined}>${tn("applyChanges")}</button>
|
|
1465
|
+
</span>
|
|
1025
1466
|
</div>`;
|
|
1026
1467
|
}
|
|
1027
1468
|
|